Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b124a77cd1 | ||
|
|
2725816273 | ||
|
|
32a6756419 | ||
|
|
2a637affdf | ||
|
|
188aa07059 |
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "kanbangit-native",
|
"name": "kanbangit-native",
|
||||||
"version": "0.2.1",
|
"version": "0.2.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -14,8 +14,9 @@
|
|||||||
"build:android": "tauri android build --debug --apk --target aarch64",
|
"build:android": "tauri android build --debug --apk --target aarch64",
|
||||||
"smoke:macos": "cargo test --manifest-path src-tauri/Cargo.toml --locked --features smoke-test,custom-protocol --test macos_smoke",
|
"smoke:macos": "cargo test --manifest-path src-tauri/Cargo.toml --locked --features smoke-test,custom-protocol --test macos_smoke",
|
||||||
"dev:ios": "tauri ios dev",
|
"dev:ios": "tauri ios dev",
|
||||||
"build:ios:simulator": "rm -rf src-tauri/gen/apple/build/arm64-sim/KanbanGit.app && tauri ios build --debug --target aarch64-sim --no-sign --ci",
|
"build:ios:simulator": "node build-ios.mjs --mode simulator",
|
||||||
"build:ios:device": "tauri ios build --debug --target aarch64 --export-method debugging --archive-only",
|
"build:ios:device": "node build-ios.mjs --mode development",
|
||||||
|
"release:ios": "node build-ios.mjs --mode testflight",
|
||||||
"open:ios": "tauri ios dev --open --no-dev-server",
|
"open:ios": "tauri ios dev --open --no-dev-server",
|
||||||
"prepare:ios": "xcodegen generate --spec src-tauri/gen/apple/project.yml",
|
"prepare:ios": "xcodegen generate --spec src-tauri/gen/apple/project.yml",
|
||||||
"test:ios": "xcodebuild -project src-tauri/gen/apple/kanbangit-native.xcodeproj -scheme kanbangit-native_iOS -configuration debug -destination \"platform=iOS Simulator,id=${IOS_SIMULATOR_ID:?Set IOS_SIMULATOR_ID to an installed simulator UUID}\" -parallel-testing-enabled NO CODE_SIGNING_ALLOWED=NO test",
|
"test:ios": "xcodebuild -project src-tauri/gen/apple/kanbangit-native.xcodeproj -scheme kanbangit-native_iOS -configuration debug -destination \"platform=iOS Simulator,id=${IOS_SIMULATOR_ID:?Set IOS_SIMULATOR_ID to an installed simulator UUID}\" -parallel-testing-enabled NO CODE_SIGNING_ALLOWED=NO test",
|
||||||
|
|||||||
@@ -1770,7 +1770,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kanbangit-native"
|
name = "kanbangit-native"
|
||||||
version = "0.2.1"
|
version = "0.2.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "kanbangit-native"
|
name = "kanbangit-native"
|
||||||
version = "0.2.1"
|
version = "0.2.2"
|
||||||
description = "KanbanGit native application"
|
description = "KanbanGit native application"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
publish = false
|
publish = false
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.os.SystemClock
|
|||||||
import android.view.InputDevice
|
import android.view.InputDevice
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
import android.webkit.CookieManager
|
import android.webkit.CookieManager
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
@@ -59,6 +60,59 @@ class PlatformTest {
|
|||||||
instrumentation.waitForIdleSync()
|
instrumentation.waitForIdleSync()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun coldStartRestoresWorkspaceWithoutSelectionOrLauncherHistory() {
|
||||||
|
val mode = InstrumentationRegistry.getArguments().getString("startupConfig", "saved")
|
||||||
|
val preferences = instrumentation.targetContext.getSharedPreferences("workspace", Context.MODE_PRIVATE)
|
||||||
|
preferences.edit().clear().apply {
|
||||||
|
if (mode == "saved") putString("server", "https://127.0.0.1:9/app/")
|
||||||
|
if (mode == "invalid") putString("server", "http://invalid.example/")
|
||||||
|
}.commit()
|
||||||
|
val scenario = ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
val launcher = AtomicReference<WebView>()
|
||||||
|
fun findLauncher(view: View): WebView? {
|
||||||
|
if (view is WebView && view.url?.startsWith(NavigationPolicy.LAUNCHER_ORIGIN) == true) return view
|
||||||
|
if (view is ViewGroup) for (index in 0 until view.childCount) {
|
||||||
|
findLauncher(view.getChildAt(index))?.let { return it }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
waitFor {
|
||||||
|
scenario.onActivity { launcher.set(findLauncher(it.window.decorView)) }
|
||||||
|
launcher.get() != null && evaluate(launcher.get(), "typeof window.updateLauncher === 'function'") == "true"
|
||||||
|
}
|
||||||
|
if (mode == "saved") {
|
||||||
|
waitFor {
|
||||||
|
val state = evaluate(launcher.get(), "document.querySelector('#connection-status').dataset.state")
|
||||||
|
if (state == "\"connecting\"") {
|
||||||
|
assertEquals("true", evaluate(launcher.get(), "document.querySelector('#workspace-settings').hidden"))
|
||||||
|
}
|
||||||
|
state == "\"connectionError\""
|
||||||
|
}
|
||||||
|
assertEquals("https://127.0.0.1:9/app/", preferences.getString("server", null))
|
||||||
|
assertEquals("\"Try again\"", evaluate(launcher.get(), "document.querySelector('.open-workspace').textContent"))
|
||||||
|
scenario.onActivity {
|
||||||
|
assertEquals("https://127.0.0.1:9/app/dashboard", it.workspace?.originalUrl)
|
||||||
|
assertFalse(it.workspace!!.canGoBack())
|
||||||
|
it.openWorkspace("https://127.0.0.1:9/app/")
|
||||||
|
}
|
||||||
|
waitFor { evaluate(launcher.get(), "document.querySelector('#connection-status').dataset.state") == "\"connectionError\"" }
|
||||||
|
} else {
|
||||||
|
waitFor { evaluate(launcher.get(), "document.querySelector('#workspace-settings').hidden") == "false" }
|
||||||
|
scenario.onActivity { assertNull(it.workspace) }
|
||||||
|
}
|
||||||
|
scenario.onActivity { it.openWorkspace("https://kanbangit.de/") }
|
||||||
|
val remote = AtomicReference<WebView>()
|
||||||
|
waitFor {
|
||||||
|
var visible = false
|
||||||
|
scenario.onActivity { remote.set(it.workspace); visible = it.workspace?.visibility == View.VISIBLE }
|
||||||
|
visible && remote.get() != null
|
||||||
|
}
|
||||||
|
assertEquals("\"/login\"", evaluate(remote.get(), "location.pathname"))
|
||||||
|
assertEquals("1", evaluate(remote.get(), "history.length"))
|
||||||
|
assertEquals("https://kanbangit.de/", preferences.getString("server", null))
|
||||||
|
scenario.moveToState(Lifecycle.State.CREATED)
|
||||||
|
}
|
||||||
|
|
||||||
@Test fun workspacePreservesFormsAndIsolatesServers() {
|
@Test fun workspacePreservesFormsAndIsolatesServers() {
|
||||||
instrumentation.targetContext.getSharedPreferences("workspace", Context.MODE_PRIVATE).edit().clear().commit()
|
instrumentation.targetContext.getSharedPreferences("workspace", Context.MODE_PRIVATE).edit().clear().commit()
|
||||||
val scenario = ActivityScenario.launch(MainActivity::class.java)
|
val scenario = ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
|||||||
@@ -164,9 +164,9 @@ class MainActivity : TauriActivity() {
|
|||||||
if (initialized) return
|
if (initialized) return
|
||||||
initialized = true
|
initialized = true
|
||||||
val saved = preferences.getString("server", null)
|
val saved = preferences.getString("server", null)
|
||||||
server = saved?.let(NavigationPolicy::installation) ?: NavigationPolicy.DEFAULT_SERVER
|
val restored = saved?.let(NavigationPolicy::installation)
|
||||||
updateLauncher()
|
server = restored ?: NavigationPolicy.DEFAULT_SERVER
|
||||||
if (saved != null) openWorkspace(server)
|
if (restored != null) openWorkspace(restored) else updateLauncher()
|
||||||
}
|
}
|
||||||
"/action/open" -> openWorkspace(url.getQueryParameter("server").orEmpty())
|
"/action/open" -> openWorkspace(url.getQueryParameter("server").orEmpty())
|
||||||
"/action/resume" -> if (ready) showWorkspace()
|
"/action/resume" -> if (ready) showWorkspace()
|
||||||
@@ -313,8 +313,8 @@ class MainActivity : TauriActivity() {
|
|||||||
val firstLoad = !ready
|
val firstLoad = !ready
|
||||||
ready = true
|
ready = true
|
||||||
CookieManager.getInstance().flush()
|
CookieManager.getInstance().flush()
|
||||||
updateLauncher()
|
|
||||||
if (firstLoad) showWorkspace()
|
if (firstLoad) showWorkspace()
|
||||||
|
updateLauncher()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) {
|
override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) {
|
||||||
@@ -333,7 +333,7 @@ class MainActivity : TauriActivity() {
|
|||||||
remote.visibility = View.GONE
|
remote.visibility = View.GONE
|
||||||
content.addView(remote, 0, FrameLayout.LayoutParams(-1, -1))
|
content.addView(remote, 0, FrameLayout.LayoutParams(-1, -1))
|
||||||
startTimeout(remote)
|
startTimeout(remote)
|
||||||
remote.loadUrl(server)
|
remote.loadUrl(NavigationPolicy.dashboard(server))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startTimeout(view: WebView) {
|
private fun startTimeout(view: WebView) {
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ object NavigationPolicy {
|
|||||||
"https://${uri.host.lowercase(Locale.ROOT)}$port$path"
|
"https://${uri.host.lowercase(Locale.ROOT)}$port$path"
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
|
|
||||||
|
fun dashboard(server: String): String = URI(requireNotNull(installation(server))).resolve("dashboard").toString()
|
||||||
|
|
||||||
fun allows(server: String, destination: String): Boolean = runCatching {
|
fun allows(server: String, destination: String): Boolean = runCatching {
|
||||||
val base = URI(server)
|
val base = URI(server)
|
||||||
val target = URI(destination)
|
val target = URI(destination)
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ class NavigationPolicyTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun startupTargetsDashboardWithinTheInstallation() {
|
||||||
|
assertEquals("https://example.com/dashboard", NavigationPolicy.dashboard("https://example.com/"))
|
||||||
|
assertEquals("https://example.com:8443/kanban/dashboard", NavigationPolicy.dashboard("https://example.com:8443/kanban/"))
|
||||||
|
}
|
||||||
|
|
||||||
@Test fun allowsLoginTwoFactorAndHistoryOnlyOnTheSelectedOrigin() {
|
@Test fun allowsLoginTwoFactorAndHistoryOnlyOnTheSelectedOrigin() {
|
||||||
val server = "https://example.com:8443/app/"
|
val server = "https://example.com:8443/app/"
|
||||||
for (path in listOf("/login", "/two-factor-challenge", "/projects/1?ticket=53#details")) {
|
for (path in listOf("/login", "/two-factor-challenge", "/projects/1?ticket=53#details")) {
|
||||||
|
|||||||
|
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()
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
private var connection = WorkspaceConnection()
|
private var connection = WorkspaceConnection()
|
||||||
private var pendingNavigation: WKNavigation?
|
private var pendingNavigation: WKNavigation?
|
||||||
private var timeout: DispatchWorkItem?
|
private var timeout: DispatchWorkItem?
|
||||||
|
private let loading = UIActivityIndicatorView(style: .large)
|
||||||
private let launcher = UIScrollView()
|
private let launcher = UIScrollView()
|
||||||
private let stack = UIStackView()
|
private let stack = UIStackView()
|
||||||
private let address = UITextField()
|
private let address = UITextField()
|
||||||
@@ -58,6 +59,14 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
view.backgroundColor = .systemBackground
|
view.backgroundColor = .systemBackground
|
||||||
view.tintColor = UIColor(red: 0, green: 0.43, blue: 0.18, alpha: 1)
|
view.tintColor = UIColor(red: 0, green: 0.43, blue: 0.18, alpha: 1)
|
||||||
|
loading.accessibilityIdentifier = "workspaceLoading"
|
||||||
|
loading.accessibilityLabel = text("connecting")
|
||||||
|
loading.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(loading)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
loading.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||||
|
loading.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||||
|
])
|
||||||
configureLauncher()
|
configureLauncher()
|
||||||
configureToolbar()
|
configureToolbar()
|
||||||
if let data = defaults.data(forKey: serverKey) {
|
if let data = defaults.data(forKey: serverKey) {
|
||||||
@@ -69,8 +78,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
address.text = server?.url.absoluteString ?? "https://kanbangit.de/"
|
address.text = server?.url.absoluteString ?? "https://kanbangit.de/"
|
||||||
showSettings()
|
if let server { open(server.url) } else { showSettings() }
|
||||||
if let server { open(server.url) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func label(_ key: String, style: UIFont.TextStyle = .body) -> UILabel {
|
private func label(_ key: String, style: UIFont.TextStyle = .body) -> UILabel {
|
||||||
@@ -83,6 +91,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func configureLauncher() {
|
private func configureLauncher() {
|
||||||
|
launcher.isHidden = true
|
||||||
launcher.accessibilityIdentifier = "serverSettings"
|
launcher.accessibilityIdentifier = "serverSettings"
|
||||||
launcher.keyboardDismissMode = .interactive
|
launcher.keyboardDismissMode = .interactive
|
||||||
launcher.alwaysBounceVertical = true
|
launcher.alwaysBounceVertical = true
|
||||||
@@ -152,6 +161,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func configureToolbar() {
|
private func configureToolbar() {
|
||||||
|
toolbar.isHidden = true
|
||||||
toolbar.translatesAutoresizingMaskIntoConstraints = false
|
toolbar.translatesAutoresizingMaskIntoConstraints = false
|
||||||
view.addSubview(toolbar)
|
view.addSubview(toolbar)
|
||||||
backButton = UIBarButtonItem(title: text("back"), style: .plain, target: self, action: #selector(goBack))
|
backButton = UIBarButtonItem(title: text("back"), style: .plain, target: self, action: #selector(goBack))
|
||||||
@@ -222,8 +232,11 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
}
|
}
|
||||||
view.endEditing(true)
|
view.endEditing(true)
|
||||||
address.text = url.absoluteString
|
address.text = url.absoluteString
|
||||||
|
launcher.isHidden = true
|
||||||
|
toolbar.isHidden = true
|
||||||
|
loading.startAnimating()
|
||||||
beginNavigation()
|
beginNavigation()
|
||||||
pendingNavigation = webview.load(URLRequest(url: url))
|
pendingNavigation = webview.load(URLRequest(url: selected.dashboardURL))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func beginNavigation() {
|
private func beginNavigation() {
|
||||||
@@ -247,6 +260,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func showSettings() {
|
@objc private func showSettings() {
|
||||||
|
loading.stopAnimating()
|
||||||
view.endEditing(true)
|
view.endEditing(true)
|
||||||
launcher.isHidden = false
|
launcher.isHidden = false
|
||||||
toolbar.isHidden = true
|
toolbar.isHidden = true
|
||||||
@@ -256,6 +270,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
|
|
||||||
@objc private func showWorkspace() {
|
@objc private func showWorkspace() {
|
||||||
guard connection.ready else { return }
|
guard connection.ready else { return }
|
||||||
|
loading.stopAnimating()
|
||||||
view.endEditing(true)
|
view.endEditing(true)
|
||||||
launcher.isHidden = true
|
launcher.isHidden = true
|
||||||
toolbar.isHidden = false
|
toolbar.isHidden = false
|
||||||
@@ -296,7 +311,8 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
}
|
}
|
||||||
|
|
||||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
guard webView === workspace, navigation === pendingNavigation else { return }
|
guard webView === workspace, navigation === pendingNavigation, connection.connecting,
|
||||||
|
let url = webView.url, server?.allows(url) == true else { return }
|
||||||
let firstLoad = !connection.ready
|
let firstLoad = !connection.ready
|
||||||
timeout?.cancel()
|
timeout?.cancel()
|
||||||
connection.finish()
|
connection.finish()
|
||||||
|
|||||||
@@ -17,6 +17,71 @@ final class WorkspaceTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testDashboardAddressPreservesTheInstallationPath() throws {
|
||||||
|
let url = try XCTUnwrap(WorkspaceServer.normalize("https://example.com:8443/kanban/"))
|
||||||
|
XCTAssertEqual(WorkspaceServer(url: url, profile: UUID()).dashboardURL.absoluteString,
|
||||||
|
"https://example.com:8443/kanban/dashboard")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testColdStartHidesSelectionUntilFailureAndPreservesSavedWorkspace() throws {
|
||||||
|
let name = "KAN69-startup-\(UUID())"
|
||||||
|
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
||||||
|
defer { defaults.removePersistentDomain(forName: name) }
|
||||||
|
let saved = WorkspaceServer(url: URL(string: "https://example.invalid/kanban/")!, profile: UUID())
|
||||||
|
let data = try JSONEncoder().encode(saved)
|
||||||
|
defaults.set(data, forKey: "ios.activeServer")
|
||||||
|
let controller = WorkspaceViewController(defaults: defaults)
|
||||||
|
controller.loadViewIfNeeded()
|
||||||
|
let launcher = try XCTUnwrap(controller.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" })
|
||||||
|
let loading = try XCTUnwrap(controller.view.subviews.first { $0.accessibilityIdentifier == "workspaceLoading" } as? UIActivityIndicatorView)
|
||||||
|
XCTAssertTrue(launcher.isHidden)
|
||||||
|
XCTAssertTrue(loading.isAnimating)
|
||||||
|
XCTAssertEqual(controller.server, saved)
|
||||||
|
let webview = try XCTUnwrap(controller.workspace)
|
||||||
|
XCTAssertFalse(webview.canGoBack)
|
||||||
|
XCTAssertTrue(webview.backForwardList.backList.isEmpty)
|
||||||
|
webview.stopLoading()
|
||||||
|
let navigation = try XCTUnwrap(webview.loadSimulatedRequest(URLRequest(url: saved.dashboardURL), responseHTML: "<html></html>"))
|
||||||
|
controller.webView(webview, didStartProvisionalNavigation: navigation)
|
||||||
|
XCTAssertTrue(launcher.isHidden)
|
||||||
|
controller.webView(webview, didFailProvisionalNavigation: navigation,
|
||||||
|
withError: NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet))
|
||||||
|
XCTAssertFalse(launcher.isHidden)
|
||||||
|
XCTAssertFalse(loading.isAnimating)
|
||||||
|
XCTAssertEqual(try JSONDecoder().decode(WorkspaceServer.self, from: XCTUnwrap(defaults.data(forKey: "ios.activeServer"))), saved)
|
||||||
|
controller.webView(webview, didFinish: navigation)
|
||||||
|
XCTAssertFalse(launcher.isHidden, "A late finish must not hide recovery after a failure")
|
||||||
|
controller.open(saved.url)
|
||||||
|
XCTAssertTrue(launcher.isHidden)
|
||||||
|
XCTAssertTrue(loading.isAnimating)
|
||||||
|
XCTAssertEqual(controller.server?.profile, saved.profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testMissingOrInvalidConfigurationShowsSetupAndServerChangesAreRestored() throws {
|
||||||
|
let name = "KAN69-setup-\(UUID())"
|
||||||
|
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
||||||
|
defer { defaults.removePersistentDomain(forName: name) }
|
||||||
|
for data in [nil, Data("invalid".utf8), try JSONEncoder().encode(WorkspaceServer(url: URL(string: "http://example.com/")!, profile: UUID()))] {
|
||||||
|
defaults.set(data, forKey: "ios.activeServer")
|
||||||
|
let controller = WorkspaceViewController(defaults: defaults)
|
||||||
|
controller.loadViewIfNeeded()
|
||||||
|
XCTAssertNil(controller.workspace)
|
||||||
|
let launcher = try XCTUnwrap(controller.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" })
|
||||||
|
XCTAssertFalse(launcher.isHidden)
|
||||||
|
}
|
||||||
|
let controller = WorkspaceViewController(defaults: defaults)
|
||||||
|
controller.loadViewIfNeeded()
|
||||||
|
controller.open(URL(string: "https://first.invalid/")!)
|
||||||
|
controller.open(URL(string: "https://second.invalid/app/")!)
|
||||||
|
let restored = WorkspaceViewController(defaults: defaults)
|
||||||
|
restored.loadViewIfNeeded()
|
||||||
|
XCTAssertEqual(restored.server, controller.server)
|
||||||
|
XCTAssertEqual(restored.server?.dashboardURL.absoluteString, "https://second.invalid/app/dashboard")
|
||||||
|
XCTAssertTrue(restored.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" }?.isHidden == true)
|
||||||
|
}
|
||||||
|
|
||||||
func testExternalLinksStayOutsideTheSelectedServer() throws {
|
func testExternalLinksStayOutsideTheSelectedServer() throws {
|
||||||
let server = WorkspaceServer(url: URL(string: "https://example.com/")!, profile: UUID())
|
let server = WorkspaceServer(url: URL(string: "https://example.com/")!, profile: UUID())
|
||||||
for value in ["https://git.example.com/org/repo/pulls/1#diff", "http://git.example.com/repo", "https://example.com:8443/other"] {
|
for value in ["https://git.example.com/org/repo/pulls/1#diff", "http://git.example.com/repo", "https://example.com:8443/other"] {
|
||||||
@@ -40,7 +105,7 @@ final class WorkspaceTests: XCTestCase {
|
|||||||
controller.open(url)
|
controller.open(url)
|
||||||
let webview = try XCTUnwrap(controller.workspace)
|
let webview = try XCTUnwrap(controller.workspace)
|
||||||
webview.stopLoading()
|
webview.stopLoading()
|
||||||
webview.loadHTMLString("<html><body><input id='draft' value='unsaved'></body></html>", baseURL: url)
|
webview.loadSimulatedRequest(URLRequest(url: url), responseHTML: "<html><body><input id='draft' value='unsaved'></body></html>")
|
||||||
var loaded = false
|
var loaded = false
|
||||||
for _ in 0..<50 {
|
for _ in 0..<50 {
|
||||||
if let value = try? await webview.evaluateJavaScript("document.getElementById('draft')?.value"),
|
if let value = try? await webview.evaluateJavaScript("document.getElementById('draft')?.value"),
|
||||||
@@ -57,10 +122,12 @@ final class WorkspaceTests: XCTestCase {
|
|||||||
XCTAssertTrue(webview.canGoBack)
|
XCTAssertTrue(webview.canGoBack)
|
||||||
XCTAssertTrue(toolbar.items?.first?.isEnabled == true)
|
XCTAssertTrue(toolbar.items?.first?.isEnabled == true)
|
||||||
webview.goBack()
|
webview.goBack()
|
||||||
|
var returned = false
|
||||||
for _ in 0..<20 {
|
for _ in 0..<20 {
|
||||||
if let hash = try? await webview.evaluateJavaScript("location.hash"), hash as? String == "" { break }
|
if let hash = try? await webview.evaluateJavaScript("location.hash"), hash as? String == "" { returned = true; break }
|
||||||
try await Task.sleep(for: .milliseconds(50))
|
try await Task.sleep(for: .milliseconds(50))
|
||||||
}
|
}
|
||||||
|
XCTAssertTrue(returned)
|
||||||
let draft = try await webview.evaluateJavaScript("document.getElementById('draft').value")
|
let draft = try await webview.evaluateJavaScript("document.getElementById('draft').value")
|
||||||
XCTAssertEqual(draft as? String, "unsaved")
|
XCTAssertEqual(draft as? String, "unsaved")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,11 +13,50 @@ final class WorkspaceUITests: XCTestCase {
|
|||||||
XCTAssertTrue(element.isHittable)
|
XCTAssertTrue(element.isHittable)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testSavedWorkspaceColdStartNeverShowsSelectionBeforeRecovery() {
|
||||||
|
continueAfterFailure = false
|
||||||
|
XCUIDevice.shared.orientation = .portrait
|
||||||
|
defer { XCUIDevice.shared.orientation = .portrait }
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launchEnvironment["OS_ACTIVITY_DT_MODE"] = "NO"
|
||||||
|
app.launchEnvironment["OS_ACTIVITY_MODE"] = "disable"
|
||||||
|
app.launch()
|
||||||
|
let settings = app.buttons["showServerSettings"]
|
||||||
|
if settings.waitForExistence(timeout: 3) { settings.tap() }
|
||||||
|
let address = app.textFields["serverAddress"]
|
||||||
|
XCTAssertTrue(address.waitForExistence(timeout: 20))
|
||||||
|
address.tap()
|
||||||
|
if address.buttons.firstMatch.exists { address.buttons.firstMatch.tap() }
|
||||||
|
address.typeText("https://127.0.0.1:9/")
|
||||||
|
app.buttons["openWorkspace"].tap()
|
||||||
|
let confirmation = app.alerts.buttons["Switch server"]
|
||||||
|
if confirmation.waitForExistence(timeout: 1) { confirmation.tap() }
|
||||||
|
XCTAssertTrue(app.staticTexts["connectionStatus"].waitForExistence(timeout: 20))
|
||||||
|
app.terminate()
|
||||||
|
app.launch()
|
||||||
|
// An unreachable saved server may fail immediately; only recovery may reveal setup.
|
||||||
|
for _ in 0..<30 {
|
||||||
|
if address.exists {
|
||||||
|
XCTAssertTrue(app.staticTexts["connectionStatus"].label.contains("server did not finish loading"))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
XCTAssertFalse(app.staticTexts["Choose your workspace."].exists)
|
||||||
|
Thread.sleep(forTimeInterval: 0.1)
|
||||||
|
}
|
||||||
|
XCTAssertTrue(address.waitForExistence(timeout: 20))
|
||||||
|
XCTAssertEqual(address.value as? String, "https://127.0.0.1:9/")
|
||||||
|
XCTAssertTrue(app.buttons["openWorkspace"].label.contains("Try again"))
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func testLauncherKeyboardValidationRotationAndAppearance() {
|
func testLauncherKeyboardValidationRotationAndAppearance() {
|
||||||
continueAfterFailure = false
|
continueAfterFailure = false
|
||||||
XCUIDevice.shared.orientation = .portrait
|
XCUIDevice.shared.orientation = .portrait
|
||||||
|
defer { XCUIDevice.shared.orientation = .portrait }
|
||||||
let app = XCUIApplication()
|
let app = XCUIApplication()
|
||||||
|
app.launchEnvironment["OS_ACTIVITY_DT_MODE"] = "NO"
|
||||||
|
app.launchEnvironment["OS_ACTIVITY_MODE"] = "disable"
|
||||||
app.launch()
|
app.launch()
|
||||||
let settings = app.buttons["showServerSettings"]
|
let settings = app.buttons["showServerSettings"]
|
||||||
if settings.waitForExistence(timeout: 3) { settings.tap() }
|
if settings.waitForExistence(timeout: 3) { settings.tap() }
|
||||||
|
|||||||
@@ -49,7 +49,9 @@
|
|||||||
47C531812A31207F97A89C5F /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
47C531812A31207F97A89C5F /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
||||||
4C76059D31C50B198779A5F7 /* en.lproj */ = {isa = PBXFileReference; path = en.lproj; sourceTree = "<group>"; };
|
4C76059D31C50B198779A5F7 /* en.lproj */ = {isa = PBXFileReference; path = en.lproj; sourceTree = "<group>"; };
|
||||||
554AB059B4228085924F4959 /* WorkspacePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacePolicy.swift; sourceTree = "<group>"; };
|
554AB059B4228085924F4959 /* WorkspacePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacePolicy.swift; sourceTree = "<group>"; };
|
||||||
|
5F71F06AB639140AB9714425 /* metadata.rs */ = {isa = PBXFileReference; path = metadata.rs; sourceTree = "<group>"; };
|
||||||
63685B1189795CAEA85F7AB7 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; };
|
63685B1189795CAEA85F7AB7 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; };
|
||||||
|
6610D376CF901AC1733D394E /* updates.rs */ = {isa = PBXFileReference; path = updates.rs; sourceTree = "<group>"; };
|
||||||
7440C7F0426E6B3977DD1D94 /* main.rs */ = {isa = PBXFileReference; path = main.rs; sourceTree = "<group>"; };
|
7440C7F0426E6B3977DD1D94 /* main.rs */ = {isa = PBXFileReference; path = main.rs; sourceTree = "<group>"; };
|
||||||
8243D0C3821DAC731FECF105 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; };
|
8243D0C3821DAC731FECF105 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; };
|
||||||
91991DFDB0C064B06DF854ED /* libapp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libapp.a; sourceTree = "<group>"; };
|
91991DFDB0C064B06DF854ED /* libapp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libapp.a; sourceTree = "<group>"; };
|
||||||
@@ -146,7 +148,9 @@
|
|||||||
2B96972F58CC158AB3DFF94A /* lib.rs */,
|
2B96972F58CC158AB3DFF94A /* lib.rs */,
|
||||||
AEBAD9ABE758897C50BC1F2E /* locale.rs */,
|
AEBAD9ABE758897C50BC1F2E /* locale.rs */,
|
||||||
7440C7F0426E6B3977DD1D94 /* main.rs */,
|
7440C7F0426E6B3977DD1D94 /* main.rs */,
|
||||||
|
5F71F06AB639140AB9714425 /* metadata.rs */,
|
||||||
E8BA104C81071623E1FF367D /* server.rs */,
|
E8BA104C81071623E1FF367D /* server.rs */,
|
||||||
|
6610D376CF901AC1733D394E /* updates.rs */,
|
||||||
);
|
);
|
||||||
name = src;
|
name = src;
|
||||||
path = ../../src;
|
path = ../../src;
|
||||||
@@ -269,12 +273,18 @@
|
|||||||
LastUpgradeCheck = 1430;
|
LastUpgradeCheck = 1430;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
721CF221CEDDC28FF2C5EBD5 = {
|
721CF221CEDDC28FF2C5EBD5 = {
|
||||||
DevelopmentTeam = "";
|
DevelopmentTeam = ZZX2KZGTHU;
|
||||||
ProvisioningStyle = Automatic;
|
ProvisioningStyle = Manual;
|
||||||
};
|
};
|
||||||
988CFF304CB81C8F3A4D6612 = {
|
988CFF304CB81C8F3A4D6612 = {
|
||||||
|
DevelopmentTeam = ZZX2KZGTHU;
|
||||||
|
ProvisioningStyle = Manual;
|
||||||
TestTargetID = 721CF221CEDDC28FF2C5EBD5;
|
TestTargetID = 721CF221CEDDC28FF2C5EBD5;
|
||||||
};
|
};
|
||||||
|
CBC4561F8018D37DD49A0598 = {
|
||||||
|
DevelopmentTeam = ZZX2KZGTHU;
|
||||||
|
ProvisioningStyle = Manual;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = A9CA551DD3F3EBCCF8C41CC1 /* Build configuration list for PBXProject "kanbangit-native" */;
|
buildConfigurationList = A9CA551DD3F3EBCCF8C41CC1 /* Build configuration list for PBXProject "kanbangit-native" */;
|
||||||
@@ -332,7 +342,7 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "export PATH=\"$(dirname \"$(rustup which cargo)\"):$PATH\"\nexport RUSTC=\"$(rustup which rustc)\"\ncd \"$SRCROOT/../../..\"\n./node_modules/.bin/tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths \"${FRAMEWORK_SEARCH_PATHS:?}\" --header-search-paths \"${HEADER_SEARCH_PATHS:?}\" --gcc-preprocessor-definitions \"${GCC_PREPROCESSOR_DEFINITIONS:-}\" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}\n";
|
shellScript = "set -eu\nif [ -n \"${KANBANGIT_IOS_LIBRARY:-}\" ]; then\n mkdir -p \"$SRCROOT/Externals/arm64/$CONFIGURATION\"\n cp \"$KANBANGIT_IOS_LIBRARY\" \"$SRCROOT/Externals/arm64/$CONFIGURATION/libapp.a\"\n exit 0\nfi\nexport PATH=\"$(dirname \"$(rustup which cargo)\"):$PATH\"\nexport RUSTC=\"$(rustup which rustc)\"\ncd \"$SRCROOT/../../..\"\n./node_modules/.bin/tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths \"${FRAMEWORK_SEARCH_PATHS:?}\" --header-search-paths \"${HEADER_SEARCH_PATHS:?}\" --gcc-preprocessor-definitions \"${GCC_PREPROCESSOR_DEFINITIONS:-}\" --configuration ${CONFIGURATION:?} ${FORCE_COLOR:-} ${ARCHS:?}\n";
|
||||||
};
|
};
|
||||||
/* End PBXShellScriptBuildPhase section */
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
@@ -404,9 +414,10 @@
|
|||||||
);
|
);
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Manual;
|
||||||
DEVELOPMENT_TEAM = "";
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
|
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
|
||||||
@@ -421,9 +432,10 @@
|
|||||||
);
|
);
|
||||||
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||||
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.desktop;
|
MARKETING_VERSION = 0.2.1;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
|
||||||
PRODUCT_MODULE_NAME = KanbanGitNative;
|
PRODUCT_MODULE_NAME = KanbanGitNative;
|
||||||
PRODUCT_NAME = "KanbanGit";
|
PRODUCT_NAME = KanbanGit;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
@@ -498,13 +510,15 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Manual;
|
||||||
|
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@loader_path/Frameworks",
|
"@loader_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.uitests;
|
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios.uitests;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
@@ -577,9 +591,10 @@
|
|||||||
);
|
);
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Manual;
|
||||||
DEVELOPMENT_TEAM = "";
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
|
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
|
||||||
@@ -594,9 +609,10 @@
|
|||||||
);
|
);
|
||||||
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||||
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.desktop;
|
MARKETING_VERSION = 0.2.1;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
|
||||||
PRODUCT_MODULE_NAME = KanbanGitNative;
|
PRODUCT_MODULE_NAME = KanbanGitNative;
|
||||||
PRODUCT_NAME = "KanbanGit";
|
PRODUCT_NAME = KanbanGit;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
@@ -608,13 +624,15 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Manual;
|
||||||
|
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@loader_path/Frameworks",
|
"@loader_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.uitests;
|
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios.uitests;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
@@ -626,13 +644,15 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Manual;
|
||||||
|
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@loader_path/Frameworks",
|
"@loader_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.tests;
|
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios.tests;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
@@ -643,13 +663,15 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Manual;
|
||||||
|
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@loader_path/Frameworks",
|
"@loader_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.tests;
|
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios.tests;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
|||||||
@@ -15,9 +15,9 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>0.1.0</string>
|
<string>$(MARKETING_VERSION)</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>0.1.0</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
|
|||||||
@@ -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.1"
|
||||||
|
CURRENT_PROJECT_VERSION: "1"
|
||||||
|
configs:
|
||||||
|
debug:
|
||||||
|
CODE_SIGN_IDENTITY: Apple Development
|
||||||
|
release:
|
||||||
|
CODE_SIGN_IDENTITY: Apple Distribution
|
||||||
targetTemplates:
|
targetTemplates:
|
||||||
app:
|
app:
|
||||||
type: application
|
type: application
|
||||||
@@ -55,8 +62,8 @@ targets:
|
|||||||
- UIInterfaceOrientationPortraitUpsideDown
|
- UIInterfaceOrientationPortraitUpsideDown
|
||||||
- UIInterfaceOrientationLandscapeLeft
|
- UIInterfaceOrientationLandscapeLeft
|
||||||
- UIInterfaceOrientationLandscapeRight
|
- UIInterfaceOrientationLandscapeRight
|
||||||
CFBundleShortVersionString: 0.1.0
|
CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||||
CFBundleVersion: "0.1.0"
|
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||||
entitlements:
|
entitlements:
|
||||||
path: kanbangit-native_iOS/kanbangit-native_iOS.entitlements
|
path: kanbangit-native_iOS/kanbangit-native_iOS.entitlements
|
||||||
scheme:
|
scheme:
|
||||||
@@ -88,10 +95,16 @@ targets:
|
|||||||
- sdk: WebKit.framework
|
- sdk: WebKit.framework
|
||||||
preBuildScripts:
|
preBuildScripts:
|
||||||
- script: |
|
- script: |
|
||||||
|
set -eu
|
||||||
|
if [ -n "${KANBANGIT_IOS_LIBRARY:-}" ]; then
|
||||||
|
mkdir -p "$SRCROOT/Externals/arm64/$CONFIGURATION"
|
||||||
|
cp "$KANBANGIT_IOS_LIBRARY" "$SRCROOT/Externals/arm64/$CONFIGURATION/libapp.a"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
export PATH="$(dirname "$(rustup which cargo)"):$PATH"
|
export PATH="$(dirname "$(rustup which cargo)"):$PATH"
|
||||||
export RUSTC="$(rustup which rustc)"
|
export RUSTC="$(rustup which rustc)"
|
||||||
cd "$SRCROOT/../../.."
|
cd "$SRCROOT/../../.."
|
||||||
./node_modules/.bin/tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths "${FRAMEWORK_SEARCH_PATHS:?}" --header-search-paths "${HEADER_SEARCH_PATHS:?}" --gcc-preprocessor-definitions "${GCC_PREPROCESSOR_DEFINITIONS:-}" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}
|
./node_modules/.bin/tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths "${FRAMEWORK_SEARCH_PATHS:?}" --header-search-paths "${HEADER_SEARCH_PATHS:?}" --gcc-preprocessor-definitions "${GCC_PREPROCESSOR_DEFINITIONS:-}" --configuration ${CONFIGURATION:?} ${FORCE_COLOR:-} ${ARCHS:?}
|
||||||
name: Build Rust Code
|
name: Build Rust Code
|
||||||
basedOnDependencyAnalysis: false
|
basedOnDependencyAnalysis: false
|
||||||
outputFiles:
|
outputFiles:
|
||||||
@@ -108,7 +121,9 @@ targets:
|
|||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
GENERATE_INFOPLIST_FILE: YES
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.tests
|
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios.tests
|
||||||
|
DEVELOPMENT_TEAM: ZZX2KZGTHU
|
||||||
|
CODE_SIGN_STYLE: Manual
|
||||||
SWIFT_VERSION: "5.0"
|
SWIFT_VERSION: "5.0"
|
||||||
WorkspaceUITests:
|
WorkspaceUITests:
|
||||||
type: bundle.ui-testing
|
type: bundle.ui-testing
|
||||||
@@ -119,6 +134,8 @@ targets:
|
|||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
GENERATE_INFOPLIST_FILE: YES
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.uitests
|
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios.uitests
|
||||||
|
DEVELOPMENT_TEAM: ZZX2KZGTHU
|
||||||
|
CODE_SIGN_STYLE: Manual
|
||||||
SWIFT_VERSION: "5.0"
|
SWIFT_VERSION: "5.0"
|
||||||
TEST_TARGET_NAME: kanbangit-native_iOS
|
TEST_TARGET_NAME: kanbangit-native_iOS
|
||||||
|
|||||||
@@ -169,7 +169,8 @@ 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 =
|
||||||
|
WebviewWindowBuilder::new(app, &label, WebviewUrl::External(server.dashboard_url()))
|
||||||
.title(en::APP_NAME)
|
.title(en::APP_NAME)
|
||||||
.inner_size(1280.0, 850.0)
|
.inner_size(1280.0, 850.0)
|
||||||
.min_inner_size(375.0, 600.0)
|
.min_inner_size(375.0, 600.0)
|
||||||
@@ -202,7 +203,12 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
|
|||||||
let state = app.state::<Mutex<Connection>>();
|
let state = app.state::<Mutex<Connection>>();
|
||||||
let should_show = {
|
let should_show = {
|
||||||
let mut state = state.lock().unwrap();
|
let mut state = state.lock().unwrap();
|
||||||
if state.label.as_deref() != Some(window.label()) {
|
if state.label.as_deref() != Some(window.label())
|
||||||
|
|| state.phase != "connecting"
|
||||||
|
|| !state.server.as_ref().is_some_and(|server| {
|
||||||
|
allows_server_navigation(&server.url, payload.url())
|
||||||
|
})
|
||||||
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let first_load = !state.ready;
|
let first_load = !state.ready;
|
||||||
@@ -210,7 +216,6 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
|
|||||||
state.phase = "connected";
|
state.phase = "connected";
|
||||||
first_load
|
first_load
|
||||||
};
|
};
|
||||||
update_launcher(app);
|
|
||||||
if should_show {
|
if should_show {
|
||||||
let _ = window.show();
|
let _ = window.show();
|
||||||
let _ = window.set_focus();
|
let _ = window.set_focus();
|
||||||
@@ -218,6 +223,7 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
|
|||||||
let _ = launcher.hide();
|
let _ = launcher.hide();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
update_launcher(app);
|
||||||
})
|
})
|
||||||
.build()
|
.build()
|
||||||
.map_err(|_| "connectionError")?;
|
.map_err(|_| "connectionError")?;
|
||||||
@@ -298,7 +304,8 @@ fn about_metadata(version: String) -> AboutMetadata<'static> {
|
|||||||
pub fn builder() -> tauri::Builder<tauri::Wry> {
|
pub fn builder() -> tauri::Builder<tauri::Wry> {
|
||||||
tauri::Builder::default().setup(|app| {
|
tauri::Builder::default().setup(|app| {
|
||||||
let (server, phase) = match Server::load(&configuration_path(app.handle())?) {
|
let (server, phase) = match Server::load(&configuration_path(app.handle())?) {
|
||||||
Ok(server) => (server, "idle"),
|
Ok(Some(server)) => (Some(server), "connecting"),
|
||||||
|
Ok(None) => (None, "idle"),
|
||||||
Err(_) => (None, "storageError"),
|
Err(_) => (None, "storageError"),
|
||||||
};
|
};
|
||||||
app.manage(Mutex::new(Connection {
|
app.manage(Mutex::new(Connection {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "KanbanGit",
|
"productName": "KanbanGit",
|
||||||
"version": "0.2.1",
|
"version": "0.2.2",
|
||||||
"identifier": "de.kanbangit.desktop",
|
"identifier": "de.kanbangit.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../ui"
|
"frontendDist": "../ui"
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
],
|
],
|
||||||
"macOS": {
|
"macOS": {
|
||||||
"minimumSystemVersion": "14.0",
|
"minimumSystemVersion": "14.0",
|
||||||
"bundleVersion": "7",
|
"bundleVersion": "8",
|
||||||
"dmg": {
|
"dmg": {
|
||||||
"windowSize": {
|
"windowSize": {
|
||||||
"width": 660,
|
"width": 660,
|
||||||
|
|||||||
@@ -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": "2"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,26 @@ fn main() {
|
|||||||
let page_stage = stage.clone();
|
let page_stage = stage.clone();
|
||||||
let result_passed = passed.clone();
|
let result_passed = passed.clone();
|
||||||
let plugin = tauri::plugin::Builder::<tauri::Wry>::new("macos-smoke")
|
let plugin = tauri::plugin::Builder::<tauri::Wry>::new("macos-smoke")
|
||||||
|
.setup(|app, _| {
|
||||||
|
let directory = app.path().app_config_dir()?;
|
||||||
|
std::fs::create_dir_all(&directory)?;
|
||||||
|
std::fs::write(directory.join("active-server"), "https://127.0.0.1:9/\n0123456789abcdef0123456789abcdef")?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.js_init_script(r#"
|
||||||
|
if (location.protocol === 'tauri:') {
|
||||||
|
window.__selectionFlashed = false;
|
||||||
|
new MutationObserver(() => {
|
||||||
|
const selection = document.querySelector('#workspace-settings');
|
||||||
|
const phase = document.querySelector('#connection-status')?.dataset.state;
|
||||||
|
if (selection && !selection.hidden && phase !== 'connectionError') window.__selectionFlashed = true;
|
||||||
|
}).observe(document, { subtree: true, attributes: true, childList: true });
|
||||||
|
}
|
||||||
|
"#)
|
||||||
.js_init_script("window.addEventListener('error', event => { location.href = 'tauri://localhost/__native_smoke_result?passed=false&error=' + encodeURIComponent(event.message || event.target.src); }, true);")
|
.js_init_script("window.addEventListener('error', event => { location.href = 'tauri://localhost/__native_smoke_result?passed=false&error=' + encodeURIComponent(event.message || event.target.src); }, true);")
|
||||||
.on_page_load(move |webview, payload| {
|
.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;
|
||||||
}
|
}
|
||||||
@@ -28,7 +45,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');
|
||||||
@@ -76,7 +95,7 @@ fn main() {
|
|||||||
input.id = 'kan52-unsaved'; input.value = 'unsaved input'; document.body.append(input);
|
input.id = 'kan52-unsaved'; input.value = 'unsaved input'; document.body.append(input);
|
||||||
document.cookie = 'kan52_fixture=isolated; Path=/; Secure; SameSite=Lax';
|
document.cookie = 'kan52_fixture=isolated; Path=/; Secure; SameSite=Lax';
|
||||||
localStorage.setItem('kan52_fixture', 'isolated');
|
localStorage.setItem('kan52_fixture', 'isolated');
|
||||||
const ok = rendered && denied && typeof window.__TAURI__ === 'undefined';
|
const ok = rendered && denied && typeof window.__TAURI__ === 'undefined' && location.pathname === '/login' && history.length === 1;
|
||||||
location.href = '/__native_smoke_result?stage=loaded&passed=' + ok;
|
location.href = '/__native_smoke_result?stage=loaded&passed=' + ok;
|
||||||
})();
|
})();
|
||||||
"#).unwrap();
|
"#).unwrap();
|
||||||
@@ -90,6 +109,7 @@ fn main() {
|
|||||||
})
|
})
|
||||||
.on_navigation(move |webview, url| {
|
.on_navigation(move |webview, url| {
|
||||||
|
|
||||||
|
eprintln!("Smoke navigation: {} {}", webview.label(), url);
|
||||||
if url.path() != "/__native_smoke_result" {
|
if url.path() != "/__native_smoke_result" {
|
||||||
return true;
|
return 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);
|
||||||
|
|||||||
@@ -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',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
<script type="module" src="./main.js"></script>
|
<script type="module" src="./main.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<p id="startup-loading" role="status" aria-live="polite" data-i18n="connecting"></p>
|
||||||
|
<main id="workspace-settings" hidden>
|
||||||
<img class="brand" src="./mark.svg" alt="" width="48" height="48" />
|
<img class="brand" src="./mark.svg" alt="" width="48" height="48" />
|
||||||
<p class="eyebrow" data-i18n="appName"></p>
|
<p class="eyebrow" data-i18n="appName"></p>
|
||||||
<h1 data-i18n="title"></h1>
|
<h1 data-i18n="title"></h1>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||