Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
386b25858b | ||
|
|
aa14068c66 | ||
|
|
4afaa79834 | ||
|
|
0662a6970e | ||
|
|
6820e0b25e | ||
|
|
b124a77cd1 | ||
|
|
2725816273 | ||
|
|
32a6756419 | ||
|
|
2a637affdf | ||
|
|
188aa07059 | ||
|
|
fa8a0cc512 | ||
|
|
d18fffe665 | ||
|
|
aece653970 | ||
|
|
2b1cf6ec13 | ||
|
|
38355c9507 | ||
|
|
d0c5d65f0e | ||
|
|
4f9bf2dec4 | ||
|
|
4ee047311f | ||
|
|
41b6dc8f10 | ||
|
|
3d5ee223e8 | ||
|
|
9dbb491dc9 | ||
|
|
40b66bf7ce | ||
|
|
dc9f400f34 | ||
|
|
48f5fc410b | ||
|
|
f6725ac025 | ||
|
|
8aa7bf6860 | ||
|
|
ba9c4fd2e8 | ||
|
|
74bcf5d30c | ||
|
|
20f9314659 |
@@ -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
|
||||||
|
|
||||||
@@ -98,6 +100,7 @@ jobs:
|
|||||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
S3_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
|
S3_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
|
||||||
S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
|
S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
|
||||||
run: |
|
run: |
|
||||||
@@ -110,7 +113,7 @@ jobs:
|
|||||||
xcodebuild -version
|
xcodebuild -version
|
||||||
xcrun --find notarytool
|
xcrun --find notarytool
|
||||||
aws --version | grep '^aws-cli/2\.' || { echo 'Install AWS CLI v2 permanently on this runner.' >&2; exit 1; }
|
aws --version | grep '^aws-cli/2\.' || { echo 'Install AWS CLI v2 permanently on this runner.' >&2; exit 1; }
|
||||||
for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_PASSWORD S3_ACCESS_KEY_ID S3_SECRET_ACCESS_KEY; do
|
for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_PASSWORD TAURI_SIGNING_PRIVATE_KEY S3_ACCESS_KEY_ID S3_SECRET_ACCESS_KEY; do
|
||||||
[[ -n "${!name}" ]] || { echo "Missing repository secret: $name" >&2; exit 1; }
|
[[ -n "${!name}" ]] || { echo "Missing repository secret: $name" >&2; exit 1; }
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -175,14 +178,19 @@ jobs:
|
|||||||
- name: Build, sign and notarize with Tauri
|
- name: Build, sign and notarize with Tauri
|
||||||
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
|
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
|
||||||
env:
|
env:
|
||||||
|
# The DMG bundler reads these variables separately from --ci.
|
||||||
|
CI: "true"
|
||||||
|
TAURI_BUNDLER_DMG_IGNORE_CI: "false"
|
||||||
APPLE_ID: dev@petsch-studios.de
|
APPLE_ID: dev@petsch-studios.de
|
||||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
with:
|
with:
|
||||||
projectPath: native
|
projectPath: native
|
||||||
tauriScript: pnpm exec tauri
|
tauriScript: pnpm exec tauri
|
||||||
args: >-
|
args: >-
|
||||||
--ci --target ${{ env.MACOS_RELEASE_TARGET }} --bundles app
|
--ci --target ${{ env.MACOS_RELEASE_TARGET }} --bundles app,dmg
|
||||||
--config '{"bundle":{"createUpdaterArtifacts":false,"macOS":{"hardenedRuntime":true}}}'
|
--config '{"bundle":{"createUpdaterArtifacts":true,"macOS":{"hardenedRuntime":true}}}'
|
||||||
-- --locked
|
-- --locked
|
||||||
uploadUpdaterJson: false
|
uploadUpdaterJson: false
|
||||||
uploadUpdaterSignatures: false
|
uploadUpdaterSignatures: false
|
||||||
@@ -192,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 }}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import { spawnSync } from 'node:child_process';
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import {
|
import {
|
||||||
appendFileSync,
|
appendFileSync,
|
||||||
|
copyFileSync,
|
||||||
|
readlinkSync,
|
||||||
existsSync,
|
existsSync,
|
||||||
mkdirSync,
|
mkdirSync,
|
||||||
mkdtempSync,
|
mkdtempSync,
|
||||||
@@ -12,6 +14,7 @@ import {
|
|||||||
} from 'node:fs';
|
} from 'node:fs';
|
||||||
import { dirname, join, resolve } from 'node:path';
|
import { dirname, join, resolve } from 'node:path';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
|
import { updateSource } from './update-manifest.mjs';
|
||||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
import {
|
import {
|
||||||
appleTeamId,
|
appleTeamId,
|
||||||
@@ -28,9 +31,10 @@ export function releaseMetadata(
|
|||||||
const config = JSON.parse(
|
const config = JSON.parse(
|
||||||
readFileSync(join(directory, 'src-tauri/tauri.conf.json'), 'utf8'),
|
readFileSync(join(directory, 'src-tauri/tauri.conf.json'), 'utf8'),
|
||||||
);
|
);
|
||||||
const packageVersion = JSON.parse(
|
const nativePackage = JSON.parse(
|
||||||
readFileSync(join(directory, 'package.json'), 'utf8'),
|
readFileSync(join(directory, 'package.json'), 'utf8'),
|
||||||
).version;
|
);
|
||||||
|
const packageVersion = nativePackage.version;
|
||||||
const cargoVersion = readFileSync(
|
const cargoVersion = readFileSync(
|
||||||
join(directory, 'src-tauri/Cargo.toml'),
|
join(directory, 'src-tauri/Cargo.toml'),
|
||||||
'utf8',
|
'utf8',
|
||||||
@@ -59,6 +63,23 @@ export function releaseMetadata(
|
|||||||
'The macOS product name and bundle identifier must stay unchanged.',
|
'The macOS product name and bundle identifier must stay unchanged.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (config.plugins?.updater?.pubkey !== updateSource.publicKey) {
|
||||||
|
throw new Error(
|
||||||
|
'The app updater key must match the permanent release key.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!/^\d+(?:\.\d+){0,2}$/.test(config.bundle?.macOS?.bundleVersion || '')
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'Set a numeric macOS bundleVersion for the About build number.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!nativePackage.releaseNotes?.trim()) {
|
||||||
|
throw new Error(
|
||||||
|
'Add releaseNotes to native/package.json before publishing.',
|
||||||
|
);
|
||||||
|
}
|
||||||
const target = env.MACOS_RELEASE_TARGET || 'aarch64-apple-darwin';
|
const target = env.MACOS_RELEASE_TARGET || 'aarch64-apple-darwin';
|
||||||
const arch = {
|
const arch = {
|
||||||
'aarch64-apple-darwin': 'arm64',
|
'aarch64-apple-darwin': 'arm64',
|
||||||
@@ -81,9 +102,11 @@ export function releaseMetadata(
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
version: config.version,
|
version: config.version,
|
||||||
|
notes: nativePackage.releaseNotes,
|
||||||
target,
|
target,
|
||||||
arch,
|
arch,
|
||||||
filename: `KanbanGit-${config.version}-${target}.zip`,
|
filename: `KanbanGit-${config.version}-${target}.dmg`,
|
||||||
|
updaterFilename: `KanbanGit-${config.version}-${target}.app.tar.gz`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,25 +169,65 @@ export function packageNotarizedApp({
|
|||||||
const staging = mkdtempSync(join(releaseRoot, '.pending-'));
|
const staging = mkdtempSync(join(releaseRoot, '.pending-'));
|
||||||
try {
|
try {
|
||||||
const archive = join(staging, metadata.filename);
|
const archive = join(staging, metadata.filename);
|
||||||
run('ditto', [
|
const bundle = join(root, metadata.target, 'release/bundle');
|
||||||
'-c',
|
copyFileSync(
|
||||||
'-k',
|
join(
|
||||||
'--sequesterRsrc',
|
bundle,
|
||||||
'--keepParent',
|
'dmg',
|
||||||
app,
|
`KanbanGit_${metadata.version}_${metadata.arch === 'arm64' ? 'aarch64' : 'x64'}.dmg`,
|
||||||
|
),
|
||||||
archive,
|
archive,
|
||||||
|
);
|
||||||
|
const mounted = join(staging, 'mounted');
|
||||||
|
mkdirSync(mounted);
|
||||||
|
run('hdiutil', ['verify', archive]);
|
||||||
|
run('hdiutil', [
|
||||||
|
'attach',
|
||||||
|
archive,
|
||||||
|
'-readonly',
|
||||||
|
'-nobrowse',
|
||||||
|
'-mountpoint',
|
||||||
|
mounted,
|
||||||
]);
|
]);
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
readlinkSync(join(mounted, 'Applications')) !== '/Applications'
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'DMG must contain the Applications installation link.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
verifyNotarizedApp(join(mounted, 'KanbanGit.app'), metadata, run);
|
||||||
|
} finally {
|
||||||
|
run('hdiutil', ['detach', mounted]);
|
||||||
|
}
|
||||||
|
rmSync(mounted, { recursive: true, force: true });
|
||||||
|
const updateArchive = join(staging, metadata.updaterFilename);
|
||||||
|
copyFileSync(join(bundle, 'macos/KanbanGit.app.tar.gz'), updateArchive);
|
||||||
|
copyFileSync(
|
||||||
|
join(bundle, 'macos/KanbanGit.app.tar.gz.sig'),
|
||||||
|
`${updateArchive}.sig`,
|
||||||
|
);
|
||||||
|
if (!readFileSync(`${updateArchive}.sig`, 'utf8').trim()) {
|
||||||
|
throw new Error('The updater signature is missing.');
|
||||||
|
}
|
||||||
|
const extracted = join(staging, 'extracted');
|
||||||
|
mkdirSync(extracted);
|
||||||
|
run('tar', ['-xzf', updateArchive, '-C', extracted]);
|
||||||
|
verifyNotarizedApp(join(extracted, 'KanbanGit.app'), metadata, run);
|
||||||
|
rmSync(extracted, { recursive: true, force: true });
|
||||||
const checksum = createHash('sha256')
|
const checksum = createHash('sha256')
|
||||||
.update(readFileSync(archive))
|
.update(readFileSync(archive))
|
||||||
.digest('hex');
|
.digest('hex');
|
||||||
writeFileSync(
|
for (const name of [metadata.filename, metadata.updaterFilename]) {
|
||||||
`${archive}.sha256`,
|
const hash = createHash('sha256')
|
||||||
`${checksum} ${metadata.filename}\n`,
|
.update(readFileSync(join(staging, name)))
|
||||||
);
|
.digest('hex');
|
||||||
const extracted = join(staging, 'extracted');
|
writeFileSync(
|
||||||
run('ditto', ['-x', '-k', archive, extracted]);
|
join(staging, `${name}.sha256`),
|
||||||
verifyNotarizedApp(join(extracted, 'KanbanGit.app'), metadata, run);
|
`${hash} ${name}\n`,
|
||||||
rmSync(extracted, { recursive: true, force: true });
|
);
|
||||||
|
}
|
||||||
renameSync(staging, destination);
|
renameSync(staging, destination);
|
||||||
return { ...metadata, directory: destination, checksum };
|
return { ...metadata, directory: destination, checksum };
|
||||||
} finally {
|
} finally {
|
||||||
@@ -191,6 +254,7 @@ if (
|
|||||||
appendFileSync(
|
appendFileSync(
|
||||||
process.env.GITHUB_OUTPUT,
|
process.env.GITHUB_OUTPUT,
|
||||||
Object.entries(output)
|
Object.entries(output)
|
||||||
|
.filter(([key]) => key !== 'notes')
|
||||||
.map(([key, value]) => `${key}=${value}\n`)
|
.map(([key, value]) => `${key}=${value}\n`)
|
||||||
.join(''),
|
.join(''),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { mkdirSync, rmSync, symlinkSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
export function createDiskImage({ app, archive, identity, run }) {
|
||||||
|
const contents = join(dirname(archive), 'dmg-contents');
|
||||||
|
mkdirSync(contents);
|
||||||
|
try {
|
||||||
|
run('ditto', [app, join(contents, 'KanbanGit.app')]);
|
||||||
|
symlinkSync('/Applications', join(contents, 'Applications'));
|
||||||
|
run('hdiutil', [
|
||||||
|
'create',
|
||||||
|
'-volname',
|
||||||
|
'KanbanGit',
|
||||||
|
'-srcfolder',
|
||||||
|
contents,
|
||||||
|
'-format',
|
||||||
|
'UDZO',
|
||||||
|
archive,
|
||||||
|
]);
|
||||||
|
run('codesign', ['--sign', identity, '--timestamp', archive]);
|
||||||
|
run('hdiutil', ['verify', archive]);
|
||||||
|
} finally {
|
||||||
|
rmSync(contents, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "kanbangit-native",
|
"name": "kanbangit-native",
|
||||||
"version": "0.1.1",
|
"version": "0.2.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -14,13 +14,16 @@
|
|||||||
"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",
|
||||||
|
"smoke:updater": "cargo test --manifest-path src-tauri/Cargo.toml --locked --features smoke-test,custom-protocol --test updater_smoke"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "2.11.4"
|
"@tauri-apps/cli": "2.11.4"
|
||||||
}
|
},
|
||||||
|
"releaseNotes": "Correct iOS and macOS navigation gestures, preserve single-step page history, and constrain vertical overscroll."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ import process from 'node:process';
|
|||||||
import { setTimeout } from 'node:timers/promises';
|
import { setTimeout } from 'node:timers/promises';
|
||||||
import { pathToFileURL } from 'node:url';
|
import { pathToFileURL } from 'node:url';
|
||||||
import { execute, releaseMetadata } from './macos-ci.mjs';
|
import { execute, releaseMetadata } from './macos-ci.mjs';
|
||||||
|
import {
|
||||||
|
compareVersions,
|
||||||
|
updateManifest,
|
||||||
|
updateSource,
|
||||||
|
verifyUpdateSignature,
|
||||||
|
} from './update-manifest.mjs';
|
||||||
|
|
||||||
export function storageConfiguration(env = process.env) {
|
export function storageConfiguration(env = process.env) {
|
||||||
const region = env.S3_REGION;
|
const region = env.S3_REGION;
|
||||||
@@ -57,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,
|
||||||
@@ -64,8 +95,15 @@ export async function publishMacos({
|
|||||||
run = execute,
|
run = execute,
|
||||||
fetchPublic = fetch,
|
fetchPublic = fetch,
|
||||||
wait = setTimeout,
|
wait = setTimeout,
|
||||||
|
publicKey = updateSource.publicKey,
|
||||||
|
feedBase = updateSource.baseUrl,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const storage = storageConfiguration(env);
|
const storage = storageConfiguration(env);
|
||||||
|
if (`${storage.publicBase}/${storage.prefix}` !== feedBase) {
|
||||||
|
throw new Error(
|
||||||
|
'Release storage does not match the update source bundled in the app.',
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!env.AWS_ACCESS_KEY_ID || !env.AWS_SECRET_ACCESS_KEY) {
|
if (!env.AWS_ACCESS_KEY_ID || !env.AWS_SECRET_ACCESS_KEY) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Set the dedicated S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY release secrets.',
|
'Set the dedicated S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY release secrets.',
|
||||||
@@ -79,6 +117,21 @@ export async function publishMacos({
|
|||||||
if (checksumFile.toString() !== `${checksum} ${metadata.filename}\n`) {
|
if (checksumFile.toString() !== `${checksum} ${metadata.filename}\n`) {
|
||||||
throw new Error('Release archive does not match its SHA-256 file.');
|
throw new Error('Release archive does not match its SHA-256 file.');
|
||||||
}
|
}
|
||||||
|
const updater = readFileSync(join(directory, metadata.updaterFilename));
|
||||||
|
const signature = readFileSync(
|
||||||
|
join(directory, `${metadata.updaterFilename}.sig`),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
verifyUpdateSignature(updater, signature, publicKey);
|
||||||
|
const updateChecksum = readFileSync(
|
||||||
|
join(directory, `${metadata.updaterFilename}.sha256`),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
updateChecksum.toString() !==
|
||||||
|
`${sha256(updater)} ${metadata.updaterFilename}\n`
|
||||||
|
) {
|
||||||
|
throw new Error('Updater archive does not match its SHA-256 file.');
|
||||||
|
}
|
||||||
const common = [
|
const common = [
|
||||||
'--endpoint-url',
|
'--endpoint-url',
|
||||||
storage.endpoint,
|
storage.endpoint,
|
||||||
@@ -88,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',
|
||||||
@@ -124,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}`;
|
||||||
async function verifyPublic(name, contents) {
|
const latestPrefix = `${storage.prefix}/${metadata.target}`;
|
||||||
|
const latestKey = `${latestPrefix}/latest.json`;
|
||||||
|
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(`${publicPrefix}/${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) {
|
||||||
@@ -164,42 +228,107 @@ 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)]);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const files = [
|
const files = [
|
||||||
[metadata.filename, archive, 'application/zip'],
|
[metadata.filename, archive, 'application/x-apple-diskimage'],
|
||||||
|
[metadata.updaterFilename, updater, 'application/gzip'],
|
||||||
|
[
|
||||||
|
`${metadata.updaterFilename}.sig`,
|
||||||
|
Buffer.from(signature),
|
||||||
|
'text/plain; charset=utf-8',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
`${metadata.updaterFilename}.sha256`,
|
||||||
|
updateChecksum,
|
||||||
|
'text/plain; charset=utf-8',
|
||||||
|
],
|
||||||
[
|
[
|
||||||
`${metadata.filename}.sha256`,
|
`${metadata.filename}.sha256`,
|
||||||
checksumFile,
|
checksumFile,
|
||||||
@@ -207,10 +336,10 @@ 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);
|
||||||
}
|
}
|
||||||
// This completion record is written only after both public downloads match.
|
// Completion metadata is written only after every public artifact matches.
|
||||||
const manifest = Buffer.from(
|
const manifest = Buffer.from(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
@@ -224,8 +353,128 @@ 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(
|
||||||
|
JSON.stringify(
|
||||||
|
updateManifest(
|
||||||
|
metadata,
|
||||||
|
publicPrefix,
|
||||||
|
signature,
|
||||||
|
metadata.notes,
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
) + '\n',
|
||||||
|
);
|
||||||
|
putReleaseFile('update.json', latest, 'application/json');
|
||||||
|
await verifyPublic('update.json', latest);
|
||||||
|
// An older release must not populate an absent download alias either.
|
||||||
|
assertUnpublishedRelease(true);
|
||||||
|
const downloadName = 'KanbanGit.dmg';
|
||||||
|
const downloadKey = `${latestPrefix}/${downloadName}`;
|
||||||
|
const downloadFile = join(staging, downloadName);
|
||||||
|
writeFileSync(downloadFile, archive);
|
||||||
|
function publishDownload(condition) {
|
||||||
|
s3('put-object', [
|
||||||
|
'--key',
|
||||||
|
downloadKey,
|
||||||
|
'--body',
|
||||||
|
downloadFile,
|
||||||
|
...condition,
|
||||||
|
'--content-type',
|
||||||
|
'application/x-apple-diskimage',
|
||||||
|
'--cache-control',
|
||||||
|
'no-cache, max-age=0, must-revalidate',
|
||||||
|
'--content-md5',
|
||||||
|
createHash('md5').update(archive).digest('base64'),
|
||||||
|
'--metadata',
|
||||||
|
`sha256=${checksum}`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
publishDownload(['--if-none-match', '*']);
|
||||||
|
} catch (error) {
|
||||||
|
if (!requiresExistingObjectCheck(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const previousFile = join(staging, 'previous-download.dmg');
|
||||||
|
const response = JSON.parse(
|
||||||
|
s3('get-object', [
|
||||||
|
'--key',
|
||||||
|
downloadKey,
|
||||||
|
'--output',
|
||||||
|
'json',
|
||||||
|
previousFile,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
if (sha256(readFileSync(previousFile)) !== checksum) {
|
||||||
|
assertUnpublishedRelease();
|
||||||
|
publishDownload(['--if-match', conditionalEtag(response.ETag)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await verifyPublic(
|
||||||
|
downloadName,
|
||||||
|
archive,
|
||||||
|
`${storage.publicBase}/${latestPrefix}`,
|
||||||
|
);
|
||||||
|
const latestFile = join(staging, 'latest.json');
|
||||||
|
writeFileSync(latestFile, latest);
|
||||||
|
function publishLatest(condition) {
|
||||||
|
s3('put-object', [
|
||||||
|
'--key',
|
||||||
|
latestKey,
|
||||||
|
'--body',
|
||||||
|
latestFile,
|
||||||
|
...condition,
|
||||||
|
'--content-type',
|
||||||
|
'application/json',
|
||||||
|
'--cache-control',
|
||||||
|
'no-cache, max-age=0, must-revalidate',
|
||||||
|
'--content-md5',
|
||||||
|
createHash('md5').update(latest).digest('base64'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
publishLatest(['--if-none-match', '*']);
|
||||||
|
} catch (error) {
|
||||||
|
if (!requiresExistingObjectCheck(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const previousFile = join(staging, 'previous-latest.json');
|
||||||
|
const response = JSON.parse(
|
||||||
|
s3('get-object', [
|
||||||
|
'--key',
|
||||||
|
latestKey,
|
||||||
|
'--output',
|
||||||
|
'json',
|
||||||
|
previousFile,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const previousBytes = readFileSync(previousFile);
|
||||||
|
const previous = JSON.parse(previousBytes);
|
||||||
|
const comparison = compareVersions(
|
||||||
|
metadata.version,
|
||||||
|
previous.version,
|
||||||
|
);
|
||||||
|
if (comparison < 0) {
|
||||||
|
throw new Error('Refusing to downgrade the update feed.');
|
||||||
|
}
|
||||||
|
if (comparison === 0) {
|
||||||
|
if (!previousBytes.equals(latest)) {
|
||||||
|
throw new Error(
|
||||||
|
'Published update metadata differs; bump the app version.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
publishLatest(['--if-match', conditionalEtag(response.ETag)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await verifyPublic(
|
||||||
|
'latest.json',
|
||||||
|
latest,
|
||||||
|
`${storage.publicBase}/${latestPrefix}`,
|
||||||
|
);
|
||||||
return `${publicPrefix}/${metadata.filename}`;
|
return `${publicPrefix}/${metadata.filename}`;
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(staging, { recursive: true, force: true });
|
rmSync(staging, { recursive: true, force: true });
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { dirname, join, resolve } from 'node:path';
|
|||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
import { parseArgs } from 'node:util';
|
import { parseArgs } from 'node:util';
|
||||||
|
import { createDiskImage } from './macos-dmg.mjs';
|
||||||
|
|
||||||
export const appleTeamId = 'ZZX2KZGTHU';
|
export const appleTeamId = 'ZZX2KZGTHU';
|
||||||
export const bundleIdentifier = 'de.kanbangit.desktop';
|
export const bundleIdentifier = 'de.kanbangit.desktop';
|
||||||
@@ -105,6 +106,11 @@ export function releaseMacos({
|
|||||||
throw new Error('--resume requires the Apple submission UUID.');
|
throw new Error('--resume requires the Apple submission UUID.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!env.TAURI_SIGNING_PRIVATE_KEY?.trim()) {
|
||||||
|
throw new Error(
|
||||||
|
'Set TAURI_SIGNING_PRIVATE_KEY to the permanent updater signing key.',
|
||||||
|
);
|
||||||
|
}
|
||||||
const identity = env.APPLE_SIGNING_IDENTITY?.trim();
|
const identity = env.APPLE_SIGNING_IDENTITY?.trim();
|
||||||
const profile = env.APPLE_NOTARY_KEYCHAIN_PROFILE?.trim();
|
const profile = env.APPLE_NOTARY_KEYCHAIN_PROFILE?.trim();
|
||||||
|
|
||||||
@@ -405,20 +411,42 @@ export function releaseMacos({
|
|||||||
app,
|
app,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const name = `KanbanGit-${config.version}-${target}.zip`;
|
const name = `KanbanGit-${config.version}-${target}.dmg`;
|
||||||
const archive = join(staging, name);
|
const archive = join(staging, name);
|
||||||
step('Packaging notarized download', 'ditto', [
|
createDiskImage({
|
||||||
'-c',
|
|
||||||
'-k',
|
|
||||||
'--sequesterRsrc',
|
|
||||||
'--keepParent',
|
|
||||||
app,
|
app,
|
||||||
archive,
|
archive,
|
||||||
]);
|
identity,
|
||||||
|
run: (command, args) =>
|
||||||
|
step('Packaging notarized download', command, args),
|
||||||
|
});
|
||||||
const checksum = createHash('sha256')
|
const checksum = createHash('sha256')
|
||||||
.update(readFileSync(archive))
|
.update(readFileSync(archive))
|
||||||
.digest('hex');
|
.digest('hex');
|
||||||
writeFileSync(`${archive}.sha256`, `${checksum} ${name}\n`);
|
writeFileSync(`${archive}.sha256`, `${checksum} ${name}\n`);
|
||||||
|
const updaterName = `KanbanGit-${config.version}-${target}.app.tar.gz`;
|
||||||
|
const updaterArchive = join(staging, updaterName);
|
||||||
|
step('Packaging updater archive', 'tar', [
|
||||||
|
'-czf',
|
||||||
|
updaterArchive,
|
||||||
|
'-C',
|
||||||
|
dirname(app),
|
||||||
|
'KanbanGit.app',
|
||||||
|
]);
|
||||||
|
step('Signing updater archive', 'pnpm', [
|
||||||
|
'exec',
|
||||||
|
'tauri',
|
||||||
|
'signer',
|
||||||
|
'sign',
|
||||||
|
updaterArchive,
|
||||||
|
]);
|
||||||
|
const updaterChecksum = createHash('sha256')
|
||||||
|
.update(readFileSync(updaterArchive))
|
||||||
|
.digest('hex');
|
||||||
|
writeFileSync(
|
||||||
|
`${updaterArchive}.sha256`,
|
||||||
|
`${updaterChecksum} ${updaterName}\n`,
|
||||||
|
);
|
||||||
rmSync(submission, { force: true });
|
rmSync(submission, { force: true });
|
||||||
renameSync(staging, destination);
|
renameSync(staging, destination);
|
||||||
log(`Verified release: ${join(destination, name)}`);
|
log(`Verified release: ${join(destination, name)}`);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "kanbangit-native"
|
name = "kanbangit-native"
|
||||||
version = "0.1.1"
|
version = "0.2.4"
|
||||||
description = "KanbanGit native application"
|
description = "KanbanGit native application"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
publish = false
|
publish = false
|
||||||
@@ -13,6 +13,9 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
|||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
semver = "1"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
@@ -27,3 +30,11 @@ custom-protocol = ["tauri/custom-protocol"]
|
|||||||
name = "macos_smoke"
|
name = "macos_smoke"
|
||||||
harness = false
|
harness = false
|
||||||
required-features = ["smoke-test"]
|
required-features = ["smoke-test"]
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
tauri-plugin-updater = "=2.12.0"
|
||||||
|
|
||||||
|
[[test]]
|
||||||
|
name = "updater_smoke"
|
||||||
|
harness = false
|
||||||
|
required-features = ["smoke-test"]
|
||||||
|
|||||||
@@ -1,3 +1,51 @@
|
|||||||
fn main() {
|
fn main() {
|
||||||
|
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") {
|
||||||
|
build_navigation();
|
||||||
|
}
|
||||||
tauri_build::build();
|
tauri_build::build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_navigation() {
|
||||||
|
use std::{env, path::PathBuf, process::Command};
|
||||||
|
|
||||||
|
println!("cargo:rerun-if-changed=src/navigation.m");
|
||||||
|
let output = PathBuf::from(env::var_os("OUT_DIR").unwrap());
|
||||||
|
let object = output.join("navigation.o");
|
||||||
|
let archive = output.join("libkanban_navigation.a");
|
||||||
|
let arch = match env::var("CARGO_CFG_TARGET_ARCH").unwrap().as_str() {
|
||||||
|
"aarch64" => "arm64",
|
||||||
|
"x86_64" => "x86_64",
|
||||||
|
_ => panic!("Unsupported macOS architecture"),
|
||||||
|
};
|
||||||
|
assert!(Command::new("xcrun")
|
||||||
|
.args([
|
||||||
|
"--sdk",
|
||||||
|
"macosx",
|
||||||
|
"clang",
|
||||||
|
"-fobjc-arc",
|
||||||
|
"-fblocks",
|
||||||
|
"-Wall",
|
||||||
|
"-Werror",
|
||||||
|
"-mmacosx-version-min=14.0",
|
||||||
|
"-arch",
|
||||||
|
arch,
|
||||||
|
"-c",
|
||||||
|
"src/navigation.m",
|
||||||
|
"-o"
|
||||||
|
])
|
||||||
|
.arg(&object)
|
||||||
|
.status()
|
||||||
|
.expect("Xcode clang is required")
|
||||||
|
.success());
|
||||||
|
assert!(Command::new("xcrun")
|
||||||
|
.args(["ar", "crs"])
|
||||||
|
.arg(&archive)
|
||||||
|
.arg(&object)
|
||||||
|
.status()
|
||||||
|
.expect("Xcode ar is required")
|
||||||
|
.success());
|
||||||
|
println!("cargo:rustc-link-search=native={}", output.display());
|
||||||
|
println!("cargo:rustc-link-lib=static=kanban_navigation");
|
||||||
|
println!("cargo:rustc-link-lib=framework=AppKit");
|
||||||
|
println!("cargo:rustc-link-lib=framework=WebKit");
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.os.SystemClock
|
|||||||
import android.view.InputDevice
|
import android.view.InputDevice
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
import android.webkit.CookieManager
|
import android.webkit.CookieManager
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
@@ -59,6 +60,59 @@ class PlatformTest {
|
|||||||
instrumentation.waitForIdleSync()
|
instrumentation.waitForIdleSync()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun coldStartRestoresWorkspaceWithoutSelectionOrLauncherHistory() {
|
||||||
|
val mode = InstrumentationRegistry.getArguments().getString("startupConfig", "saved")
|
||||||
|
val preferences = instrumentation.targetContext.getSharedPreferences("workspace", Context.MODE_PRIVATE)
|
||||||
|
preferences.edit().clear().apply {
|
||||||
|
if (mode == "saved") putString("server", "https://127.0.0.1:9/app/")
|
||||||
|
if (mode == "invalid") putString("server", "http://invalid.example/")
|
||||||
|
}.commit()
|
||||||
|
val scenario = ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
val launcher = AtomicReference<WebView>()
|
||||||
|
fun findLauncher(view: View): WebView? {
|
||||||
|
if (view is WebView && view.url?.startsWith(NavigationPolicy.LAUNCHER_ORIGIN) == true) return view
|
||||||
|
if (view is ViewGroup) for (index in 0 until view.childCount) {
|
||||||
|
findLauncher(view.getChildAt(index))?.let { return it }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
waitFor {
|
||||||
|
scenario.onActivity { launcher.set(findLauncher(it.window.decorView)) }
|
||||||
|
launcher.get() != null && evaluate(launcher.get(), "typeof window.updateLauncher === 'function'") == "true"
|
||||||
|
}
|
||||||
|
if (mode == "saved") {
|
||||||
|
waitFor {
|
||||||
|
val state = evaluate(launcher.get(), "document.querySelector('#connection-status').dataset.state")
|
||||||
|
if (state == "\"connecting\"") {
|
||||||
|
assertEquals("true", evaluate(launcher.get(), "document.querySelector('#workspace-settings').hidden"))
|
||||||
|
}
|
||||||
|
state == "\"connectionError\""
|
||||||
|
}
|
||||||
|
assertEquals("https://127.0.0.1:9/app/", preferences.getString("server", null))
|
||||||
|
assertEquals("\"Try again\"", evaluate(launcher.get(), "document.querySelector('.open-workspace').textContent"))
|
||||||
|
scenario.onActivity {
|
||||||
|
assertEquals("https://127.0.0.1:9/app/dashboard", it.workspace?.originalUrl)
|
||||||
|
assertFalse(it.workspace!!.canGoBack())
|
||||||
|
it.openWorkspace("https://127.0.0.1:9/app/")
|
||||||
|
}
|
||||||
|
waitFor { evaluate(launcher.get(), "document.querySelector('#connection-status').dataset.state") == "\"connectionError\"" }
|
||||||
|
} else {
|
||||||
|
waitFor { evaluate(launcher.get(), "document.querySelector('#workspace-settings').hidden") == "false" }
|
||||||
|
scenario.onActivity { assertNull(it.workspace) }
|
||||||
|
}
|
||||||
|
scenario.onActivity { it.openWorkspace("https://kanbangit.de/") }
|
||||||
|
val remote = AtomicReference<WebView>()
|
||||||
|
waitFor {
|
||||||
|
var visible = false
|
||||||
|
scenario.onActivity { remote.set(it.workspace); visible = it.workspace?.visibility == View.VISIBLE }
|
||||||
|
visible && remote.get() != null
|
||||||
|
}
|
||||||
|
assertEquals("\"/login\"", evaluate(remote.get(), "location.pathname"))
|
||||||
|
assertEquals("1", evaluate(remote.get(), "history.length"))
|
||||||
|
assertEquals("https://kanbangit.de/", preferences.getString("server", null))
|
||||||
|
scenario.moveToState(Lifecycle.State.CREATED)
|
||||||
|
}
|
||||||
|
|
||||||
@Test fun workspacePreservesFormsAndIsolatesServers() {
|
@Test fun workspacePreservesFormsAndIsolatesServers() {
|
||||||
instrumentation.targetContext.getSharedPreferences("workspace", Context.MODE_PRIVATE).edit().clear().commit()
|
instrumentation.targetContext.getSharedPreferences("workspace", Context.MODE_PRIVATE).edit().clear().commit()
|
||||||
val scenario = ActivityScenario.launch(MainActivity::class.java)
|
val scenario = ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
|||||||
@@ -164,9 +164,9 @@ class MainActivity : TauriActivity() {
|
|||||||
if (initialized) return
|
if (initialized) return
|
||||||
initialized = true
|
initialized = true
|
||||||
val saved = preferences.getString("server", null)
|
val saved = preferences.getString("server", null)
|
||||||
server = saved?.let(NavigationPolicy::installation) ?: NavigationPolicy.DEFAULT_SERVER
|
val restored = saved?.let(NavigationPolicy::installation)
|
||||||
updateLauncher()
|
server = restored ?: NavigationPolicy.DEFAULT_SERVER
|
||||||
if (saved != null) openWorkspace(server)
|
if (restored != null) openWorkspace(restored) else updateLauncher()
|
||||||
}
|
}
|
||||||
"/action/open" -> openWorkspace(url.getQueryParameter("server").orEmpty())
|
"/action/open" -> openWorkspace(url.getQueryParameter("server").orEmpty())
|
||||||
"/action/resume" -> if (ready) showWorkspace()
|
"/action/resume" -> if (ready) showWorkspace()
|
||||||
@@ -313,8 +313,8 @@ class MainActivity : TauriActivity() {
|
|||||||
val firstLoad = !ready
|
val firstLoad = !ready
|
||||||
ready = true
|
ready = true
|
||||||
CookieManager.getInstance().flush()
|
CookieManager.getInstance().flush()
|
||||||
updateLauncher()
|
|
||||||
if (firstLoad) showWorkspace()
|
if (firstLoad) showWorkspace()
|
||||||
|
updateLauncher()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) {
|
override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) {
|
||||||
@@ -333,7 +333,7 @@ class MainActivity : TauriActivity() {
|
|||||||
remote.visibility = View.GONE
|
remote.visibility = View.GONE
|
||||||
content.addView(remote, 0, FrameLayout.LayoutParams(-1, -1))
|
content.addView(remote, 0, FrameLayout.LayoutParams(-1, -1))
|
||||||
startTimeout(remote)
|
startTimeout(remote)
|
||||||
remote.loadUrl(server)
|
remote.loadUrl(NavigationPolicy.dashboard(server))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startTimeout(view: WebView) {
|
private fun startTimeout(view: WebView) {
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ object NavigationPolicy {
|
|||||||
"https://${uri.host.lowercase(Locale.ROOT)}$port$path"
|
"https://${uri.host.lowercase(Locale.ROOT)}$port$path"
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
|
|
||||||
|
fun dashboard(server: String): String = URI(requireNotNull(installation(server))).resolve("dashboard").toString()
|
||||||
|
|
||||||
fun allows(server: String, destination: String): Boolean = runCatching {
|
fun allows(server: String, destination: String): Boolean = runCatching {
|
||||||
val base = URI(server)
|
val base = URI(server)
|
||||||
val target = URI(destination)
|
val target = URI(destination)
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ class NavigationPolicyTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun startupTargetsDashboardWithinTheInstallation() {
|
||||||
|
assertEquals("https://example.com/dashboard", NavigationPolicy.dashboard("https://example.com/"))
|
||||||
|
assertEquals("https://example.com:8443/kanban/dashboard", NavigationPolicy.dashboard("https://example.com:8443/kanban/"))
|
||||||
|
}
|
||||||
|
|
||||||
@Test fun allowsLoginTwoFactorAndHistoryOnlyOnTheSelectedOrigin() {
|
@Test fun allowsLoginTwoFactorAndHistoryOnlyOnTheSelectedOrigin() {
|
||||||
val server = "https://example.com:8443/app/"
|
val server = "https://example.com:8443/app/"
|
||||||
for (path in listOf("/login", "/two-factor-challenge", "/projects/1?ticket=53#details")) {
|
for (path in listOf("/login", "/two-factor-challenge", "/projects/1?ticket=53#details")) {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 422 B After Width: | Height: | Size: 638 B |
|
Before Width: | Height: | Size: 548 B After Width: | Height: | Size: 774 B |
|
Before Width: | Height: | Size: 548 B After Width: | Height: | Size: 774 B |
|
Before Width: | Height: | Size: 800 B After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 415 B After Width: | Height: | Size: 658 B |
|
Before Width: | Height: | Size: 845 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 845 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 548 B After Width: | Height: | Size: 774 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 955 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.1 KiB |
|
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()
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func installIOSWorkspace() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKUIDelegate, UITextFieldDelegate {
|
final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKUIDelegate, UITextFieldDelegate, UIGestureRecognizerDelegate {
|
||||||
private let defaults: UserDefaults
|
private let defaults: UserDefaults
|
||||||
private let serverKey = "ios.activeServer"
|
private let serverKey = "ios.activeServer"
|
||||||
private(set) var server: WorkspaceServer?
|
private(set) var server: WorkspaceServer?
|
||||||
@@ -32,6 +32,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
private var connection = WorkspaceConnection()
|
private var connection = WorkspaceConnection()
|
||||||
private var pendingNavigation: WKNavigation?
|
private var pendingNavigation: WKNavigation?
|
||||||
private var timeout: DispatchWorkItem?
|
private var timeout: DispatchWorkItem?
|
||||||
|
private let loading = UIActivityIndicatorView(style: .large)
|
||||||
private let launcher = UIScrollView()
|
private let launcher = UIScrollView()
|
||||||
private let stack = UIStackView()
|
private let stack = UIStackView()
|
||||||
private let address = UITextField()
|
private let address = UITextField()
|
||||||
@@ -39,8 +40,9 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
private let connectButton = UIButton(type: .system)
|
private let connectButton = UIButton(type: .system)
|
||||||
private let resumeButton = UIButton(type: .system)
|
private let resumeButton = UIButton(type: .system)
|
||||||
private let toolbar = UIToolbar()
|
private let toolbar = UIToolbar()
|
||||||
private var historyObservation: NSKeyValueObservation?
|
private var gestureStart = CGPoint.zero
|
||||||
private var backButton: UIBarButtonItem!
|
private var gestureAllowed = false
|
||||||
|
private var gestureRevision = 0
|
||||||
private var appearance: UISegmentedControl!
|
private var appearance: UISegmentedControl!
|
||||||
|
|
||||||
init(defaults: UserDefaults = .standard) {
|
init(defaults: UserDefaults = .standard) {
|
||||||
@@ -58,6 +60,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 +79,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
address.text = server?.url.absoluteString ?? "https://kanbangit.de/"
|
address.text = server?.url.absoluteString ?? "https://kanbangit.de/"
|
||||||
showSettings()
|
if let server { open(server.url) } else { showSettings() }
|
||||||
if let server { open(server.url) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func label(_ key: String, style: UIFont.TextStyle = .body) -> UILabel {
|
private func label(_ key: String, style: UIFont.TextStyle = .body) -> UILabel {
|
||||||
@@ -83,9 +92,10 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func configureLauncher() {
|
private func configureLauncher() {
|
||||||
|
launcher.isHidden = true
|
||||||
launcher.accessibilityIdentifier = "serverSettings"
|
launcher.accessibilityIdentifier = "serverSettings"
|
||||||
launcher.keyboardDismissMode = .interactive
|
launcher.keyboardDismissMode = .interactive
|
||||||
launcher.alwaysBounceVertical = true
|
launcher.bounces = false
|
||||||
view.addSubview(launcher)
|
view.addSubview(launcher)
|
||||||
launcher.translatesAutoresizingMaskIntoConstraints = false
|
launcher.translatesAutoresizingMaskIntoConstraints = false
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
@@ -152,13 +162,12 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func configureToolbar() {
|
private func configureToolbar() {
|
||||||
|
toolbar.isHidden = true
|
||||||
toolbar.translatesAutoresizingMaskIntoConstraints = false
|
toolbar.translatesAutoresizingMaskIntoConstraints = false
|
||||||
view.addSubview(toolbar)
|
view.addSubview(toolbar)
|
||||||
backButton = UIBarButtonItem(title: text("back"), style: .plain, target: self, action: #selector(goBack))
|
|
||||||
backButton.accessibilityIdentifier = "workspaceBack"
|
|
||||||
let settings = UIBarButtonItem(title: text("serverSettings"), style: .plain, target: self, action: #selector(showSettings))
|
let settings = UIBarButtonItem(title: text("serverSettings"), style: .plain, target: self, action: #selector(showSettings))
|
||||||
settings.accessibilityIdentifier = "showServerSettings"
|
settings.accessibilityIdentifier = "showServerSettings"
|
||||||
toolbar.items = [backButton, UIBarButtonItem(systemItem: .flexibleSpace), settings]
|
toolbar.items = [UIBarButtonItem(systemItem: .flexibleSpace), settings]
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
toolbar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
toolbar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||||
toolbar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
toolbar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
||||||
@@ -193,7 +202,6 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
guard let data = try? JSONEncoder().encode(selected) else { status.text = text("storageError"); return }
|
guard let data = try? JSONEncoder().encode(selected) else { status.text = text("storageError"); return }
|
||||||
defaults.set(data, forKey: serverKey)
|
defaults.set(data, forKey: serverKey)
|
||||||
timeout?.cancel()
|
timeout?.cancel()
|
||||||
historyObservation = nil
|
|
||||||
workspace?.stopLoading()
|
workspace?.stopLoading()
|
||||||
workspace?.navigationDelegate = nil
|
workspace?.navigationDelegate = nil
|
||||||
workspace?.removeFromSuperview()
|
workspace?.removeFromSuperview()
|
||||||
@@ -201,11 +209,24 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
server = selected
|
server = selected
|
||||||
let configuration = WKWebViewConfiguration()
|
let configuration = WKWebViewConfiguration()
|
||||||
configuration.websiteDataStore = WKWebsiteDataStore(forIdentifier: selected.profile)
|
configuration.websiteDataStore = WKWebsiteDataStore(forIdentifier: selected.profile)
|
||||||
// Remote content receives a plain WKWebView, with no native message handlers or Tauri bridge.
|
let scriptURL = Bundle(for: WorkspaceViewController.self).url(forResource: "workspace-navigation", withExtension: "js")!
|
||||||
|
let script = try! String(contentsOf: scriptURL, encoding: .utf8)
|
||||||
|
configuration.userContentController.addUserScript(WKUserScript(source: script, injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
||||||
|
// Remote content receives no native message handlers or Tauri bridge.
|
||||||
let webview = WKWebView(frame: .zero, configuration: configuration)
|
let webview = WKWebView(frame: .zero, configuration: configuration)
|
||||||
webview.navigationDelegate = self
|
webview.navigationDelegate = self
|
||||||
webview.uiDelegate = self
|
webview.uiDelegate = self
|
||||||
webview.allowsBackForwardNavigationGestures = true
|
// Native WebKit swipes can skip async pushState entries. One edge recognizer
|
||||||
|
// owns each gesture and traverses the same history that Inertia restores.
|
||||||
|
webview.allowsBackForwardNavigationGestures = false
|
||||||
|
webview.scrollView.bounces = false
|
||||||
|
for edge: UIRectEdge in [.left, .right] {
|
||||||
|
let gesture = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(navigateGesture(_:)))
|
||||||
|
gesture.edges = edge
|
||||||
|
gesture.delegate = self
|
||||||
|
webview.addGestureRecognizer(gesture)
|
||||||
|
webview.scrollView.panGestureRecognizer.require(toFail: gesture)
|
||||||
|
}
|
||||||
webview.accessibilityIdentifier = "workspace"
|
webview.accessibilityIdentifier = "workspace"
|
||||||
webview.translatesAutoresizingMaskIntoConstraints = false
|
webview.translatesAutoresizingMaskIntoConstraints = false
|
||||||
webview.isHidden = true
|
webview.isHidden = true
|
||||||
@@ -217,13 +238,13 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
webview.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
webview.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
||||||
])
|
])
|
||||||
workspace = webview
|
workspace = webview
|
||||||
historyObservation = webview.observe(\.canGoBack, options: [.initial, .new]) { [weak self] view, _ in
|
|
||||||
self?.backButton.isEnabled = view.canGoBack
|
|
||||||
}
|
|
||||||
view.endEditing(true)
|
view.endEditing(true)
|
||||||
address.text = url.absoluteString
|
address.text = url.absoluteString
|
||||||
|
launcher.isHidden = true
|
||||||
|
toolbar.isHidden = true
|
||||||
|
loading.startAnimating()
|
||||||
beginNavigation()
|
beginNavigation()
|
||||||
pendingNavigation = webview.load(URLRequest(url: url))
|
pendingNavigation = webview.load(URLRequest(url: selected.dashboardURL))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func beginNavigation() {
|
private func beginNavigation() {
|
||||||
@@ -247,6 +268,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,14 +278,51 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
|
|
||||||
@objc private func showWorkspace() {
|
@objc private func showWorkspace() {
|
||||||
guard connection.ready else { return }
|
guard connection.ready else { return }
|
||||||
|
loading.stopAnimating()
|
||||||
view.endEditing(true)
|
view.endEditing(true)
|
||||||
launcher.isHidden = true
|
launcher.isHidden = true
|
||||||
toolbar.isHidden = false
|
toolbar.isHidden = false
|
||||||
workspace?.isHidden = false
|
workspace?.isHidden = false
|
||||||
backButton.isEnabled = workspace?.canGoBack == true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func goBack() { if workspace?.canGoBack == true { workspace?.goBack() } }
|
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
||||||
|
guard gestureRecognizer is UIScreenEdgePanGestureRecognizer, let webview = workspace else { return false }
|
||||||
|
gestureStart = touch.location(in: webview)
|
||||||
|
gestureAllowed = false
|
||||||
|
gestureRevision += 1
|
||||||
|
let revision = gestureRevision
|
||||||
|
webview.evaluateJavaScript("window.__kanbanNavigation?.canSwipe(\(gestureStart.x), \(gestureStart.y)) === true") { [weak self, weak gestureRecognizer] result, _ in
|
||||||
|
guard let self, self.gestureRevision == revision, self.workspace === webview else { return }
|
||||||
|
self.gestureAllowed = result as? Bool == true
|
||||||
|
// Release the scroll view immediately when a board, field or overlay owns the touch.
|
||||||
|
if !self.gestureAllowed, gestureRecognizer?.state == .possible { gestureRecognizer?.state = .failed }
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||||
|
guard let edge = gestureRecognizer as? UIScreenEdgePanGestureRecognizer,
|
||||||
|
let webview = workspace, !webview.isHidden, gestureAllowed else { return false }
|
||||||
|
let velocity = edge.velocity(in: webview)
|
||||||
|
return abs(velocity.x) > abs(velocity.y)
|
||||||
|
&& (edge.edges == .left ? webview.canGoBack && velocity.x > 0 : webview.canGoForward && velocity.x < 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func navigateGesture(_ gesture: UIScreenEdgePanGestureRecognizer) {
|
||||||
|
guard gesture.state == .ended, let webview = workspace else { return }
|
||||||
|
let direction = gesture.edges == .left ? -1 : 1
|
||||||
|
let distance = gesture.translation(in: webview).x * CGFloat(-direction)
|
||||||
|
let velocity = gesture.velocity(in: webview).x * CGFloat(-direction)
|
||||||
|
guard distance > webview.bounds.width * 0.25 || (distance > 30 && velocity > 500) else { return }
|
||||||
|
navigateHistory(direction, from: gestureStart)
|
||||||
|
}
|
||||||
|
|
||||||
|
func navigateHistory(_ direction: Int, from point: CGPoint) {
|
||||||
|
guard let webview = workspace,
|
||||||
|
direction == -1 ? webview.canGoBack : webview.canGoForward else { return }
|
||||||
|
let script = "if (window.__kanbanNavigation?.canSwipe(\(point.x), \(point.y))) window.__kanbanNavigation.step(\(direction))"
|
||||||
|
webview.evaluateJavaScript(script, completionHandler: nil)
|
||||||
|
}
|
||||||
|
|
||||||
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||||
guard webView === workspace, let url = action.request.url else { decisionHandler(.cancel); return }
|
guard webView === workspace, let url = action.request.url else { decisionHandler(.cancel); return }
|
||||||
@@ -296,7 +355,8 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
}
|
}
|
||||||
|
|
||||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
guard webView === workspace, navigation === pendingNavigation else { return }
|
guard webView === workspace, navigation === pendingNavigation, connection.connecting,
|
||||||
|
let url = webView.url, server?.allows(url) == true else { return }
|
||||||
let firstLoad = !connection.ready
|
let firstLoad = !connection.ready
|
||||||
timeout?.cancel()
|
timeout?.cancel()
|
||||||
connection.finish()
|
connection.finish()
|
||||||
@@ -304,7 +364,6 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
connectButton.setTitle(text("openWorkspace"), for: .normal)
|
connectButton.setTitle(text("openWorkspace"), for: .normal)
|
||||||
status.text = text("connected")
|
status.text = text("connected")
|
||||||
resumeButton.isHidden = false
|
resumeButton.isHidden = false
|
||||||
backButton.isEnabled = webView.canGoBack
|
|
||||||
if firstLoad { showWorkspace() }
|
if firstLoad { showWorkspace() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,71 @@ final class WorkspaceTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testDashboardAddressPreservesTheInstallationPath() throws {
|
||||||
|
let url = try XCTUnwrap(WorkspaceServer.normalize("https://example.com:8443/kanban/"))
|
||||||
|
XCTAssertEqual(WorkspaceServer(url: url, profile: UUID()).dashboardURL.absoluteString,
|
||||||
|
"https://example.com:8443/kanban/dashboard")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testColdStartHidesSelectionUntilFailureAndPreservesSavedWorkspace() throws {
|
||||||
|
let name = "KAN69-startup-\(UUID())"
|
||||||
|
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
||||||
|
defer { defaults.removePersistentDomain(forName: name) }
|
||||||
|
let saved = WorkspaceServer(url: URL(string: "https://example.invalid/kanban/")!, profile: UUID())
|
||||||
|
let data = try JSONEncoder().encode(saved)
|
||||||
|
defaults.set(data, forKey: "ios.activeServer")
|
||||||
|
let controller = WorkspaceViewController(defaults: defaults)
|
||||||
|
controller.loadViewIfNeeded()
|
||||||
|
let launcher = try XCTUnwrap(controller.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" })
|
||||||
|
let loading = try XCTUnwrap(controller.view.subviews.first { $0.accessibilityIdentifier == "workspaceLoading" } as? UIActivityIndicatorView)
|
||||||
|
XCTAssertTrue(launcher.isHidden)
|
||||||
|
XCTAssertTrue(loading.isAnimating)
|
||||||
|
XCTAssertEqual(controller.server, saved)
|
||||||
|
let webview = try XCTUnwrap(controller.workspace)
|
||||||
|
XCTAssertFalse(webview.canGoBack)
|
||||||
|
XCTAssertTrue(webview.backForwardList.backList.isEmpty)
|
||||||
|
webview.stopLoading()
|
||||||
|
let navigation = try XCTUnwrap(webview.loadSimulatedRequest(URLRequest(url: saved.dashboardURL), responseHTML: "<html></html>"))
|
||||||
|
controller.webView(webview, didStartProvisionalNavigation: navigation)
|
||||||
|
XCTAssertTrue(launcher.isHidden)
|
||||||
|
controller.webView(webview, didFailProvisionalNavigation: navigation,
|
||||||
|
withError: NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet))
|
||||||
|
XCTAssertFalse(launcher.isHidden)
|
||||||
|
XCTAssertFalse(loading.isAnimating)
|
||||||
|
XCTAssertEqual(try JSONDecoder().decode(WorkspaceServer.self, from: XCTUnwrap(defaults.data(forKey: "ios.activeServer"))), saved)
|
||||||
|
controller.webView(webview, didFinish: navigation)
|
||||||
|
XCTAssertFalse(launcher.isHidden, "A late finish must not hide recovery after a failure")
|
||||||
|
controller.open(saved.url)
|
||||||
|
XCTAssertTrue(launcher.isHidden)
|
||||||
|
XCTAssertTrue(loading.isAnimating)
|
||||||
|
XCTAssertEqual(controller.server?.profile, saved.profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testMissingOrInvalidConfigurationShowsSetupAndServerChangesAreRestored() throws {
|
||||||
|
let name = "KAN69-setup-\(UUID())"
|
||||||
|
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
||||||
|
defer { defaults.removePersistentDomain(forName: name) }
|
||||||
|
for data in [nil, Data("invalid".utf8), try JSONEncoder().encode(WorkspaceServer(url: URL(string: "http://example.com/")!, profile: UUID()))] {
|
||||||
|
defaults.set(data, forKey: "ios.activeServer")
|
||||||
|
let controller = WorkspaceViewController(defaults: defaults)
|
||||||
|
controller.loadViewIfNeeded()
|
||||||
|
XCTAssertNil(controller.workspace)
|
||||||
|
let launcher = try XCTUnwrap(controller.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" })
|
||||||
|
XCTAssertFalse(launcher.isHidden)
|
||||||
|
}
|
||||||
|
let controller = WorkspaceViewController(defaults: defaults)
|
||||||
|
controller.loadViewIfNeeded()
|
||||||
|
controller.open(URL(string: "https://first.invalid/")!)
|
||||||
|
controller.open(URL(string: "https://second.invalid/app/")!)
|
||||||
|
let restored = WorkspaceViewController(defaults: defaults)
|
||||||
|
restored.loadViewIfNeeded()
|
||||||
|
XCTAssertEqual(restored.server, controller.server)
|
||||||
|
XCTAssertEqual(restored.server?.dashboardURL.absoluteString, "https://second.invalid/app/dashboard")
|
||||||
|
XCTAssertTrue(restored.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" }?.isHidden == true)
|
||||||
|
}
|
||||||
|
|
||||||
func testExternalLinksStayOutsideTheSelectedServer() throws {
|
func testExternalLinksStayOutsideTheSelectedServer() throws {
|
||||||
let server = WorkspaceServer(url: URL(string: "https://example.com/")!, profile: UUID())
|
let server = WorkspaceServer(url: URL(string: "https://example.com/")!, profile: UUID())
|
||||||
for value in ["https://git.example.com/org/repo/pulls/1#diff", "http://git.example.com/repo", "https://example.com:8443/other"] {
|
for value in ["https://git.example.com/org/repo/pulls/1#diff", "http://git.example.com/repo", "https://example.com:8443/other"] {
|
||||||
@@ -30,39 +95,82 @@ final class WorkspaceTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func testBackButtonTracksSameDocumentHistory() async throws {
|
func testGesturesTraverseEverySPAEntryOnceAndPreserveForwardHistory() async throws {
|
||||||
let name = "KAN55-history-\(UUID())"
|
let name = "KAN71-history-\(UUID())"
|
||||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
||||||
defer { defaults.removePersistentDomain(forName: name) }
|
defer { defaults.removePersistentDomain(forName: name) }
|
||||||
let controller = WorkspaceViewController(defaults: defaults)
|
let controller = WorkspaceViewController(defaults: defaults)
|
||||||
controller.loadViewIfNeeded()
|
controller.loadViewIfNeeded()
|
||||||
let url = try XCTUnwrap(WorkspaceServer.normalize("https://example.invalid/"))
|
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||||
controller.open(url)
|
let url = URL(string: "https://example.invalid/dashboard")!
|
||||||
|
controller.open(URL(string: "https://example.invalid/")!)
|
||||||
let webview = try XCTUnwrap(controller.workspace)
|
let webview = try XCTUnwrap(controller.workspace)
|
||||||
webview.stopLoading()
|
webview.stopLoading()
|
||||||
webview.loadHTMLString("<html><body><input id='draft' value='unsaved'></body></html>", baseURL: url)
|
webview.frame = controller.view.bounds
|
||||||
var loaded = false
|
webview.loadSimulatedRequest(URLRequest(url: url), responseHTML: """
|
||||||
|
<html><head><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
||||||
|
<body style="margin:0"><p>Workspace</p><input id="draft" value="unsaved"></body></html>
|
||||||
|
""")
|
||||||
|
try await waitFor(webview, "document.querySelector('#draft')?.value === 'unsaved'")
|
||||||
|
XCTAssertFalse(webview.canGoBack)
|
||||||
|
controller.navigateHistory(-1, from: CGPoint(x: 1, y: 1))
|
||||||
|
try await waitFor(webview, "location.pathname === '/dashboard'")
|
||||||
|
for index in 1...3 {
|
||||||
|
_ = try await webview.evaluateJavaScript("history.pushState({page: {url: '/page\(index)'}}, '', '/page\(index)')")
|
||||||
|
}
|
||||||
|
_ = try await webview.evaluateJavaScript("window.steps = 0; addEventListener('popstate', () => window.steps++)")
|
||||||
|
let edge = TestEdgeGesture()
|
||||||
|
edge.edges = .left
|
||||||
|
edge.testVelocity = CGPoint(x: 800, y: 0)
|
||||||
|
XCTAssertTrue(controller.gestureRecognizer(edge, shouldReceive: TestTouch()))
|
||||||
|
var allowed = false
|
||||||
for _ in 0..<50 {
|
for _ in 0..<50 {
|
||||||
if let value = try? await webview.evaluateJavaScript("document.getElementById('draft')?.value"),
|
if controller.gestureRecognizerShouldBegin(edge) { allowed = true; break }
|
||||||
value as? String == "unsaved" { loaded = true; break }
|
try await Task.sleep(for: .milliseconds(20))
|
||||||
try await Task.sleep(for: .milliseconds(100))
|
|
||||||
}
|
}
|
||||||
XCTAssertTrue(loaded)
|
XCTAssertTrue(allowed)
|
||||||
_ = try await webview.evaluateJavaScript("history.pushState({}, '', '#ticket')")
|
edge.testState = .cancelled
|
||||||
let toolbar = try XCTUnwrap(controller.view.subviews.compactMap { $0 as? UIToolbar }.first)
|
edge.testTranslation = CGPoint(x: 200, y: 0)
|
||||||
for _ in 0..<20 {
|
controller.perform(NSSelectorFromString("navigateGesture:"), with: edge)
|
||||||
if toolbar.items?.first?.isEnabled == true { break }
|
try await Task.sleep(for: .milliseconds(100))
|
||||||
try await Task.sleep(for: .milliseconds(50))
|
try await waitFor(webview, "location.pathname === '/page3' && window.steps === 0")
|
||||||
}
|
edge.testState = .ended
|
||||||
XCTAssertTrue(webview.canGoBack)
|
edge.testTranslation = CGPoint(x: 10, y: 0)
|
||||||
XCTAssertTrue(toolbar.items?.first?.isEnabled == true)
|
controller.perform(NSSelectorFromString("navigateGesture:"), with: edge)
|
||||||
webview.goBack()
|
try await Task.sleep(for: .milliseconds(100))
|
||||||
for _ in 0..<20 {
|
try await waitFor(webview, "location.pathname === '/page3' && window.steps === 0")
|
||||||
if let hash = try? await webview.evaluateJavaScript("location.hash"), hash as? String == "" { break }
|
edge.testTranslation = CGPoint(x: 200, y: 0)
|
||||||
try await Task.sleep(for: .milliseconds(50))
|
controller.perform(NSSelectorFromString("navigateGesture:"), with: edge)
|
||||||
}
|
try await waitFor(webview, "location.pathname === '/page2' && window.steps === 1")
|
||||||
let draft = try await webview.evaluateJavaScript("document.getElementById('draft').value")
|
controller.navigateHistory(-1, from: CGPoint(x: 1, y: 1))
|
||||||
|
try await waitFor(webview, "location.pathname === '/page1' && window.steps === 2")
|
||||||
|
controller.navigateHistory(1, from: CGPoint(x: 1, y: 1))
|
||||||
|
try await waitFor(webview, "location.pathname === '/page2' && window.steps === 3")
|
||||||
|
let draft = try await webview.evaluateJavaScript("document.querySelector('#draft').value")
|
||||||
XCTAssertEqual(draft as? String, "unsaved")
|
XCTAssertEqual(draft as? String, "unsaved")
|
||||||
|
XCTAssertFalse(webview.scrollView.bounces)
|
||||||
|
XCTAssertFalse(webview.allowsBackForwardNavigationGestures)
|
||||||
|
XCTAssertEqual(webview.gestureRecognizers?.compactMap { $0 as? UIScreenEdgePanGestureRecognizer }.count, 2)
|
||||||
|
let toolbar = try XCTUnwrap(controller.view.subviews.compactMap { $0 as? UIToolbar }.first)
|
||||||
|
XCTAssertFalse(toolbar.items!.contains { $0.accessibilityIdentifier == "workspaceBack" })
|
||||||
|
XCTAssertTrue(toolbar.items!.contains { $0.accessibilityIdentifier == "showServerSettings" })
|
||||||
|
let scroll = try await webview.evaluateJavaScript("getComputedStyle(document.documentElement).overscrollBehaviorY")
|
||||||
|
XCTAssertEqual(scroll as? String, "none")
|
||||||
|
let horizontal = try await webview.evaluateJavaScript("getComputedStyle(document.documentElement).overscrollBehaviorX")
|
||||||
|
XCTAssertEqual(horizontal as? String, "auto")
|
||||||
|
_ = try await webview.evaluateJavaScript("document.body.insertAdjacentHTML('beforeend', '<div role=dialog style=\"position:fixed;inset:0\">Dialog</div>')")
|
||||||
|
controller.navigateHistory(-1, from: CGPoint(x: 1, y: 1))
|
||||||
|
try await Task.sleep(for: .milliseconds(200))
|
||||||
|
try await waitFor(webview, "location.pathname === '/page2' && window.steps === 3")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func waitFor(_ webview: WKWebView, _ expression: String) async throws {
|
||||||
|
for _ in 0..<100 {
|
||||||
|
if let result = try? await webview.evaluateJavaScript(expression), result as? Bool == true { return }
|
||||||
|
try await Task.sleep(for: .milliseconds(50))
|
||||||
|
}
|
||||||
|
XCTFail("WebView condition timed out: \(expression)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testServerChangesNeverReusePreviousProfile() throws {
|
func testServerChangesNeverReusePreviousProfile() throws {
|
||||||
@@ -122,7 +230,7 @@ final class WorkspaceTests: XCTestCase {
|
|||||||
controller.open(url)
|
controller.open(url)
|
||||||
let server = try XCTUnwrap(controller.server)
|
let server = try XCTUnwrap(controller.server)
|
||||||
XCTAssertEqual(controller.workspace?.configuration.websiteDataStore.identifier, server.profile)
|
XCTAssertEqual(controller.workspace?.configuration.websiteDataStore.identifier, server.profile)
|
||||||
XCTAssertTrue(controller.workspace?.configuration.userContentController.userScripts.isEmpty == true)
|
XCTAssertEqual(controller.workspace?.configuration.userContentController.userScripts.count, 1)
|
||||||
let restored = WorkspaceViewController(defaults: defaults)
|
let restored = WorkspaceViewController(defaults: defaults)
|
||||||
restored.loadViewIfNeeded()
|
restored.loadViewIfNeeded()
|
||||||
XCTAssertEqual(restored.server, server)
|
XCTAssertEqual(restored.server, server)
|
||||||
@@ -164,3 +272,19 @@ final class WorkspaceTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final class TestTouch: UITouch {
|
||||||
|
override func location(in view: UIView?) -> CGPoint { CGPoint(x: 1, y: 1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class TestEdgeGesture: UIScreenEdgePanGestureRecognizer {
|
||||||
|
var testState: UIGestureRecognizer.State = .possible
|
||||||
|
var testVelocity = CGPoint.zero
|
||||||
|
var testTranslation = CGPoint.zero
|
||||||
|
override var state: UIGestureRecognizer.State {
|
||||||
|
get { testState }
|
||||||
|
set { testState = newValue }
|
||||||
|
}
|
||||||
|
override func velocity(in view: UIView?) -> CGPoint { testVelocity }
|
||||||
|
override func translation(in view: UIView?) -> CGPoint { testTranslation }
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,11 +13,50 @@ final class WorkspaceUITests: XCTestCase {
|
|||||||
XCTAssertTrue(element.isHittable)
|
XCTAssertTrue(element.isHittable)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testSavedWorkspaceColdStartNeverShowsSelectionBeforeRecovery() {
|
||||||
|
continueAfterFailure = false
|
||||||
|
XCUIDevice.shared.orientation = .portrait
|
||||||
|
defer { XCUIDevice.shared.orientation = .portrait }
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launchEnvironment["OS_ACTIVITY_DT_MODE"] = "NO"
|
||||||
|
app.launchEnvironment["OS_ACTIVITY_MODE"] = "disable"
|
||||||
|
app.launch()
|
||||||
|
let settings = app.buttons["showServerSettings"]
|
||||||
|
if settings.waitForExistence(timeout: 3) { settings.tap() }
|
||||||
|
let address = app.textFields["serverAddress"]
|
||||||
|
XCTAssertTrue(address.waitForExistence(timeout: 20))
|
||||||
|
address.tap()
|
||||||
|
if address.buttons.firstMatch.exists { address.buttons.firstMatch.tap() }
|
||||||
|
address.typeText("https://127.0.0.1:9/")
|
||||||
|
app.buttons["openWorkspace"].tap()
|
||||||
|
let confirmation = app.alerts.buttons["Switch server"]
|
||||||
|
if confirmation.waitForExistence(timeout: 1) { confirmation.tap() }
|
||||||
|
XCTAssertTrue(app.staticTexts["connectionStatus"].waitForExistence(timeout: 20))
|
||||||
|
app.terminate()
|
||||||
|
app.launch()
|
||||||
|
// An unreachable saved server may fail immediately; only recovery may reveal setup.
|
||||||
|
for _ in 0..<30 {
|
||||||
|
if address.exists {
|
||||||
|
XCTAssertTrue(app.staticTexts["connectionStatus"].label.contains("server did not finish loading"))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
XCTAssertFalse(app.staticTexts["Choose your workspace."].exists)
|
||||||
|
Thread.sleep(forTimeInterval: 0.1)
|
||||||
|
}
|
||||||
|
XCTAssertTrue(address.waitForExistence(timeout: 20))
|
||||||
|
XCTAssertEqual(address.value as? String, "https://127.0.0.1:9/")
|
||||||
|
XCTAssertTrue(app.buttons["openWorkspace"].label.contains("Try again"))
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func testLauncherKeyboardValidationRotationAndAppearance() {
|
func testLauncherKeyboardValidationRotationAndAppearance() {
|
||||||
continueAfterFailure = false
|
continueAfterFailure = false
|
||||||
XCUIDevice.shared.orientation = .portrait
|
XCUIDevice.shared.orientation = .portrait
|
||||||
|
defer { XCUIDevice.shared.orientation = .portrait }
|
||||||
let app = XCUIApplication()
|
let app = XCUIApplication()
|
||||||
|
app.launchEnvironment["OS_ACTIVITY_DT_MODE"] = "NO"
|
||||||
|
app.launchEnvironment["OS_ACTIVITY_MODE"] = "disable"
|
||||||
app.launch()
|
app.launch()
|
||||||
let settings = app.buttons["showServerSettings"]
|
let settings = app.buttons["showServerSettings"]
|
||||||
if settings.waitForExistence(timeout: 3) { settings.tap() }
|
if settings.waitForExistence(timeout: 3) { settings.tap() }
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
009F8FEC2765B2F19DC12CA8 /* WorkspaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */; };
|
009F8FEC2765B2F19DC12CA8 /* WorkspaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */; };
|
||||||
|
0EC5CB2D7F774DBDCA69BCD8 /* workspace-navigation.js in Resources */ = {isa = PBXBuildFile; fileRef = 7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */; };
|
||||||
0FAB3CC7BB466BFF871C0C30 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 31630170C358EA8657E34854 /* Assets.xcassets */; };
|
0FAB3CC7BB466BFF871C0C30 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 31630170C358EA8657E34854 /* Assets.xcassets */; };
|
||||||
205D0CBA33F7D3C59AAD5300 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 63685B1189795CAEA85F7AB7 /* QuartzCore.framework */; };
|
205D0CBA33F7D3C59AAD5300 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 63685B1189795CAEA85F7AB7 /* QuartzCore.framework */; };
|
||||||
333E7761EA87AE29CE04F1AC /* main.mm in Sources */ = {isa = PBXBuildFile; fileRef = F46963153D20DB21D97843E9 /* main.mm */; };
|
333E7761EA87AE29CE04F1AC /* main.mm in Sources */ = {isa = PBXBuildFile; fileRef = F46963153D20DB21D97843E9 /* main.mm */; };
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
7245AD23F39C4620BAA3AA92 /* WorkspaceUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED199829CC1B1598F58EDC9B /* WorkspaceUITests.swift */; };
|
7245AD23F39C4620BAA3AA92 /* WorkspaceUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED199829CC1B1598F58EDC9B /* WorkspaceUITests.swift */; };
|
||||||
73954B7AADE8C37663BEF288 /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CA65E67046326EA008175045 /* Metal.framework */; };
|
73954B7AADE8C37663BEF288 /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CA65E67046326EA008175045 /* Metal.framework */; };
|
||||||
98D5B23C9BE2F62C23DF397A /* WorkspacePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 554AB059B4228085924F4959 /* WorkspacePolicy.swift */; };
|
98D5B23C9BE2F62C23DF397A /* WorkspacePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 554AB059B4228085924F4959 /* WorkspacePolicy.swift */; };
|
||||||
|
99291BB0C1DE7714FB32F554 /* workspace-navigation.js in Resources */ = {isa = PBXBuildFile; fileRef = 7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */; };
|
||||||
A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C8C02BC0E757897278EB954A /* Localizable.strings */; };
|
A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C8C02BC0E757897278EB954A /* Localizable.strings */; };
|
||||||
B939982700E800053A9C7663 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE3B71153677F722CAC6DCF7 /* Security.framework */; };
|
B939982700E800053A9C7663 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE3B71153677F722CAC6DCF7 /* Security.framework */; };
|
||||||
C362C102D4950A81D89BB8C8 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */; };
|
C362C102D4950A81D89BB8C8 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */; };
|
||||||
@@ -49,9 +51,13 @@
|
|||||||
47C531812A31207F97A89C5F /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
47C531812A31207F97A89C5F /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
||||||
4C76059D31C50B198779A5F7 /* en.lproj */ = {isa = PBXFileReference; path = en.lproj; sourceTree = "<group>"; };
|
4C76059D31C50B198779A5F7 /* en.lproj */ = {isa = PBXFileReference; path = en.lproj; sourceTree = "<group>"; };
|
||||||
554AB059B4228085924F4959 /* WorkspacePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacePolicy.swift; sourceTree = "<group>"; };
|
554AB059B4228085924F4959 /* WorkspacePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacePolicy.swift; sourceTree = "<group>"; };
|
||||||
|
5F71F06AB639140AB9714425 /* metadata.rs */ = {isa = PBXFileReference; path = metadata.rs; sourceTree = "<group>"; };
|
||||||
63685B1189795CAEA85F7AB7 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; };
|
63685B1189795CAEA85F7AB7 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; };
|
||||||
|
6610D376CF901AC1733D394E /* updates.rs */ = {isa = PBXFileReference; path = updates.rs; sourceTree = "<group>"; };
|
||||||
7440C7F0426E6B3977DD1D94 /* main.rs */ = {isa = PBXFileReference; path = main.rs; sourceTree = "<group>"; };
|
7440C7F0426E6B3977DD1D94 /* main.rs */ = {isa = PBXFileReference; path = main.rs; sourceTree = "<group>"; };
|
||||||
|
7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "workspace-navigation.js"; sourceTree = "<group>"; };
|
||||||
8243D0C3821DAC731FECF105 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; };
|
8243D0C3821DAC731FECF105 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; };
|
||||||
|
8C0F963909020429B3D257D5 /* navigation.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = navigation.m; sourceTree = "<group>"; };
|
||||||
91991DFDB0C064B06DF854ED /* libapp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libapp.a; sourceTree = "<group>"; };
|
91991DFDB0C064B06DF854ED /* libapp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libapp.a; sourceTree = "<group>"; };
|
||||||
92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; };
|
92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; };
|
||||||
946BA5CC3FD90AD90D101A36 /* kanbangit-native_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "kanbangit-native_iOS.entitlements"; sourceTree = "<group>"; };
|
946BA5CC3FD90AD90D101A36 /* kanbangit-native_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "kanbangit-native_iOS.entitlements"; sourceTree = "<group>"; };
|
||||||
@@ -108,6 +114,7 @@
|
|||||||
8606F3EE5A736385061692EF /* Sources */,
|
8606F3EE5A736385061692EF /* Sources */,
|
||||||
335B1262A9019C70C7F887A5 /* src */,
|
335B1262A9019C70C7F887A5 /* src */,
|
||||||
140A7DB7E2461E64B1FBFA70 /* Tests */,
|
140A7DB7E2461E64B1FBFA70 /* Tests */,
|
||||||
|
709BE6DF191B953724A55555 /* ui */,
|
||||||
3C9F31BB065F210267ECA390 /* UITests */,
|
3C9F31BB065F210267ECA390 /* UITests */,
|
||||||
3028C3786B437115B6486F1C /* Frameworks */,
|
3028C3786B437115B6486F1C /* Frameworks */,
|
||||||
59D2DE9FD94E6D2D64CB0117 /* Products */,
|
59D2DE9FD94E6D2D64CB0117 /* Products */,
|
||||||
@@ -146,7 +153,10 @@
|
|||||||
2B96972F58CC158AB3DFF94A /* lib.rs */,
|
2B96972F58CC158AB3DFF94A /* lib.rs */,
|
||||||
AEBAD9ABE758897C50BC1F2E /* locale.rs */,
|
AEBAD9ABE758897C50BC1F2E /* locale.rs */,
|
||||||
7440C7F0426E6B3977DD1D94 /* main.rs */,
|
7440C7F0426E6B3977DD1D94 /* main.rs */,
|
||||||
|
5F71F06AB639140AB9714425 /* metadata.rs */,
|
||||||
|
8C0F963909020429B3D257D5 /* navigation.m */,
|
||||||
E8BA104C81071623E1FF367D /* server.rs */,
|
E8BA104C81071623E1FF367D /* server.rs */,
|
||||||
|
6610D376CF901AC1733D394E /* updates.rs */,
|
||||||
);
|
);
|
||||||
name = src;
|
name = src;
|
||||||
path = ../../src;
|
path = ../../src;
|
||||||
@@ -179,6 +189,15 @@
|
|||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
709BE6DF191B953724A55555 /* ui */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */,
|
||||||
|
);
|
||||||
|
name = ui;
|
||||||
|
path = ../../../ui;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
8606F3EE5A736385061692EF /* Sources */ = {
|
8606F3EE5A736385061692EF /* Sources */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -247,6 +266,7 @@
|
|||||||
buildConfigurationList = 26F675D59EF454AE0D263F35 /* Build configuration list for PBXNativeTarget "WorkspaceTests" */;
|
buildConfigurationList = 26F675D59EF454AE0D263F35 /* Build configuration list for PBXNativeTarget "WorkspaceTests" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
2A0467365B852D44D1A9FD5B /* Sources */,
|
2A0467365B852D44D1A9FD5B /* Sources */,
|
||||||
|
DC3295DE0893151786FEA37B /* Resources */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -269,12 +289,18 @@
|
|||||||
LastUpgradeCheck = 1430;
|
LastUpgradeCheck = 1430;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
721CF221CEDDC28FF2C5EBD5 = {
|
721CF221CEDDC28FF2C5EBD5 = {
|
||||||
DevelopmentTeam = "";
|
DevelopmentTeam = ZZX2KZGTHU;
|
||||||
ProvisioningStyle = Automatic;
|
ProvisioningStyle = Manual;
|
||||||
};
|
};
|
||||||
988CFF304CB81C8F3A4D6612 = {
|
988CFF304CB81C8F3A4D6612 = {
|
||||||
|
DevelopmentTeam = ZZX2KZGTHU;
|
||||||
|
ProvisioningStyle = Manual;
|
||||||
TestTargetID = 721CF221CEDDC28FF2C5EBD5;
|
TestTargetID = 721CF221CEDDC28FF2C5EBD5;
|
||||||
};
|
};
|
||||||
|
CBC4561F8018D37DD49A0598 = {
|
||||||
|
DevelopmentTeam = ZZX2KZGTHU;
|
||||||
|
ProvisioningStyle = Manual;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = A9CA551DD3F3EBCCF8C41CC1 /* Build configuration list for PBXProject "kanbangit-native" */;
|
buildConfigurationList = A9CA551DD3F3EBCCF8C41CC1 /* Build configuration list for PBXProject "kanbangit-native" */;
|
||||||
@@ -307,6 +333,15 @@
|
|||||||
C53E7EC983C008DF98244C74 /* InfoPlist.strings in Resources */,
|
C53E7EC983C008DF98244C74 /* InfoPlist.strings in Resources */,
|
||||||
34C189B72A1B9EBE33D700D1 /* LaunchScreen.storyboard in Resources */,
|
34C189B72A1B9EBE33D700D1 /* LaunchScreen.storyboard in Resources */,
|
||||||
A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */,
|
A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */,
|
||||||
|
99291BB0C1DE7714FB32F554 /* workspace-navigation.js in Resources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
DC3295DE0893151786FEA37B /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
0EC5CB2D7F774DBDCA69BCD8 /* workspace-navigation.js in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -332,7 +367,7 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "export PATH=\"$(dirname \"$(rustup which cargo)\"):$PATH\"\nexport RUSTC=\"$(rustup which rustc)\"\ncd \"$SRCROOT/../../..\"\n./node_modules/.bin/tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths \"${FRAMEWORK_SEARCH_PATHS:?}\" --header-search-paths \"${HEADER_SEARCH_PATHS:?}\" --gcc-preprocessor-definitions \"${GCC_PREPROCESSOR_DEFINITIONS:-}\" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}\n";
|
shellScript = "set -eu\nif [ -n \"${KANBANGIT_IOS_LIBRARY:-}\" ]; then\n mkdir -p \"$SRCROOT/Externals/arm64/$CONFIGURATION\"\n cp \"$KANBANGIT_IOS_LIBRARY\" \"$SRCROOT/Externals/arm64/$CONFIGURATION/libapp.a\"\n exit 0\nfi\nexport PATH=\"$(dirname \"$(rustup which cargo)\"):$PATH\"\nexport RUSTC=\"$(rustup which rustc)\"\ncd \"$SRCROOT/../../..\"\n./node_modules/.bin/tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths \"${FRAMEWORK_SEARCH_PATHS:?}\" --header-search-paths \"${HEADER_SEARCH_PATHS:?}\" --gcc-preprocessor-definitions \"${GCC_PREPROCESSOR_DEFINITIONS:-}\" --configuration ${CONFIGURATION:?} ${FORCE_COLOR:-} ${ARCHS:?}\n";
|
||||||
};
|
};
|
||||||
/* End PBXShellScriptBuildPhase section */
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
@@ -404,9 +439,10 @@
|
|||||||
);
|
);
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Manual;
|
||||||
DEVELOPMENT_TEAM = "";
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
|
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
|
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
|
||||||
@@ -421,9 +457,10 @@
|
|||||||
);
|
);
|
||||||
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||||
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.desktop;
|
MARKETING_VERSION = 0.2.4;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
|
||||||
PRODUCT_MODULE_NAME = KanbanGitNative;
|
PRODUCT_MODULE_NAME = KanbanGitNative;
|
||||||
PRODUCT_NAME = "KanbanGit";
|
PRODUCT_NAME = KanbanGit;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
@@ -498,13 +535,15 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Manual;
|
||||||
|
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@loader_path/Frameworks",
|
"@loader_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.uitests;
|
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios.uitests;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
@@ -577,9 +616,10 @@
|
|||||||
);
|
);
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Manual;
|
||||||
DEVELOPMENT_TEAM = "";
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
|
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
|
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
|
||||||
@@ -594,9 +634,10 @@
|
|||||||
);
|
);
|
||||||
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||||
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.desktop;
|
MARKETING_VERSION = 0.2.4;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
|
||||||
PRODUCT_MODULE_NAME = KanbanGitNative;
|
PRODUCT_MODULE_NAME = KanbanGitNative;
|
||||||
PRODUCT_NAME = "KanbanGit";
|
PRODUCT_NAME = KanbanGit;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
@@ -608,13 +649,15 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Manual;
|
||||||
|
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@loader_path/Frameworks",
|
"@loader_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.uitests;
|
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios.uitests;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
@@ -626,13 +669,15 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Manual;
|
||||||
|
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@loader_path/Frameworks",
|
"@loader_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.tests;
|
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios.tests;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
@@ -643,13 +688,15 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Manual;
|
||||||
|
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@loader_path/Frameworks",
|
"@loader_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.tests;
|
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios.tests;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
|||||||
@@ -15,9 +15,9 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>0.1.0</string>
|
<string>$(MARKETING_VERSION)</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>0.1.0</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
@@ -45,4 +45,4 @@
|
|||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -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.4"
|
||||||
|
CURRENT_PROJECT_VERSION: "4"
|
||||||
|
configs:
|
||||||
|
debug:
|
||||||
|
CODE_SIGN_IDENTITY: Apple Development
|
||||||
|
release:
|
||||||
|
CODE_SIGN_IDENTITY: Apple Distribution
|
||||||
targetTemplates:
|
targetTemplates:
|
||||||
app:
|
app:
|
||||||
type: application
|
type: application
|
||||||
@@ -35,6 +42,7 @@ targets:
|
|||||||
platform: iOS
|
platform: iOS
|
||||||
sources:
|
sources:
|
||||||
- path: Sources
|
- path: Sources
|
||||||
|
- path: ../../../ui/workspace-navigation.js
|
||||||
- path: Assets.xcassets
|
- path: Assets.xcassets
|
||||||
- path: kanbangit-native_iOS
|
- path: kanbangit-native_iOS
|
||||||
- path: LaunchScreen.storyboard
|
- path: LaunchScreen.storyboard
|
||||||
@@ -55,8 +63,8 @@ targets:
|
|||||||
- UIInterfaceOrientationPortraitUpsideDown
|
- UIInterfaceOrientationPortraitUpsideDown
|
||||||
- UIInterfaceOrientationLandscapeLeft
|
- UIInterfaceOrientationLandscapeLeft
|
||||||
- UIInterfaceOrientationLandscapeRight
|
- UIInterfaceOrientationLandscapeRight
|
||||||
CFBundleShortVersionString: 0.1.0
|
CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||||
CFBundleVersion: "0.1.0"
|
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||||
entitlements:
|
entitlements:
|
||||||
path: kanbangit-native_iOS/kanbangit-native_iOS.entitlements
|
path: kanbangit-native_iOS/kanbangit-native_iOS.entitlements
|
||||||
scheme:
|
scheme:
|
||||||
@@ -88,10 +96,16 @@ targets:
|
|||||||
- sdk: WebKit.framework
|
- sdk: WebKit.framework
|
||||||
preBuildScripts:
|
preBuildScripts:
|
||||||
- script: |
|
- script: |
|
||||||
|
set -eu
|
||||||
|
if [ -n "${KANBANGIT_IOS_LIBRARY:-}" ]; then
|
||||||
|
mkdir -p "$SRCROOT/Externals/arm64/$CONFIGURATION"
|
||||||
|
cp "$KANBANGIT_IOS_LIBRARY" "$SRCROOT/Externals/arm64/$CONFIGURATION/libapp.a"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
export PATH="$(dirname "$(rustup which cargo)"):$PATH"
|
export PATH="$(dirname "$(rustup which cargo)"):$PATH"
|
||||||
export RUSTC="$(rustup which rustc)"
|
export RUSTC="$(rustup which rustc)"
|
||||||
cd "$SRCROOT/../../.."
|
cd "$SRCROOT/../../.."
|
||||||
./node_modules/.bin/tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths "${FRAMEWORK_SEARCH_PATHS:?}" --header-search-paths "${HEADER_SEARCH_PATHS:?}" --gcc-preprocessor-definitions "${GCC_PREPROCESSOR_DEFINITIONS:-}" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}
|
./node_modules/.bin/tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths "${FRAMEWORK_SEARCH_PATHS:?}" --header-search-paths "${HEADER_SEARCH_PATHS:?}" --gcc-preprocessor-definitions "${GCC_PREPROCESSOR_DEFINITIONS:-}" --configuration ${CONFIGURATION:?} ${FORCE_COLOR:-} ${ARCHS:?}
|
||||||
name: Build Rust Code
|
name: Build Rust Code
|
||||||
basedOnDependencyAnalysis: false
|
basedOnDependencyAnalysis: false
|
||||||
outputFiles:
|
outputFiles:
|
||||||
@@ -101,6 +115,7 @@ targets:
|
|||||||
type: bundle.unit-test
|
type: bundle.unit-test
|
||||||
platform: iOS
|
platform: iOS
|
||||||
sources:
|
sources:
|
||||||
|
- path: ../../../ui/workspace-navigation.js
|
||||||
- path: Tests
|
- path: Tests
|
||||||
- path: Sources/kanbangit-native/WorkspacePolicy.swift
|
- path: Sources/kanbangit-native/WorkspacePolicy.swift
|
||||||
- path: Sources/kanbangit-native/WorkspaceViewController.swift
|
- path: Sources/kanbangit-native/WorkspaceViewController.swift
|
||||||
@@ -108,7 +123,9 @@ targets:
|
|||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
GENERATE_INFOPLIST_FILE: YES
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.tests
|
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios.tests
|
||||||
|
DEVELOPMENT_TEAM: ZZX2KZGTHU
|
||||||
|
CODE_SIGN_STYLE: Manual
|
||||||
SWIFT_VERSION: "5.0"
|
SWIFT_VERSION: "5.0"
|
||||||
WorkspaceUITests:
|
WorkspaceUITests:
|
||||||
type: bundle.ui-testing
|
type: bundle.ui-testing
|
||||||
@@ -119,6 +136,8 @@ targets:
|
|||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
GENERATE_INFOPLIST_FILE: YES
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.uitests
|
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios.uitests
|
||||||
|
DEVELOPMENT_TEAM: ZZX2KZGTHU
|
||||||
|
CODE_SIGN_STYLE: Manual
|
||||||
SWIFT_VERSION: "5.0"
|
SWIFT_VERSION: "5.0"
|
||||||
TEST_TARGET_NAME: kanbangit-native_iOS
|
TEST_TARGET_NAME: kanbangit-native_iOS
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use crate::locale::en;
|
|||||||
use crate::server::{validate_url, Server, DEFAULT_SERVER};
|
use crate::server::{validate_url, Server, DEFAULT_SERVER};
|
||||||
use std::{path::PathBuf, sync::Mutex, time::Duration};
|
use std::{path::PathBuf, sync::Mutex, time::Duration};
|
||||||
use tauri::{
|
use tauri::{
|
||||||
menu::{MenuBuilder, SubmenuBuilder},
|
menu::{AboutMetadata, MenuBuilder, MenuItem, SubmenuBuilder},
|
||||||
webview::{NewWindowResponse, PageLoadEvent},
|
webview::{NewWindowResponse, PageLoadEvent},
|
||||||
AppHandle, Manager, Url, WebviewUrl, WebviewWindowBuilder, WindowEvent,
|
AppHandle, Manager, Url, WebviewUrl, WebviewWindowBuilder, WindowEvent,
|
||||||
};
|
};
|
||||||
@@ -169,76 +169,87 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
|
|||||||
let popup_server = server_url.clone();
|
let popup_server = server_url.clone();
|
||||||
let popup_handle = app.clone();
|
let popup_handle = app.clone();
|
||||||
let popup_label = label.clone();
|
let popup_label = label.clone();
|
||||||
let window = WebviewWindowBuilder::new(app, &label, WebviewUrl::External(server.url))
|
let window =
|
||||||
.title(en::APP_NAME)
|
WebviewWindowBuilder::new(app, &label, WebviewUrl::External(server.dashboard_url()))
|
||||||
.inner_size(1280.0, 850.0)
|
.title(en::APP_NAME)
|
||||||
.min_inner_size(375.0, 600.0)
|
.inner_size(1280.0, 850.0)
|
||||||
.center()
|
.min_inner_size(375.0, 600.0)
|
||||||
.visible(false)
|
.center()
|
||||||
.data_store_identifier(server.profile)
|
.visible(false)
|
||||||
.on_navigation(move |destination| {
|
.data_store_identifier(server.profile)
|
||||||
if !allows_server_navigation(&server_url, destination) {
|
.initialization_script(include_str!("../../ui/workspace-navigation.js"))
|
||||||
open_external(destination);
|
.on_navigation(move |destination| {
|
||||||
return false;
|
if !allows_server_navigation(&server_url, destination) {
|
||||||
}
|
open_external(destination);
|
||||||
begin_navigation(&handle, &navigation_label);
|
return false;
|
||||||
true
|
|
||||||
})
|
|
||||||
.on_new_window(move |destination, _| {
|
|
||||||
if allows_server_navigation(&popup_server, &destination) {
|
|
||||||
if let Some(window) = popup_handle.get_webview_window(&popup_label) {
|
|
||||||
let _ = window.navigate(destination);
|
|
||||||
}
|
}
|
||||||
} else {
|
begin_navigation(&handle, &navigation_label);
|
||||||
open_external(&destination);
|
true
|
||||||
}
|
})
|
||||||
NewWindowResponse::Deny
|
.on_new_window(move |destination, _| {
|
||||||
})
|
if allows_server_navigation(&popup_server, &destination) {
|
||||||
.on_page_load(|window, payload| {
|
if let Some(window) = popup_handle.get_webview_window(&popup_label) {
|
||||||
if payload.event() != PageLoadEvent::Finished {
|
let _ = window.navigate(destination);
|
||||||
return;
|
}
|
||||||
}
|
} else {
|
||||||
let app = window.app_handle();
|
open_external(&destination);
|
||||||
let state = app.state::<Mutex<Connection>>();
|
}
|
||||||
let should_show = {
|
NewWindowResponse::Deny
|
||||||
let mut state = state.lock().unwrap();
|
})
|
||||||
if state.label.as_deref() != Some(window.label()) {
|
.on_page_load(|window, payload| {
|
||||||
|
if payload.event() != PageLoadEvent::Finished {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let first_load = !state.ready;
|
let app = window.app_handle();
|
||||||
state.ready = true;
|
let state = app.state::<Mutex<Connection>>();
|
||||||
state.phase = "connected";
|
let should_show = {
|
||||||
first_load
|
let mut state = state.lock().unwrap();
|
||||||
};
|
if state.label.as_deref() != Some(window.label())
|
||||||
update_launcher(app);
|
|| state.phase != "connecting"
|
||||||
if should_show {
|
|| !state.server.as_ref().is_some_and(|server| {
|
||||||
let _ = window.show();
|
allows_server_navigation(&server.url, payload.url())
|
||||||
let _ = window.set_focus();
|
})
|
||||||
if let Some(launcher) = app.get_webview_window("launcher") {
|
{
|
||||||
let _ = launcher.hide();
|
return;
|
||||||
|
}
|
||||||
|
let first_load = !state.ready;
|
||||||
|
state.ready = true;
|
||||||
|
state.phase = "connected";
|
||||||
|
first_load
|
||||||
|
};
|
||||||
|
if should_show {
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
if let Some(launcher) = app.get_webview_window("launcher") {
|
||||||
|
let _ = launcher.hide();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
update_launcher(app);
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
.map_err(|_| "connectionError")?;
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
window
|
||||||
|
.with_webview(|webview| unsafe {
|
||||||
|
unsafe extern "C" {
|
||||||
|
fn install_workspace_navigation(webview: *mut std::ffi::c_void);
|
||||||
}
|
}
|
||||||
|
install_workspace_navigation(webview.inner().cast());
|
||||||
})
|
})
|
||||||
.build()
|
|
||||||
.map_err(|_| "connectionError")?;
|
.map_err(|_| "connectionError")?;
|
||||||
let handle = app.clone();
|
let handle = app.clone();
|
||||||
window.on_window_event(move |event| {
|
window.on_window_event(move |event| {
|
||||||
if matches!(event, WindowEvent::Destroyed) {
|
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||||
let state = handle.state::<Mutex<Connection>>();
|
let current = handle
|
||||||
let current = {
|
.state::<Mutex<Connection>>()
|
||||||
let mut state = state.lock().unwrap();
|
.lock()
|
||||||
if state.label.as_deref() == Some(&label) {
|
.unwrap()
|
||||||
state.label = None;
|
.label
|
||||||
state.ready = false;
|
.as_deref()
|
||||||
state.revision += 1;
|
== Some(&label);
|
||||||
state.phase = "idle";
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if current {
|
if current {
|
||||||
show_launcher(&handle);
|
api.prevent_close();
|
||||||
|
handle.exit(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -247,6 +258,11 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
|
|||||||
|
|
||||||
fn handle_launcher_action(app: &AppHandle, destination: &Url) {
|
fn handle_launcher_action(app: &AppHandle, destination: &Url) {
|
||||||
let result = match destination.path() {
|
let result = match destination.path() {
|
||||||
|
"/action/updates" => {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
crate::updates::show(app);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
"/action/open" => {
|
"/action/open" => {
|
||||||
let input = destination
|
let input = destination
|
||||||
.query_pairs()
|
.query_pairs()
|
||||||
@@ -280,12 +296,24 @@ pub fn context<R: tauri::Runtime>() -> tauri::Context<R> {
|
|||||||
tauri::generate_context!()
|
tauri::generate_context!()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn about_metadata(version: String) -> AboutMetadata<'static> {
|
||||||
|
AboutMetadata {
|
||||||
|
name: Some(en::APP_NAME.into()),
|
||||||
|
version: Some(version),
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
short_version: Some(crate::metadata::build_number().into()),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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,
|
||||||
@@ -293,7 +321,20 @@ 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(
|
||||||
|
en::ABOUT,
|
||||||
|
Some(about_metadata(app.package_info().version.to_string())),
|
||||||
|
)
|
||||||
|
.text("check-updates", en::CHECK_UPDATES)
|
||||||
|
.item(&MenuItem::with_id(
|
||||||
|
app,
|
||||||
|
"install-update",
|
||||||
|
en::INSTALL_UPDATE,
|
||||||
|
false,
|
||||||
|
None::<&str>,
|
||||||
|
)?)
|
||||||
|
.separator()
|
||||||
.text("server-settings", en::SERVER_SETTINGS)
|
.text("server-settings", en::SERVER_SETTINGS)
|
||||||
.separator()
|
.separator()
|
||||||
.quit()
|
.quit()
|
||||||
@@ -318,7 +359,21 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
|
|||||||
)
|
)
|
||||||
.build()?;
|
.build()?;
|
||||||
app.set_menu(menu)?;
|
app.set_menu(menu)?;
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
crate::updates::setup(app.handle())?;
|
||||||
app.on_menu_event(|app, event| {
|
app.on_menu_event(|app, event| {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
match event.id().as_ref() {
|
||||||
|
"check-updates" => {
|
||||||
|
crate::updates::check(app, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
"install-update" => {
|
||||||
|
crate::updates::show(app);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
if event.id().as_ref() == "server-settings" {
|
if event.id().as_ref() == "server-settings" {
|
||||||
show_launcher(app);
|
show_launcher(app);
|
||||||
} else if let Some(script) = match event.id().as_ref() {
|
} else if let Some(script) = match event.id().as_ref() {
|
||||||
@@ -343,6 +398,7 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
|
|||||||
.title(en::APP_NAME)
|
.title(en::APP_NAME)
|
||||||
.inner_size(560.0, 720.0)
|
.inner_size(560.0, 720.0)
|
||||||
.min_inner_size(375.0, 640.0)
|
.min_inner_size(375.0, 640.0)
|
||||||
|
.visible(false)
|
||||||
.center()
|
.center()
|
||||||
.on_navigation(move |destination| {
|
.on_navigation(move |destination| {
|
||||||
if !allows_launcher_navigation(destination) {
|
if !allows_launcher_navigation(destination) {
|
||||||
@@ -363,25 +419,17 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
|
|||||||
if payload.event() == PageLoadEvent::Finished {
|
if payload.event() == PageLoadEvent::Finished {
|
||||||
let app = window.app_handle().clone();
|
let app = window.app_handle().clone();
|
||||||
update_launcher(&app);
|
update_launcher(&app);
|
||||||
std::thread::spawn(move || {
|
#[cfg(target_os = "macos")]
|
||||||
let handle = app.clone();
|
crate::updates::refresh(&app);
|
||||||
let _ = app.run_on_main_thread(move || {
|
let needs_setup = app
|
||||||
let server = handle
|
.state::<Mutex<Connection>>()
|
||||||
.state::<Mutex<Connection>>()
|
.lock()
|
||||||
.lock()
|
.unwrap()
|
||||||
.unwrap()
|
.server
|
||||||
.server
|
.is_none();
|
||||||
.clone();
|
if needs_setup {
|
||||||
if let Some(server) = server {
|
show_launcher(&app);
|
||||||
if let Err(error) = open_workspace(&handle, server.url.as_str())
|
}
|
||||||
{
|
|
||||||
handle.state::<Mutex<Connection>>().lock().unwrap().phase =
|
|
||||||
error;
|
|
||||||
show_launcher(&handle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.build()?;
|
.build()?;
|
||||||
@@ -389,9 +437,25 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
|
|||||||
launcher.on_window_event(move |event| {
|
launcher.on_window_event(move |event| {
|
||||||
if let WindowEvent::CloseRequested { api, .. } = event {
|
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||||
api.prevent_close();
|
api.prevent_close();
|
||||||
let _ = window.hide();
|
let has_workspace = window
|
||||||
|
.app_handle()
|
||||||
|
.state::<Mutex<Connection>>()
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.ready;
|
||||||
|
if has_workspace {
|
||||||
|
let _ = window.hide();
|
||||||
|
} else {
|
||||||
|
window.app_handle().exit(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if let Some(server) = startup_server {
|
||||||
|
if let Err(error) = open_workspace(app.handle(), server.url.as_str()) {
|
||||||
|
app.state::<Mutex<Connection>>().lock().unwrap().phase = error;
|
||||||
|
show_launcher(app.handle());
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -400,6 +464,18 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
#[test]
|
||||||
|
fn about_shows_app_version_followed_by_the_actual_build_number() {
|
||||||
|
let metadata = about_metadata("9.8.7".into());
|
||||||
|
assert_eq!(metadata.version.as_deref(), Some("9.8.7"));
|
||||||
|
assert_eq!(
|
||||||
|
metadata.short_version.as_deref(),
|
||||||
|
Some(crate::metadata::build_number())
|
||||||
|
);
|
||||||
|
assert_ne!(metadata.version, metadata.short_version);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn external_links_accept_only_web_urls_without_credentials() {
|
fn external_links_accept_only_web_urls_without_credentials() {
|
||||||
for input in [
|
for input in [
|
||||||
@@ -482,7 +558,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn remote_pages_cannot_invoke_native_commands() {
|
fn remote_pages_cannot_invoke_native_commands() {
|
||||||
let app = tauri::test::mock_builder().build(context()).unwrap();
|
let builder = tauri::test::mock_builder();
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
let builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
|
||||||
|
let app = builder.build(context()).unwrap();
|
||||||
let window = WebviewWindowBuilder::new(
|
let window = WebviewWindowBuilder::new(
|
||||||
&app,
|
&app,
|
||||||
"workspace-1",
|
"workspace-1",
|
||||||
@@ -491,6 +570,8 @@ mod tests {
|
|||||||
.build()
|
.build()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
for command in [
|
for command in [
|
||||||
|
"plugin:updater|check",
|
||||||
|
"plugin:updater|download_and_install",
|
||||||
"plugin:window|close",
|
"plugin:window|close",
|
||||||
"plugin:webview|create_webview_window",
|
"plugin:webview|create_webview_window",
|
||||||
"plugin:path|resolve_directory",
|
"plugin:path|resolve_directory",
|
||||||
|
|||||||
@@ -6,8 +6,12 @@ mod desktop;
|
|||||||
mod ios;
|
mod ios;
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
mod locale;
|
mod locale;
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
mod metadata;
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
mod server;
|
mod server;
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
mod updates;
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
pub use android::{builder, context};
|
pub use android::{builder, context};
|
||||||
@@ -16,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,4 +1,8 @@
|
|||||||
pub mod en {
|
pub mod en {
|
||||||
|
pub const ABOUT: &str = "About KanbanGit";
|
||||||
|
pub const CHECK_UPDATES: &str = "Check For Updates…";
|
||||||
|
pub const INSTALL_UPDATE: &str = "Install Update…";
|
||||||
|
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…";
|
||||||
pub const NAVIGATION: &str = "Navigation";
|
pub const NAVIGATION: &str = "Navigation";
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
use std::{path::Path, sync::LazyLock};
|
||||||
|
|
||||||
|
fn build_from_executable(executable: &Path) -> Option<String> {
|
||||||
|
let macos = executable.parent()?;
|
||||||
|
let contents = macos.parent()?;
|
||||||
|
if macos.file_name()? != "MacOS" || contents.file_name()? != "Contents" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let output = std::process::Command::new("/usr/bin/plutil")
|
||||||
|
.args(["-extract", "CFBundleVersion", "raw", "-o", "-"])
|
||||||
|
.arg(contents.join("Info.plist"))
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Some("unknown".into());
|
||||||
|
}
|
||||||
|
let build = String::from_utf8(output.stdout).ok()?;
|
||||||
|
Some(build.trim().to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_number() -> &'static str {
|
||||||
|
static BUILD: LazyLock<String> = LazyLock::new(|| {
|
||||||
|
std::env::current_exe()
|
||||||
|
.ok()
|
||||||
|
.and_then(|executable| build_from_executable(&executable))
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
// Tauri omits macOS bundle settings from its generated runtime config.
|
||||||
|
let config: serde_json::Value =
|
||||||
|
serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
|
||||||
|
config["bundle"]["macOS"]["bundleVersion"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_owned()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
&BUILD
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn installed_build_comes_from_the_app_plist_instead_of_the_app_version() {
|
||||||
|
let root = std::env::temp_dir().join(format!("kanbangit-build-{}", std::process::id()));
|
||||||
|
let contents = root.join("KanbanGit.app/Contents");
|
||||||
|
std::fs::create_dir_all(contents.join("MacOS")).unwrap();
|
||||||
|
let executable = contents.join("MacOS/kanbangit-native");
|
||||||
|
std::fs::write(contents.join("Info.plist"), r#"<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict><key>CFBundleShortVersionString</key><string>0.1.3</string><key>CFBundleVersion</key><string>42</string></dict></plist>"#).unwrap();
|
||||||
|
assert_eq!(build_from_executable(&executable).as_deref(), Some("42"));
|
||||||
|
std::fs::remove_file(contents.join("Info.plist")).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
build_from_executable(&executable).as_deref(),
|
||||||
|
Some("unknown")
|
||||||
|
);
|
||||||
|
std::fs::remove_dir_all(root).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unbundled_execution_uses_the_configured_build() {
|
||||||
|
assert!(build_from_executable(Path::new("/tmp/target/debug/kanbangit-native")).is_none());
|
||||||
|
let config: serde_json::Value =
|
||||||
|
serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
build_number(),
|
||||||
|
config["bundle"]["macOS"]["bundleVersion"].as_str().unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
#import <AppKit/AppKit.h>
|
||||||
|
#import <WebKit/WebKit.h>
|
||||||
|
#import <objc/runtime.h>
|
||||||
|
|
||||||
|
// Keep AppKit's system swipe preferences and cancellation thresholds, but use
|
||||||
|
// script history traversal so asynchronously created Inertia entries aren't skipped.
|
||||||
|
@interface KanbanNavigation : NSObject
|
||||||
|
@property(nonatomic, weak) WKWebView *webview;
|
||||||
|
@property(nonatomic, strong) id monitor;
|
||||||
|
@property(nonatomic) BOOL pending;
|
||||||
|
@property(nonatomic) BOOL tracking;
|
||||||
|
@property(nonatomic) BOOL blocked;
|
||||||
|
@property(nonatomic) NSUInteger sequence;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation KanbanNavigation
|
||||||
|
- (BOOL)swipeTrackingEnabled {
|
||||||
|
return NSEvent.isSwipeTrackingFromScrollEventsEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)dealloc {
|
||||||
|
if (_monitor) [NSEvent removeMonitor:_monitor];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSEvent *)handleEvent:(NSEvent *)event {
|
||||||
|
WKWebView *webview = self.webview;
|
||||||
|
if (!webview || event.window != webview.window || !webview.window.isKeyWindow ||
|
||||||
|
webview.hidden || ![self swipeTrackingEnabled]) return event;
|
||||||
|
if (event.phase == NSEventPhaseBegan) {
|
||||||
|
self.sequence++;
|
||||||
|
self.blocked = NO;
|
||||||
|
self.pending = NO;
|
||||||
|
self.tracking = NO;
|
||||||
|
}
|
||||||
|
if (self.tracking) return nil;
|
||||||
|
if (event.phase == NSEventPhaseEnded || event.phase == NSEventPhaseCancelled) {
|
||||||
|
self.sequence++;
|
||||||
|
self.pending = NO;
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
if (self.blocked || self.pending || event.momentumPhase != NSEventPhaseNone ||
|
||||||
|
!(event.phase & (NSEventPhaseBegan | NSEventPhaseChanged)) || !event.hasPreciseScrollingDeltas) return event;
|
||||||
|
if (fabs(event.scrollingDeltaX) <= fabs(event.scrollingDeltaY)) {
|
||||||
|
if (fabs(event.scrollingDeltaY) > 0) self.blocked = YES;
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
NSPoint point = [webview convertPoint:event.locationInWindow fromView:nil];
|
||||||
|
if (!NSPointInRect(point, webview.bounds)) return event;
|
||||||
|
if (!webview.isFlipped) point.y = webview.bounds.size.height - point.y;
|
||||||
|
NSString *script = [NSString stringWithFormat:@"window.__kanbanNavigation?.canSwipe(%f, %f) === true", point.x, point.y];
|
||||||
|
self.pending = YES;
|
||||||
|
NSUInteger sequence = self.sequence;
|
||||||
|
NSURL *page = webview.URL;
|
||||||
|
__weak KanbanNavigation *weakSelf = self;
|
||||||
|
[webview evaluateJavaScript:script completionHandler:^(id allowed, NSError *error) {
|
||||||
|
KanbanNavigation *owner = weakSelf;
|
||||||
|
if (!owner || owner.sequence != sequence) return;
|
||||||
|
owner.pending = NO;
|
||||||
|
owner.blocked = YES;
|
||||||
|
if (error || ![allowed isEqual:@YES] || ![webview.URL isEqual:page] ||
|
||||||
|
(!webview.canGoBack && !webview.canGoForward)) return;
|
||||||
|
owner.tracking = YES;
|
||||||
|
[event trackSwipeEventWithOptions:NSEventSwipeTrackingLockDirection | NSEventSwipeTrackingClampGestureAmount
|
||||||
|
dampenAmountThresholdMin:webview.canGoForward ? -1 : 0
|
||||||
|
max:webview.canGoBack ? 1 : 0
|
||||||
|
usingHandler:^(CGFloat amount, NSEventPhase phase, BOOL complete, BOOL *stop) {
|
||||||
|
KanbanNavigation *current = weakSelf;
|
||||||
|
if (current.sequence != sequence) {
|
||||||
|
*stop = YES;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!current || !webview.window.isKeyWindow || ![webview.URL isEqual:page]) {
|
||||||
|
current.tracking = NO;
|
||||||
|
*stop = YES;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!complete) return;
|
||||||
|
current.tracking = NO;
|
||||||
|
if (phase == NSEventPhaseCancelled) return;
|
||||||
|
NSInteger direction = amount >= 1 ? -1 : amount <= -1 ? 1 : 0;
|
||||||
|
if ((direction == -1 && webview.canGoBack) || (direction == 1 && webview.canGoForward)) {
|
||||||
|
NSString *navigate = [NSString stringWithFormat:@"if (%@) window.__kanbanNavigation.step(%ld)", script, (long)direction];
|
||||||
|
[webview evaluateJavaScript:navigate completionHandler:nil];
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
}];
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
@end
|
||||||
|
|
||||||
|
void install_workspace_navigation(void *pointer) {
|
||||||
|
WKWebView *webview = (__bridge WKWebView *)pointer;
|
||||||
|
webview.allowsBackForwardNavigationGestures = NO;
|
||||||
|
KanbanNavigation *navigation = [KanbanNavigation new];
|
||||||
|
navigation.webview = webview;
|
||||||
|
__weak KanbanNavigation *weakNavigation = navigation;
|
||||||
|
navigation.monitor = [NSEvent addLocalMonitorForEventsMatchingMask:NSEventMaskScrollWheel handler:^NSEvent *(NSEvent *event) {
|
||||||
|
KanbanNavigation *owner = weakNavigation;
|
||||||
|
return owner ? [owner handleEvent:event] : event;
|
||||||
|
}];
|
||||||
|
objc_setAssociatedObject(webview, @selector(handleEvent:), navigation, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -0,0 +1,802 @@
|
|||||||
|
use semver::Version;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{
|
||||||
|
fs,
|
||||||
|
io::Read,
|
||||||
|
path::Path,
|
||||||
|
sync::Mutex,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
use tauri::{
|
||||||
|
webview::{NewWindowResponse, PageLoadEvent},
|
||||||
|
AppHandle, Manager, Url, WebviewUrl, WebviewWindowBuilder,
|
||||||
|
};
|
||||||
|
use tauri_plugin_updater::{Update, UpdaterExt};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct Source {
|
||||||
|
base_url: String,
|
||||||
|
public_key: String,
|
||||||
|
}
|
||||||
|
fn source() -> &'static Source {
|
||||||
|
static SOURCE: std::sync::LazyLock<Source> = std::sync::LazyLock::new(|| {
|
||||||
|
serde_json::from_str(include_str!("../../update-source.json"))
|
||||||
|
.expect("Bundled update source must be valid")
|
||||||
|
});
|
||||||
|
&SOURCE
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct Preferences {
|
||||||
|
automatic: bool,
|
||||||
|
interval_hours: u64,
|
||||||
|
}
|
||||||
|
impl Default for Preferences {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
automatic: true,
|
||||||
|
interval_hours: 6,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Preferences {
|
||||||
|
fn valid(&self) -> bool {
|
||||||
|
[1, 6, 24].contains(&self.interval_hours)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct View {
|
||||||
|
phase: &'static str,
|
||||||
|
current_version: String,
|
||||||
|
build: String,
|
||||||
|
version: Option<String>,
|
||||||
|
notes: Option<String>,
|
||||||
|
downloaded: u64,
|
||||||
|
total: Option<u64>,
|
||||||
|
error: Option<&'static str>,
|
||||||
|
preferences: Preferences,
|
||||||
|
}
|
||||||
|
struct State {
|
||||||
|
view: View,
|
||||||
|
update: Option<Update>,
|
||||||
|
last_check: Option<Instant>,
|
||||||
|
}
|
||||||
|
impl State {
|
||||||
|
fn busy(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self.view.phase,
|
||||||
|
"checking" | "downloading" | "verifying" | "installing" | "restarting"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fn begin_check(&mut self, manual: bool, now: Instant) -> bool {
|
||||||
|
if self.busy()
|
||||||
|
|| (!manual
|
||||||
|
&& (!self.view.preferences.automatic
|
||||||
|
|| self.last_check.is_some_and(|last| {
|
||||||
|
now.duration_since(last)
|
||||||
|
< Duration::from_secs(self.view.preferences.interval_hours * 3600)
|
||||||
|
})))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.last_check = Some(now);
|
||||||
|
self.view.phase = "checking";
|
||||||
|
self.view.error = None;
|
||||||
|
self.update = None;
|
||||||
|
self.view.version = None;
|
||||||
|
self.view.notes = None;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn target() -> String {
|
||||||
|
format!("{}-apple-darwin", std::env::consts::ARCH)
|
||||||
|
}
|
||||||
|
fn feed_url() -> Url {
|
||||||
|
Url::parse(&format!("{}/{}/latest.json", source().base_url, target())).unwrap()
|
||||||
|
}
|
||||||
|
fn newer_stable(current: &str, candidate: &str) -> bool {
|
||||||
|
match (Version::parse(current), Version::parse(candidate)) {
|
||||||
|
(Ok(current), Ok(candidate)) => {
|
||||||
|
candidate.pre.is_empty() && candidate.build.is_empty() && candidate > current
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn valid_download(current: &str, version: &str, url: &Url) -> bool {
|
||||||
|
newer_stable(current, version)
|
||||||
|
&& url.as_str()
|
||||||
|
== format!(
|
||||||
|
"{}/{}/{version}/KanbanGit-{version}-{}.app.tar.gz",
|
||||||
|
source().base_url,
|
||||||
|
target(),
|
||||||
|
target()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fn trusted_navigation(url: &Url) -> bool {
|
||||||
|
url.scheme() == "tauri"
|
||||||
|
&& url.host_str() == Some("localhost")
|
||||||
|
&& url.port().is_none()
|
||||||
|
&& url.username().is_empty()
|
||||||
|
&& 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) {
|
||||||
|
let (view, available, busy) = {
|
||||||
|
let state = app.state::<Mutex<State>>();
|
||||||
|
let state = state.lock().unwrap();
|
||||||
|
(state.view.clone(), state.update.is_some(), state.busy())
|
||||||
|
};
|
||||||
|
refresh_menu(app, available, busy);
|
||||||
|
if let Some(window) = app.get_webview_window("updates") {
|
||||||
|
if let Ok(json) = serde_json::to_string(&view) {
|
||||||
|
let _ = window.eval(format!("window.updateStatus?.({json})"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(window) = app.get_webview_window("launcher") {
|
||||||
|
let _ = window.eval(format!("window.updateAvailable?.({available})"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn show(app: &AppHandle) {
|
||||||
|
if let Some(window) = app.get_webview_window("updates") {
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
refresh(app);
|
||||||
|
}
|
||||||
|
fn fail(app: &AppHandle, message: &'static str) {
|
||||||
|
{
|
||||||
|
let state = app.state::<Mutex<State>>();
|
||||||
|
let mut state = state.lock().unwrap();
|
||||||
|
state.view.phase = "error";
|
||||||
|
state.view.error = Some(message);
|
||||||
|
}
|
||||||
|
refresh(app);
|
||||||
|
}
|
||||||
|
fn update_error(error: &tauri_plugin_updater::Error) -> &'static str {
|
||||||
|
use tauri_plugin_updater::Error;
|
||||||
|
match error {
|
||||||
|
Error::Minisign(_)
|
||||||
|
| Error::Base64(_)
|
||||||
|
| Error::SignatureUtf8(_)
|
||||||
|
| Error::SignedVersionMismatch { .. }
|
||||||
|
| Error::MissingSignedVersion => "signatureError",
|
||||||
|
Error::Serialization(_)
|
||||||
|
| Error::Semver(_)
|
||||||
|
| Error::TargetsNotFound(_)
|
||||||
|
| Error::TargetNotFound(_) => "metadataError",
|
||||||
|
_ => "networkError",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn check(app: &AppHandle, manual: bool) {
|
||||||
|
if manual {
|
||||||
|
show(app);
|
||||||
|
}
|
||||||
|
let begin = app
|
||||||
|
.state::<Mutex<State>>()
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.begin_check(manual, Instant::now());
|
||||||
|
if !begin {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
refresh(app);
|
||||||
|
let app = app.clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let result = async {
|
||||||
|
app.updater_builder()
|
||||||
|
.pubkey(&source().public_key)
|
||||||
|
.endpoints(vec![feed_url()])?
|
||||||
|
.timeout(Duration::from_secs(60))
|
||||||
|
.configure_client(|client| client.https_only(true))
|
||||||
|
.version_comparator(|current, release| {
|
||||||
|
newer_stable(¤t.to_string(), &release.version.to_string())
|
||||||
|
})
|
||||||
|
.build()?
|
||||||
|
.check()
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
match result {
|
||||||
|
Ok(Some(mut update)) => {
|
||||||
|
if !valid_download(
|
||||||
|
&update.current_version,
|
||||||
|
&update.version,
|
||||||
|
&update.download_url,
|
||||||
|
) || update.signature.trim().is_empty()
|
||||||
|
{
|
||||||
|
fail(&app, "metadataError");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
update.timeout = Some(Duration::from_secs(600));
|
||||||
|
let state = app.state::<Mutex<State>>();
|
||||||
|
let mut state = state.lock().unwrap();
|
||||||
|
state.view.phase = "available";
|
||||||
|
state.view.version = Some(update.version.clone());
|
||||||
|
state.view.notes = update.body.clone();
|
||||||
|
state.update = Some(update);
|
||||||
|
}
|
||||||
|
Ok(None) => app.state::<Mutex<State>>().lock().unwrap().view.phase = "current",
|
||||||
|
Err(error) => {
|
||||||
|
fail(&app, update_error(&error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refresh(&app);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
fn command(program: &str, args: &[&str]) -> Result<String, &'static str> {
|
||||||
|
let output = std::process::Command::new(program)
|
||||||
|
.args(args)
|
||||||
|
.output()
|
||||||
|
.map_err(|_| "verificationError")?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err("verificationError");
|
||||||
|
}
|
||||||
|
Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
|
||||||
|
}
|
||||||
|
fn matches_architecture(header: &[u8]) -> bool {
|
||||||
|
let expected_cpu: u32 = if cfg!(target_arch = "aarch64") {
|
||||||
|
0x0100_000c
|
||||||
|
} else {
|
||||||
|
0x0100_0007
|
||||||
|
};
|
||||||
|
header.len() >= 8
|
||||||
|
&& header[..4] == [0xcf, 0xfa, 0xed, 0xfe]
|
||||||
|
&& header[4..8] == expected_cpu.to_le_bytes()
|
||||||
|
}
|
||||||
|
fn verify_bundle(archive: &Path, directory: &Path, version: &str) -> Result<(), &'static str> {
|
||||||
|
command(
|
||||||
|
"/usr/bin/tar",
|
||||||
|
&[
|
||||||
|
"-xzf",
|
||||||
|
archive.to_str().ok_or("verificationError")?,
|
||||||
|
"-C",
|
||||||
|
directory.to_str().ok_or("verificationError")?,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
let app = directory.join("KanbanGit.app");
|
||||||
|
let plist = app.join("Contents/Info.plist");
|
||||||
|
let plist = plist.to_str().ok_or("verificationError")?;
|
||||||
|
for (field, expected) in [
|
||||||
|
("CFBundleShortVersionString", version),
|
||||||
|
("CFBundleIdentifier", "de.kanbangit.desktop"),
|
||||||
|
] {
|
||||||
|
if command(
|
||||||
|
"/usr/bin/plutil",
|
||||||
|
&["-extract", field, "raw", "-o", "-", plist],
|
||||||
|
)? != expected
|
||||||
|
{
|
||||||
|
return Err("verificationError");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let executable = app.join("Contents/MacOS/kanbangit-native");
|
||||||
|
let mut header = [0u8; 8];
|
||||||
|
fs::File::open(executable)
|
||||||
|
.and_then(|mut file| file.read_exact(&mut header))
|
||||||
|
.map_err(|_| "verificationError")?;
|
||||||
|
if !matches_architecture(&header) {
|
||||||
|
return Err("verificationError");
|
||||||
|
}
|
||||||
|
command("/usr/bin/codesign", &["--verify", "--deep", "--strict", "-R", "=anchor apple generic and identifier \"de.kanbangit.desktop\" and certificate leaf[subject.OU] = \"ZZX2KZGTHU\" and certificate leaf[field.1.2.840.113635.100.6.1.13] exists", app.to_str().ok_or("verificationError")?])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn install(app: &AppHandle, confirmed_version: Option<&str>) {
|
||||||
|
let update = {
|
||||||
|
let state = app.state::<Mutex<State>>();
|
||||||
|
let mut state = state.lock().unwrap();
|
||||||
|
if state.busy() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(update) = state.update.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if confirmed_version != Some(update.version.as_str()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.view.phase = "downloading";
|
||||||
|
state.view.error = None;
|
||||||
|
state.view.downloaded = 0;
|
||||||
|
state.view.total = None;
|
||||||
|
update
|
||||||
|
};
|
||||||
|
refresh(app);
|
||||||
|
let app = app.clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let bytes = update
|
||||||
|
.download(
|
||||||
|
|chunk, total| {
|
||||||
|
{
|
||||||
|
let state = app.state::<Mutex<State>>();
|
||||||
|
let mut state = state.lock().unwrap();
|
||||||
|
state.view.downloaded += chunk as u64;
|
||||||
|
state.view.total = total;
|
||||||
|
}
|
||||||
|
refresh(&app);
|
||||||
|
},
|
||||||
|
|| {
|
||||||
|
app.state::<Mutex<State>>().lock().unwrap().view.phase = "verifying";
|
||||||
|
refresh(&app);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let bytes = match bytes {
|
||||||
|
Ok(bytes) => bytes,
|
||||||
|
Err(error) => {
|
||||||
|
fail(&app, update_error(&error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let handle = app.clone();
|
||||||
|
let result = tauri::async_runtime::spawn_blocking(move || -> Result<(), &'static str> {
|
||||||
|
let cache = handle
|
||||||
|
.path()
|
||||||
|
.app_cache_dir()
|
||||||
|
.map_err(|_| "installationError")?
|
||||||
|
.join("verified-update");
|
||||||
|
if cache.exists() {
|
||||||
|
fs::remove_dir_all(&cache).map_err(|_| "installationError")?;
|
||||||
|
}
|
||||||
|
fs::create_dir_all(&cache).map_err(|_| "installationError")?;
|
||||||
|
let archive = cache.join("update.tar.gz");
|
||||||
|
let result = (|| {
|
||||||
|
fs::write(&archive, &bytes).map_err(|_| "installationError")?;
|
||||||
|
verify_bundle(&archive, &cache, &update.version)?;
|
||||||
|
handle.state::<Mutex<State>>().lock().unwrap().view.phase = "installing";
|
||||||
|
refresh(&handle);
|
||||||
|
update.install(&bytes).map_err(|_| "installationError")
|
||||||
|
})();
|
||||||
|
let _ = fs::remove_dir_all(cache);
|
||||||
|
result
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
match result {
|
||||||
|
Ok(Ok(())) => {
|
||||||
|
app.state::<Mutex<State>>().lock().unwrap().view.phase = "restarting";
|
||||||
|
refresh(&app);
|
||||||
|
app.restart();
|
||||||
|
}
|
||||||
|
Ok(Err(message)) => fail(&app, message),
|
||||||
|
Err(_) => fail(&app, "installationError"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
fn action(app: &AppHandle, url: &Url) {
|
||||||
|
match url.path() {
|
||||||
|
"/updates/check" => check(app, true),
|
||||||
|
"/updates/install" => install(
|
||||||
|
app,
|
||||||
|
url.query_pairs()
|
||||||
|
.find(|(key, _)| key == "version")
|
||||||
|
.as_ref()
|
||||||
|
.map(|(_, value)| value.as_ref()),
|
||||||
|
),
|
||||||
|
"/updates/preferences" => {
|
||||||
|
let params: std::collections::HashMap<_, _> = url.query_pairs().collect();
|
||||||
|
let preferences = Preferences {
|
||||||
|
automatic: params.get("automatic").is_some_and(|v| v == "true"),
|
||||||
|
interval_hours: params
|
||||||
|
.get("interval")
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(0),
|
||||||
|
};
|
||||||
|
if !preferences.valid() {
|
||||||
|
fail(app, "preferencesError");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let saved = (|| -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let directory = app.path().app_config_dir()?;
|
||||||
|
fs::create_dir_all(&directory)?;
|
||||||
|
let pending = directory.join("update-preferences.pending");
|
||||||
|
fs::write(&pending, serde_json::to_vec(&preferences)?)?;
|
||||||
|
fs::rename(pending, directory.join("update-preferences.json"))?;
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
if saved.is_err() {
|
||||||
|
fail(app, "preferencesError");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
app.state::<Mutex<State>>().lock().unwrap().view.preferences = preferences;
|
||||||
|
refresh(app);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn setup(app: &AppHandle) -> tauri::Result<()> {
|
||||||
|
app.plugin(
|
||||||
|
tauri_plugin_updater::Builder::new()
|
||||||
|
.pubkey(&source().public_key)
|
||||||
|
.build(),
|
||||||
|
)?;
|
||||||
|
let path = app.path().app_config_dir()?.join("update-preferences.json");
|
||||||
|
let (preferences, error) = match fs::read(path) {
|
||||||
|
Ok(bytes) => match serde_json::from_slice::<Preferences>(&bytes) {
|
||||||
|
Ok(p) if p.valid() => (p, None),
|
||||||
|
_ => (
|
||||||
|
Preferences {
|
||||||
|
automatic: false,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Some("preferencesError"),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
(Preferences::default(), None)
|
||||||
|
}
|
||||||
|
Err(_) => (
|
||||||
|
Preferences {
|
||||||
|
automatic: false,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Some("preferencesError"),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
app.manage(Mutex::new(State {
|
||||||
|
view: View {
|
||||||
|
phase: if error.is_some() { "error" } else { "idle" },
|
||||||
|
current_version: app.package_info().version.to_string(),
|
||||||
|
build: crate::metadata::build_number().into(),
|
||||||
|
version: None,
|
||||||
|
notes: None,
|
||||||
|
downloaded: 0,
|
||||||
|
total: None,
|
||||||
|
error,
|
||||||
|
preferences,
|
||||||
|
},
|
||||||
|
update: None,
|
||||||
|
last_check: None,
|
||||||
|
}));
|
||||||
|
let handle = app.clone();
|
||||||
|
let window = WebviewWindowBuilder::new(app, "updates", WebviewUrl::App("updates.html".into()))
|
||||||
|
.title(crate::locale::en::UPDATES)
|
||||||
|
.inner_size(560.0, 690.0)
|
||||||
|
.min_inner_size(420.0, 580.0)
|
||||||
|
.center()
|
||||||
|
.visible(false)
|
||||||
|
.on_navigation(move |url| {
|
||||||
|
if !trusted_navigation(url) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if url.path().starts_with("/updates/") {
|
||||||
|
action(&handle, url);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
url.path() == "/updates.html"
|
||||||
|
})
|
||||||
|
.on_new_window(|_, _| NewWindowResponse::Deny)
|
||||||
|
.on_page_load(|window, payload| {
|
||||||
|
if payload.event() == PageLoadEvent::Finished {
|
||||||
|
refresh(window.app_handle());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build()?;
|
||||||
|
let hide = window.clone();
|
||||||
|
window.on_window_event(move |event| {
|
||||||
|
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||||
|
api.prevent_close();
|
||||||
|
let _ = hide.hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let handle = app.clone();
|
||||||
|
std::thread::spawn(move || loop {
|
||||||
|
check(&handle, false);
|
||||||
|
std::thread::sleep(Duration::from_secs(30));
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
fn state() -> State {
|
||||||
|
State {
|
||||||
|
view: View {
|
||||||
|
phase: "idle",
|
||||||
|
current_version: "1.0.0".into(),
|
||||||
|
build: "1".into(),
|
||||||
|
version: None,
|
||||||
|
notes: None,
|
||||||
|
downloaded: 0,
|
||||||
|
total: None,
|
||||||
|
error: None,
|
||||||
|
preferences: Preferences::default(),
|
||||||
|
},
|
||||||
|
update: None,
|
||||||
|
last_check: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn accepts_only_newer_stable_versions_and_exact_release_paths() {
|
||||||
|
assert!(newer_stable("1.9.9", "1.10.0"));
|
||||||
|
for version in ["0.9.0", "1.0.0", "1.2.0-beta", "1.2.0+build", "invalid"] {
|
||||||
|
assert!(!newer_stable("1.0.0", version));
|
||||||
|
}
|
||||||
|
let good = format!(
|
||||||
|
"{}/{}/1.1.0/KanbanGit-1.1.0-{}.app.tar.gz",
|
||||||
|
source().base_url,
|
||||||
|
target(),
|
||||||
|
target()
|
||||||
|
);
|
||||||
|
assert!(valid_download(
|
||||||
|
"1.0.0",
|
||||||
|
"1.1.0",
|
||||||
|
&Url::parse(&good).unwrap()
|
||||||
|
));
|
||||||
|
for input in [
|
||||||
|
good.replace("https:", "http:"),
|
||||||
|
good.replace(".app.tar.gz", ".dmg"),
|
||||||
|
good.replace("1.1.0/", "0.9.0/"),
|
||||||
|
good.replace(&target(), "foreign-apple-darwin"),
|
||||||
|
format!("{good}?token=anything"),
|
||||||
|
format!("{good}#fragment"),
|
||||||
|
good.replace("https://", "https://user@"),
|
||||||
|
"https://untrusted.example/update.app.tar.gz".into(),
|
||||||
|
] {
|
||||||
|
assert!(!valid_download(
|
||||||
|
"1.0.0",
|
||||||
|
"1.1.0",
|
||||||
|
&Url::parse(&input).unwrap()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn automatic_checks_start_once_then_respect_interval_and_opt_out() {
|
||||||
|
let mut state = state();
|
||||||
|
let now = Instant::now();
|
||||||
|
assert!(state.begin_check(false, now));
|
||||||
|
assert!(!state.begin_check(false, now));
|
||||||
|
state.view.phase = "current";
|
||||||
|
assert!(!state.begin_check(false, now + Duration::from_secs(6 * 3600 - 1)));
|
||||||
|
assert!(state.begin_check(false, now + Duration::from_secs(6 * 3600)));
|
||||||
|
state.view.phase = "current";
|
||||||
|
state.view.preferences.automatic = false;
|
||||||
|
assert!(!state.begin_check(false, now + Duration::from_secs(24 * 3600)));
|
||||||
|
assert!(state.begin_check(true, now + Duration::from_secs(24 * 3600)));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn manual_checks_retry_errors_but_never_overlap_downloads_or_installation() {
|
||||||
|
let mut state = state();
|
||||||
|
state.view.phase = "error";
|
||||||
|
state.view.error = Some("networkError");
|
||||||
|
assert!(state.begin_check(true, Instant::now()));
|
||||||
|
assert!(state.view.error.is_none());
|
||||||
|
for phase in [
|
||||||
|
"checking",
|
||||||
|
"downloading",
|
||||||
|
"verifying",
|
||||||
|
"installing",
|
||||||
|
"restarting",
|
||||||
|
] {
|
||||||
|
state.view.phase = phase;
|
||||||
|
assert!(!state.begin_check(true, Instant::now()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn preferences_validate_and_round_trip_without_server_settings() {
|
||||||
|
let p = Preferences {
|
||||||
|
automatic: false,
|
||||||
|
interval_hours: 24,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&p).unwrap();
|
||||||
|
let loaded: Preferences = serde_json::from_str(&json).unwrap();
|
||||||
|
assert!(!loaded.automatic);
|
||||||
|
assert_eq!(loaded.interval_hours, 24);
|
||||||
|
assert!(loaded.valid());
|
||||||
|
for interval in [0, 2, 100, u64::MAX] {
|
||||||
|
assert!(!Preferences {
|
||||||
|
automatic: true,
|
||||||
|
interval_hours: interval
|
||||||
|
}
|
||||||
|
.valid());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn update_actions_are_confined_to_the_local_trusted_window() {
|
||||||
|
assert!(trusted_navigation(
|
||||||
|
&Url::parse("tauri://localhost/updates/install").unwrap()
|
||||||
|
));
|
||||||
|
for address in [
|
||||||
|
"https://kanbangit.de/updates/install",
|
||||||
|
"https://localhost/updates/install",
|
||||||
|
"tauri://user@localhost/updates/install",
|
||||||
|
"tauri://localhost:443/updates/install",
|
||||||
|
"tauri://other/updates/install",
|
||||||
|
] {
|
||||||
|
assert!(!trusted_navigation(&Url::parse(address).unwrap()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn rejects_foreign_or_invalid_mach_o_headers_without_developer_tools() {
|
||||||
|
let cpu: u32 = if cfg!(target_arch = "aarch64") {
|
||||||
|
0x0100_000c
|
||||||
|
} else {
|
||||||
|
0x0100_0007
|
||||||
|
};
|
||||||
|
let mut header = vec![0xcf, 0xfa, 0xed, 0xfe];
|
||||||
|
header.extend(cpu.to_le_bytes());
|
||||||
|
assert!(matches_architecture(&header));
|
||||||
|
header[4] ^= 1;
|
||||||
|
assert!(!matches_architecture(&header));
|
||||||
|
assert!(!matches_architecture(b"MZ"));
|
||||||
|
assert!(!matches_architecture(&[0; 8]));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn error_categories_offer_specific_retryable_messages() {
|
||||||
|
use tauri_plugin_updater::Error;
|
||||||
|
assert_eq!(
|
||||||
|
update_error(&Error::Network("offline".into())),
|
||||||
|
"networkError"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
update_error(&Error::TargetNotFound("darwin-other".into())),
|
||||||
|
"metadataError"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
update_error(&Error::SignatureUtf8("invalid".into())),
|
||||||
|
"signatureError"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod signed_upgrade_test {
|
||||||
|
use super::*;
|
||||||
|
use std::{io::Write, net::TcpListener};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "Requires KANBANGIT_UPGRADE_BASE and KANBANGIT_UPGRADE_ARCHIVE pointing to two Developer-ID-signed macOS versions"]
|
||||||
|
fn installs_a_real_signed_upgrade_and_rejects_tampering() {
|
||||||
|
let base = std::env::var("KANBANGIT_UPGRADE_BASE").expect("Set the signed base app path");
|
||||||
|
let archive = std::env::var("KANBANGIT_UPGRADE_ARCHIVE")
|
||||||
|
.expect("Set the signed updater archive path");
|
||||||
|
let base_version = env!("CARGO_PKG_VERSION");
|
||||||
|
let mut next_version = Version::parse(base_version).unwrap();
|
||||||
|
next_version.patch += 1;
|
||||||
|
let candidate_version =
|
||||||
|
std::env::var("KANBANGIT_UPGRADE_VERSION").unwrap_or_else(|_| next_version.to_string());
|
||||||
|
assert!(newer_stable(base_version, &candidate_version));
|
||||||
|
let root = std::env::temp_dir().join(format!("kanbangit-upgrade-{}", std::process::id()));
|
||||||
|
fs::create_dir(&root).unwrap();
|
||||||
|
struct Cleanup(std::path::PathBuf);
|
||||||
|
impl Drop for Cleanup {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = fs::remove_dir_all(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _cleanup = Cleanup(root.clone());
|
||||||
|
let installed = root.join("KanbanGit.app");
|
||||||
|
command("/usr/bin/ditto", &[&base, installed.to_str().unwrap()]).unwrap();
|
||||||
|
let data = root.join("user-data");
|
||||||
|
fs::create_dir(&data).unwrap();
|
||||||
|
fs::write(data.join("active-server"), "https://workspace.example/").unwrap();
|
||||||
|
fs::write(data.join("session"), "existing-session").unwrap();
|
||||||
|
let bytes = fs::read(&archive).unwrap();
|
||||||
|
let signature = fs::read_to_string(format!("{archive}.sig")).unwrap();
|
||||||
|
let verify_dir = root.join("verify");
|
||||||
|
fs::create_dir(&verify_dir).unwrap();
|
||||||
|
verify_bundle(Path::new(&archive), &verify_dir, &candidate_version).unwrap();
|
||||||
|
assert!(verify_bundle(Path::new(&archive), &verify_dir, base_version).is_err());
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let address = listener.local_addr().unwrap();
|
||||||
|
let download = bytes.clone();
|
||||||
|
let announced_version = candidate_version.clone();
|
||||||
|
let server = std::thread::spawn(move || {
|
||||||
|
for index in 0..3 {
|
||||||
|
let (mut stream, _) = listener.accept().unwrap();
|
||||||
|
stream
|
||||||
|
.set_read_timeout(Some(Duration::from_secs(10)))
|
||||||
|
.unwrap();
|
||||||
|
let mut request = [0; 4096];
|
||||||
|
let _ = stream.read(&mut request).unwrap();
|
||||||
|
let body = if index == 0 {
|
||||||
|
serde_json::to_vec(&serde_json::json!({"version":announced_version, "platforms":{(format!("darwin-{}", std::env::consts::ARCH)): {"url":format!("http://{address}/update.tar.gz"), "signature":signature.trim()}}})).unwrap()
|
||||||
|
} else if index == 1 {
|
||||||
|
b"tampered update".to_vec()
|
||||||
|
} else {
|
||||||
|
download.clone()
|
||||||
|
};
|
||||||
|
write!(
|
||||||
|
stream,
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||||
|
body.len()
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
stream.write_all(&body).unwrap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let mut context = crate::desktop::context();
|
||||||
|
context.config_mut().plugins.0.insert("updater".into(), serde_json::json!({"pubkey":source().public_key, "dangerousInsecureTransportProtocol":true}));
|
||||||
|
let app = tauri::test::mock_builder()
|
||||||
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
|
.build(context)
|
||||||
|
.unwrap();
|
||||||
|
let update = tauri::async_runtime::block_on(async {
|
||||||
|
app.updater_builder()
|
||||||
|
.executable_path(installed.join("Contents/MacOS/kanbangit-native"))
|
||||||
|
.endpoints(vec![
|
||||||
|
Url::parse(&format!("http://{address}/latest.json")).unwrap()
|
||||||
|
])
|
||||||
|
.unwrap()
|
||||||
|
.timeout(Duration::from_secs(20))
|
||||||
|
.build()
|
||||||
|
.unwrap()
|
||||||
|
.check()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
});
|
||||||
|
assert_eq!(update.current_version, base_version);
|
||||||
|
let rejected =
|
||||||
|
tauri::async_runtime::block_on(update.download(|_, _| {}, || {})).unwrap_err();
|
||||||
|
assert_eq!(update_error(&rejected), "signatureError");
|
||||||
|
let plist = installed.join("Contents/Info.plist");
|
||||||
|
assert_eq!(
|
||||||
|
command(
|
||||||
|
"/usr/bin/plutil",
|
||||||
|
&[
|
||||||
|
"-extract",
|
||||||
|
"CFBundleShortVersionString",
|
||||||
|
"raw",
|
||||||
|
"-o",
|
||||||
|
"-",
|
||||||
|
plist.to_str().unwrap()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
base_version
|
||||||
|
);
|
||||||
|
let verified = tauri::async_runtime::block_on(update.download(|_, _| {}, || {})).unwrap();
|
||||||
|
assert_eq!(verified, bytes);
|
||||||
|
update.install(verified).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
command(
|
||||||
|
"/usr/bin/plutil",
|
||||||
|
&[
|
||||||
|
"-extract",
|
||||||
|
"CFBundleShortVersionString",
|
||||||
|
"raw",
|
||||||
|
"-o",
|
||||||
|
"-",
|
||||||
|
plist.to_str().unwrap()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
candidate_version
|
||||||
|
);
|
||||||
|
command(
|
||||||
|
"/usr/bin/codesign",
|
||||||
|
&[
|
||||||
|
"--verify",
|
||||||
|
"--deep",
|
||||||
|
"--strict",
|
||||||
|
installed.to_str().unwrap(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(data.join("active-server")).unwrap(),
|
||||||
|
"https://workspace.example/"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(data.join("session")).unwrap(),
|
||||||
|
"existing-session"
|
||||||
|
);
|
||||||
|
server.join().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.1",
|
"version": "0.2.4",
|
||||||
"identifier": "de.kanbangit.desktop",
|
"identifier": "de.kanbangit.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../ui"
|
"frontendDist": "../ui"
|
||||||
@@ -16,10 +16,38 @@
|
|||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": ["app"],
|
"targets": [
|
||||||
"icon": ["icons/icon.png", "icons/icon.icns"],
|
"app"
|
||||||
|
],
|
||||||
|
"icon": [
|
||||||
|
"icons/icon.png",
|
||||||
|
"icons/icon.icns"
|
||||||
|
],
|
||||||
"macOS": {
|
"macOS": {
|
||||||
"minimumSystemVersion": "14.0"
|
"minimumSystemVersion": "14.0",
|
||||||
|
"bundleVersion": "9",
|
||||||
|
"dmg": {
|
||||||
|
"windowSize": {
|
||||||
|
"width": 660,
|
||||||
|
"height": 400
|
||||||
|
},
|
||||||
|
"appPosition": {
|
||||||
|
"x": 180,
|
||||||
|
"y": 170
|
||||||
|
},
|
||||||
|
"applicationFolderPosition": {
|
||||||
|
"x": 480,
|
||||||
|
"y": 170
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"versionCode": 2004
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"updater": {
|
||||||
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhBQzA0QjhENTI3OEE1QzAKUldUQXBYaFNqVXZBaXJVWmErWUZTNVp6QnllT25NWFMyemxqcFBnYnJHT3JBSWxrSkVYU1VVMFEK"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "4"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,27 @@ 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 initialHistory = history.length;
|
||||||
|
const initialPath = location.pathname;
|
||||||
|
const navigation = window.__kanbanNavigation;
|
||||||
|
if (!navigation || getComputedStyle(document.documentElement).overscrollBehaviorY !== 'none' || getComputedStyle(document.documentElement).overscrollBehaviorX !== 'auto') throw new Error('Scroll boundary policy missing');
|
||||||
|
const traverse = direction => new Promise(resolve => {
|
||||||
|
addEventListener('popstate', resolve, { once: true });
|
||||||
|
navigation.step(direction);
|
||||||
|
});
|
||||||
|
for (const hash of ['#board', '#ticket', '#settings']) history.pushState({ page: { url: hash } }, '', hash);
|
||||||
|
await traverse(-1);
|
||||||
|
if (location.hash !== '#ticket') throw new Error('Back skipped an SPA entry');
|
||||||
|
await traverse(-1);
|
||||||
|
if (location.hash !== '#board') throw new Error('Back skipped a second SPA entry');
|
||||||
|
await traverse(1);
|
||||||
|
if (location.hash !== '#ticket' || history.length !== initialHistory + 3) throw new Error('Forward history was lost');
|
||||||
|
const dialog = document.createElement('dialog');
|
||||||
|
dialog.textContent = 'Navigation overlay'; document.body.append(dialog); dialog.showModal();
|
||||||
|
if (navigation.canSwipe(1, 1)) throw new Error('Overlay must own navigation');
|
||||||
|
dialog.close(); dialog.remove();
|
||||||
|
if (input.value !== 'unsaved input') throw new Error('History discarded form state');
|
||||||
|
const ok = rendered && denied && typeof window.__TAURI__ === 'undefined' && initialPath === '/login' && initialHistory === 1;
|
||||||
location.href = '/__native_smoke_result?stage=loaded&passed=' + ok;
|
location.href = '/__native_smoke_result?stage=loaded&passed=' + ok;
|
||||||
})();
|
})();
|
||||||
"#).unwrap();
|
"#).unwrap();
|
||||||
@@ -90,7 +159,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 +189,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 +210,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 +236,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),
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
#[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")]
|
||||||
|
#[tauri::command]
|
||||||
|
fn updater_smoke_report(app: tauri::AppHandle, passed: bool, reason: String) {
|
||||||
|
use tauri::Manager;
|
||||||
|
println!("Updater WebView test: passed={passed}, {reason}");
|
||||||
|
app.state::<std::sync::Arc<std::sync::atomic::AtomicBool>>()
|
||||||
|
.store(passed, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
app.exit(if passed { 0 } else { 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn main() {
|
||||||
|
use std::sync::{
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
Arc,
|
||||||
|
};
|
||||||
|
use tauri::webview::PageLoadEvent;
|
||||||
|
let passed = Arc::new(AtomicBool::new(false));
|
||||||
|
let config: serde_json::Value =
|
||||||
|
serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
|
||||||
|
let expected_installed = format!(
|
||||||
|
"Installed version {} (build {})",
|
||||||
|
config["version"].as_str().unwrap(),
|
||||||
|
config["bundle"]["macOS"]["bundleVersion"].as_str().unwrap()
|
||||||
|
);
|
||||||
|
|
||||||
|
let plugin = tauri::plugin::Builder::<tauri::Wry>::new("updater-smoke")
|
||||||
|
.js_init_script(format!("window.expectedInstalled = {};", serde_json::to_string(&expected_installed).unwrap()))
|
||||||
|
.on_page_load(|window, payload| {
|
||||||
|
if window.label() != "updates" || payload.event() != PageLoadEvent::Finished { return; }
|
||||||
|
window.eval(r#"
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < 100 && !window.updateStatus; i++) await new Promise(r => setTimeout(r, 50));
|
||||||
|
if (!window.updateStatus) throw new Error('Update UI did not load');
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
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);
|
||||||
|
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 } };
|
||||||
|
window.updateStatus(state);
|
||||||
|
if (!$('installed').textContent.includes('0.1.2 (build 2)') || !$('install').hidden || $('check').disabled) throw new Error('Current version state failed');
|
||||||
|
Object.assign(state, { phase: 'available', version: '0.1.3', notes: '<script>unsafe()</script>\nRelease notes' });
|
||||||
|
window.updateStatus(state);
|
||||||
|
if ($('install').hidden || $('release-notes').querySelector('script') || !$('release-notes').textContent.includes('<script>')) throw new Error('Available state or text escaping failed');
|
||||||
|
$('install').click();
|
||||||
|
if ($('install-confirmation').hidden || !$('install-confirmation').textContent.includes('Unsaved inputs')) throw new Error('Missing restart confirmation');
|
||||||
|
$('cancel-install').click();
|
||||||
|
if (!$('install-confirmation').hidden) throw new Error('Cancel failed');
|
||||||
|
Object.assign(state, { phase: 'downloading', downloaded: 500, total: 1000 }); window.updateStatus(state);
|
||||||
|
if (!$('check').disabled || !$('install').hidden || $('download-progress').value !== 500) throw new Error('Progress state failed');
|
||||||
|
Object.assign(state, { phase: 'error', error: 'signatureError' }); window.updateStatus(state);
|
||||||
|
if ($('install').hidden || !$('update-error').textContent.includes('Nothing was installed')) throw new Error('Retry failed');
|
||||||
|
state.preferences.automatic = false; window.updateStatus(state);
|
||||||
|
if ($('automatic').checked || !$('interval').disabled) throw new Error('Opt out failed');
|
||||||
|
await window.__TAURI_INTERNALS__.invoke('updater_smoke_report', { passed: true, reason: '' });
|
||||||
|
} catch (error) {
|
||||||
|
await window.__TAURI_INTERNALS__.invoke('updater_smoke_report', { passed: false, reason: error.message });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
"#).unwrap();
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
let mut context = kanbangit_native::context();
|
||||||
|
context.config_mut().identifier = format!("de.kanbangit.smoke.updates.{}", std::process::id());
|
||||||
|
let app = kanbangit_native::builder()
|
||||||
|
.manage(passed.clone())
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
updater_smoke_report,
|
||||||
|
updater_smoke_menu
|
||||||
|
])
|
||||||
|
.plugin(plugin)
|
||||||
|
.build(context)
|
||||||
|
.expect("App must start");
|
||||||
|
let handle = app.handle().clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(30));
|
||||||
|
handle.exit(1);
|
||||||
|
});
|
||||||
|
app.run_return(|_, _| {});
|
||||||
|
assert!(passed.load(Ordering::SeqCst), "Updater UI test must pass");
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
fn main() {
|
||||||
|
panic!("Requires macOS");
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import {
|
|||||||
mkdirSync,
|
mkdirSync,
|
||||||
mkdtempSync,
|
mkdtempSync,
|
||||||
readFileSync,
|
readFileSync,
|
||||||
|
symlinkSync,
|
||||||
rmSync,
|
rmSync,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
} from 'node:fs';
|
} from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
import { updateSource } from '../update-manifest.mjs';
|
||||||
import {
|
import {
|
||||||
packageNotarizedApp,
|
packageNotarizedApp,
|
||||||
releaseMetadata,
|
releaseMetadata,
|
||||||
@@ -22,6 +24,8 @@ function fixture(t) {
|
|||||||
mkdirSync(join(directory, 'src-tauri'));
|
mkdirSync(join(directory, 'src-tauri'));
|
||||||
const config = {
|
const config = {
|
||||||
version: '1.2.3',
|
version: '1.2.3',
|
||||||
|
plugins: { updater: { pubkey: updateSource.publicKey } },
|
||||||
|
bundle: { macOS: { bundleVersion: '123' } },
|
||||||
productName: 'KanbanGit',
|
productName: 'KanbanGit',
|
||||||
identifier: 'de.kanbangit.desktop',
|
identifier: 'de.kanbangit.desktop',
|
||||||
};
|
};
|
||||||
@@ -31,7 +35,7 @@ function fixture(t) {
|
|||||||
);
|
);
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
join(directory, 'package.json'),
|
join(directory, 'package.json'),
|
||||||
JSON.stringify({ version: '1.2.3' }),
|
JSON.stringify({ version: '1.2.3', releaseNotes: 'Release notes' }),
|
||||||
);
|
);
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
join(directory, 'src-tauri/Cargo.toml'),
|
join(directory, 'src-tauri/Cargo.toml'),
|
||||||
@@ -42,6 +46,21 @@ function fixture(t) {
|
|||||||
APPLE_SIGNING_IDENTITY:
|
APPLE_SIGNING_IDENTITY:
|
||||||
'Developer ID Application: Example (ZZX2KZGTHU)',
|
'Developer ID Application: Example (ZZX2KZGTHU)',
|
||||||
};
|
};
|
||||||
|
const bundle = join(
|
||||||
|
directory,
|
||||||
|
'src-tauri/target/aarch64-apple-darwin/release/bundle',
|
||||||
|
);
|
||||||
|
mkdirSync(join(bundle, 'dmg'), { recursive: true });
|
||||||
|
mkdirSync(join(bundle, 'macos'), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(bundle, 'dmg/KanbanGit_1.2.3_aarch64.dmg'),
|
||||||
|
'notarized archive',
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(bundle, 'macos/KanbanGit.app.tar.gz'),
|
||||||
|
'notarized updater',
|
||||||
|
);
|
||||||
|
writeFileSync(join(bundle, 'macos/KanbanGit.app.tar.gz.sig'), 'signature');
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const run = (command, args) => {
|
const run = (command, args) => {
|
||||||
calls.push({ command, args });
|
calls.push({ command, args });
|
||||||
@@ -54,8 +73,8 @@ function fixture(t) {
|
|||||||
if (command === 'lipo') {
|
if (command === 'lipo') {
|
||||||
return 'arm64';
|
return 'arm64';
|
||||||
}
|
}
|
||||||
if (command === 'ditto' && args.includes('-c')) {
|
if (command === 'hdiutil' && args[0] === 'attach') {
|
||||||
writeFileSync(args.at(-1), 'notarized archive');
|
symlinkSync('/Applications', join(args.at(-1), 'Applications'));
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
@@ -66,9 +85,11 @@ test('release metadata binds tag, all app versions, identity and architecture',
|
|||||||
const f = fixture(t);
|
const f = fixture(t);
|
||||||
assert.deepEqual(releaseMetadata(f.env, f.directory), {
|
assert.deepEqual(releaseMetadata(f.env, f.directory), {
|
||||||
version: '1.2.3',
|
version: '1.2.3',
|
||||||
|
notes: 'Release notes',
|
||||||
target: 'aarch64-apple-darwin',
|
target: 'aarch64-apple-darwin',
|
||||||
arch: 'arm64',
|
arch: 'arm64',
|
||||||
filename: 'KanbanGit-1.2.3-aarch64-apple-darwin.zip',
|
filename: 'KanbanGit-1.2.3-aarch64-apple-darwin.dmg',
|
||||||
|
updaterFilename: 'KanbanGit-1.2.3-aarch64-apple-darwin.app.tar.gz',
|
||||||
});
|
});
|
||||||
assert.equal(
|
assert.equal(
|
||||||
releaseMetadata(
|
releaseMetadata(
|
||||||
@@ -127,8 +148,8 @@ test('packages only a verified app and checks the extracted download again', (t)
|
|||||||
),
|
),
|
||||||
`${output.checksum} ${output.filename}\n`,
|
`${output.checksum} ${output.filename}\n`,
|
||||||
);
|
);
|
||||||
assert.equal(f.calls.filter((call) => call.command === 'spctl').length, 2);
|
assert.equal(f.calls.filter((call) => call.command === 'spctl').length, 3);
|
||||||
assert.equal(f.calls.filter((call) => call.command === 'xcrun').length, 2);
|
assert.equal(f.calls.filter((call) => call.command === 'xcrun').length, 3);
|
||||||
assert.match(
|
assert.match(
|
||||||
f.calls.findLast((call) => call.command === 'spctl').args.at(-1),
|
f.calls.findLast((call) => call.command === 'spctl').args.at(-1),
|
||||||
/extracted\/KanbanGit\.app$/,
|
/extracted\/KanbanGit\.app$/,
|
||||||
@@ -136,7 +157,14 @@ test('packages only a verified app and checks the extracted download again', (t)
|
|||||||
assert.throws(() => packageNotarizedApp(f), /already exists/);
|
assert.throws(() => packageNotarizedApp(f), /already exists/);
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const failure of ['codesign', 'lipo', 'xcrun', 'spctl', 'ditto']) {
|
for (const failure of [
|
||||||
|
'codesign',
|
||||||
|
'lipo',
|
||||||
|
'xcrun',
|
||||||
|
'spctl',
|
||||||
|
'hdiutil',
|
||||||
|
'tar',
|
||||||
|
]) {
|
||||||
test(`does not publish local output when ${failure} verification fails`, (t) => {
|
test(`does not publish local output when ${failure} verification fails`, (t) => {
|
||||||
const f = fixture(t);
|
const f = fixture(t);
|
||||||
const run = f.run;
|
const run = f.run;
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ function fixture(t, overrides = {}) {
|
|||||||
arch: 'arm64',
|
arch: 'arm64',
|
||||||
log() {},
|
log() {},
|
||||||
env: {
|
env: {
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: 'test-private-key',
|
||||||
APPLE_SIGNING_IDENTITY: identity,
|
APPLE_SIGNING_IDENTITY: identity,
|
||||||
APPLE_NOTARY_KEYCHAIN_PROFILE: 'kanbangit-notary',
|
APPLE_NOTARY_KEYCHAIN_PROFILE: 'kanbangit-notary',
|
||||||
},
|
},
|
||||||
@@ -57,7 +58,11 @@ function fixture(t, overrides = {}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command === 'pnpm' && !overrides.missingApp) {
|
if (
|
||||||
|
command === 'pnpm' &&
|
||||||
|
args.includes('build') &&
|
||||||
|
!overrides.missingApp
|
||||||
|
) {
|
||||||
const target = args[args.indexOf('--target') + 1];
|
const target = args[args.indexOf('--target') + 1];
|
||||||
mkdirSync(
|
mkdirSync(
|
||||||
join(
|
join(
|
||||||
@@ -137,7 +142,16 @@ function fixture(t, overrides = {}) {
|
|||||||
stapled = true;
|
stapled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command === 'ditto') {
|
if (command === 'pnpm' && args.includes('signer')) {
|
||||||
|
writeFileSync(`${args.at(-1)}.sig`, 'signature');
|
||||||
|
}
|
||||||
|
if (command === 'tar') {
|
||||||
|
writeFileSync(args[1], 'updater with stapled ticket');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(command === 'hdiutil' && args[0] === 'create') ||
|
||||||
|
command === 'ditto'
|
||||||
|
) {
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
args.at(-1),
|
args.at(-1),
|
||||||
stapled
|
stapled
|
||||||
@@ -171,11 +185,14 @@ test('publishes only the stapled app archive with its exact SHA-256 checksum aft
|
|||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
readFileSync(`${result.archive}.sha256`, 'utf8'),
|
readFileSync(`${result.archive}.sha256`, 'utf8'),
|
||||||
`${result.checksum} KanbanGit-1.2.3-aarch64-apple-darwin.zip\n`,
|
`${result.checksum} KanbanGit-1.2.3-aarch64-apple-darwin.dmg\n`,
|
||||||
);
|
);
|
||||||
assert.deepEqual(readdirSync(f.destination).sort(), [
|
assert.deepEqual(readdirSync(f.destination).sort(), [
|
||||||
'KanbanGit-1.2.3-aarch64-apple-darwin.zip',
|
'KanbanGit-1.2.3-aarch64-apple-darwin.app.tar.gz',
|
||||||
'KanbanGit-1.2.3-aarch64-apple-darwin.zip.sha256',
|
'KanbanGit-1.2.3-aarch64-apple-darwin.app.tar.gz.sha256',
|
||||||
|
'KanbanGit-1.2.3-aarch64-apple-darwin.app.tar.gz.sig',
|
||||||
|
'KanbanGit-1.2.3-aarch64-apple-darwin.dmg',
|
||||||
|
'KanbanGit-1.2.3-aarch64-apple-darwin.dmg.sha256',
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(readdirSync(f.releaseDirectory), [
|
assert.deepEqual(readdirSync(f.releaseDirectory), [
|
||||||
'1.2.3-aarch64-apple-darwin',
|
'1.2.3-aarch64-apple-darwin',
|
||||||
@@ -195,6 +212,11 @@ test('publishes only the stapled app archive with its exact SHA-256 checksum aft
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const [name, env, expected] of [
|
for (const [name, env, expected] of [
|
||||||
|
[
|
||||||
|
'missing updater signing key',
|
||||||
|
{ TAURI_SIGNING_PRIVATE_KEY: '' },
|
||||||
|
/TAURI_SIGNING_PRIVATE_KEY/,
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'missing identity',
|
'missing identity',
|
||||||
{ APPLE_SIGNING_IDENTITY: '' },
|
{ APPLE_SIGNING_IDENTITY: '' },
|
||||||
@@ -343,7 +365,7 @@ test('supports an explicit Intel target with distinct output naming', (t) => {
|
|||||||
assert.equal(result.target, 'x86_64-apple-darwin');
|
assert.equal(result.target, 'x86_64-apple-darwin');
|
||||||
assert.match(
|
assert.match(
|
||||||
result.archive,
|
result.archive,
|
||||||
/1\.2\.3-x86_64-apple-darwin\/KanbanGit-1\.2\.3-x86_64-apple-darwin\.zip$/,
|
/1\.2\.3-x86_64-apple-darwin\/KanbanGit-1\.2\.3-x86_64-apple-darwin\.dmg$/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -454,7 +476,9 @@ test('persists submission ID on timeout and resumes the same app without rebuild
|
|||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
f.calls.some(
|
f.calls.some(
|
||||||
(call) => call.command === 'pnpm' || call.args[1] === 'submit',
|
(call) =>
|
||||||
|
(call.command === 'pnpm' && call.args.includes('build')) ||
|
||||||
|
call.args[1] === 'submit',
|
||||||
),
|
),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
@@ -481,7 +505,9 @@ test('resume never rebuilds a missing previously submitted app', (t) => {
|
|||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
f.calls.some(
|
f.calls.some(
|
||||||
(call) => call.command === 'pnpm' || call.args[1] === 'submit',
|
(call) =>
|
||||||
|
(call.command === 'pnpm' && call.args.includes('build')) ||
|
||||||
|
call.args[1] === 'submit',
|
||||||
),
|
),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
@@ -552,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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,9 +111,56 @@ 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', () => {
|
||||||
|
const build = workflow
|
||||||
|
.split(' - name: Build, sign and notarize with Tauri\n')[1]
|
||||||
|
.split('\n - name:')[0];
|
||||||
|
const environment = build
|
||||||
|
.split(' env:\n')[1]
|
||||||
|
.split(' with:\n')[0];
|
||||||
|
assert.match(environment, /^ {10}CI: "true"$/m);
|
||||||
|
assert.match(environment, /^ {10}TAURI_BUNDLER_DMG_IGNORE_CI: "false"$/m);
|
||||||
|
assert.match(build, /--bundles app,dmg/);
|
||||||
|
assert.match(build, /"createUpdaterArtifacts":true/);
|
||||||
});
|
});
|
||||||
|
|
||||||
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' }],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -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',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
import process from 'node:process';
|
||||||
|
|
||||||
|
const { chromium } = await import(
|
||||||
|
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
|
||||||
|
);
|
||||||
|
const script = await readFile(
|
||||||
|
new URL('../ui/workspace-navigation.js', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const server = createServer((request, response) => {
|
||||||
|
response.setHeader('Content-Type', 'text/html');
|
||||||
|
response.end(`<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><style>
|
||||||
|
body { margin: 0; } #board { overflow-x: auto; width: 300px; height: 100px; }
|
||||||
|
#board > div { width: 1500px; height: 100px; } dialog { max-height: 200px; overflow-y: auto; }
|
||||||
|
</style></head><body><p id="page">${request.url}</p><input id="draft" value="retained"><div id="board"><div>Board columns</div></div>
|
||||||
|
<dialog><button id="close">Close</button><div style="height:1000px">Long dialog</div></dialog><div id="long"></div></body></html>`);
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||||
|
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||||
|
await page.addInitScript(script);
|
||||||
|
const root = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
try {
|
||||||
|
await page.goto(root);
|
||||||
|
const length = await page.evaluate(() => history.length);
|
||||||
|
for (const path of ['/board', '/ticket', '/settings']) {
|
||||||
|
await page.evaluate(
|
||||||
|
(path) =>
|
||||||
|
history.pushState(
|
||||||
|
{
|
||||||
|
page: { url: path },
|
||||||
|
rememberedState: { draft: 'retained' },
|
||||||
|
},
|
||||||
|
'',
|
||||||
|
path,
|
||||||
|
),
|
||||||
|
path,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.steps = 0;
|
||||||
|
addEventListener('popstate', () => window.steps++);
|
||||||
|
});
|
||||||
|
await page.evaluate(() => window.__kanbanNavigation.step(-1));
|
||||||
|
await page.waitForFunction(() => location.pathname === '/ticket');
|
||||||
|
assert.equal(await page.evaluate(() => window.steps), 1);
|
||||||
|
await page.evaluate(() => window.__kanbanNavigation.step(-1));
|
||||||
|
await page.waitForFunction(() => location.pathname === '/board');
|
||||||
|
await page.evaluate(() => window.__kanbanNavigation.step(1));
|
||||||
|
await page.waitForFunction(() => location.pathname === '/ticket');
|
||||||
|
assert.equal(await page.evaluate(() => window.steps), 3);
|
||||||
|
assert.equal(await page.evaluate(() => history.length), length + 3);
|
||||||
|
assert.equal(await page.locator('#draft').inputValue(), 'retained');
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(() => history.state.rememberedState.draft),
|
||||||
|
'retained',
|
||||||
|
);
|
||||||
|
await page.goto(`${root}/full-page`);
|
||||||
|
await page.evaluate(() => window.__kanbanNavigation.step(-1));
|
||||||
|
await page.waitForURL(`${root}/ticket`);
|
||||||
|
await page.evaluate(() => window.__kanbanNavigation.step(1));
|
||||||
|
await page.waitForURL(`${root}/full-page`);
|
||||||
|
await page.locator('dialog').evaluate((dialog) => dialog.showModal());
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(() => window.__kanbanNavigation.canSwipe(1, 1)),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
await page.locator('dialog').evaluate((dialog) => dialog.close());
|
||||||
|
for (const selector of ['#board', '#draft']) {
|
||||||
|
const point = await page.locator(selector).boundingBox();
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(
|
||||||
|
({ x, y }) => window.__kanbanNavigation.canSwipe(x + 5, y + 5),
|
||||||
|
point,
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(() => window.__kanbanNavigation.canSwipe(1, 1)),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await page.dispatchEvent('#board', 'dragstart');
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(() => window.__kanbanNavigation.canSwipe(1, 1)),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
await page.dispatchEvent('#board', 'dragend');
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(() => window.__kanbanNavigation.canSwipe(1, 1)),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
getComputedStyle(document.documentElement).overscrollBehaviorY,
|
||||||
|
),
|
||||||
|
'none',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
getComputedStyle(document.documentElement).overscrollBehaviorX,
|
||||||
|
),
|
||||||
|
'auto',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(() => document.documentElement.scrollHeight),
|
||||||
|
844,
|
||||||
|
);
|
||||||
|
await page.mouse.move(380, 400);
|
||||||
|
await page.mouse.wheel(0, -1000);
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
assert.equal(await page.evaluate(() => scrollY), 0);
|
||||||
|
await page
|
||||||
|
.locator('#long')
|
||||||
|
.evaluate((element) => (element.style.height = '3000px'));
|
||||||
|
await page.mouse.wheel(0, 1000);
|
||||||
|
await page.waitForFunction(() => scrollY > 0);
|
||||||
|
await page.evaluate(() =>
|
||||||
|
scrollTo(0, document.documentElement.scrollHeight),
|
||||||
|
);
|
||||||
|
await page.mouse.wheel(0, 5000);
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(() => scrollY + innerHeight),
|
||||||
|
await page.evaluate(() => document.documentElement.scrollHeight),
|
||||||
|
);
|
||||||
|
await page.evaluate(() => scrollTo(0, 0));
|
||||||
|
await page.locator('#board').hover();
|
||||||
|
await page.mouse.wheel(500, 0);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.querySelector('#board').scrollLeft > 0,
|
||||||
|
);
|
||||||
|
assert.equal(new URL(page.url()).pathname, '/full-page');
|
||||||
|
await page.locator('dialog').evaluate((dialog) => dialog.showModal());
|
||||||
|
await page.locator('dialog').hover();
|
||||||
|
await page.mouse.wheel(0, 500);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.querySelector('dialog').scrollTop > 0,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
'PASS: SPA/full-page history, forward history, overlays, short/long scroll bounds, horizontal board, drag and input guards',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
#import "../src-tauri/src/navigation.m"
|
||||||
|
|
||||||
|
// Exercise the AppKit event adapter deterministically; macos_smoke separately
|
||||||
|
// runs the shared history script against a real WKWebView.
|
||||||
|
@interface TestWindow : NSWindow
|
||||||
|
@end
|
||||||
|
@implementation TestWindow
|
||||||
|
- (BOOL)isKeyWindow { return YES; }
|
||||||
|
@end
|
||||||
|
|
||||||
|
@interface TestWebView : WKWebView
|
||||||
|
@property(nonatomic, strong) NSWindow *testWindow;
|
||||||
|
@property(nonatomic, strong) NSURL *testURL;
|
||||||
|
@property(nonatomic) BOOL back;
|
||||||
|
@property(nonatomic) BOOL forward;
|
||||||
|
@property(nonatomic) BOOL allowsSwipe;
|
||||||
|
@property(nonatomic) BOOL deferPermission;
|
||||||
|
@property(nonatomic, copy) void (^permission)(id, NSError *);
|
||||||
|
@property(nonatomic, strong) NSMutableArray<NSString *> *steps;
|
||||||
|
@end
|
||||||
|
@implementation TestWebView
|
||||||
|
- (NSWindow *)window { return self.testWindow; }
|
||||||
|
- (NSURL *)URL { return self.testURL; }
|
||||||
|
- (BOOL)canGoBack { return self.back; }
|
||||||
|
- (BOOL)canGoForward { return self.forward; }
|
||||||
|
- (NSPoint)convertPoint:(NSPoint)point fromView:(NSView *)view { return point; }
|
||||||
|
- (void)evaluateJavaScript:(NSString *)script completionHandler:(void (^)(id, NSError *))handler {
|
||||||
|
if ([script containsString:@".step("]) [self.steps addObject:script];
|
||||||
|
else if (self.deferPermission) self.permission = handler;
|
||||||
|
else handler(@(self.allowsSwipe), nil);
|
||||||
|
}
|
||||||
|
@end
|
||||||
|
|
||||||
|
@interface TestEvent : NSEvent
|
||||||
|
@property(nonatomic, strong) NSWindow *testWindow;
|
||||||
|
@property(nonatomic) NSEventPhase testPhase;
|
||||||
|
@property(nonatomic) NSEventPhase testMomentum;
|
||||||
|
@property(nonatomic) CGFloat dx;
|
||||||
|
@property(nonatomic) CGFloat dy;
|
||||||
|
@property(nonatomic) BOOL precise;
|
||||||
|
@property(nonatomic) NSInteger trackCount;
|
||||||
|
@property(nonatomic, copy) void (^tracking)(CGFloat, NSEventPhase, BOOL, BOOL *);
|
||||||
|
@end
|
||||||
|
@implementation TestEvent
|
||||||
|
- (NSWindow *)window { return self.testWindow; }
|
||||||
|
- (NSEventPhase)phase { return self.testPhase; }
|
||||||
|
- (NSEventPhase)momentumPhase { return self.testMomentum; }
|
||||||
|
- (CGFloat)scrollingDeltaX { return self.dx; }
|
||||||
|
- (CGFloat)scrollingDeltaY { return self.dy; }
|
||||||
|
- (BOOL)hasPreciseScrollingDeltas { return self.precise; }
|
||||||
|
- (NSPoint)locationInWindow { return NSMakePoint(20, 20); }
|
||||||
|
- (void)trackSwipeEventWithOptions:(NSEventSwipeTrackingOptions)options dampenAmountThresholdMin:(CGFloat)minimum max:(CGFloat)maximum usingHandler:(void (^)(CGFloat, NSEventPhase, BOOL, BOOL *))handler {
|
||||||
|
NSCAssert(options & NSEventSwipeTrackingLockDirection, @"Direction must be locked");
|
||||||
|
self.trackCount++;
|
||||||
|
self.tracking = handler;
|
||||||
|
}
|
||||||
|
@end
|
||||||
|
|
||||||
|
@interface TestNavigation : KanbanNavigation
|
||||||
|
@property(nonatomic) BOOL enabled;
|
||||||
|
@end
|
||||||
|
@implementation TestNavigation
|
||||||
|
- (BOOL)swipeTrackingEnabled { return self.enabled; }
|
||||||
|
@end
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
@autoreleasepool {
|
||||||
|
[NSApplication sharedApplication];
|
||||||
|
TestWindow *window = [[TestWindow alloc] initWithContentRect:NSMakeRect(0, 0, 600, 400) styleMask:NSWindowStyleMaskBorderless backing:NSBackingStoreBuffered defer:NO];
|
||||||
|
TestWebView *webview = [[TestWebView alloc] initWithFrame:NSMakeRect(0, 0, 600, 400)];
|
||||||
|
webview.testWindow = window;
|
||||||
|
webview.testURL = [NSURL URLWithString:@"https://example.com/board"];
|
||||||
|
webview.back = YES;
|
||||||
|
webview.forward = YES;
|
||||||
|
webview.allowsSwipe = YES;
|
||||||
|
webview.steps = [NSMutableArray new];
|
||||||
|
TestNavigation *navigation = [TestNavigation new];
|
||||||
|
navigation.webview = webview;
|
||||||
|
navigation.enabled = YES;
|
||||||
|
TestEvent *(^event)(void) = ^{
|
||||||
|
TestEvent *value = [TestEvent new];
|
||||||
|
value.testWindow = window;
|
||||||
|
value.testPhase = NSEventPhaseBegan;
|
||||||
|
value.precise = YES;
|
||||||
|
value.dx = 15;
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
BOOL stop = NO;
|
||||||
|
TestEvent *back = event();
|
||||||
|
[navigation handleEvent:back];
|
||||||
|
NSCAssert(back.trackCount == 1, @"A horizontal gesture must use AppKit tracking");
|
||||||
|
back.tracking(0.5, NSEventPhaseChanged, NO, &stop);
|
||||||
|
NSCAssert(webview.steps.count == 0, @"A partial swipe must not navigate");
|
||||||
|
back.tracking(1, NSEventPhaseNone, YES, &stop);
|
||||||
|
NSCAssert(webview.steps.count == 1 && [webview.steps.lastObject containsString:@"step(-1)"], @"Back must request exactly one step");
|
||||||
|
TestEvent *forward = event();
|
||||||
|
forward.dx = -15;
|
||||||
|
[navigation handleEvent:forward];
|
||||||
|
forward.tracking(-1, NSEventPhaseNone, YES, &stop);
|
||||||
|
NSCAssert(webview.steps.count == 2 && [webview.steps.lastObject containsString:@"step(1)"], @"Forward must request exactly one step");
|
||||||
|
TestEvent *cancelled = event();
|
||||||
|
[navigation handleEvent:cancelled];
|
||||||
|
cancelled.tracking(0, NSEventPhaseCancelled, YES, &stop);
|
||||||
|
NSCAssert(webview.steps.count == 2, @"Cancelled swipes must not navigate");
|
||||||
|
TestEvent *interrupted = event();
|
||||||
|
[navigation handleEvent:interrupted];
|
||||||
|
webview.allowsSwipe = NO;
|
||||||
|
[navigation handleEvent:event()];
|
||||||
|
interrupted.tracking(1, NSEventPhaseNone, YES, &stop);
|
||||||
|
NSCAssert(webview.steps.count == 2, @"A new gesture must cancel the previous gesture's animation callback");
|
||||||
|
|
||||||
|
for (NSInteger test = 0; test < 7; test++) {
|
||||||
|
TestEvent *ignored = event();
|
||||||
|
navigation.enabled = test != 0;
|
||||||
|
webview.allowsSwipe = test != 1;
|
||||||
|
webview.back = webview.forward = test != 2;
|
||||||
|
if (test == 3) ignored.precise = NO;
|
||||||
|
if (test == 4) ignored.dy = 30;
|
||||||
|
if (test == 5) ignored.testMomentum = NSEventPhaseChanged;
|
||||||
|
if (test == 6) ignored.testWindow = nil;
|
||||||
|
NSCAssert([navigation handleEvent:ignored] == ignored && ignored.trackCount == 0, @"Non-navigation events must remain available for scrolling");
|
||||||
|
}
|
||||||
|
navigation.enabled = YES;
|
||||||
|
webview.allowsSwipe = YES;
|
||||||
|
webview.back = YES;
|
||||||
|
webview.deferPermission = YES;
|
||||||
|
TestEvent *stale = event();
|
||||||
|
[navigation handleEvent:stale];
|
||||||
|
TestEvent *ended = event();
|
||||||
|
ended.testPhase = NSEventPhaseEnded;
|
||||||
|
[navigation handleEvent:ended];
|
||||||
|
webview.permission(@YES, nil);
|
||||||
|
NSCAssert(stale.trackCount == 0, @"Late hit tests must not resurrect ended gestures");
|
||||||
|
TestEvent *changedPage = event();
|
||||||
|
[navigation handleEvent:changedPage];
|
||||||
|
webview.testURL = [NSURL URLWithString:@"https://example.com/settings"];
|
||||||
|
webview.permission(@YES, nil);
|
||||||
|
NSCAssert(changedPage.trackCount == 0, @"Page changes must invalidate a pending gesture");
|
||||||
|
puts("PASS: AppKit back/forward, cancelled/partial swipes, system preference, nested scroll ownership, empty history, mouse/vertical/momentum events, stale callbacks");
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import process from 'node:process';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
test(
|
||||||
|
'AppKit gestures preserve single steps, cancellation, system preferences and scroll ownership',
|
||||||
|
{ skip: process.platform !== 'darwin' },
|
||||||
|
() => {
|
||||||
|
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-navigation-'));
|
||||||
|
const binary = join(directory, 'navigation-test');
|
||||||
|
try {
|
||||||
|
const compile = spawnSync(
|
||||||
|
'xcrun',
|
||||||
|
[
|
||||||
|
'--sdk',
|
||||||
|
'macosx',
|
||||||
|
'clang',
|
||||||
|
'-fobjc-arc',
|
||||||
|
'-fblocks',
|
||||||
|
'-Wall',
|
||||||
|
'-Werror',
|
||||||
|
'-framework',
|
||||||
|
'AppKit',
|
||||||
|
'-framework',
|
||||||
|
'WebKit',
|
||||||
|
fileURLToPath(
|
||||||
|
new URL('./navigation-macos.m', import.meta.url),
|
||||||
|
),
|
||||||
|
'-o',
|
||||||
|
binary,
|
||||||
|
],
|
||||||
|
{ encoding: 'utf8', timeout: 30000 },
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
compile.status,
|
||||||
|
0,
|
||||||
|
compile.stderr || compile.error?.message,
|
||||||
|
);
|
||||||
|
const result = spawnSync(binary, [], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
result.status,
|
||||||
|
0,
|
||||||
|
result.stderr || result.error?.message,
|
||||||
|
);
|
||||||
|
assert.match(result.stdout, /PASS: AppKit/);
|
||||||
|
} finally {
|
||||||
|
rmSync(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -1,19 +1,71 @@
|
|||||||
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';
|
||||||
|
|
||||||
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 = {
|
||||||
version: '1.2.3',
|
version: '1.2.3',
|
||||||
target: 'aarch64-apple-darwin',
|
target: 'aarch64-apple-darwin',
|
||||||
filename: 'KanbanGit-1.2.3-aarch64-apple-darwin.zip',
|
filename: 'KanbanGit-1.2.3-aarch64-apple-darwin.dmg',
|
||||||
|
updaterFilename: 'KanbanGit-1.2.3-aarch64-apple-darwin.app.tar.gz',
|
||||||
};
|
};
|
||||||
const archive = Buffer.from('verified release');
|
const archive = Buffer.from('verified release');
|
||||||
const checksum = createHash('sha256').update(archive).digest('hex');
|
const checksum = createHash('sha256').update(archive).digest('hex');
|
||||||
@@ -22,6 +74,17 @@ function fixture(t) {
|
|||||||
join(directory, `${metadata.filename}.sha256`),
|
join(directory, `${metadata.filename}.sha256`),
|
||||||
`${checksum} ${metadata.filename}\n`,
|
`${checksum} ${metadata.filename}\n`,
|
||||||
);
|
);
|
||||||
|
const updater = Buffer.from('signed update');
|
||||||
|
const signing = signedFixture(updater);
|
||||||
|
writeFileSync(join(directory, metadata.updaterFilename), updater);
|
||||||
|
writeFileSync(
|
||||||
|
join(directory, `${metadata.updaterFilename}.sig`),
|
||||||
|
signing.signature,
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(directory, `${metadata.updaterFilename}.sha256`),
|
||||||
|
`${createHash('sha256').update(updater).digest('hex')} ${metadata.updaterFilename}\n`,
|
||||||
|
);
|
||||||
const env = {
|
const env = {
|
||||||
S3_BUCKET: 'release-bucket',
|
S3_BUCKET: 'release-bucket',
|
||||||
S3_REGION: 'nbg1',
|
S3_REGION: 'nbg1',
|
||||||
@@ -36,24 +99,35 @@ 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 '';
|
||||||
}
|
}
|
||||||
if (operation === 'put-object') {
|
if (operation === 'put-object') {
|
||||||
assert.equal(value('--if-none-match'), '*');
|
if (!args.includes('--if-match')) {
|
||||||
|
assert.equal(value('--if-none-match'), '*');
|
||||||
|
}
|
||||||
if (objects.has(key)) {
|
if (objects.has(key)) {
|
||||||
throw new Error(
|
if (!args.includes('--if-match')) {
|
||||||
'aws failed: (PreconditionFailed) object exists',
|
throw new Error(conflictMessage);
|
||||||
|
}
|
||||||
|
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 '{}';
|
return JSON.stringify({
|
||||||
|
ETag: '"b88312b96169f4b2098a4cc43b0abb6a"',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
throw new Error(`Unexpected S3 operation: ${operation}`);
|
throw new Error(`Unexpected S3 operation: ${operation}`);
|
||||||
};
|
};
|
||||||
@@ -72,6 +146,9 @@ function fixture(t) {
|
|||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
directory,
|
directory,
|
||||||
|
publicKey: signing.publicKey,
|
||||||
|
feedBase:
|
||||||
|
'https://release-bucket.nbg1.your-objectstorage.com/releases/macos',
|
||||||
metadata,
|
metadata,
|
||||||
env,
|
env,
|
||||||
objects,
|
objects,
|
||||||
@@ -82,35 +159,152 @@ 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(
|
||||||
url,
|
url,
|
||||||
`https://release-bucket.nbg1.your-objectstorage.com/releases/macos/aarch64-apple-darwin/1.2.3/${f.metadata.filename}`,
|
`https://release-bucket.nbg1.your-objectstorage.com/releases/macos/aarch64-apple-darwin/1.2.3/${f.metadata.filename}`,
|
||||||
);
|
);
|
||||||
assert.deepEqual(
|
assert.equal(
|
||||||
f.events.map((event) => event.operation),
|
f.events.filter((event) => event.operation === 'put-object').length,
|
||||||
[
|
9,
|
||||||
'get-bucket-versioning',
|
|
||||||
'put-object',
|
|
||||||
'public-get',
|
|
||||||
'put-object',
|
|
||||||
'public-get',
|
|
||||||
'put-object',
|
|
||||||
'public-get',
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
assert.equal(f.objects.size, 3);
|
assert.equal(f.objects.size, 9);
|
||||||
assert(f.events.at(-1).key.endsWith('/release.json'));
|
for (const event of f.events.filter(
|
||||||
assert(![...f.objects.keys()].some((key) => key.includes('latest')));
|
(event) => event.operation === 'put-object',
|
||||||
assert.deepEqual(JSON.parse(f.objects.get(f.events.at(-1).key)), {
|
)) {
|
||||||
version: '1.2.3',
|
assert.equal(
|
||||||
target: f.metadata.target,
|
event.args[event.args.indexOf('--cache-control') + 1],
|
||||||
url,
|
'no-cache, max-age=0, must-revalidate',
|
||||||
sha256: createHash('sha256').update('verified release').digest('hex'),
|
);
|
||||||
checksumUrl: `${url}.sha256`,
|
}
|
||||||
});
|
assert(f.events.at(-1).key.endsWith('/latest.json'));
|
||||||
|
assert.equal(
|
||||||
|
f.events.at(-1).key,
|
||||||
|
'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(
|
||||||
|
JSON.parse(
|
||||||
|
f.objects.get(
|
||||||
|
'releases/macos/aarch64-apple-darwin/1.2.3/release.json',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
{
|
||||||
|
version: '1.2.3',
|
||||||
|
target: f.metadata.target,
|
||||||
|
url,
|
||||||
|
sha256: createHash('sha256')
|
||||||
|
.update('verified release')
|
||||||
|
.digest('hex'),
|
||||||
|
checksumUrl: `${url}.sha256`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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', '{}']) {
|
||||||
@@ -122,9 +316,9 @@ 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, 3);
|
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('/release.json'));
|
assert(f.events.at(-1).key.endsWith('/latest.json'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,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,
|
||||||
3,
|
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);
|
||||||
@@ -214,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) => {
|
||||||
@@ -227,12 +427,209 @@ test('a partial upload can resume with the same files', async (t) => {
|
|||||||
return run(command, args);
|
return run(command, args);
|
||||||
};
|
};
|
||||||
await assert.rejects(publishMacos(f), /interrupted/);
|
await assert.rejects(publishMacos(f), /interrupted/);
|
||||||
assert.equal(f.objects.size, 1);
|
assert.equal(f.objects.size, 3);
|
||||||
f.run = run;
|
f.run = run;
|
||||||
await publishMacos(f);
|
await publishMacos(f);
|
||||||
assert.equal(f.objects.size, 3);
|
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);
|
||||||
@@ -257,8 +654,8 @@ 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, 3);
|
assert.equal(f.objects.size, 9);
|
||||||
assert(f.events.at(-1).key.endsWith('/release.json'));
|
assert(f.events.at(-1).key.endsWith('/latest.json'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,15 +690,15 @@ test('checksum access retries finish before publishing the completion record', a
|
|||||||
const fetchPublic = f.fetchPublic;
|
const fetchPublic = f.fetchPublic;
|
||||||
let failures = 0;
|
let failures = 0;
|
||||||
f.fetchPublic = async (url, options) => {
|
f.fetchPublic = async (url, options) => {
|
||||||
if (url.endsWith('.sha256') && ++failures <= 2) {
|
if (url.endsWith(`${f.metadata.filename}.sha256`) && ++failures <= 2) {
|
||||||
assert.equal(f.objects.size, 2);
|
assert.equal(f.objects.size, 5);
|
||||||
return { ok: false, status: 403 };
|
return { ok: false, status: 403 };
|
||||||
}
|
}
|
||||||
return fetchPublic(url, options);
|
return fetchPublic(url, options);
|
||||||
};
|
};
|
||||||
await publishMacos(f);
|
await publishMacos(f);
|
||||||
assert.equal(failures, 3);
|
assert.equal(failures, 3);
|
||||||
assert.equal(f.objects.size, 3);
|
assert.equal(f.objects.size, 9);
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const overrides of [
|
for (const overrides of [
|
||||||
@@ -338,3 +735,154 @@ test('derives Hetzner endpoints and supports an explicit public proxy URL', () =
|
|||||||
assert.equal(config.publicBase, 'https://downloads.example.com');
|
assert.equal(config.publicBase, 'https://downloads.example.com');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('refuses a destination that differs from the built-in update source', async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
f.feedBase = 'https://another.example/releases/macos';
|
||||||
|
await assert.rejects(publishMacos(f), /does not match/);
|
||||||
|
assert.equal(f.objects.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a corrupt updater archive before publishing any object', async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
writeFileSync(join(f.directory, f.metadata.updaterFilename), 'tampered');
|
||||||
|
await assert.rejects(publishMacos(f), /signature/);
|
||||||
|
assert.equal(f.objects.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('atomically promotes a newer feed only after all release files are public', async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
||||||
|
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.0.0' })));
|
||||||
|
await publishMacos(f);
|
||||||
|
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.2.3');
|
||||||
|
const releaseVerified = f.events.findIndex(
|
||||||
|
(event) =>
|
||||||
|
event.operation === 'public-get' &&
|
||||||
|
event.key.endsWith('/update.json'),
|
||||||
|
);
|
||||||
|
const latestPut = f.events.findIndex(
|
||||||
|
(event) => event.operation === 'put-object' && event.key === latestKey,
|
||||||
|
);
|
||||||
|
assert(latestPut > releaseVerified);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const previous of [
|
||||||
|
{ version: '2.0.0' },
|
||||||
|
{ version: '1.2.3', notes: 'different' },
|
||||||
|
]) {
|
||||||
|
test(`does not replace conflicting update feed ${previous.version}`, async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
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('concurrent feed replacement fails without overwriting the winner', async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
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;
|
||||||
|
f.run = (command, args) => {
|
||||||
|
if (args.includes('--if-match')) {
|
||||||
|
f.objects.set(
|
||||||
|
key,
|
||||||
|
Buffer.from(JSON.stringify({ version: '2.0.0' })),
|
||||||
|
);
|
||||||
|
throw new Error('(PreconditionFailed) concurrent publication');
|
||||||
|
}
|
||||||
|
return run(command, args);
|
||||||
|
};
|
||||||
|
await assert.rejects(publishMacos(f), /concurrent publication/);
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Buffer } from 'node:buffer';
|
||||||
|
import { createHash, generateKeyPairSync, sign } from 'node:crypto';
|
||||||
|
|
||||||
|
export function signedFixture(bytes) {
|
||||||
|
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
||||||
|
const keyId = Buffer.alloc(8, 7);
|
||||||
|
const key = Buffer.concat([
|
||||||
|
Buffer.from('Ed'),
|
||||||
|
keyId,
|
||||||
|
publicKey.export({ type: 'spki', format: 'der' }).subarray(-32),
|
||||||
|
]);
|
||||||
|
const signature = sign(
|
||||||
|
null,
|
||||||
|
createHash('blake2b512').update(bytes).digest(),
|
||||||
|
privateKey,
|
||||||
|
);
|
||||||
|
const trusted = 'timestamp:123456';
|
||||||
|
const globalSignature = sign(
|
||||||
|
null,
|
||||||
|
Buffer.concat([signature, Buffer.from(trusted)]),
|
||||||
|
privateKey,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
publicKey: Buffer.from(
|
||||||
|
`untrusted comment: test\n${key.toString('base64')}\n`,
|
||||||
|
).toString('base64'),
|
||||||
|
signature: Buffer.from(
|
||||||
|
`untrusted comment: test\n${Buffer.concat([Buffer.from('ED'), keyId, signature]).toString('base64')}\ntrusted comment: ${trusted}\n${globalSignature.toString('base64')}\n`,
|
||||||
|
).toString('base64'),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { Buffer } from 'node:buffer';
|
||||||
|
import { signedFixture } from './updater-fixture.mjs';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
compareVersions,
|
||||||
|
updateManifest,
|
||||||
|
verifyUpdateSignature,
|
||||||
|
} from '../update-manifest.mjs';
|
||||||
|
import { updatePresentation } from '../ui/updates-state.js';
|
||||||
|
|
||||||
|
test('compares stable versions numerically and rejects prerelease or malformed versions', () => {
|
||||||
|
assert.equal(compareVersions('1.10.0', '1.9.9'), 1);
|
||||||
|
assert.equal(compareVersions('1.0.0', '2.0.0'), -1);
|
||||||
|
assert.equal(compareVersions('1.0.0', '1.0.0'), 0);
|
||||||
|
for (const invalid of [
|
||||||
|
'v1.0.0',
|
||||||
|
'1.0.0-beta',
|
||||||
|
'01.0.0',
|
||||||
|
'1.0.0+2',
|
||||||
|
'',
|
||||||
|
'1.2',
|
||||||
|
]) {
|
||||||
|
assert.throws(() => compareVersions(invalid, '1.0.0'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('verifies minisign archive and trusted comment with the permanent key', () => {
|
||||||
|
const bytes = Buffer.from('signed app');
|
||||||
|
const fixture = signedFixture(bytes);
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
verifyUpdateSignature(bytes, fixture.signature, fixture.publicKey),
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
verifyUpdateSignature(
|
||||||
|
Buffer.from('tampered app'),
|
||||||
|
fixture.signature,
|
||||||
|
fixture.publicKey,
|
||||||
|
),
|
||||||
|
/signature/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
verifyUpdateSignature(
|
||||||
|
bytes,
|
||||||
|
fixture.signature,
|
||||||
|
signedFixture(bytes).publicKey,
|
||||||
|
),
|
||||||
|
/signature/,
|
||||||
|
);
|
||||||
|
for (const signature of [
|
||||||
|
'',
|
||||||
|
'invalid',
|
||||||
|
Buffer.from(
|
||||||
|
Buffer.from(fixture.signature, 'base64')
|
||||||
|
.toString()
|
||||||
|
.replace('timestamp:123456', 'timestamp:999999'),
|
||||||
|
).toString('base64'),
|
||||||
|
]) {
|
||||||
|
assert.throws(
|
||||||
|
() => verifyUpdateSignature(bytes, signature, fixture.publicKey),
|
||||||
|
/signature/,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('manifest selects the exact supported architecture and embeds release notes', () => {
|
||||||
|
const metadata = {
|
||||||
|
version: '1.2.3',
|
||||||
|
target: 'aarch64-apple-darwin',
|
||||||
|
updaterFilename: 'KanbanGit.app.tar.gz',
|
||||||
|
};
|
||||||
|
assert.deepEqual(
|
||||||
|
updateManifest(
|
||||||
|
metadata,
|
||||||
|
'https://release.example/1.2.3',
|
||||||
|
'signed',
|
||||||
|
'Release notes',
|
||||||
|
),
|
||||||
|
{
|
||||||
|
version: '1.2.3',
|
||||||
|
notes: 'Release notes',
|
||||||
|
platforms: {
|
||||||
|
'darwin-aarch64': {
|
||||||
|
url: 'https://release.example/1.2.3/KanbanGit.app.tar.gz',
|
||||||
|
signature: 'signed',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.throws(() =>
|
||||||
|
updateManifest(
|
||||||
|
{ ...metadata, target: 'android' },
|
||||||
|
'https://example.com',
|
||||||
|
'signed',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test('update controls prevent concurrent operations and allow failed downloads to retry', () => {
|
||||||
|
for (const phase of [
|
||||||
|
'checking',
|
||||||
|
'downloading',
|
||||||
|
'verifying',
|
||||||
|
'installing',
|
||||||
|
'restarting',
|
||||||
|
]) {
|
||||||
|
const view = updatePresentation({ phase, version: '2.0.0' });
|
||||||
|
assert.equal(view.busy, true);
|
||||||
|
assert.equal(view.canInstall, false);
|
||||||
|
}
|
||||||
|
assert.equal(
|
||||||
|
updatePresentation({ phase: 'available', version: '2.0.0' }).canInstall,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
updatePresentation({ phase: 'error', version: '2.0.0' }).canInstall,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
updatePresentation({ phase: 'current', version: null }).canInstall,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -9,10 +9,12 @@
|
|||||||
<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>
|
||||||
|
<button id="update-notice" type="button" data-i18n="updateNotice" hidden></button>
|
||||||
<p class="description" data-i18n="description"></p>
|
<p class="description" data-i18n="description"></p>
|
||||||
<form id="server-form" novalidate>
|
<form id="server-form" novalidate>
|
||||||
<label for="server-url" data-i18n="serverLabel"></label>
|
<label for="server-url" data-i18n="serverLabel"></label>
|
||||||
|
|||||||
@@ -1,4 +1,44 @@
|
|||||||
export const en = {
|
export const en = {
|
||||||
|
updatesTitle: 'Software updates',
|
||||||
|
installedVersion: 'Installed version',
|
||||||
|
version: 'Version',
|
||||||
|
build: 'build',
|
||||||
|
checkUpdates: 'Check For Updates…',
|
||||||
|
installUpdate: 'Install Update…',
|
||||||
|
installAndRestart: 'Install and restart',
|
||||||
|
installWarning:
|
||||||
|
'Save your work before continuing. KanbanGit will download and verify the update, then install it and restart. Unsaved inputs will be lost. Your server settings and stored sessions are kept.',
|
||||||
|
updatePreferences: 'Update preferences',
|
||||||
|
automaticUpdates: 'Automatically check for updates',
|
||||||
|
checkInterval: 'Check interval',
|
||||||
|
hourly: 'Every hour',
|
||||||
|
sixHourly: 'Every 6 hours',
|
||||||
|
daily: 'Every day',
|
||||||
|
updatesPrivacy:
|
||||||
|
'Checks run at startup and at the selected interval. Downloads and restarts only happen after your confirmation.',
|
||||||
|
noReleaseNotes: 'No release notes provided.',
|
||||||
|
update_idle: 'Check for the latest stable version of KanbanGit.',
|
||||||
|
update_checking: 'Checking for updates…',
|
||||||
|
update_available: 'A new version of KanbanGit is available.',
|
||||||
|
update_current: 'KanbanGit is up to date.',
|
||||||
|
update_downloading: 'Downloading update…',
|
||||||
|
update_verifying: 'Verifying update…',
|
||||||
|
update_installing: 'Installing update…',
|
||||||
|
update_restarting: 'Restarting KanbanGit…',
|
||||||
|
update_error: 'The update could not be completed.',
|
||||||
|
networkError:
|
||||||
|
'Unable to contact the update service or finish the download. Check your connection and try again.',
|
||||||
|
metadataError:
|
||||||
|
'The update information is invalid or does not match this Mac. Try checking again later.',
|
||||||
|
signatureError:
|
||||||
|
'The update signature could not be verified. Nothing was installed. Try again or contact support.',
|
||||||
|
verificationError:
|
||||||
|
'The downloaded app does not match the announced version, architecture or publisher. Nothing was installed.',
|
||||||
|
installationError:
|
||||||
|
'The update could not be installed. Move KanbanGit to Applications, check available disk space and permissions, then try again.',
|
||||||
|
preferencesError:
|
||||||
|
'Update preferences could not be read or saved. Check your local storage permissions and select your preferences again.',
|
||||||
|
updateNotice: 'An update is available. View update…',
|
||||||
appName: 'KanbanGit',
|
appName: 'KanbanGit',
|
||||||
title: 'Choose your workspace.',
|
title: 'Choose your workspace.',
|
||||||
description: 'Connect to your KanbanGit installation over HTTPS.',
|
description: 'Connect to your KanbanGit installation over HTTPS.',
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -98,3 +101,10 @@ document.querySelector('#appearance').addEventListener('change', (event) => {
|
|||||||
if (window.nativeLauncher) {
|
if (window.nativeLauncher) {
|
||||||
action('ready');
|
action('ready');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.updateAvailable = (available) => {
|
||||||
|
document.querySelector('#update-notice').hidden = !available;
|
||||||
|
};
|
||||||
|
document
|
||||||
|
.querySelector('#update-notice')
|
||||||
|
.addEventListener('click', () => action('updates'));
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export function updatePresentation(state) {
|
||||||
|
const busy = [
|
||||||
|
'checking',
|
||||||
|
'downloading',
|
||||||
|
'verifying',
|
||||||
|
'installing',
|
||||||
|
'restarting',
|
||||||
|
].includes(state.phase);
|
||||||
|
return {
|
||||||
|
busy,
|
||||||
|
message: `update_${state.phase}`,
|
||||||
|
canInstall: Boolean(state.version) && !busy,
|
||||||
|
showProgress: ['downloading', 'verifying', 'installing'].includes(
|
||||||
|
state.phase,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
body { display: block; padding: 28px; }
|
||||||
|
main { margin: 0 auto; width: min(100%, 460px); }
|
||||||
|
.brand { margin-bottom: 16px; }
|
||||||
|
h2 { font-size: 18px; }
|
||||||
|
#release-notes { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 180px; overflow: auto; }
|
||||||
|
progress { width: 100%; accent-color: var(--primary); }
|
||||||
|
fieldset { margin: 28px 0 0; padding: 16px; border: 1px solid var(--border); border-radius: 6px; }
|
||||||
|
.automatic { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.automatic input { display: inline-block; width: 18px; min-height: 18px; margin: 0; }
|
||||||
|
.appearance { margin-top: 16px; }
|
||||||
|
#install-confirmation { margin-top: 16px; padding: 12px; border: 1px solid var(--border); border-radius: 6px; }
|
||||||
|
#confirm-install { background: var(--primary); color: var(--surface); }
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
<title>KanbanGit Updates</title>
|
||||||
|
<link rel="stylesheet" href="./styles.css" />
|
||||||
|
<link rel="stylesheet" href="./updates.css" />
|
||||||
|
<script type="module" src="./updates.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<img class="brand" src="./mark.svg" alt="" width="48" height="48" />
|
||||||
|
<p class="eyebrow" data-i18n="appName"></p>
|
||||||
|
<h1 data-i18n="updatesTitle"></h1>
|
||||||
|
<p id="installed" class="description"></p>
|
||||||
|
<p id="update-status" role="status" aria-live="polite"></p>
|
||||||
|
<p id="update-error" class="description" role="alert" hidden></p>
|
||||||
|
<section id="release" hidden>
|
||||||
|
<h2 id="available-version"></h2>
|
||||||
|
<p id="release-notes" class="description"></p>
|
||||||
|
</section>
|
||||||
|
<progress id="download-progress" hidden></progress>
|
||||||
|
<p id="download-size" class="description" hidden></p>
|
||||||
|
<button id="check" type="button" data-i18n="checkUpdates"></button>
|
||||||
|
<button id="install" type="button" data-i18n="installUpdate" hidden></button>
|
||||||
|
<section id="install-confirmation" hidden>
|
||||||
|
<p class="description" data-i18n="installWarning"></p>
|
||||||
|
<button id="confirm-install" type="button" data-i18n="installAndRestart"></button>
|
||||||
|
<button id="cancel-install" type="button" data-i18n="cancel"></button>
|
||||||
|
</section>
|
||||||
|
<fieldset id="preferences">
|
||||||
|
<legend data-i18n="updatePreferences"></legend>
|
||||||
|
<label class="automatic"><input id="automatic" type="checkbox" checked /><span data-i18n="automaticUpdates"></span></label>
|
||||||
|
<label class="appearance" for="interval"><span data-i18n="checkInterval"></span>
|
||||||
|
<select id="interval"><option value="1" data-i18n="hourly"></option><option value="6" data-i18n="sixHourly"></option><option value="24" data-i18n="daily"></option></select>
|
||||||
|
</label>
|
||||||
|
<p class="description" data-i18n="updatesPrivacy"></p>
|
||||||
|
</fieldset>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { en } from './locales/en.js';
|
||||||
|
import { updatePresentation } from './updates-state.js';
|
||||||
|
|
||||||
|
for (const element of document.querySelectorAll('[data-i18n]')) {
|
||||||
|
element.textContent = en[element.dataset.i18n];
|
||||||
|
}
|
||||||
|
const element = (id) => document.getElementById(id);
|
||||||
|
const action = (name, params = {}) => {
|
||||||
|
const url = new URL(`/updates/${name}`, location.href);
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
url.searchParams.set(key, value);
|
||||||
|
}
|
||||||
|
location.href = url.href;
|
||||||
|
};
|
||||||
|
let state;
|
||||||
|
window.updateStatus = (next) => {
|
||||||
|
state = next;
|
||||||
|
const view = updatePresentation(state);
|
||||||
|
element('installed').textContent =
|
||||||
|
`${en.installedVersion} ${state.currentVersion} (${en.build} ${state.build})`;
|
||||||
|
element('update-status').textContent = en[view.message];
|
||||||
|
element('update-error').hidden = !state.error;
|
||||||
|
element('update-error').textContent = en[state.error] ?? '';
|
||||||
|
element('check').disabled = view.busy;
|
||||||
|
element('preferences').disabled = view.busy;
|
||||||
|
element('release').hidden = !state.version;
|
||||||
|
element('available-version').textContent =
|
||||||
|
`${en.version} ${state.version ?? ''}`;
|
||||||
|
element('release-notes').textContent = state.notes || en.noReleaseNotes;
|
||||||
|
element('install').hidden = !view.canInstall;
|
||||||
|
element('download-progress').hidden = !view.showProgress;
|
||||||
|
element('download-progress').max = state.total || 1;
|
||||||
|
if (state.total) {
|
||||||
|
element('download-progress').value = state.downloaded;
|
||||||
|
} else {
|
||||||
|
element('download-progress').removeAttribute('value');
|
||||||
|
}
|
||||||
|
element('download-size').hidden = !view.showProgress;
|
||||||
|
element('download-size').textContent =
|
||||||
|
`${(state.downloaded / 1048576).toFixed(1)} MB${state.total ? ` / ${(state.total / 1048576).toFixed(1)} MB` : ''}`;
|
||||||
|
element('automatic').checked = state.preferences.automatic;
|
||||||
|
element('interval').value = String(state.preferences.intervalHours);
|
||||||
|
element('interval').disabled = !state.preferences.automatic;
|
||||||
|
if (view.busy || !view.canInstall) {
|
||||||
|
element('install-confirmation').hidden = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
element('check').addEventListener('click', () => action('check'));
|
||||||
|
element('install').addEventListener('click', () => {
|
||||||
|
element('install-confirmation').hidden = false;
|
||||||
|
element('confirm-install').focus();
|
||||||
|
});
|
||||||
|
element('cancel-install').addEventListener('click', () => {
|
||||||
|
element('install-confirmation').hidden = true;
|
||||||
|
element('install').focus();
|
||||||
|
});
|
||||||
|
element('confirm-install').addEventListener('click', () => {
|
||||||
|
if (state && updatePresentation(state).canInstall) {
|
||||||
|
element('install-confirmation').hidden = true;
|
||||||
|
action('install', { version: state.version });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for (const id of ['automatic', 'interval']) {
|
||||||
|
element(id).addEventListener('change', () =>
|
||||||
|
action('preferences', {
|
||||||
|
automatic: String(element('automatic').checked),
|
||||||
|
interval: element('interval').value,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
(() => {
|
||||||
|
if (window.top !== window || window.__kanbanNavigation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dragging = false;
|
||||||
|
const overlay = () =>
|
||||||
|
[
|
||||||
|
...document.querySelectorAll(
|
||||||
|
'dialog[open], [role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"], [data-reka-popper-content-wrapper]',
|
||||||
|
),
|
||||||
|
].some(
|
||||||
|
(element) =>
|
||||||
|
element.getAttribute('data-state') !== 'closed' &&
|
||||||
|
element.getClientRects().length > 0 &&
|
||||||
|
getComputedStyle(element).visibility !== 'hidden',
|
||||||
|
);
|
||||||
|
|
||||||
|
const constrainScroll = () => {
|
||||||
|
if (document.documentElement) {
|
||||||
|
document.documentElement.style.overscrollBehaviorY = 'none';
|
||||||
|
}
|
||||||
|
if (document.body) {
|
||||||
|
document.body.style.overscrollBehaviorY = 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
constrainScroll();
|
||||||
|
document.addEventListener('DOMContentLoaded', constrainScroll, {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
|
document.addEventListener('dragstart', () => {
|
||||||
|
dragging = true;
|
||||||
|
});
|
||||||
|
document.addEventListener('dragend', () => {
|
||||||
|
dragging = false;
|
||||||
|
});
|
||||||
|
document.addEventListener('drop', () => {
|
||||||
|
dragging = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
window.__kanbanNavigation = Object.freeze({
|
||||||
|
canSwipe(x, y) {
|
||||||
|
if (dragging || overlay()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (
|
||||||
|
let element = document.elementFromPoint(x, y);
|
||||||
|
element;
|
||||||
|
element = element.parentElement
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
element.matches(
|
||||||
|
'input, textarea, select, [contenteditable]:not([contenteditable="false"]), [draggable="true"]',
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
if (
|
||||||
|
/(auto|scroll)/.test(style.overflowX) &&
|
||||||
|
element.scrollWidth > element.clientWidth
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
style.overscrollBehaviorX === 'contain' ||
|
||||||
|
style.overscrollBehaviorX === 'none'
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
step(direction) {
|
||||||
|
// The host checks canGoBack/canGoForward. JS traversal preserves SPA entries
|
||||||
|
// that WebKit's user-initiated history traversal is allowed to skip.
|
||||||
|
if (direction === -1 || direction === 1) {
|
||||||
|
window.history.go(direction);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { Buffer } from 'node:buffer';
|
||||||
|
import { createHash, createPublicKey, verify } from 'node:crypto';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
export const updateSource = JSON.parse(
|
||||||
|
readFileSync(new URL('./update-source.json', import.meta.url), 'utf8'),
|
||||||
|
);
|
||||||
|
export function compareVersions(left, right) {
|
||||||
|
const pattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
||||||
|
if (!pattern.test(left) || !pattern.test(right)) {
|
||||||
|
throw new Error(
|
||||||
|
'Update versions must be stable MAJOR.MINOR.PATCH versions.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const a = left.split('.').map(BigInt);
|
||||||
|
const b = right.split('.').map(BigInt);
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
if (a[i] !== b[i]) {
|
||||||
|
return a[i] > b[i] ? 1 : -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
export function verifyUpdateSignature(
|
||||||
|
bytes,
|
||||||
|
signature,
|
||||||
|
publicKey = updateSource.publicKey,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const key = Buffer.from(
|
||||||
|
Buffer.from(publicKey, 'base64')
|
||||||
|
.toString('utf8')
|
||||||
|
.trim()
|
||||||
|
.split('\n')[1],
|
||||||
|
'base64',
|
||||||
|
);
|
||||||
|
const lines = Buffer.from(signature.trim(), 'base64')
|
||||||
|
.toString('utf8')
|
||||||
|
.trim()
|
||||||
|
.split('\n');
|
||||||
|
const signed = Buffer.from(lines[1], 'base64');
|
||||||
|
if (
|
||||||
|
key.length !== 42 ||
|
||||||
|
signed.length !== 74 ||
|
||||||
|
!key.subarray(2, 10).equals(signed.subarray(2, 10)) ||
|
||||||
|
!lines[2].startsWith('trusted comment: ')
|
||||||
|
) {
|
||||||
|
throw new Error('Invalid signature structure');
|
||||||
|
}
|
||||||
|
const algorithm = signed.subarray(0, 2).toString();
|
||||||
|
if (!['Ed', 'ED'].includes(algorithm)) {
|
||||||
|
throw new Error('Unsupported signature');
|
||||||
|
}
|
||||||
|
const cryptoKey = createPublicKey({
|
||||||
|
key: Buffer.concat([
|
||||||
|
Buffer.from('302a300506032b6570032100', 'hex'),
|
||||||
|
key.subarray(10),
|
||||||
|
]),
|
||||||
|
format: 'der',
|
||||||
|
type: 'spki',
|
||||||
|
});
|
||||||
|
const contents =
|
||||||
|
algorithm === 'ED'
|
||||||
|
? createHash('blake2b512').update(bytes).digest()
|
||||||
|
: bytes;
|
||||||
|
if (
|
||||||
|
!verify(null, contents, cryptoKey, signed.subarray(10)) ||
|
||||||
|
!verify(
|
||||||
|
null,
|
||||||
|
Buffer.concat([
|
||||||
|
signed.subarray(10),
|
||||||
|
Buffer.from(lines[2].slice('trusted comment: '.length)),
|
||||||
|
]),
|
||||||
|
cryptoKey,
|
||||||
|
Buffer.from(lines[3], 'base64'),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error('Signature mismatch');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
'Updater archive signature does not match the permanent public key.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function updateManifest(metadata, publicPrefix, signature, notes) {
|
||||||
|
compareVersions(metadata.version, '0.0.0');
|
||||||
|
const platform = {
|
||||||
|
'aarch64-apple-darwin': 'darwin-aarch64',
|
||||||
|
'x86_64-apple-darwin': 'darwin-x86_64',
|
||||||
|
}[metadata.target];
|
||||||
|
if (!platform || !signature.trim()) {
|
||||||
|
throw new Error('Invalid updater platform or missing signature.');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: metadata.version,
|
||||||
|
notes: notes?.trim() || `KanbanGit ${metadata.version}`,
|
||||||
|
platforms: {
|
||||||
|
[platform]: {
|
||||||
|
url: `${publicPrefix}/${metadata.updaterFilename}`,
|
||||||
|
signature: signature.trim(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"baseUrl": "https://petsch-studios.nbg1.your-objectstorage.com/releases/macos",
|
||||||
|
"publicKey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhBQzA0QjhENTI3OEE1QzAKUldUQXBYaFNqVXZBaXJVWmErWUZTNVp6QnllT25NWFMyemxqcFBnYnJHT3JBSWxrSkVYU1VVMFEK"
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||