Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8ca4975e5 | ||
|
|
f2e1bd6a0d | ||
|
|
b0cc9dad14 | ||
|
|
52304c7eb2 | ||
|
|
b4cd6a5808 | ||
|
|
f52cffeaeb | ||
|
|
1c620654a3 | ||
|
|
fcba7673c2 | ||
|
|
2f99b8e65c | ||
|
|
2b85517965 | ||
|
|
7001776c48 | ||
|
|
9d472b5091 | ||
|
|
8bed02a484 |
@@ -0,0 +1,58 @@
|
|||||||
|
name: macOS checks
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths: ["native/**", ".github/**", "package.json", "pnpm-lock.yaml", "eslint.config.mjs", "resources/css/app.css"]
|
||||||
|
pull_request:
|
||||||
|
paths: ["native/**", ".github/**", "package.json", "pnpm-lock.yaml", "eslint.config.mjs", "resources/css/app.css"]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
macos:
|
||||||
|
name: Native Rust tests and unsigned build
|
||||||
|
runs-on: ${{ vars.MACOS_CHECK_RUNNER || vars.MACOS_RUNNER || 'macos-arm64' }}
|
||||||
|
timeout-minutes: 45
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v11.7.0
|
||||||
|
with:
|
||||||
|
package-json-file: package.json
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||||
|
with:
|
||||||
|
toolchain: 1.98.1
|
||||||
|
components: rustfmt, clippy
|
||||||
|
|
||||||
|
- name: Install locked native dependencies
|
||||||
|
run: pnpm --dir native install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Run native tests
|
||||||
|
run: pnpm --dir native test
|
||||||
|
|
||||||
|
- name: Check Rust formatting and warnings
|
||||||
|
run: pnpm --dir native check
|
||||||
|
|
||||||
|
- name: Build unsigned macOS app without release credentials
|
||||||
|
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
|
||||||
|
with:
|
||||||
|
projectPath: native
|
||||||
|
tauriScript: pnpm exec tauri
|
||||||
|
args: --ci --debug --no-sign --bundles app -- --locked
|
||||||
|
uploadUpdaterJson: false
|
||||||
|
uploadUpdaterSignatures: false
|
||||||
|
uploadWorkflowArtifacts: false
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
name: macOS release to Hetzner
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
# Gitea enforces the configured macos-v* protection when tags are pushed.
|
||||||
|
# Reading /tag_protections requires admin rights unavailable to job tokens.
|
||||||
|
tags:
|
||||||
|
- "macos-v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: "Existing protected macos-vMAJOR.MINOR.PATCH tag on main"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: macos-release
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
APPLE_SIGNING_IDENTITY: "Developer ID Application: Nico Schröder (ZZX2KZGTHU)"
|
||||||
|
APPLE_TEAM_ID: ZZX2KZGTHU
|
||||||
|
MACOS_RELEASE_TARGET: ${{ vars.MACOS_RELEASE_TARGET || 'aarch64-apple-darwin' }}
|
||||||
|
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||||
|
S3_REGION: ${{ vars.S3_REGION }}
|
||||||
|
S3_PREFIX: ${{ vars.S3_PREFIX || 'releases/macos' }}
|
||||||
|
S3_PUBLIC_BASE_URL: ${{ vars.S3_PUBLIC_BASE_URL }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
prepare:
|
||||||
|
name: Validate release ref and configuration
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
outputs:
|
||||||
|
commit: ${{ steps.ref.outputs.commit }}
|
||||||
|
tag: ${{ steps.ref.outputs.tag }}
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Select an existing version tag contained in main
|
||||||
|
id: ref
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
REQUESTED_TAG: ${{ inputs.tag }}
|
||||||
|
MACOS_RUNNER: ${{ vars.MACOS_RUNNER }}
|
||||||
|
run: |
|
||||||
|
set -Eeuo pipefail
|
||||||
|
if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then
|
||||||
|
[[ "$GITHUB_REF" == refs/heads/main ]] || { echo 'Dispatch this workflow from main.' >&2; exit 1; }
|
||||||
|
tag="$REQUESTED_TAG"
|
||||||
|
else
|
||||||
|
tag="${GITHUB_REF#refs/tags/}"
|
||||||
|
fi
|
||||||
|
[[ "$tag" =~ ^macos-v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || { echo 'Invalid macOS release tag.' >&2; exit 1; }
|
||||||
|
[[ "$MACOS_RUNNER" =~ ^[a-zA-Z0-9_-]+$ ]] || { echo 'Set MACOS_RUNNER to a registered macOS runner label.' >&2; exit 1; }
|
||||||
|
git fetch --no-tags origin main:refs/remotes/origin/main
|
||||||
|
commit="$(git rev-parse --verify "refs/tags/$tag^{commit}")"
|
||||||
|
git merge-base --is-ancestor "$commit" origin/main || { echo 'Release tag must belong to main.' >&2; exit 1; }
|
||||||
|
git checkout --detach "$commit"
|
||||||
|
printf 'commit=%s\ntag=%s\n' "$commit" "$tag" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Validate app versions and Hetzner configuration
|
||||||
|
env:
|
||||||
|
RELEASE_TAG: ${{ steps.ref.outputs.tag }}
|
||||||
|
run: |
|
||||||
|
node native/macos-ci.mjs metadata
|
||||||
|
node native/publish-macos.mjs config
|
||||||
|
|
||||||
|
release:
|
||||||
|
name: Sign, notarize, verify and publish macOS
|
||||||
|
needs: prepare
|
||||||
|
# Gitea resolves runner labels before outputs from prepare are available.
|
||||||
|
runs-on: ${{ vars.MACOS_RUNNER }}
|
||||||
|
timeout-minutes: 120
|
||||||
|
env:
|
||||||
|
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
|
||||||
|
steps:
|
||||||
|
- name: Check out the validated commit
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
ref: ${{ needs.prepare.outputs.commit }}
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Validate runner and required secrets
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
|
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
|
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||||
|
S3_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
|
||||||
|
S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
|
||||||
|
run: |
|
||||||
|
set -Eeuo pipefail
|
||||||
|
[[ "$(uname -s)" == Darwin ]] || { echo 'A macOS runner is required.' >&2; exit 1; }
|
||||||
|
case "$MACOS_RELEASE_TARGET:$(uname -m)" in
|
||||||
|
aarch64-apple-darwin:arm64|x86_64-apple-darwin:x86_64) ;;
|
||||||
|
*) echo 'Runner architecture must match MACOS_RELEASE_TARGET.' >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
xcodebuild -version
|
||||||
|
xcrun --find notarytool
|
||||||
|
aws --version | grep '^aws-cli/2\.' || { echo 'Install AWS CLI v2 permanently on this runner.' >&2; exit 1; }
|
||||||
|
for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_PASSWORD S3_ACCESS_KEY_ID S3_SECRET_ACCESS_KEY; do
|
||||||
|
[[ -n "${!name}" ]] || { echo "Missing repository secret: $name" >&2; exit 1; }
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v11.7.0
|
||||||
|
with:
|
||||||
|
package-json-file: package.json
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||||
|
with:
|
||||||
|
toolchain: 1.98.1
|
||||||
|
targets: ${{ env.MACOS_RELEASE_TARGET }}
|
||||||
|
components: rustfmt, clippy
|
||||||
|
|
||||||
|
- name: Install locked dependencies
|
||||||
|
run: |
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm --dir native install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Run native checks before importing signing credentials
|
||||||
|
run: |
|
||||||
|
pnpm exec eslint native --max-warnings 0
|
||||||
|
pnpm --dir native test
|
||||||
|
pnpm --dir native check
|
||||||
|
|
||||||
|
- name: Prepare isolated signing keychain
|
||||||
|
id: signing
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
|
run: |
|
||||||
|
set -Eeuo pipefail
|
||||||
|
umask 077
|
||||||
|
signing_dir="$(mktemp -d "$RUNNER_TEMP/kanbangit-signing.XXXXXX")"
|
||||||
|
printf 'SIGNING_DIRECTORY=%s\n' "$signing_dir" >> "$GITHUB_ENV"
|
||||||
|
security list-keychains -d user > "$signing_dir/original-keychains.txt"
|
||||||
|
printf '%s' "$APPLE_CERTIFICATE" | base64 --decode > "$signing_dir/certificate.p12"
|
||||||
|
password="$(openssl rand -hex 32)"
|
||||||
|
echo "::add-mask::$password"
|
||||||
|
printf 'keychain=kanbangit-%s-%s\npassword=%s\ncertificate=%s/certificate.p12\n' "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" "$password" "$signing_dir" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Import Developer ID certificate
|
||||||
|
uses: Apple-Actions/import-codesign-certs@5142e029c445c10ffc7149d172e540235a065466 # v7.0.0; deletes its keychain in the post step
|
||||||
|
with:
|
||||||
|
keychain: ${{ steps.signing.outputs.keychain }}
|
||||||
|
keychain-password: ${{ steps.signing.outputs.password }}
|
||||||
|
p12-filepath: ${{ steps.signing.outputs.certificate }}
|
||||||
|
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Confirm release signing identity
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -Eeuo pipefail
|
||||||
|
security find-identity -v -p codesigning | grep -F "\"$APPLE_SIGNING_IDENTITY\""
|
||||||
|
|
||||||
|
- name: Build, sign and notarize with Tauri
|
||||||
|
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
|
||||||
|
env:
|
||||||
|
APPLE_ID: dev@petsch-studios.de
|
||||||
|
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||||
|
with:
|
||||||
|
projectPath: native
|
||||||
|
tauriScript: pnpm exec tauri
|
||||||
|
args: >-
|
||||||
|
--ci --target ${{ env.MACOS_RELEASE_TARGET }} --bundles app
|
||||||
|
--config '{"bundle":{"createUpdaterArtifacts":false,"macOS":{"hardenedRuntime":true}}}'
|
||||||
|
-- --locked
|
||||||
|
uploadUpdaterJson: false
|
||||||
|
uploadUpdaterSignatures: false
|
||||||
|
uploadWorkflowArtifacts: false
|
||||||
|
|
||||||
|
- name: Verify signed app and packaged download
|
||||||
|
id: package
|
||||||
|
run: node native/macos-ci.mjs package
|
||||||
|
|
||||||
|
- name: Publish immutable files and verify public Hetzner downloads
|
||||||
|
env:
|
||||||
|
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
|
||||||
|
AWS_EC2_METADATA_DISABLED: "true"
|
||||||
|
AWS_REQUEST_CHECKSUM_CALCULATION: when_required
|
||||||
|
AWS_RESPONSE_CHECKSUM_VALIDATION: when_required
|
||||||
|
RELEASE_DIRECTORY: ${{ steps.package.outputs.directory }}
|
||||||
|
run: node native/publish-macos.mjs upload
|
||||||
|
|
||||||
|
- name: Restore keychain search list and remove certificate export
|
||||||
|
if: ${{ always() }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
python3 - <<'PY'
|
||||||
|
import os, pathlib, shlex, shutil, subprocess
|
||||||
|
value = os.environ.get('SIGNING_DIRECTORY')
|
||||||
|
if value:
|
||||||
|
directory = pathlib.Path(value)
|
||||||
|
original = directory / 'original-keychains.txt'
|
||||||
|
try:
|
||||||
|
if original.exists():
|
||||||
|
subprocess.run(['security', 'list-keychains', '-d', 'user', '-s', *shlex.split(original.read_text())], check=True)
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(directory)
|
||||||
|
PY
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
name: Native JavaScript checks
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- "native/**"
|
||||||
|
- ".github/**"
|
||||||
|
- "package.json"
|
||||||
|
- "pnpm-lock.yaml"
|
||||||
|
- "eslint.config.mjs"
|
||||||
|
- "resources/css/app.css"
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "native/**"
|
||||||
|
- ".github/**"
|
||||||
|
- "package.json"
|
||||||
|
- "pnpm-lock.yaml"
|
||||||
|
- "eslint.config.mjs"
|
||||||
|
- "resources/css/app.css"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
native_javascript:
|
||||||
|
name: Native launcher and release tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v11.7.0
|
||||||
|
with:
|
||||||
|
package-json-file: package.json
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
cache-dependency-path: pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Install locked development dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Lint native JavaScript
|
||||||
|
run: pnpm exec eslint native --max-warnings 0
|
||||||
|
|
||||||
|
- name: Test native launcher and release safeguards without credentials
|
||||||
|
run: node --test native/tests/*.test.js
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import {
|
||||||
|
appendFileSync,
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
renameSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from 'node:fs';
|
||||||
|
import { dirname, join, resolve } from 'node:path';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
|
import {
|
||||||
|
appleTeamId,
|
||||||
|
bundleIdentifier,
|
||||||
|
verifySignedMacosApp,
|
||||||
|
} from './release-macos.mjs';
|
||||||
|
|
||||||
|
const nativeDirectory = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
export function releaseMetadata(
|
||||||
|
env = process.env,
|
||||||
|
directory = nativeDirectory,
|
||||||
|
) {
|
||||||
|
const config = JSON.parse(
|
||||||
|
readFileSync(join(directory, 'src-tauri/tauri.conf.json'), 'utf8'),
|
||||||
|
);
|
||||||
|
const packageVersion = JSON.parse(
|
||||||
|
readFileSync(join(directory, 'package.json'), 'utf8'),
|
||||||
|
).version;
|
||||||
|
const cargoVersion = readFileSync(
|
||||||
|
join(directory, 'src-tauri/Cargo.toml'),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
.match(/\[package\]([\s\S]*?)(?:\n\[|$)/)?.[1]
|
||||||
|
.match(/^version\s*=\s*"([^"]+)"/m)?.[1];
|
||||||
|
if (
|
||||||
|
!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(config.version) ||
|
||||||
|
config.version !== packageVersion ||
|
||||||
|
config.version !== cargoVersion
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'Tauri, package.json and Cargo.toml must have the same stable release version.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (env.RELEASE_TAG !== `macos-v${config.version}`) {
|
||||||
|
throw new Error(
|
||||||
|
'Release tag must be macos-vMAJOR.MINOR.PATCH and match the app version.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
config.identifier !== bundleIdentifier ||
|
||||||
|
config.productName !== 'KanbanGit'
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'The macOS product name and bundle identifier must stay unchanged.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const target = env.MACOS_RELEASE_TARGET || 'aarch64-apple-darwin';
|
||||||
|
const arch = {
|
||||||
|
'aarch64-apple-darwin': 'arm64',
|
||||||
|
'x86_64-apple-darwin': 'x86_64',
|
||||||
|
}[target];
|
||||||
|
if (!arch) {
|
||||||
|
throw new Error(
|
||||||
|
'MACOS_RELEASE_TARGET must select Apple Silicon or Intel macOS.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const identity = env.APPLE_SIGNING_IDENTITY;
|
||||||
|
if (
|
||||||
|
!identity?.startsWith('Developer ID Application: ') ||
|
||||||
|
!identity.endsWith(` (${appleTeamId})`) ||
|
||||||
|
/[\r\n"]/.test(identity)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`A Developer ID Application identity for ${appleTeamId} is required.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: config.version,
|
||||||
|
target,
|
||||||
|
arch,
|
||||||
|
filename: `KanbanGit-${config.version}-${target}.zip`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function execute(command, args) {
|
||||||
|
const result = spawnSync(command, args, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
maxBuffer: 16 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
if (result.error || result.status !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`${command} failed (exit ${result.status ?? 'unknown'}). ${result.stderr || ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result.stdout?.trim() || result.stderr?.trim() || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyNotarizedApp(app, metadata, run = execute) {
|
||||||
|
verifySignedMacosApp({
|
||||||
|
app,
|
||||||
|
version: metadata.version,
|
||||||
|
step: (_label, command, args) => run(command, args),
|
||||||
|
});
|
||||||
|
const arch = run('lipo', [
|
||||||
|
'-archs',
|
||||||
|
join(app, 'Contents/MacOS/kanbangit-native'),
|
||||||
|
]).trim();
|
||||||
|
if (arch !== metadata.arch) {
|
||||||
|
throw new Error(
|
||||||
|
'The app architecture does not match its download path.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
run('xcrun', ['stapler', 'validate', app]);
|
||||||
|
run('spctl', ['--assess', '--type', 'execute', '--verbose=2', app]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function packageNotarizedApp({
|
||||||
|
env = process.env,
|
||||||
|
directory = nativeDirectory,
|
||||||
|
run = execute,
|
||||||
|
} = {}) {
|
||||||
|
const metadata = releaseMetadata(env, directory);
|
||||||
|
const root = join(directory, 'src-tauri/target');
|
||||||
|
const app = join(
|
||||||
|
root,
|
||||||
|
metadata.target,
|
||||||
|
'release/bundle/macos/KanbanGit.app',
|
||||||
|
);
|
||||||
|
verifyNotarizedApp(app, metadata, run);
|
||||||
|
const releaseRoot = join(root, 'macos-release');
|
||||||
|
const destination = join(
|
||||||
|
releaseRoot,
|
||||||
|
`${metadata.version}-${metadata.target}`,
|
||||||
|
);
|
||||||
|
if (existsSync(destination)) {
|
||||||
|
throw new Error(
|
||||||
|
'Release output already exists; start from a clean checkout.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
mkdirSync(releaseRoot, { recursive: true });
|
||||||
|
const staging = mkdtempSync(join(releaseRoot, '.pending-'));
|
||||||
|
try {
|
||||||
|
const archive = join(staging, metadata.filename);
|
||||||
|
run('ditto', [
|
||||||
|
'-c',
|
||||||
|
'-k',
|
||||||
|
'--sequesterRsrc',
|
||||||
|
'--keepParent',
|
||||||
|
app,
|
||||||
|
archive,
|
||||||
|
]);
|
||||||
|
const checksum = createHash('sha256')
|
||||||
|
.update(readFileSync(archive))
|
||||||
|
.digest('hex');
|
||||||
|
writeFileSync(
|
||||||
|
`${archive}.sha256`,
|
||||||
|
`${checksum} ${metadata.filename}\n`,
|
||||||
|
);
|
||||||
|
const extracted = join(staging, 'extracted');
|
||||||
|
run('ditto', ['-x', '-k', archive, extracted]);
|
||||||
|
verifyNotarizedApp(join(extracted, 'KanbanGit.app'), metadata, run);
|
||||||
|
rmSync(extracted, { recursive: true, force: true });
|
||||||
|
renameSync(staging, destination);
|
||||||
|
return { ...metadata, directory: destination, checksum };
|
||||||
|
} finally {
|
||||||
|
rmSync(staging, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
process.argv[1] &&
|
||||||
|
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
let output;
|
||||||
|
if (process.argv[2] === 'metadata') {
|
||||||
|
output = releaseMetadata();
|
||||||
|
} else if (process.argv[2] === 'package') {
|
||||||
|
output = packageNotarizedApp();
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
'Use metadata or package. Building and notarizing are handled by tauri-action.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (process.env.GITHUB_OUTPUT) {
|
||||||
|
appendFileSync(
|
||||||
|
process.env.GITHUB_OUTPUT,
|
||||||
|
Object.entries(output)
|
||||||
|
.map(([key, value]) => `${key}=${value}\n`)
|
||||||
|
.join(''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(JSON.stringify(output));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
"dev": "tauri dev",
|
"dev": "tauri dev",
|
||||||
"build": "tauri build",
|
"build": "tauri build",
|
||||||
"build:macos": "tauri build --debug --bundles app",
|
"build:macos": "tauri build --debug --bundles app",
|
||||||
|
"release:macos": "node release-macos.mjs",
|
||||||
"test": "node --test tests/*.test.js && cargo test --manifest-path src-tauri/Cargo.toml --locked",
|
"test": "node --test tests/*.test.js && cargo test --manifest-path src-tauri/Cargo.toml --locked",
|
||||||
"check": "cargo fmt --manifest-path src-tauri/Cargo.toml --check && cargo clippy --manifest-path src-tauri/Cargo.toml --locked --all-targets --all-features -- -D warnings",
|
"check": "cargo fmt --manifest-path src-tauri/Cargo.toml --check && cargo clippy --manifest-path src-tauri/Cargo.toml --locked --all-targets --all-features -- -D warnings",
|
||||||
"dev:android": "tauri android dev",
|
"dev:android": "tauri android dev",
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { Buffer } from 'node:buffer';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import {
|
||||||
|
appendFileSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
import { execute, releaseMetadata } from './macos-ci.mjs';
|
||||||
|
|
||||||
|
export function storageConfiguration(env = process.env) {
|
||||||
|
const region = env.S3_REGION;
|
||||||
|
const bucket = env.S3_BUCKET;
|
||||||
|
const prefix = env.S3_PREFIX || 'releases/macos';
|
||||||
|
if (!['nbg1', 'fsn1', 'hel1'].includes(region)) {
|
||||||
|
throw new Error('S3_REGION must be nbg1, fsn1 or hel1.');
|
||||||
|
}
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/.test(bucket || '')) {
|
||||||
|
throw new Error('Set S3_BUCKET to the Hetzner release bucket name.');
|
||||||
|
}
|
||||||
|
if (!/^[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/.test(prefix)) {
|
||||||
|
throw new Error(
|
||||||
|
'S3_PREFIX must contain plain path segments without leading or trailing slashes.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const endpoint = `https://${region}.your-objectstorage.com`;
|
||||||
|
const publicBase =
|
||||||
|
env.S3_PUBLIC_BASE_URL ||
|
||||||
|
`https://${bucket}.${region}.your-objectstorage.com`;
|
||||||
|
const url = new URL(publicBase);
|
||||||
|
if (
|
||||||
|
url.protocol !== 'https:' ||
|
||||||
|
url.username ||
|
||||||
|
url.password ||
|
||||||
|
url.search ||
|
||||||
|
url.hash ||
|
||||||
|
/[\s]/.test(publicBase)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'S3_PUBLIC_BASE_URL must be a public HTTPS URL without credentials or query parameters.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
region,
|
||||||
|
bucket,
|
||||||
|
prefix,
|
||||||
|
endpoint,
|
||||||
|
publicBase: publicBase.replace(/\/$/, ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sha256 = (data) => createHash('sha256').update(data).digest('hex');
|
||||||
|
|
||||||
|
export async function publishMacos({
|
||||||
|
env = process.env,
|
||||||
|
directory,
|
||||||
|
metadata,
|
||||||
|
run = execute,
|
||||||
|
fetchPublic = fetch,
|
||||||
|
} = {}) {
|
||||||
|
const storage = storageConfiguration(env);
|
||||||
|
if (!env.AWS_ACCESS_KEY_ID || !env.AWS_SECRET_ACCESS_KEY) {
|
||||||
|
throw new Error(
|
||||||
|
'Set the dedicated S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY release secrets.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const archive = readFileSync(join(directory, metadata.filename));
|
||||||
|
const checksum = sha256(archive);
|
||||||
|
const checksumFile = readFileSync(
|
||||||
|
join(directory, `${metadata.filename}.sha256`),
|
||||||
|
);
|
||||||
|
if (checksumFile.toString() !== `${checksum} ${metadata.filename}\n`) {
|
||||||
|
throw new Error('Release archive does not match its SHA-256 file.');
|
||||||
|
}
|
||||||
|
const common = [
|
||||||
|
'--endpoint-url',
|
||||||
|
storage.endpoint,
|
||||||
|
'--region',
|
||||||
|
storage.region,
|
||||||
|
'--no-cli-pager',
|
||||||
|
's3api',
|
||||||
|
];
|
||||||
|
function s3(operation, args) {
|
||||||
|
return run('aws', [
|
||||||
|
...common,
|
||||||
|
operation,
|
||||||
|
'--bucket',
|
||||||
|
storage.bucket,
|
||||||
|
...args,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
const versioningOutput = s3('get-bucket-versioning', [
|
||||||
|
'--output',
|
||||||
|
'json',
|
||||||
|
]).trim();
|
||||||
|
let versioning;
|
||||||
|
try {
|
||||||
|
// AWS CLI prints nothing for a successful empty VersioningConfiguration.
|
||||||
|
versioning = JSON.parse(versioningOutput || '{}');
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid JSON from S3 bucket versioning check.');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!versioning ||
|
||||||
|
typeof versioning !== 'object' ||
|
||||||
|
Array.isArray(versioning)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'Invalid S3 bucket versioning response: expected an object.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (versioning.Status) {
|
||||||
|
throw new Error(
|
||||||
|
'Use an unversioned release bucket: Hetzner does not support conditional writes on versioned buckets.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const staging = mkdtempSync(join(tmpdir(), 'kanbangit-s3-'));
|
||||||
|
const keyPrefix = `${storage.prefix}/${metadata.target}/${metadata.version}`;
|
||||||
|
const publicPrefix = `${storage.publicBase}/${keyPrefix}`;
|
||||||
|
async function verifyPublic(name, contents) {
|
||||||
|
const response = await fetchPublic(`${publicPrefix}/${name}`, {
|
||||||
|
redirect: 'error',
|
||||||
|
signal: AbortSignal.timeout(60000),
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!response.ok ||
|
||||||
|
sha256(Buffer.from(await response.arrayBuffer())) !==
|
||||||
|
sha256(contents)
|
||||||
|
) {
|
||||||
|
throw new Error(`Public download verification failed for ${name}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function putImmutable(name, contents, contentType) {
|
||||||
|
const file = join(staging, name);
|
||||||
|
writeFileSync(file, contents);
|
||||||
|
try {
|
||||||
|
s3('put-object', [
|
||||||
|
'--key',
|
||||||
|
`${keyPrefix}/${name}`,
|
||||||
|
'--body',
|
||||||
|
file,
|
||||||
|
'--if-none-match',
|
||||||
|
'*',
|
||||||
|
'--content-type',
|
||||||
|
contentType,
|
||||||
|
'--cache-control',
|
||||||
|
'public, max-age=31536000, immutable',
|
||||||
|
'--content-md5',
|
||||||
|
createHash('md5').update(contents).digest('base64'),
|
||||||
|
'--metadata',
|
||||||
|
`sha256=${sha256(contents)}`,
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
if (!/\(PreconditionFailed\)|\(412\)/.test(error.message)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const existing = join(staging, `existing-${name}`);
|
||||||
|
s3('get-object', ['--key', `${keyPrefix}/${name}`, existing]);
|
||||||
|
if (sha256(readFileSync(existing)) !== sha256(contents)) {
|
||||||
|
throw new Error(
|
||||||
|
`Published ${name} differs; bump the app version. Existing objects were not overwritten.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const files = [
|
||||||
|
[metadata.filename, archive, 'application/zip'],
|
||||||
|
[
|
||||||
|
`${metadata.filename}.sha256`,
|
||||||
|
checksumFile,
|
||||||
|
'text/plain; charset=utf-8',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
for (const [name, contents, type] of files) {
|
||||||
|
putImmutable(name, contents, type);
|
||||||
|
await verifyPublic(name, contents);
|
||||||
|
}
|
||||||
|
// This completion record is written only after both public downloads match.
|
||||||
|
const manifest = Buffer.from(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
version: metadata.version,
|
||||||
|
target: metadata.target,
|
||||||
|
url: `${publicPrefix}/${metadata.filename}`,
|
||||||
|
sha256: checksum,
|
||||||
|
checksumUrl: `${publicPrefix}/${metadata.filename}.sha256`,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
) + '\n',
|
||||||
|
);
|
||||||
|
putImmutable('release.json', manifest, 'application/json');
|
||||||
|
await verifyPublic('release.json', manifest);
|
||||||
|
return `${publicPrefix}/${metadata.filename}`;
|
||||||
|
} finally {
|
||||||
|
rmSync(staging, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
process.argv[1] &&
|
||||||
|
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
if (process.argv[2] === 'config') {
|
||||||
|
console.log(JSON.stringify(storageConfiguration()));
|
||||||
|
} else if (
|
||||||
|
process.argv[2] === 'upload' &&
|
||||||
|
process.env.RELEASE_DIRECTORY
|
||||||
|
) {
|
||||||
|
const url = await publishMacos({
|
||||||
|
directory: process.env.RELEASE_DIRECTORY,
|
||||||
|
metadata: releaseMetadata(),
|
||||||
|
});
|
||||||
|
console.log(`Verified public release: ${url}`);
|
||||||
|
if (process.env.GITHUB_STEP_SUMMARY) {
|
||||||
|
appendFileSync(
|
||||||
|
process.env.GITHUB_STEP_SUMMARY,
|
||||||
|
`Download: [KanbanGit](${url})\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
'Use config or upload; upload requires RELEASE_DIRECTORY.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
renameSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from 'node:fs';
|
||||||
|
import { dirname, join, resolve } 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.desktop';
|
||||||
|
const submissionIdPattern =
|
||||||
|
/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i;
|
||||||
|
|
||||||
|
function execute(command, args, options) {
|
||||||
|
const result = spawnSync(command, args, {
|
||||||
|
cwd: options.cwd,
|
||||||
|
env: options.env,
|
||||||
|
encoding: 'utf8',
|
||||||
|
maxBuffer: 16 * 1024 * 1024,
|
||||||
|
stdio: options.inherit ? 'inherit' : 'pipe',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error || result.status !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`${options.step} failed${result.error ? `: ${result.error.message}` : ` (exit ${result.status ?? result.signal})`}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.stdout?.trim() || result.stderr?.trim() || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifySignedMacosApp({ app, version, step }) {
|
||||||
|
step('Verifying Developer ID signature and team', 'codesign', [
|
||||||
|
'--verify',
|
||||||
|
'--deep',
|
||||||
|
'--strict',
|
||||||
|
'--verbose=2',
|
||||||
|
'-R',
|
||||||
|
`=anchor apple generic and identifier "${bundleIdentifier}" and certificate leaf[subject.OU] = "${appleTeamId}" and certificate leaf[field.1.2.840.113635.100.6.1.13] exists`,
|
||||||
|
app,
|
||||||
|
]);
|
||||||
|
const signature = step('Checking hardened runtime', 'codesign', [
|
||||||
|
'--display',
|
||||||
|
'--verbose=4',
|
||||||
|
app,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!/^CodeDirectory .*flags=.*\bruntime\b/m.test(signature)) {
|
||||||
|
throw new Error(
|
||||||
|
'The release signature does not enable Hardened Runtime.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cdhash = signature.match(/^CDHash=([\da-f]+)$/m)?.[1];
|
||||||
|
|
||||||
|
if (!cdhash) {
|
||||||
|
throw new Error(
|
||||||
|
'The signed app has no CodeDirectory hash to match against Apple notarization.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundledVersion = step('Checking bundled release version', 'plutil', [
|
||||||
|
'-extract',
|
||||||
|
'CFBundleShortVersionString',
|
||||||
|
'raw',
|
||||||
|
'-o',
|
||||||
|
'-',
|
||||||
|
join(app, 'Contents/Info.plist'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (bundledVersion.trim() !== version) {
|
||||||
|
throw new Error(
|
||||||
|
'The built app version does not match the release version.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cdhash;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function releaseMacos({
|
||||||
|
env = process.env,
|
||||||
|
platform = process.platform,
|
||||||
|
arch = process.arch,
|
||||||
|
directory = dirname(fileURLToPath(import.meta.url)),
|
||||||
|
run = execute,
|
||||||
|
log = console.log,
|
||||||
|
resumeSubmissionId = null,
|
||||||
|
} = {}) {
|
||||||
|
if (platform !== 'darwin') {
|
||||||
|
throw new Error('macOS releases require a Mac with Xcode installed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
resumeSubmissionId !== null &&
|
||||||
|
!submissionIdPattern.test(resumeSubmissionId)
|
||||||
|
) {
|
||||||
|
throw new Error('--resume requires the Apple submission UUID.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const identity = env.APPLE_SIGNING_IDENTITY?.trim();
|
||||||
|
const profile = env.APPLE_NOTARY_KEYCHAIN_PROFILE?.trim();
|
||||||
|
|
||||||
|
if (
|
||||||
|
!identity?.startsWith('Developer ID Application: ') ||
|
||||||
|
!identity.endsWith(` (${appleTeamId})`) ||
|
||||||
|
/[\r\n"]/.test(identity)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Set APPLE_SIGNING_IDENTITY to the full Developer ID Application certificate name for team ${appleTeamId}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (env.APPLE_TEAM_ID && env.APPLE_TEAM_ID !== appleTeamId) {
|
||||||
|
throw new Error(`APPLE_TEAM_ID must be ${appleTeamId}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
throw new Error(
|
||||||
|
'Set APPLE_NOTARY_KEYCHAIN_PROFILE to a notarytool profile created with store-credentials for the new Apple team.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const target =
|
||||||
|
env.MACOS_RELEASE_TARGET ||
|
||||||
|
{ arm64: 'aarch64-apple-darwin', x64: 'x86_64-apple-darwin' }[arch];
|
||||||
|
|
||||||
|
if (!['aarch64-apple-darwin', 'x86_64-apple-darwin'].includes(target)) {
|
||||||
|
throw new Error(
|
||||||
|
'MACOS_RELEASE_TARGET must be aarch64-apple-darwin or x86_64-apple-darwin.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = JSON.parse(
|
||||||
|
readFileSync(join(directory, 'src-tauri/tauri.conf.json'), 'utf8'),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
config.identifier !== bundleIdentifier ||
|
||||||
|
config.productName !== 'KanbanGit'
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'The macOS release must keep the KanbanGit product name and bundle identifier.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(config.version)) {
|
||||||
|
throw new Error(
|
||||||
|
'The macOS release version must be a stable MAJOR.MINOR.PATCH version.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetDirectory = join(directory, 'src-tauri/target');
|
||||||
|
const releaseDirectory = join(targetDirectory, 'macos-release');
|
||||||
|
const destination = join(releaseDirectory, `${config.version}-${target}`);
|
||||||
|
|
||||||
|
if (existsSync(destination)) {
|
||||||
|
throw new Error(
|
||||||
|
`Release output already exists: ${destination}. Move it aside or use a new version.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notarization is handled below with the explicit keychain profile, never ambient Apple credentials.
|
||||||
|
const buildEnv = Object.fromEntries(
|
||||||
|
Object.entries(env).filter(
|
||||||
|
([key]) => !key.startsWith('APPLE_') && key !== 'TAURI_CONFIG',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
buildEnv.APPLE_SIGNING_IDENTITY = identity;
|
||||||
|
buildEnv.CARGO_TARGET_DIR = targetDirectory;
|
||||||
|
|
||||||
|
function step(label, command, args, inherit = false) {
|
||||||
|
log(label);
|
||||||
|
|
||||||
|
return run(command, args, {
|
||||||
|
cwd: directory,
|
||||||
|
env: buildEnv,
|
||||||
|
step: label,
|
||||||
|
inherit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const identities = step('Checking signing certificate', 'security', [
|
||||||
|
'find-identity',
|
||||||
|
'-v',
|
||||||
|
'-p',
|
||||||
|
'codesigning',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!identities
|
||||||
|
.split('\n')
|
||||||
|
.some((line) => line.trim().endsWith(`"${identity}"`))
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Install the Developer ID Application certificate for ${appleTeamId}, including its private key, in an unlocked keychain.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const notaryCredentials = ['--keychain-profile', profile];
|
||||||
|
|
||||||
|
if (env.APPLE_NOTARY_KEYCHAIN?.trim()) {
|
||||||
|
notaryCredentials.push('--keychain', env.APPLE_NOTARY_KEYCHAIN.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
step(
|
||||||
|
'Checking notarization access (check the keychain profile if this fails)',
|
||||||
|
'xcrun',
|
||||||
|
[
|
||||||
|
'notarytool',
|
||||||
|
'history',
|
||||||
|
...notaryCredentials,
|
||||||
|
'--output-format',
|
||||||
|
'json',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const app = join(
|
||||||
|
targetDirectory,
|
||||||
|
target,
|
||||||
|
'release/bundle/macos/KanbanGit.app',
|
||||||
|
);
|
||||||
|
if (!resumeSubmissionId) {
|
||||||
|
step(
|
||||||
|
'Building signed macOS release',
|
||||||
|
'pnpm',
|
||||||
|
[
|
||||||
|
'exec',
|
||||||
|
'tauri',
|
||||||
|
'build',
|
||||||
|
'--ci',
|
||||||
|
'--target',
|
||||||
|
target,
|
||||||
|
'--bundles',
|
||||||
|
'app',
|
||||||
|
'--config',
|
||||||
|
JSON.stringify({
|
||||||
|
bundle: {
|
||||||
|
createUpdaterArtifacts: false,
|
||||||
|
macOS: {
|
||||||
|
signingIdentity: identity,
|
||||||
|
hardenedRuntime: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'--',
|
||||||
|
'--locked',
|
||||||
|
],
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(app)) {
|
||||||
|
throw new Error('The release build did not produce KanbanGit.app.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cdhash = verifySignedMacosApp({ app, version: config.version, step });
|
||||||
|
|
||||||
|
mkdirSync(releaseDirectory, { recursive: true });
|
||||||
|
const staging = mkdtempSync(join(releaseDirectory, '.pending-'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const submission = join(staging, 'notarization.zip');
|
||||||
|
let submissionId = resumeSubmissionId;
|
||||||
|
|
||||||
|
if (!submissionId) {
|
||||||
|
step('Preparing notarization archive', 'ditto', [
|
||||||
|
'-c',
|
||||||
|
'-k',
|
||||||
|
'--sequesterRsrc',
|
||||||
|
'--keepParent',
|
||||||
|
app,
|
||||||
|
submission,
|
||||||
|
]);
|
||||||
|
const submitted = JSON.parse(
|
||||||
|
step('Submitting to Apple', 'xcrun', [
|
||||||
|
'notarytool',
|
||||||
|
'submit',
|
||||||
|
submission,
|
||||||
|
...notaryCredentials,
|
||||||
|
'--no-wait',
|
||||||
|
'--output-format',
|
||||||
|
'json',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
submissionId = submitted.id;
|
||||||
|
|
||||||
|
if (!submissionIdPattern.test(submissionId ?? '')) {
|
||||||
|
throw new Error(
|
||||||
|
'Apple did not return a valid submission UUID. Check notarytool history before retrying.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resumeHint = `Resume without rebuilding or uploading: pnpm --dir native release:macos --resume ${submissionId}`;
|
||||||
|
log(`Apple submission: ${submissionId}. ${resumeHint}`);
|
||||||
|
writeFileSync(
|
||||||
|
join(
|
||||||
|
targetDirectory,
|
||||||
|
`notarization-${submissionId}.submission.json`,
|
||||||
|
),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
submissionId,
|
||||||
|
version: config.version,
|
||||||
|
target,
|
||||||
|
cdhash,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
) + '\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
let result;
|
||||||
|
|
||||||
|
try {
|
||||||
|
result = JSON.parse(
|
||||||
|
step('Waiting for Apple notarization', 'xcrun', [
|
||||||
|
'notarytool',
|
||||||
|
'wait',
|
||||||
|
submissionId,
|
||||||
|
...notaryCredentials,
|
||||||
|
'--timeout',
|
||||||
|
'30m',
|
||||||
|
'--output-format',
|
||||||
|
'json',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`${error.message} Apple may still be processing this submission. ${resumeHint}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status !== 'Accepted') {
|
||||||
|
throw new Error(
|
||||||
|
`Apple did not accept the release (status: ${result.status ?? 'unknown'}, submission: ${submissionId}). Inspect it with notarytool log. ${resumeHint}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const notaryLog = JSON.parse(
|
||||||
|
step('Checking Apple notarization log', 'xcrun', [
|
||||||
|
'notarytool',
|
||||||
|
'log',
|
||||||
|
submissionId,
|
||||||
|
...notaryCredentials,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(targetDirectory, `notarization-${submissionId}.log.json`),
|
||||||
|
JSON.stringify(notaryLog, null, 2) + '\n',
|
||||||
|
);
|
||||||
|
const expectedArch =
|
||||||
|
target === 'aarch64-apple-darwin' ? 'arm64' : 'x86_64';
|
||||||
|
|
||||||
|
if (
|
||||||
|
notaryLog.jobId !== submissionId ||
|
||||||
|
notaryLog.status !== 'Accepted' ||
|
||||||
|
!notaryLog.ticketContents?.some(
|
||||||
|
(ticket) =>
|
||||||
|
ticket.path.endsWith('/KanbanGit.app') &&
|
||||||
|
ticket.cdhash === cdhash &&
|
||||||
|
ticket.arch === expectedArch,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'The accepted Apple submission does not match this signed app and architecture. Restore the submitted app or create a new release.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notaryLog.issues?.length) {
|
||||||
|
throw new Error(
|
||||||
|
`Apple reported notarization issues. Review src-tauri/target/notarization-${submissionId}.log.json before distribution.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
step('Stapling notarization ticket', 'xcrun', [
|
||||||
|
'stapler',
|
||||||
|
'staple',
|
||||||
|
app,
|
||||||
|
]);
|
||||||
|
step('Validating notarization ticket', 'xcrun', [
|
||||||
|
'stapler',
|
||||||
|
'validate',
|
||||||
|
app,
|
||||||
|
]);
|
||||||
|
step('Verifying final app signature', 'codesign', [
|
||||||
|
'--verify',
|
||||||
|
'--deep',
|
||||||
|
'--strict',
|
||||||
|
app,
|
||||||
|
]);
|
||||||
|
step('Checking Gatekeeper acceptance', 'spctl', [
|
||||||
|
'--assess',
|
||||||
|
'--type',
|
||||||
|
'execute',
|
||||||
|
'--verbose=2',
|
||||||
|
app,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const name = `KanbanGit-${config.version}-${target}.zip`;
|
||||||
|
const archive = join(staging, name);
|
||||||
|
step('Packaging notarized download', 'ditto', [
|
||||||
|
'-c',
|
||||||
|
'-k',
|
||||||
|
'--sequesterRsrc',
|
||||||
|
'--keepParent',
|
||||||
|
app,
|
||||||
|
archive,
|
||||||
|
]);
|
||||||
|
const checksum = createHash('sha256')
|
||||||
|
.update(readFileSync(archive))
|
||||||
|
.digest('hex');
|
||||||
|
writeFileSync(`${archive}.sha256`, `${checksum} ${name}\n`);
|
||||||
|
rmSync(submission, { force: true });
|
||||||
|
renameSync(staging, destination);
|
||||||
|
log(`Verified release: ${join(destination, name)}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
archive: join(destination, name),
|
||||||
|
checksum,
|
||||||
|
version: config.version,
|
||||||
|
target,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
rmSync(staging, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
process.argv[1] &&
|
||||||
|
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { values } = parseArgs({
|
||||||
|
options: { resume: { type: 'string' } },
|
||||||
|
});
|
||||||
|
releaseMacos({ resumeSubmissionId: values.resume ?? null });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`macOS release failed: ${error.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
+104
-4
@@ -1,6 +1,8 @@
|
|||||||
package de.kanbangit.desktop
|
package de.kanbangit.desktop
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Intent
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
@@ -8,10 +10,12 @@ import android.net.http.SslError
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
|
import android.os.Message
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.webkit.CookieManager
|
import android.webkit.CookieManager
|
||||||
import android.webkit.SslErrorHandler
|
import android.webkit.SslErrorHandler
|
||||||
|
import android.webkit.ValueCallback
|
||||||
import android.webkit.WebChromeClient
|
import android.webkit.WebChromeClient
|
||||||
import android.webkit.WebResourceError
|
import android.webkit.WebResourceError
|
||||||
import android.webkit.WebResourceRequest
|
import android.webkit.WebResourceRequest
|
||||||
@@ -24,8 +28,10 @@ import android.widget.Button
|
|||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.activity.OnBackPressedCallback
|
import androidx.activity.OnBackPressedCallback
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
@@ -50,6 +56,47 @@ class MainActivity : TauriActivity() {
|
|||||||
private var keyboardVisible = false
|
private var keyboardVisible = false
|
||||||
private var initialized = false
|
private var initialized = false
|
||||||
|
|
||||||
|
private var fileCallback: ValueCallback<Array<Uri>>? = null
|
||||||
|
private var fileWorkspace: WebView? = null
|
||||||
|
private var filePage: String? = null
|
||||||
|
private var multipleFiles = false
|
||||||
|
private var filePickerActive = false
|
||||||
|
private val popups = mutableSetOf<WebView>()
|
||||||
|
private val filePicker = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
|
filePickerActive = false
|
||||||
|
val callback = fileCallback
|
||||||
|
val owner = fileWorkspace
|
||||||
|
val page = filePage
|
||||||
|
fileCallback = null
|
||||||
|
fileWorkspace = null
|
||||||
|
filePage = null
|
||||||
|
val files = WebChromeClient.FileChooserParams.parseResult(result.resultCode, result.data)
|
||||||
|
val valid = owner != null && owner === workspace && owner.url == page &&
|
||||||
|
NavigationPolicy.allows(server, page.orEmpty()) && files?.isNotEmpty() == true &&
|
||||||
|
(multipleFiles || files.size == 1) && files.all { uri ->
|
||||||
|
NavigationPolicy.selectedFile(uri.toString(), packageName) && runCatching {
|
||||||
|
contentResolver.openFileDescriptor(uri, "r")?.use { true } == true
|
||||||
|
}.getOrDefault(false)
|
||||||
|
}
|
||||||
|
callback?.onReceiveValue(if (valid) files else null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelFileSelection() {
|
||||||
|
fileCallback?.onReceiveValue(null)
|
||||||
|
fileCallback = null
|
||||||
|
fileWorkspace = null
|
||||||
|
filePage = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openExternal(url: String) {
|
||||||
|
if (!NavigationPolicy.external(url)) return
|
||||||
|
try {
|
||||||
|
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addCategory(Intent.CATEGORY_BROWSABLE))
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
Toast.makeText(this, R.string.external_link_error, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
@@ -144,6 +191,7 @@ class MainActivity : TauriActivity() {
|
|||||||
showWorkspace()
|
showWorkspace()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
cancelFileSelection()
|
||||||
val changed = address != preferences.getString("server", null)
|
val changed = address != preferences.getString("server", null)
|
||||||
val clearSession = changed || preferences.getBoolean("clearing", false)
|
val clearSession = changed || preferences.getBoolean("clearing", false)
|
||||||
if (!preferences.edit().putString("server", address).putBoolean("clearing", clearSession).commit()) {
|
if (!preferences.edit().putString("server", address).putBoolean("clearing", clearSession).commit()) {
|
||||||
@@ -187,18 +235,67 @@ class MainActivity : TauriActivity() {
|
|||||||
javaScriptEnabled = true
|
javaScriptEnabled = true
|
||||||
domStorageEnabled = true
|
domStorageEnabled = true
|
||||||
allowFileAccess = false
|
allowFileAccess = false
|
||||||
allowContentAccess = false
|
allowContentAccess = true
|
||||||
mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
|
mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
|
||||||
javaScriptCanOpenWindowsAutomatically = false
|
javaScriptCanOpenWindowsAutomatically = false
|
||||||
setSupportMultipleWindows(true)
|
setSupportMultipleWindows(true)
|
||||||
}
|
}
|
||||||
CookieManager.getInstance().setAcceptThirdPartyCookies(remote, false)
|
CookieManager.getInstance().setAcceptThirdPartyCookies(remote, false)
|
||||||
remote.webChromeClient = WebChromeClient()
|
remote.webChromeClient = object : WebChromeClient() {
|
||||||
|
override fun onShowFileChooser(view: WebView, callback: ValueCallback<Array<Uri>>, params: FileChooserParams): Boolean {
|
||||||
|
if (view !== workspace || !NavigationPolicy.allows(server, view.url.orEmpty()) ||
|
||||||
|
params.mode !in listOf(FileChooserParams.MODE_OPEN, FileChooserParams.MODE_OPEN_MULTIPLE)) return false
|
||||||
|
// Keep one outstanding picker; stale results must never reach a different page.
|
||||||
|
if (filePickerActive) return false
|
||||||
|
fileCallback = callback
|
||||||
|
fileWorkspace = view
|
||||||
|
filePage = view.url
|
||||||
|
multipleFiles = params.mode == FileChooserParams.MODE_OPEN_MULTIPLE
|
||||||
|
return try {
|
||||||
|
filePickerActive = true
|
||||||
|
filePicker.launch(params.createIntent().addCategory(Intent.CATEGORY_OPENABLE))
|
||||||
|
true
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
filePickerActive = false
|
||||||
|
cancelFileSelection()
|
||||||
|
Toast.makeText(this@MainActivity, R.string.file_picker_error, Toast.LENGTH_LONG).show()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateWindow(view: WebView, isDialog: Boolean, isUserGesture: Boolean, result: Message): Boolean {
|
||||||
|
if (view !== workspace || !isUserGesture || !NavigationPolicy.allows(server, view.url.orEmpty())) return false
|
||||||
|
val popup = WebView(this@MainActivity)
|
||||||
|
popups.add(popup)
|
||||||
|
fun dispose() {
|
||||||
|
if (popups.remove(popup)) popup.destroy()
|
||||||
|
}
|
||||||
|
popup.webViewClient = object : WebViewClient() {
|
||||||
|
override fun shouldOverrideUrlLoading(child: WebView, request: WebResourceRequest): Boolean {
|
||||||
|
if (request.isForMainFrame && workspace === view) {
|
||||||
|
val url = request.url.toString()
|
||||||
|
if (NavigationPolicy.allows(server, url)) view.loadUrl(url) else openExternal(url)
|
||||||
|
}
|
||||||
|
child.post { dispose() }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(result.obj as WebView.WebViewTransport).webView = popup
|
||||||
|
result.sendToTarget()
|
||||||
|
handler.postDelayed({ dispose() }, 10000)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
remote.webViewClient = object : WebViewClient() {
|
remote.webViewClient = object : WebViewClient() {
|
||||||
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean =
|
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
|
||||||
!NavigationPolicy.allows(server, request.url.toString())
|
val url = request.url.toString()
|
||||||
|
if (NavigationPolicy.allows(server, url)) return false
|
||||||
|
if (workspace === view && request.isForMainFrame && request.hasGesture()) openExternal(url)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
|
override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
|
||||||
|
if (workspace === view) cancelFileSelection()
|
||||||
if (!NavigationPolicy.allows(server, url)) {
|
if (!NavigationPolicy.allows(server, url)) {
|
||||||
view.stopLoading()
|
view.stopLoading()
|
||||||
fail(view)
|
fail(view)
|
||||||
@@ -299,6 +396,9 @@ class MainActivity : TauriActivity() {
|
|||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
handler.removeCallbacksAndMessages(null)
|
handler.removeCallbacksAndMessages(null)
|
||||||
|
cancelFileSelection()
|
||||||
|
popups.forEach { it.destroy() }
|
||||||
|
popups.clear()
|
||||||
workspace?.destroy()
|
workspace?.destroy()
|
||||||
workspace = null
|
workspace = null
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
|
|||||||
+15
@@ -31,6 +31,21 @@ object NavigationPolicy {
|
|||||||
target.rawUserInfo == null && !destination.contains('\\')
|
target.rawUserInfo == null && !destination.contains('\\')
|
||||||
}.getOrDefault(false)
|
}.getOrDefault(false)
|
||||||
|
|
||||||
|
fun external(destination: String): Boolean = runCatching {
|
||||||
|
val target = URI(destination)
|
||||||
|
target.scheme.lowercase(Locale.ROOT) in setOf("https", "http") &&
|
||||||
|
!target.host.isNullOrBlank() && target.rawUserInfo == null &&
|
||||||
|
(target.port == -1 || target.port in 1..65535) &&
|
||||||
|
destination.none { it.isISOControl() || it == '\\' }
|
||||||
|
}.getOrDefault(false)
|
||||||
|
|
||||||
|
fun selectedFile(destination: String, packageName: String): Boolean = runCatching {
|
||||||
|
val target = URI(destination)
|
||||||
|
target.scheme == "content" && !target.host.isNullOrBlank() &&
|
||||||
|
target.rawUserInfo == null && target.port == -1 &&
|
||||||
|
target.host != packageName && target.host != "$packageName.fileprovider"
|
||||||
|
}.getOrDefault(false)
|
||||||
|
|
||||||
fun backAction(keyboard: Boolean, launcher: Boolean, ready: Boolean, history: Boolean): String = when {
|
fun backAction(keyboard: Boolean, launcher: Boolean, ready: Boolean, history: Boolean): String = when {
|
||||||
keyboard -> "keyboard"
|
keyboard -> "keyboard"
|
||||||
launcher && ready -> "resume"
|
launcher && ready -> "resume"
|
||||||
|
|||||||
@@ -3,4 +3,6 @@
|
|||||||
<string name="main_activity_title">"KanbanGit"</string>
|
<string name="main_activity_title">"KanbanGit"</string>
|
||||||
<string name="server_settings">Server settings</string>
|
<string name="server_settings">Server settings</string>
|
||||||
<string name="update_webview">Update Android System WebView to connect to your workspace.</string>
|
<string name="update_webview">Update Android System WebView to connect to your workspace.</string>
|
||||||
|
<string name="external_link_error">No browser is available to open this link.</string>
|
||||||
|
<string name="file_picker_error">No file picker is available. Please try again.</string>
|
||||||
</resources>
|
</resources>
|
||||||
+18
@@ -23,6 +23,24 @@ class NavigationPolicyTest {
|
|||||||
assertTrue(NavigationPolicy.allows("https://example.com/", "https://example.com:443/login"))
|
assertTrue(NavigationPolicy.allows("https://example.com/", "https://example.com:443/login"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun externalLinksNeverBecomeServerNavigation() {
|
||||||
|
val server = "https://example.com/"
|
||||||
|
for (url in listOf("https://git.example.com/org/repo/pulls/1#diff", "http://git.example.com/repo", "https://example.com:8443/other")) {
|
||||||
|
assertTrue(url, NavigationPolicy.external(url))
|
||||||
|
assertFalse(url, NavigationPolicy.allows(server, url))
|
||||||
|
}
|
||||||
|
for (url in listOf("https://user:password@git.example.com/", "https://example.com:99999/", "javascript:alert(1)", "intent://example.com/", "file:///etc/passwd", "content://provider/file", "data:text/html,test", "mailto:user@example.com", "https://example.com\\evil")) {
|
||||||
|
assertFalse(url, NavigationPolicy.external(url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun filePickerAcceptsOnlyExternalContentProviders() {
|
||||||
|
assertTrue(NavigationPolicy.selectedFile("content://com.android.providers.media.documents/document/image%3A1", "de.kanbangit.desktop"))
|
||||||
|
for (url in listOf("file:///data/data/de.kanbangit.desktop/session", "https://example.com/avatar.png", "content://de.kanbangit.desktop.fileprovider/private/key", "content://de.kanbangit.desktop/private/key", "content://user@provider/path", "content://provider:123/path", "content:/missing-provider")) {
|
||||||
|
assertFalse(url, NavigationPolicy.selectedFile(url, "de.kanbangit.desktop"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test fun backClosesKeyboardBeforeNavigatingAndPreservesTheWorkspace() {
|
@Test fun backClosesKeyboardBeforeNavigatingAndPreservesTheWorkspace() {
|
||||||
assertEquals("keyboard", NavigationPolicy.backAction(true, false, true, true))
|
assertEquals("keyboard", NavigationPolicy.backAction(true, false, true, true))
|
||||||
assertEquals("resume", NavigationPolicy.backAction(false, true, true, true))
|
assertEquals("resume", NavigationPolicy.backAction(false, true, true, true))
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ struct WorkspaceServer: Codable, Equatable {
|
|||||||
&& destination.user == nil && destination.password == nil
|
&& destination.user == nil && destination.password == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func allowsExternal(_ destination: URL) -> Bool {
|
||||||
|
["https", "http"].contains(destination.scheme?.lowercased() ?? "")
|
||||||
|
&& destination.host?.isEmpty == false
|
||||||
|
&& (destination.port.map({ (1...65535).contains($0) }) ?? true)
|
||||||
|
&& destination.user == nil && destination.password == nil
|
||||||
|
}
|
||||||
|
|
||||||
static func selecting(_ url: URL, current: WorkspaceServer?) -> WorkspaceServer {
|
static func selecting(_ url: URL, current: WorkspaceServer?) -> WorkspaceServer {
|
||||||
current?.url == url ? current! : WorkspaceServer(url: url, profile: UUID())
|
current?.url == url ? current! : WorkspaceServer(url: url, profile: UUID())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
private let connectButton = UIButton(type: .system)
|
private let connectButton = UIButton(type: .system)
|
||||||
private let resumeButton = UIButton(type: .system)
|
private let resumeButton = UIButton(type: .system)
|
||||||
private let toolbar = UIToolbar()
|
private let toolbar = UIToolbar()
|
||||||
|
private var historyObservation: NSKeyValueObservation?
|
||||||
private var backButton: UIBarButtonItem!
|
private var backButton: UIBarButtonItem!
|
||||||
private var appearance: UISegmentedControl!
|
private var appearance: UISegmentedControl!
|
||||||
|
|
||||||
@@ -192,6 +193,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
guard let data = try? JSONEncoder().encode(selected) else { status.text = text("storageError"); return }
|
guard let data = try? JSONEncoder().encode(selected) else { status.text = text("storageError"); return }
|
||||||
defaults.set(data, forKey: serverKey)
|
defaults.set(data, forKey: serverKey)
|
||||||
timeout?.cancel()
|
timeout?.cancel()
|
||||||
|
historyObservation = nil
|
||||||
workspace?.stopLoading()
|
workspace?.stopLoading()
|
||||||
workspace?.navigationDelegate = nil
|
workspace?.navigationDelegate = nil
|
||||||
workspace?.removeFromSuperview()
|
workspace?.removeFromSuperview()
|
||||||
@@ -215,6 +217,9 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
webview.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
webview.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
||||||
])
|
])
|
||||||
workspace = webview
|
workspace = webview
|
||||||
|
historyObservation = webview.observe(\.canGoBack, options: [.initial, .new]) { [weak self] view, _ in
|
||||||
|
self?.backButton.isEnabled = view.canGoBack
|
||||||
|
}
|
||||||
view.endEditing(true)
|
view.endEditing(true)
|
||||||
address.text = url.absoluteString
|
address.text = url.absoluteString
|
||||||
beginNavigation()
|
beginNavigation()
|
||||||
@@ -261,8 +266,17 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
@objc private func goBack() { if workspace?.canGoBack == true { workspace?.goBack() } }
|
@objc private func goBack() { if workspace?.canGoBack == true { workspace?.goBack() } }
|
||||||
|
|
||||||
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||||
guard webView === workspace, let url = action.request.url,
|
guard webView === workspace, let url = action.request.url else { decisionHandler(.cancel); return }
|
||||||
server?.allows(url) == true else { decisionHandler(.cancel); return }
|
guard server?.allows(url) == true else {
|
||||||
|
decisionHandler(.cancel)
|
||||||
|
if action.navigationType == .linkActivated,
|
||||||
|
action.sourceFrame.isMainFrame,
|
||||||
|
action.targetFrame == nil || action.targetFrame?.isMainFrame == true,
|
||||||
|
WorkspaceServer.allowsExternal(url) {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if action.targetFrame == nil {
|
if action.targetFrame == nil {
|
||||||
decisionHandler(.cancel)
|
decisionHandler(.cancel)
|
||||||
webView.load(action.request)
|
webView.load(action.request)
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"NSCameraUsageDescription" = "Choose a profile picture using your camera.";
|
||||||
|
"NSPhotoLibraryUsageDescription" = "Choose a profile picture from your photo library.";
|
||||||
@@ -17,6 +17,54 @@ final class WorkspaceTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testExternalLinksStayOutsideTheSelectedServer() throws {
|
||||||
|
let server = WorkspaceServer(url: URL(string: "https://example.com/")!, profile: UUID())
|
||||||
|
for value in ["https://git.example.com/org/repo/pulls/1#diff", "http://git.example.com/repo", "https://example.com:8443/other"] {
|
||||||
|
let url = try XCTUnwrap(URL(string: value))
|
||||||
|
XCTAssertTrue(WorkspaceServer.allowsExternal(url))
|
||||||
|
XCTAssertFalse(server.allows(url))
|
||||||
|
}
|
||||||
|
for value in ["https://user:secret@git.example.com/", "https://example.com:99999/", "javascript:alert(1)", "file:///etc/passwd", "tauri://localhost/action/open", "mailto:user@example.com", "data:text/html,test"] {
|
||||||
|
XCTAssertFalse(WorkspaceServer.allowsExternal(try XCTUnwrap(URL(string: value))), value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testBackButtonTracksSameDocumentHistory() async throws {
|
||||||
|
let name = "KAN55-history-\(UUID())"
|
||||||
|
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
||||||
|
defer { defaults.removePersistentDomain(forName: name) }
|
||||||
|
let controller = WorkspaceViewController(defaults: defaults)
|
||||||
|
controller.loadViewIfNeeded()
|
||||||
|
let url = try XCTUnwrap(WorkspaceServer.normalize("https://example.invalid/"))
|
||||||
|
controller.open(url)
|
||||||
|
let webview = try XCTUnwrap(controller.workspace)
|
||||||
|
webview.stopLoading()
|
||||||
|
webview.loadHTMLString("<html><body><input id='draft' value='unsaved'></body></html>", baseURL: url)
|
||||||
|
var loaded = false
|
||||||
|
for _ in 0..<50 {
|
||||||
|
if let value = try? await webview.evaluateJavaScript("document.getElementById('draft')?.value"),
|
||||||
|
value as? String == "unsaved" { loaded = true; break }
|
||||||
|
try await Task.sleep(for: .milliseconds(100))
|
||||||
|
}
|
||||||
|
XCTAssertTrue(loaded)
|
||||||
|
_ = try await webview.evaluateJavaScript("history.pushState({}, '', '#ticket')")
|
||||||
|
let toolbar = try XCTUnwrap(controller.view.subviews.compactMap { $0 as? UIToolbar }.first)
|
||||||
|
for _ in 0..<20 {
|
||||||
|
if toolbar.items?.first?.isEnabled == true { break }
|
||||||
|
try await Task.sleep(for: .milliseconds(50))
|
||||||
|
}
|
||||||
|
XCTAssertTrue(webview.canGoBack)
|
||||||
|
XCTAssertTrue(toolbar.items?.first?.isEnabled == true)
|
||||||
|
webview.goBack()
|
||||||
|
for _ in 0..<20 {
|
||||||
|
if let hash = try? await webview.evaluateJavaScript("location.hash"), hash as? String == "" { break }
|
||||||
|
try await Task.sleep(for: .milliseconds(50))
|
||||||
|
}
|
||||||
|
let draft = try await webview.evaluateJavaScript("document.getElementById('draft').value")
|
||||||
|
XCTAssertEqual(draft as? String, "unsaved")
|
||||||
|
}
|
||||||
|
|
||||||
func testServerChangesNeverReusePreviousProfile() throws {
|
func testServerChangesNeverReusePreviousProfile() throws {
|
||||||
let firstURL = try XCTUnwrap(WorkspaceServer.normalize("https://example.com/"))
|
let firstURL = try XCTUnwrap(WorkspaceServer.normalize("https://example.com/"))
|
||||||
let otherURL = try XCTUnwrap(WorkspaceServer.normalize("https://example.com/other/"))
|
let otherURL = try XCTUnwrap(WorkspaceServer.normalize("https://example.com/other/"))
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
B939982700E800053A9C7663 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE3B71153677F722CAC6DCF7 /* Security.framework */; };
|
B939982700E800053A9C7663 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE3B71153677F722CAC6DCF7 /* Security.framework */; };
|
||||||
C362C102D4950A81D89BB8C8 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */; };
|
C362C102D4950A81D89BB8C8 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */; };
|
||||||
C44BDCDEA454DBD106D51C9C /* WorkspaceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE63F98563975EF4FF2A10FB /* WorkspaceViewController.swift */; };
|
C44BDCDEA454DBD106D51C9C /* WorkspaceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE63F98563975EF4FF2A10FB /* WorkspaceViewController.swift */; };
|
||||||
|
C53E7EC983C008DF98244C74 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 2AB6F9398EA3CB62A68287D2 /* InfoPlist.strings */; };
|
||||||
CD64D99EBD87E6FFC0C9001C /* MetalKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D581BB90601D7A89F8F188E2 /* MetalKit.framework */; };
|
CD64D99EBD87E6FFC0C9001C /* MetalKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D581BB90601D7A89F8F188E2 /* MetalKit.framework */; };
|
||||||
D8772A5E508092D6F831058A /* libapp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 91991DFDB0C064B06DF854ED /* libapp.a */; };
|
D8772A5E508092D6F831058A /* libapp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 91991DFDB0C064B06DF854ED /* libapp.a */; };
|
||||||
EB37EAF992C1A156795CAEC0 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */; };
|
EB37EAF992C1A156795CAEC0 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */; };
|
||||||
@@ -45,6 +46,7 @@
|
|||||||
23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = "kanbangit-native_iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = "kanbangit-native_iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
2B96972F58CC158AB3DFF94A /* lib.rs */ = {isa = PBXFileReference; path = lib.rs; sourceTree = "<group>"; };
|
2B96972F58CC158AB3DFF94A /* lib.rs */ = {isa = PBXFileReference; path = lib.rs; sourceTree = "<group>"; };
|
||||||
31630170C358EA8657E34854 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
31630170C358EA8657E34854 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; 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>"; };
|
||||||
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; };
|
||||||
@@ -53,6 +55,7 @@
|
|||||||
91991DFDB0C064B06DF854ED /* libapp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libapp.a; sourceTree = "<group>"; };
|
91991DFDB0C064B06DF854ED /* libapp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libapp.a; sourceTree = "<group>"; };
|
||||||
92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; };
|
92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; };
|
||||||
946BA5CC3FD90AD90D101A36 /* kanbangit-native_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "kanbangit-native_iOS.entitlements"; sourceTree = "<group>"; };
|
946BA5CC3FD90AD90D101A36 /* kanbangit-native_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "kanbangit-native_iOS.entitlements"; sourceTree = "<group>"; };
|
||||||
|
9C56BE6890F0452F69D64795 /* android.rs */ = {isa = PBXFileReference; path = android.rs; sourceTree = "<group>"; };
|
||||||
9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
AEBAD9ABE758897C50BC1F2E /* locale.rs */ = {isa = PBXFileReference; path = locale.rs; sourceTree = "<group>"; };
|
AEBAD9ABE758897C50BC1F2E /* locale.rs */ = {isa = PBXFileReference; path = locale.rs; sourceTree = "<group>"; };
|
||||||
@@ -137,6 +140,7 @@
|
|||||||
335B1262A9019C70C7F887A5 /* src */ = {
|
335B1262A9019C70C7F887A5 /* src */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
9C56BE6890F0452F69D64795 /* android.rs */,
|
||||||
05A753935B6CB0316DA56D96 /* desktop.rs */,
|
05A753935B6CB0316DA56D96 /* desktop.rs */,
|
||||||
CAF08AD60D2F29A879CDAEBB /* ios.rs */,
|
CAF08AD60D2F29A879CDAEBB /* ios.rs */,
|
||||||
2B96972F58CC158AB3DFF94A /* lib.rs */,
|
2B96972F58CC158AB3DFF94A /* lib.rs */,
|
||||||
@@ -191,6 +195,7 @@
|
|||||||
554AB059B4228085924F4959 /* WorkspacePolicy.swift */,
|
554AB059B4228085924F4959 /* WorkspacePolicy.swift */,
|
||||||
EE63F98563975EF4FF2A10FB /* WorkspaceViewController.swift */,
|
EE63F98563975EF4FF2A10FB /* WorkspaceViewController.swift */,
|
||||||
2A11A26F0082A5075CD710C6 /* bindings */,
|
2A11A26F0082A5075CD710C6 /* bindings */,
|
||||||
|
2AB6F9398EA3CB62A68287D2 /* InfoPlist.strings */,
|
||||||
C8C02BC0E757897278EB954A /* Localizable.strings */,
|
C8C02BC0E757897278EB954A /* Localizable.strings */,
|
||||||
);
|
);
|
||||||
path = "kanbangit-native";
|
path = "kanbangit-native";
|
||||||
@@ -299,6 +304,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
0FAB3CC7BB466BFF871C0C30 /* Assets.xcassets in Resources */,
|
0FAB3CC7BB466BFF871C0C30 /* Assets.xcassets in Resources */,
|
||||||
|
C53E7EC983C008DF98244C74 /* InfoPlist.strings in Resources */,
|
||||||
34C189B72A1B9EBE33D700D1 /* LaunchScreen.storyboard in Resources */,
|
34C189B72A1B9EBE33D700D1 /* LaunchScreen.storyboard in Resources */,
|
||||||
A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */,
|
A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */,
|
||||||
);
|
);
|
||||||
@@ -370,6 +376,14 @@
|
|||||||
/* End PBXTargetDependency section */
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin PBXVariantGroup section */
|
/* Begin PBXVariantGroup section */
|
||||||
|
2AB6F9398EA3CB62A68287D2 /* InfoPlist.strings */ = {
|
||||||
|
isa = PBXVariantGroup;
|
||||||
|
children = (
|
||||||
|
47C531812A31207F97A89C5F /* en */,
|
||||||
|
);
|
||||||
|
name = InfoPlist.strings;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
C8C02BC0E757897278EB954A /* Localizable.strings */ = {
|
C8C02BC0E757897278EB954A /* Localizable.strings */ = {
|
||||||
isa = PBXVariantGroup;
|
isa = PBXVariantGroup;
|
||||||
children = (
|
children = (
|
||||||
|
|||||||
@@ -20,6 +20,10 @@
|
|||||||
<string>0.1.0</string>
|
<string>0.1.0</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>Choose a profile picture using your camera.</string>
|
||||||
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
|
<string>Choose a profile picture from your photo library.</string>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
<string>LaunchScreen</string>
|
<string>LaunchScreen</string>
|
||||||
<key>UIRequiredDeviceCapabilities</key>
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ targets:
|
|||||||
info:
|
info:
|
||||||
path: kanbangit-native_iOS/Info.plist
|
path: kanbangit-native_iOS/Info.plist
|
||||||
properties:
|
properties:
|
||||||
|
NSCameraUsageDescription: Choose a profile picture using your camera.
|
||||||
|
NSPhotoLibraryUsageDescription: Choose a profile picture from your photo library.
|
||||||
LSRequiresIPhoneOS: true
|
LSRequiresIPhoneOS: true
|
||||||
UILaunchStoryboardName: LaunchScreen
|
UILaunchStoryboardName: LaunchScreen
|
||||||
UIRequiredDeviceCapabilities: [arm64, metal]
|
UIRequiredDeviceCapabilities: [arm64, metal]
|
||||||
|
|||||||
@@ -39,6 +39,26 @@ fn allows_server_navigation(server: &Url, destination: &Url) -> bool {
|
|||||||
&& destination.password().is_none()
|
&& destination.password().is_none()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn allows_external_navigation(destination: &Url) -> bool {
|
||||||
|
matches!(destination.scheme(), "https" | "http")
|
||||||
|
&& destination.host_str().is_some()
|
||||||
|
&& destination.username().is_empty()
|
||||||
|
&& destination.password().is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_external(destination: &Url) {
|
||||||
|
if allows_external_navigation(destination) {
|
||||||
|
// Arguments are passed directly to Launch Services, never through a shell.
|
||||||
|
let destination = destination.to_string();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _ = std::process::Command::new("/usr/bin/open")
|
||||||
|
.arg("--")
|
||||||
|
.arg(destination)
|
||||||
|
.status();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn allows_launcher_navigation(destination: &Url) -> bool {
|
fn allows_launcher_navigation(destination: &Url) -> bool {
|
||||||
destination.scheme() == "tauri"
|
destination.scheme() == "tauri"
|
||||||
&& destination.host_str() == Some("localhost")
|
&& destination.host_str() == Some("localhost")
|
||||||
@@ -146,6 +166,9 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
|
|||||||
let handle = app.clone();
|
let handle = app.clone();
|
||||||
let navigation_label = label.clone();
|
let navigation_label = label.clone();
|
||||||
let server_url = server.url.clone();
|
let server_url = server.url.clone();
|
||||||
|
let popup_server = server_url.clone();
|
||||||
|
let popup_handle = app.clone();
|
||||||
|
let popup_label = label.clone();
|
||||||
let window = WebviewWindowBuilder::new(app, &label, WebviewUrl::External(server.url))
|
let window = WebviewWindowBuilder::new(app, &label, WebviewUrl::External(server.url))
|
||||||
.title(en::APP_NAME)
|
.title(en::APP_NAME)
|
||||||
.inner_size(1280.0, 850.0)
|
.inner_size(1280.0, 850.0)
|
||||||
@@ -155,12 +178,22 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
|
|||||||
.data_store_identifier(server.profile)
|
.data_store_identifier(server.profile)
|
||||||
.on_navigation(move |destination| {
|
.on_navigation(move |destination| {
|
||||||
if !allows_server_navigation(&server_url, destination) {
|
if !allows_server_navigation(&server_url, destination) {
|
||||||
|
open_external(destination);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
begin_navigation(&handle, &navigation_label);
|
begin_navigation(&handle, &navigation_label);
|
||||||
true
|
true
|
||||||
})
|
})
|
||||||
.on_new_window(|_, _| NewWindowResponse::Deny)
|
.on_new_window(move |destination, _| {
|
||||||
|
if allows_server_navigation(&popup_server, &destination) {
|
||||||
|
if let Some(window) = popup_handle.get_webview_window(&popup_label) {
|
||||||
|
let _ = window.navigate(destination);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
open_external(&destination);
|
||||||
|
}
|
||||||
|
NewWindowResponse::Deny
|
||||||
|
})
|
||||||
.on_page_load(|window, payload| {
|
.on_page_load(|window, payload| {
|
||||||
if payload.event() != PageLoadEvent::Finished {
|
if payload.event() != PageLoadEvent::Finished {
|
||||||
return;
|
return;
|
||||||
@@ -277,11 +310,31 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
|
|||||||
.select_all()
|
.select_all()
|
||||||
.build()?,
|
.build()?,
|
||||||
)
|
)
|
||||||
|
.item(
|
||||||
|
&SubmenuBuilder::new(app, en::NAVIGATION)
|
||||||
|
.text("workspace-back", en::BACK)
|
||||||
|
.text("workspace-forward", en::FORWARD)
|
||||||
|
.build()?,
|
||||||
|
)
|
||||||
.build()?;
|
.build()?;
|
||||||
app.set_menu(menu)?;
|
app.set_menu(menu)?;
|
||||||
app.on_menu_event(|app, event| {
|
app.on_menu_event(|app, event| {
|
||||||
if event.id().as_ref() == "server-settings" {
|
if event.id().as_ref() == "server-settings" {
|
||||||
show_launcher(app);
|
show_launcher(app);
|
||||||
|
} else if let Some(script) = match event.id().as_ref() {
|
||||||
|
"workspace-back" => Some("window.history.back()"),
|
||||||
|
"workspace-forward" => Some("window.history.forward()"),
|
||||||
|
_ => None,
|
||||||
|
} {
|
||||||
|
let label = app
|
||||||
|
.state::<Mutex<Connection>>()
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.label
|
||||||
|
.clone();
|
||||||
|
if let Some(window) = label.and_then(|label| app.get_webview_window(&label)) {
|
||||||
|
let _ = window.eval(script);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let handle = app.handle().clone();
|
let handle = app.handle().clone();
|
||||||
@@ -347,6 +400,26 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn external_links_accept_only_web_urls_without_credentials() {
|
||||||
|
for input in [
|
||||||
|
"https://git.example.com/org/repo/pulls/1#diff",
|
||||||
|
"http://git.example.com/repo",
|
||||||
|
] {
|
||||||
|
assert!(allows_external_navigation(&Url::parse(input).unwrap()));
|
||||||
|
}
|
||||||
|
for input in [
|
||||||
|
"https://user:secret@git.example.com/",
|
||||||
|
"file:///etc/passwd",
|
||||||
|
"javascript:alert(1)",
|
||||||
|
"tauri://localhost/action/open",
|
||||||
|
"mailto:user@example.com",
|
||||||
|
"data:text/html,test",
|
||||||
|
] {
|
||||||
|
assert!(!allows_external_navigation(&Url::parse(input).unwrap()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn navigation_is_limited_to_the_selected_https_origin() {
|
fn navigation_is_limited_to_the_selected_https_origin() {
|
||||||
let server = validate_url("https://example.com:8443/app/").unwrap();
|
let server = validate_url("https://example.com:8443/app/").unwrap();
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
pub mod en {
|
pub mod en {
|
||||||
pub const APP_NAME: &str = "KanbanGit";
|
pub const APP_NAME: &str = "KanbanGit";
|
||||||
pub const SERVER_SETTINGS: &str = "Server settings…";
|
pub const SERVER_SETTINGS: &str = "Server settings…";
|
||||||
|
pub const NAVIGATION: &str = "Navigation";
|
||||||
|
pub const BACK: &str = "Back";
|
||||||
|
pub const FORWARD: &str = "Forward";
|
||||||
pub const EDIT: &str = "Edit";
|
pub const EDIT: &str = "Edit";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
packageNotarizedApp,
|
||||||
|
releaseMetadata,
|
||||||
|
verifyNotarizedApp,
|
||||||
|
} from '../macos-ci.mjs';
|
||||||
|
|
||||||
|
function fixture(t) {
|
||||||
|
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-ci-test-'));
|
||||||
|
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||||
|
mkdirSync(join(directory, 'src-tauri'));
|
||||||
|
const config = {
|
||||||
|
version: '1.2.3',
|
||||||
|
productName: 'KanbanGit',
|
||||||
|
identifier: 'de.kanbangit.desktop',
|
||||||
|
};
|
||||||
|
writeFileSync(
|
||||||
|
join(directory, 'src-tauri/tauri.conf.json'),
|
||||||
|
JSON.stringify(config),
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(directory, 'package.json'),
|
||||||
|
JSON.stringify({ version: '1.2.3' }),
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(directory, 'src-tauri/Cargo.toml'),
|
||||||
|
'[package]\nname = "kanbangit-native"\nversion = "1.2.3"\n[dependencies]\nother = "2"\n',
|
||||||
|
);
|
||||||
|
const env = {
|
||||||
|
RELEASE_TAG: 'macos-v1.2.3',
|
||||||
|
APPLE_SIGNING_IDENTITY:
|
||||||
|
'Developer ID Application: Example (ZZX2KZGTHU)',
|
||||||
|
};
|
||||||
|
const calls = [];
|
||||||
|
const run = (command, args) => {
|
||||||
|
calls.push({ command, args });
|
||||||
|
if (command === 'codesign' && args.includes('--display')) {
|
||||||
|
return 'CodeDirectory flags=0x10000(runtime)\nCDHash=abcdef123456';
|
||||||
|
}
|
||||||
|
if (command === 'plutil') {
|
||||||
|
return '1.2.3';
|
||||||
|
}
|
||||||
|
if (command === 'lipo') {
|
||||||
|
return 'arm64';
|
||||||
|
}
|
||||||
|
if (command === 'ditto' && args.includes('-c')) {
|
||||||
|
writeFileSync(args.at(-1), 'notarized archive');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
return { directory, env, config, run, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('release metadata binds tag, all app versions, identity and architecture', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
assert.deepEqual(releaseMetadata(f.env, f.directory), {
|
||||||
|
version: '1.2.3',
|
||||||
|
target: 'aarch64-apple-darwin',
|
||||||
|
arch: 'arm64',
|
||||||
|
filename: 'KanbanGit-1.2.3-aarch64-apple-darwin.zip',
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
releaseMetadata(
|
||||||
|
{ ...f.env, MACOS_RELEASE_TARGET: 'x86_64-apple-darwin' },
|
||||||
|
f.directory,
|
||||||
|
).arch,
|
||||||
|
'x86_64',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const env of [
|
||||||
|
{ RELEASE_TAG: 'v1.2.3' },
|
||||||
|
{ RELEASE_TAG: 'macos-v1.2.4' },
|
||||||
|
{ RELEASE_TAG: 'macos-v1.2.3; echo bad' },
|
||||||
|
{ MACOS_RELEASE_TARGET: 'x86_64-unknown-linux-gnu' },
|
||||||
|
{ APPLE_SIGNING_IDENTITY: '-' },
|
||||||
|
{ APPLE_SIGNING_IDENTITY: 'Apple Development: Example (ZZX2KZGTHU)' },
|
||||||
|
{
|
||||||
|
APPLE_SIGNING_IDENTITY:
|
||||||
|
'Developer ID Application: Example (OLDTEAM123)',
|
||||||
|
},
|
||||||
|
]) {
|
||||||
|
test(`rejects invalid release inputs ${JSON.stringify(env)}`, (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
assert.throws(() => releaseMetadata({ ...f.env, ...env }, f.directory));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [file, contents] of [
|
||||||
|
['package.json', '{"version":"1.2.4"}'],
|
||||||
|
['src-tauri/Cargo.toml', '[package]\nversion = "1.2.4"'],
|
||||||
|
['src-tauri/tauri.conf.json', '{"version":"01.2.3"}'],
|
||||||
|
[
|
||||||
|
'src-tauri/tauri.conf.json',
|
||||||
|
'{"version":"1.2.3","identifier":"other","productName":"KanbanGit"}',
|
||||||
|
],
|
||||||
|
]) {
|
||||||
|
test(`rejects inconsistent metadata in ${file}: ${contents}`, (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
writeFileSync(join(f.directory, file), contents);
|
||||||
|
assert.throws(() => releaseMetadata(f.env, f.directory));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('packages only a verified app and checks the extracted download again', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const output = packageNotarizedApp(f);
|
||||||
|
assert.equal(
|
||||||
|
readFileSync(join(output.directory, output.filename), 'utf8'),
|
||||||
|
'notarized archive',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
readFileSync(
|
||||||
|
join(output.directory, `${output.filename}.sha256`),
|
||||||
|
'utf8',
|
||||||
|
),
|
||||||
|
`${output.checksum} ${output.filename}\n`,
|
||||||
|
);
|
||||||
|
assert.equal(f.calls.filter((call) => call.command === 'spctl').length, 2);
|
||||||
|
assert.equal(f.calls.filter((call) => call.command === 'xcrun').length, 2);
|
||||||
|
assert.match(
|
||||||
|
f.calls.findLast((call) => call.command === 'spctl').args.at(-1),
|
||||||
|
/extracted\/KanbanGit\.app$/,
|
||||||
|
);
|
||||||
|
assert.throws(() => packageNotarizedApp(f), /already exists/);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const failure of ['codesign', 'lipo', 'xcrun', 'spctl', 'ditto']) {
|
||||||
|
test(`does not publish local output when ${failure} verification fails`, (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const run = f.run;
|
||||||
|
f.run = (command, args) => {
|
||||||
|
if (command === failure) {
|
||||||
|
throw new Error('verification failed');
|
||||||
|
}
|
||||||
|
return run(command, args);
|
||||||
|
};
|
||||||
|
assert.throws(() => packageNotarizedApp(f), /verification failed/);
|
||||||
|
assert.equal(
|
||||||
|
existsSync(
|
||||||
|
join(
|
||||||
|
f.directory,
|
||||||
|
'src-tauri/target/macos-release/1.2.3-aarch64-apple-darwin',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('rejects an app built for a different architecture', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const metadata = releaseMetadata(f.env, f.directory);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
verifyNotarizedApp('KanbanGit.app', metadata, (command, args) =>
|
||||||
|
command === 'lipo' ? 'x86_64' : f.run(command, args),
|
||||||
|
),
|
||||||
|
/architecture/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed validation of the extracted app discards staged download', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const run = f.run;
|
||||||
|
f.run = (command, args) => {
|
||||||
|
if (command === 'spctl' && args.at(-1).includes('extracted/')) {
|
||||||
|
throw new Error('roundtrip invalid');
|
||||||
|
}
|
||||||
|
return run(command, args);
|
||||||
|
};
|
||||||
|
assert.throws(() => packageNotarizedApp(f), /roundtrip invalid/);
|
||||||
|
assert.equal(
|
||||||
|
existsSync(
|
||||||
|
join(
|
||||||
|
f.directory,
|
||||||
|
'src-tauri/target/macos-release/1.2.3-aarch64-apple-darwin',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,554 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
readdirSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { releaseMacos } from '../release-macos.mjs';
|
||||||
|
|
||||||
|
const identity = 'Developer ID Application: Example Developer (ZZX2KZGTHU)';
|
||||||
|
const submissionId = '3aa7effe-4333-4b03-925c-78427354a3e6';
|
||||||
|
const cdhash = 'a3850211578ca9130445ec77918a619778cb465d';
|
||||||
|
|
||||||
|
function fixture(t, overrides = {}) {
|
||||||
|
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-release-test-'));
|
||||||
|
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||||
|
const config = {
|
||||||
|
productName: 'KanbanGit',
|
||||||
|
identifier: 'de.kanbangit.desktop',
|
||||||
|
version: '1.2.3',
|
||||||
|
};
|
||||||
|
const configPath = join(directory, 'src-tauri/tauri.conf.json');
|
||||||
|
mkdirSync(dirname(configPath), { recursive: true });
|
||||||
|
writeFileSync(configPath, JSON.stringify(config));
|
||||||
|
|
||||||
|
const calls = [];
|
||||||
|
const releaseDirectory = join(directory, 'src-tauri/target/macos-release');
|
||||||
|
const destination = join(releaseDirectory, '1.2.3-aarch64-apple-darwin');
|
||||||
|
let stapled = false;
|
||||||
|
const options = {
|
||||||
|
directory,
|
||||||
|
platform: 'darwin',
|
||||||
|
arch: 'arm64',
|
||||||
|
log() {},
|
||||||
|
env: {
|
||||||
|
APPLE_SIGNING_IDENTITY: identity,
|
||||||
|
APPLE_NOTARY_KEYCHAIN_PROFILE: 'kanbangit-notary',
|
||||||
|
},
|
||||||
|
run(command, args, settings) {
|
||||||
|
calls.push({ command, args, settings });
|
||||||
|
|
||||||
|
if (overrides.failStep === settings.step) {
|
||||||
|
throw new Error('Simulated external failure');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === 'security') {
|
||||||
|
return (
|
||||||
|
overrides.identities ??
|
||||||
|
`1) ABCDEF "${identity}"\n1 valid identities found`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === 'pnpm' && !overrides.missingApp) {
|
||||||
|
const target = args[args.indexOf('--target') + 1];
|
||||||
|
mkdirSync(
|
||||||
|
join(
|
||||||
|
directory,
|
||||||
|
'src-tauri/target',
|
||||||
|
target,
|
||||||
|
'release/bundle/macos/KanbanGit.app',
|
||||||
|
),
|
||||||
|
{ recursive: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === 'codesign' && args.includes('--display')) {
|
||||||
|
return (
|
||||||
|
overrides.signature ??
|
||||||
|
`CodeDirectory v=20500 size=1024 flags=0x10000(runtime) hashes=10\nCDHash=${cdhash}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === 'plutil') {
|
||||||
|
return overrides.version ?? '1.2.3';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
command === 'xcrun' &&
|
||||||
|
args[0] === 'notarytool' &&
|
||||||
|
args[1] === 'submit'
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
overrides.submissionResult ??
|
||||||
|
JSON.stringify({ id: submissionId })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
command === 'xcrun' &&
|
||||||
|
args[0] === 'notarytool' &&
|
||||||
|
args[1] === 'wait'
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
overrides.notaryResult ??
|
||||||
|
JSON.stringify({ status: 'Accepted', id: submissionId })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
command === 'xcrun' &&
|
||||||
|
args[0] === 'notarytool' &&
|
||||||
|
args[1] === 'log'
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
overrides.notaryLog ??
|
||||||
|
JSON.stringify({
|
||||||
|
jobId: submissionId,
|
||||||
|
status: 'Accepted',
|
||||||
|
issues: null,
|
||||||
|
ticketContents: [
|
||||||
|
{
|
||||||
|
path: 'notarization.zip/KanbanGit.app',
|
||||||
|
cdhash,
|
||||||
|
arch:
|
||||||
|
options.env.MACOS_RELEASE_TARGET ===
|
||||||
|
'x86_64-apple-darwin'
|
||||||
|
? 'x86_64'
|
||||||
|
: 'arm64',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
command === 'xcrun' &&
|
||||||
|
args[0] === 'stapler' &&
|
||||||
|
args[1] === 'staple'
|
||||||
|
) {
|
||||||
|
stapled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === 'ditto') {
|
||||||
|
writeFileSync(
|
||||||
|
args.at(-1),
|
||||||
|
stapled
|
||||||
|
? 'archive with stapled ticket'
|
||||||
|
: 'archive before notarization',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
options,
|
||||||
|
calls,
|
||||||
|
config,
|
||||||
|
configPath,
|
||||||
|
releaseDirectory,
|
||||||
|
destination,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('publishes only the stapled app archive with its exact SHA-256 checksum after verification', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const result = releaseMacos(f.options);
|
||||||
|
const contents = readFileSync(result.archive, 'utf8');
|
||||||
|
assert.equal(contents, 'archive with stapled ticket');
|
||||||
|
assert.equal(
|
||||||
|
result.checksum,
|
||||||
|
createHash('sha256').update(contents).digest('hex'),
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
readFileSync(`${result.archive}.sha256`, 'utf8'),
|
||||||
|
`${result.checksum} KanbanGit-1.2.3-aarch64-apple-darwin.zip\n`,
|
||||||
|
);
|
||||||
|
assert.deepEqual(readdirSync(f.destination).sort(), [
|
||||||
|
'KanbanGit-1.2.3-aarch64-apple-darwin.zip',
|
||||||
|
'KanbanGit-1.2.3-aarch64-apple-darwin.zip.sha256',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(readdirSync(f.releaseDirectory), [
|
||||||
|
'1.2.3-aarch64-apple-darwin',
|
||||||
|
]);
|
||||||
|
const labels = f.calls.map((call) => call.settings.step);
|
||||||
|
assert.ok(
|
||||||
|
labels.indexOf('Checking Gatekeeper acceptance') <
|
||||||
|
labels.indexOf('Packaging notarized download'),
|
||||||
|
);
|
||||||
|
const verify = f.calls.find((call) => call.args.includes('-R'));
|
||||||
|
assert.ok(verify.args.includes('--strict'));
|
||||||
|
assert.ok(verify.args.includes('--deep'));
|
||||||
|
assert.match(
|
||||||
|
verify.args[verify.args.indexOf('-R') + 1],
|
||||||
|
/anchor apple generic.*de\.kanbangit\.desktop.*ZZX2KZGTHU.*1\.2\.840\.113635\.100\.6\.1\.13/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [name, env, expected] of [
|
||||||
|
[
|
||||||
|
'missing identity',
|
||||||
|
{ APPLE_SIGNING_IDENTITY: '' },
|
||||||
|
/APPLE_SIGNING_IDENTITY/,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'ad hoc identity',
|
||||||
|
{ APPLE_SIGNING_IDENTITY: '-' },
|
||||||
|
/Developer ID Application/,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'development certificate',
|
||||||
|
{ APPLE_SIGNING_IDENTITY: 'Apple Development: Example (ZZX2KZGTHU)' },
|
||||||
|
/Developer ID Application/,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'old team certificate',
|
||||||
|
{
|
||||||
|
APPLE_SIGNING_IDENTITY:
|
||||||
|
'Developer ID Application: Example (OLDTEAM123)',
|
||||||
|
},
|
||||||
|
/ZZX2KZGTHU/,
|
||||||
|
],
|
||||||
|
['old team environment', { APPLE_TEAM_ID: 'OLDTEAM123' }, /APPLE_TEAM_ID/],
|
||||||
|
[
|
||||||
|
'missing notary profile',
|
||||||
|
{ APPLE_NOTARY_KEYCHAIN_PROFILE: ' ' },
|
||||||
|
/APPLE_NOTARY_KEYCHAIN_PROFILE/,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'unsupported target',
|
||||||
|
{ MACOS_RELEASE_TARGET: 'aarch64-apple-ios' },
|
||||||
|
/MACOS_RELEASE_TARGET/,
|
||||||
|
],
|
||||||
|
]) {
|
||||||
|
test(`${name} fails before running external commands`, (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
Object.assign(f.options.env, env);
|
||||||
|
assert.throws(() => releaseMacos(f.options), expected);
|
||||||
|
assert.equal(f.calls.length, 0);
|
||||||
|
assert.equal(existsSync(f.destination), false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('does not allow a non-macOS host', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
assert.throws(
|
||||||
|
() => releaseMacos({ ...f.options, platform: 'linux' }),
|
||||||
|
/require a Mac/,
|
||||||
|
);
|
||||||
|
assert.equal(f.calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const change of [
|
||||||
|
{ identifier: 'de.oldteam.app' },
|
||||||
|
{ productName: '../Other' },
|
||||||
|
{ version: '../outside' },
|
||||||
|
{ version: '1.2.3-beta.1' },
|
||||||
|
{ version: '01.2.3' },
|
||||||
|
]) {
|
||||||
|
test(`rejects inconsistent release configuration ${JSON.stringify(change)}`, (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
writeFileSync(f.configPath, JSON.stringify({ ...f.config, ...change }));
|
||||||
|
assert.throws(() => releaseMacos(f.options), /release/);
|
||||||
|
assert.equal(f.calls.length, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('requires the exact installed signing identity before building', (t) => {
|
||||||
|
const f = fixture(t, { identities: '0 valid identities found' });
|
||||||
|
assert.throws(() => releaseMacos(f.options), /including its private key/);
|
||||||
|
assert.equal(f.calls.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validates notarization access before the build', (t) => {
|
||||||
|
const f = fixture(t, {
|
||||||
|
failStep:
|
||||||
|
'Checking notarization access (check the keychain profile if this fails)',
|
||||||
|
});
|
||||||
|
assert.throws(() => releaseMacos(f.options), /external failure/);
|
||||||
|
assert.equal(
|
||||||
|
f.calls.some((call) => call.command === 'pnpm'),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pins release signing, hardening and target directory without inheriting unrelated Apple credentials', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
Object.assign(f.options.env, {
|
||||||
|
APPLE_ID: 'old-account@example.test',
|
||||||
|
APPLE_PASSWORD: 'must-not-be-passed',
|
||||||
|
APPLE_API_KEY: 'old-key',
|
||||||
|
APPLE_CERTIFICATE: 'old-certificate',
|
||||||
|
APPLE_TEAM_ID: 'ZZX2KZGTHU',
|
||||||
|
TAURI_CONFIG: '{"bundle":{"macOS":{"hardenedRuntime":false}}}',
|
||||||
|
CARGO_TARGET_DIR: '/other-build',
|
||||||
|
APPLE_NOTARY_KEYCHAIN: '/ci/release.keychain-db',
|
||||||
|
});
|
||||||
|
const originalEnv = { ...f.options.env };
|
||||||
|
releaseMacos(f.options);
|
||||||
|
assert.deepEqual(f.options.env, originalEnv);
|
||||||
|
const build = f.calls.find((call) => call.command === 'pnpm');
|
||||||
|
assert.equal(build.args.includes('--debug'), false);
|
||||||
|
assert.deepEqual(build.args.slice(-2), ['--', '--locked']);
|
||||||
|
assert.deepEqual(
|
||||||
|
JSON.parse(build.args[build.args.indexOf('--config') + 1]),
|
||||||
|
{
|
||||||
|
bundle: {
|
||||||
|
createUpdaterArtifacts: false,
|
||||||
|
macOS: { signingIdentity: identity, hardenedRuntime: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(build.settings.env.APPLE_SIGNING_IDENTITY, identity);
|
||||||
|
assert.equal(
|
||||||
|
build.settings.env.CARGO_TARGET_DIR,
|
||||||
|
join(f.options.directory, 'src-tauri/target'),
|
||||||
|
);
|
||||||
|
for (const key of [
|
||||||
|
'APPLE_ID',
|
||||||
|
'APPLE_PASSWORD',
|
||||||
|
'APPLE_API_KEY',
|
||||||
|
'APPLE_CERTIFICATE',
|
||||||
|
'TAURI_CONFIG',
|
||||||
|
]) {
|
||||||
|
assert.equal(build.settings.env[key], undefined);
|
||||||
|
}
|
||||||
|
for (const call of f.calls.filter(
|
||||||
|
(call) => call.args[0] === 'notarytool',
|
||||||
|
)) {
|
||||||
|
assert.equal(
|
||||||
|
call.args[call.args.indexOf('--keychain-profile') + 1],
|
||||||
|
'kanbangit-notary',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
call.args[call.args.indexOf('--keychain') + 1],
|
||||||
|
'/ci/release.keychain-db',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supports an explicit Intel target with distinct output naming', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
f.options.env.MACOS_RELEASE_TARGET = 'x86_64-apple-darwin';
|
||||||
|
const result = releaseMacos(f.options);
|
||||||
|
assert.equal(result.target, 'x86_64-apple-darwin');
|
||||||
|
assert.match(
|
||||||
|
result.archive,
|
||||||
|
/1\.2\.3-x86_64-apple-darwin\/KanbanGit-1\.2\.3-x86_64-apple-darwin\.zip$/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [name, override, expected] of [
|
||||||
|
['missing app', { missingApp: true }, /did not produce/],
|
||||||
|
[
|
||||||
|
'missing hardened runtime',
|
||||||
|
{ signature: 'CodeDirectory flags=0x0(none)' },
|
||||||
|
/Hardened Runtime/,
|
||||||
|
],
|
||||||
|
['mismatched version', { version: '0.9.0' }, /version/],
|
||||||
|
]) {
|
||||||
|
test(`${name} prevents submission to Apple`, (t) => {
|
||||||
|
const f = fixture(t, override);
|
||||||
|
assert.throws(() => releaseMacos(f.options), expected);
|
||||||
|
assert.equal(
|
||||||
|
f.calls.some((call) => call.args[1] === 'submit'),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(existsSync(f.destination), false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const failStep of [
|
||||||
|
'Building signed macOS release',
|
||||||
|
'Verifying Developer ID signature and team',
|
||||||
|
'Preparing notarization archive',
|
||||||
|
'Submitting to Apple',
|
||||||
|
'Waiting for Apple notarization',
|
||||||
|
'Checking Apple notarization log',
|
||||||
|
'Stapling notarization ticket',
|
||||||
|
'Validating notarization ticket',
|
||||||
|
'Verifying final app signature',
|
||||||
|
'Checking Gatekeeper acceptance',
|
||||||
|
'Packaging notarized download',
|
||||||
|
]) {
|
||||||
|
test(`${failStep} failure never publishes a download`, (t) => {
|
||||||
|
const f = fixture(t, { failStep });
|
||||||
|
assert.throws(() => releaseMacos(f.options), /external failure/);
|
||||||
|
assert.equal(existsSync(f.destination), false);
|
||||||
|
if (existsSync(f.releaseDirectory)) {
|
||||||
|
assert.deepEqual(readdirSync(f.releaseDirectory), []);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const notaryResult of [
|
||||||
|
'{"status":"Invalid","id":"rejected-id"}',
|
||||||
|
'{"status":"In Progress","id":"pending-id"}',
|
||||||
|
'{"id":"no-status"}',
|
||||||
|
'not valid JSON',
|
||||||
|
]) {
|
||||||
|
test(`unaccepted notarization prevents stapling and publication: ${notaryResult}`, (t) => {
|
||||||
|
const f = fixture(t, { notaryResult });
|
||||||
|
assert.throws(() => releaseMacos(f.options));
|
||||||
|
assert.equal(
|
||||||
|
f.calls.some((call) => call.args[0] === 'stapler'),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(existsSync(f.destination), false);
|
||||||
|
assert.deepEqual(readdirSync(f.releaseDirectory), []);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('never overwrites an existing verified release', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const result = releaseMacos(f.options);
|
||||||
|
const original = readFileSync(result.archive);
|
||||||
|
f.calls.length = 0;
|
||||||
|
assert.throws(() => releaseMacos(f.options), /already exists/);
|
||||||
|
assert.deepEqual(readFileSync(result.archive), original);
|
||||||
|
assert.equal(f.calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persists submission ID on timeout and resumes the same app without rebuilding or uploading', (t) => {
|
||||||
|
const overrides = { failStep: 'Waiting for Apple notarization' };
|
||||||
|
const f = fixture(t, overrides);
|
||||||
|
assert.throws(
|
||||||
|
() => releaseMacos(f.options),
|
||||||
|
new RegExp(`Apple may still be processing.*--resume ${submissionId}`),
|
||||||
|
);
|
||||||
|
const record = JSON.parse(
|
||||||
|
readFileSync(
|
||||||
|
join(
|
||||||
|
f.options.directory,
|
||||||
|
'src-tauri/target',
|
||||||
|
`notarization-${submissionId}.submission.json`,
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.deepEqual(record, {
|
||||||
|
submissionId,
|
||||||
|
version: '1.2.3',
|
||||||
|
target: 'aarch64-apple-darwin',
|
||||||
|
cdhash,
|
||||||
|
});
|
||||||
|
assert.equal(existsSync(f.destination), false);
|
||||||
|
overrides.failStep = null;
|
||||||
|
f.calls.length = 0;
|
||||||
|
const result = releaseMacos({
|
||||||
|
...f.options,
|
||||||
|
resumeSubmissionId: submissionId,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
readFileSync(result.archive, 'utf8'),
|
||||||
|
'archive with stapled ticket',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
f.calls.some(
|
||||||
|
(call) => call.command === 'pnpm' || call.args[1] === 'submit',
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
f.calls.find((call) => call.args[1] === 'wait').args[2],
|
||||||
|
submissionId,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects invalid resume arguments before any commands', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
assert.throws(
|
||||||
|
() => releaseMacos({ ...f.options, resumeSubmissionId: '--invalid' }),
|
||||||
|
/submission UUID/,
|
||||||
|
);
|
||||||
|
assert.equal(f.calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resume never rebuilds a missing previously submitted app', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
assert.throws(
|
||||||
|
() => releaseMacos({ ...f.options, resumeSubmissionId: submissionId }),
|
||||||
|
/did not produce/,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
f.calls.some(
|
||||||
|
(call) => call.command === 'pnpm' || call.args[1] === 'submit',
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const submissionResult of ['{}', '{"id":"not-a-uuid"}', 'invalid JSON']) {
|
||||||
|
test(`invalid submission response cannot continue: ${submissionResult}`, (t) => {
|
||||||
|
const f = fixture(t, { submissionResult });
|
||||||
|
assert.throws(() => releaseMacos(f.options));
|
||||||
|
assert.equal(
|
||||||
|
f.calls.some(
|
||||||
|
(call) => call.args[1] === 'wait' || call.args[0] === 'stapler',
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(existsSync(f.destination), false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const change of [
|
||||||
|
{ jobId: '00000000-0000-0000-0000-000000000000' },
|
||||||
|
{ status: 'Invalid' },
|
||||||
|
{ ticketContents: [] },
|
||||||
|
{
|
||||||
|
ticketContents: [
|
||||||
|
{
|
||||||
|
path: 'notarization.zip/KanbanGit.app',
|
||||||
|
cdhash: 'different-code',
|
||||||
|
arch: 'arm64',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ticketContents: [
|
||||||
|
{ path: 'notarization.zip/KanbanGit.app', cdhash, arch: 'x86_64' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ticketContents: [
|
||||||
|
{ path: 'notarization.zip/Other.app', cdhash, arch: 'arm64' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ issues: [{ severity: 'warning', message: 'Review this warning' }] },
|
||||||
|
]) {
|
||||||
|
test(`Apple log must approve the exact app without outstanding issues: ${JSON.stringify(change)}`, (t) => {
|
||||||
|
const notaryLog = JSON.stringify({
|
||||||
|
jobId: submissionId,
|
||||||
|
status: 'Accepted',
|
||||||
|
issues: null,
|
||||||
|
ticketContents: [
|
||||||
|
{
|
||||||
|
path: 'notarization.zip/KanbanGit.app',
|
||||||
|
cdhash,
|
||||||
|
arch: 'arm64',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
...change,
|
||||||
|
});
|
||||||
|
const f = fixture(t, { notaryLog });
|
||||||
|
assert.throws(
|
||||||
|
() => releaseMacos(f.options),
|
||||||
|
/does not match|notarization issues/,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
f.calls.some((call) => call.args[0] === 'stapler'),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(existsSync(f.destination), false);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import {
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import process from 'node:process';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const workflow = readFileSync(
|
||||||
|
new URL('../../.github/workflows/macos-release.yml', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const selectRef = workflow
|
||||||
|
.split(
|
||||||
|
' - name: Select an existing version tag contained in main\n',
|
||||||
|
)[1]
|
||||||
|
.split('\n - name:')[0]
|
||||||
|
.split(' run: |\n')[1]
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.replace(/^ {10}/, ''))
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
function fixture(t) {
|
||||||
|
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-release-ref-'));
|
||||||
|
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||||
|
const remote = join(directory, 'remote');
|
||||||
|
const checkout = join(directory, 'checkout');
|
||||||
|
mkdirSync(remote);
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
GIT_AUTHOR_NAME: 'Release test',
|
||||||
|
GIT_COMMITTER_NAME: 'Release test',
|
||||||
|
GIT_AUTHOR_EMAIL: 'release@example.invalid',
|
||||||
|
GIT_COMMITTER_EMAIL: 'release@example.invalid',
|
||||||
|
GIT_CONFIG_NOSYSTEM: '1',
|
||||||
|
GIT_CONFIG_GLOBAL: '/dev/null',
|
||||||
|
};
|
||||||
|
const git = (cwd, ...args) => {
|
||||||
|
const result = spawnSync('git', args, { cwd, env, encoding: 'utf8' });
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
return result.stdout.trim();
|
||||||
|
};
|
||||||
|
git(remote, 'init', '-b', 'main');
|
||||||
|
writeFileSync(join(remote, 'app.txt'), 'release');
|
||||||
|
git(remote, 'add', 'app.txt');
|
||||||
|
git(remote, '-c', 'commit.gpgsign=false', 'commit', '-m', 'Release');
|
||||||
|
const commit = git(remote, 'rev-parse', 'HEAD');
|
||||||
|
git(remote, 'tag', 'macos-v1.2.3');
|
||||||
|
git(directory, 'clone', remote, checkout);
|
||||||
|
const output = join(directory, 'outputs');
|
||||||
|
const run = (overrides = {}) => {
|
||||||
|
writeFileSync(output, '');
|
||||||
|
const result = spawnSync(
|
||||||
|
'bash',
|
||||||
|
['--noprofile', '--norc', '-c', selectRef],
|
||||||
|
{
|
||||||
|
cwd: checkout,
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: {
|
||||||
|
...env,
|
||||||
|
GITHUB_EVENT_NAME: 'push',
|
||||||
|
GITHUB_REF: 'refs/tags/macos-v1.2.3',
|
||||||
|
REQUESTED_TAG: '',
|
||||||
|
MACOS_RUNNER: 'macos-local',
|
||||||
|
GITHUB_OUTPUT: output,
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return { ...result, output: readFileSync(output, 'utf8') };
|
||||||
|
};
|
||||||
|
return { run, git, checkout, commit };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('release selection succeeds using Git alone without an administrative API token', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const result = f.run({
|
||||||
|
GITEA_TOKEN: '',
|
||||||
|
GITHUB_TOKEN: '',
|
||||||
|
GITHUB_API_URL: 'http://127.0.0.1:1',
|
||||||
|
});
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.equal(result.output, `commit=${f.commit}\ntag=macos-v1.2.3\n`);
|
||||||
|
assert.equal(f.git(f.checkout, 'rev-parse', 'HEAD'), f.commit);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('macOS scheduling uses a repository variable while release steps remain gated by preparation', () => {
|
||||||
|
const release = workflow.split('\n release:\n')[1];
|
||||||
|
const runner = release.match(/^ {4}runs-on: (.+)$/m)?.[1];
|
||||||
|
assert.equal(runner, '${{ vars.MACOS_RUNNER }}');
|
||||||
|
assert.match(release, /^ {4}needs: prepare$/m);
|
||||||
|
assert.match(release, /ref: \$\{\{ needs\.prepare\.outputs\.commit \}\}/);
|
||||||
|
assert.match(
|
||||||
|
release,
|
||||||
|
/RELEASE_TAG: \$\{\{ needs\.prepare\.outputs\.tag \}\}/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('manual release on main accepts an existing tag without moving it', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const result = f.run({
|
||||||
|
GITHUB_EVENT_NAME: 'workflow_dispatch',
|
||||||
|
GITHUB_REF: 'refs/heads/main',
|
||||||
|
REQUESTED_TAG: 'macos-v1.2.3',
|
||||||
|
});
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.equal(f.git(f.checkout, 'rev-parse', 'macos-v1.2.3'), f.commit);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [name, overrides] of [
|
||||||
|
['web release tag', { GITHUB_REF: 'refs/tags/v1.2.3' }],
|
||||||
|
['missing tag', { GITHUB_REF: 'refs/tags/macos-v9.9.9' }],
|
||||||
|
[
|
||||||
|
'shell characters in tag',
|
||||||
|
{ GITHUB_REF: 'refs/tags/macos-v1.2.3;exit 0' },
|
||||||
|
],
|
||||||
|
['missing runner label', { MACOS_RUNNER: '' }],
|
||||||
|
[
|
||||||
|
'output injection in runner label',
|
||||||
|
{ MACOS_RUNNER: 'macos-local\ncommit=other' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'manual release outside main',
|
||||||
|
{
|
||||||
|
GITHUB_EVENT_NAME: 'workflow_dispatch',
|
||||||
|
GITHUB_REF: 'refs/heads/feature',
|
||||||
|
REQUESTED_TAG: 'macos-v1.2.3',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]) {
|
||||||
|
test(`release selection rejects ${name} before producing job outputs`, (t) => {
|
||||||
|
const result = fixture(t).run(overrides);
|
||||||
|
assert.notEqual(result.status, 0);
|
||||||
|
assert.equal(result.output, '');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('release selection rejects a tag whose commit is outside main', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
f.git(f.checkout, 'switch', '-c', 'unmerged');
|
||||||
|
f.git(
|
||||||
|
f.checkout,
|
||||||
|
'-c',
|
||||||
|
'commit.gpgsign=false',
|
||||||
|
'commit',
|
||||||
|
'--allow-empty',
|
||||||
|
'-m',
|
||||||
|
'Unmerged',
|
||||||
|
);
|
||||||
|
f.git(f.checkout, 'tag', 'macos-v1.2.4');
|
||||||
|
const result = f.run({ GITHUB_REF: 'refs/tags/macos-v1.2.4' });
|
||||||
|
assert.notEqual(result.status, 0);
|
||||||
|
assert.match(result.stderr, /Release tag must belong to main/);
|
||||||
|
assert.equal(result.output, '');
|
||||||
|
});
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { Buffer } from 'node:buffer';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { publishMacos, storageConfiguration } from '../publish-macos.mjs';
|
||||||
|
|
||||||
|
function fixture(t) {
|
||||||
|
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-upload-test-'));
|
||||||
|
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||||
|
const metadata = {
|
||||||
|
version: '1.2.3',
|
||||||
|
target: 'aarch64-apple-darwin',
|
||||||
|
filename: 'KanbanGit-1.2.3-aarch64-apple-darwin.zip',
|
||||||
|
};
|
||||||
|
const archive = Buffer.from('verified release');
|
||||||
|
const checksum = createHash('sha256').update(archive).digest('hex');
|
||||||
|
writeFileSync(join(directory, metadata.filename), archive);
|
||||||
|
writeFileSync(
|
||||||
|
join(directory, `${metadata.filename}.sha256`),
|
||||||
|
`${checksum} ${metadata.filename}\n`,
|
||||||
|
);
|
||||||
|
const env = {
|
||||||
|
S3_BUCKET: 'release-bucket',
|
||||||
|
S3_REGION: 'nbg1',
|
||||||
|
AWS_ACCESS_KEY_ID: 'test-key',
|
||||||
|
AWS_SECRET_ACCESS_KEY: 'test-secret',
|
||||||
|
};
|
||||||
|
const objects = new Map();
|
||||||
|
const events = [];
|
||||||
|
const run = (command, args) => {
|
||||||
|
assert.equal(command, 'aws');
|
||||||
|
assert.equal(args[1], 'https://nbg1.your-objectstorage.com');
|
||||||
|
const operation = args[args.indexOf('s3api') + 1];
|
||||||
|
const value = (name) => args[args.indexOf(name) + 1];
|
||||||
|
const key = value('--key');
|
||||||
|
events.push({ operation, key });
|
||||||
|
if (operation === 'get-bucket-versioning') {
|
||||||
|
assert.equal(value('--output'), 'json');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (operation === 'put-object') {
|
||||||
|
assert.equal(value('--if-none-match'), '*');
|
||||||
|
if (objects.has(key)) {
|
||||||
|
throw new Error(
|
||||||
|
'aws failed: (PreconditionFailed) object exists',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
objects.set(key, readFileSync(value('--body')));
|
||||||
|
return '{}';
|
||||||
|
}
|
||||||
|
if (operation === 'get-object') {
|
||||||
|
writeFileSync(args.at(-1), objects.get(key));
|
||||||
|
return '{}';
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected S3 operation: ${operation}`);
|
||||||
|
};
|
||||||
|
const fetchPublic = async (url, options) => {
|
||||||
|
assert.equal(options.redirect, 'error');
|
||||||
|
assert.equal(
|
||||||
|
new URL(url).hostname,
|
||||||
|
'release-bucket.nbg1.your-objectstorage.com',
|
||||||
|
);
|
||||||
|
const key = new URL(url).pathname.slice(1);
|
||||||
|
events.push({ operation: 'public-get', key });
|
||||||
|
return {
|
||||||
|
ok: objects.has(key),
|
||||||
|
arrayBuffer: async () => objects.get(key),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
return { directory, metadata, env, objects, events, run, fetchPublic };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('uploads immutable files and writes completion record after public verification', async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const url = await publishMacos(f);
|
||||||
|
assert.equal(
|
||||||
|
url,
|
||||||
|
`https://release-bucket.nbg1.your-objectstorage.com/releases/macos/aarch64-apple-darwin/1.2.3/${f.metadata.filename}`,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
f.events.map((event) => event.operation),
|
||||||
|
[
|
||||||
|
'get-bucket-versioning',
|
||||||
|
'put-object',
|
||||||
|
'public-get',
|
||||||
|
'put-object',
|
||||||
|
'public-get',
|
||||||
|
'put-object',
|
||||||
|
'public-get',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.equal(f.objects.size, 3);
|
||||||
|
assert(f.events.at(-1).key.endsWith('/release.json'));
|
||||||
|
assert(![...f.objects.keys()].some((key) => key.includes('latest')));
|
||||||
|
assert.deepEqual(JSON.parse(f.objects.get(f.events.at(-1).key)), {
|
||||||
|
version: '1.2.3',
|
||||||
|
target: f.metadata.target,
|
||||||
|
url,
|
||||||
|
sha256: createHash('sha256').update('verified release').digest('hex'),
|
||||||
|
checksumUrl: `${url}.sha256`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const response of ['', ' \n\t', '{}']) {
|
||||||
|
test(`unversioned bucket response ${JSON.stringify(response)} permits a verified upload`, async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const run = f.run;
|
||||||
|
f.run = (command, args) => {
|
||||||
|
const result = run(command, args);
|
||||||
|
return args.includes('get-bucket-versioning') ? response : result;
|
||||||
|
};
|
||||||
|
await publishMacos(f);
|
||||||
|
assert.equal(f.objects.size, 3);
|
||||||
|
assert.equal(f.events.at(-1).operation, 'public-get');
|
||||||
|
assert(f.events.at(-1).key.endsWith('/release.json'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const response of ['{', 'null', '[]', '"Enabled"', 'false']) {
|
||||||
|
test(`invalid versioning response ${response} aborts before any upload`, async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
f.run = (_command, args) => {
|
||||||
|
assert(args.includes('get-bucket-versioning'));
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
await assert.rejects(publishMacos(f), /Invalid .*versioning/);
|
||||||
|
assert.equal(f.objects.size, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('retry accepts byte-identical objects without replacing them', async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
await publishMacos(f);
|
||||||
|
const originals = [...f.objects.entries()];
|
||||||
|
await publishMacos(f);
|
||||||
|
assert.deepEqual([...f.objects.entries()], originals);
|
||||||
|
assert.equal(
|
||||||
|
f.events.filter((event) => event.operation === 'get-object').length,
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('different existing release is never overwritten', async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
|
||||||
|
f.objects.set(key, Buffer.from('previous version content'));
|
||||||
|
await assert.rejects(publishMacos(f), /bump the app version/);
|
||||||
|
assert.equal(f.objects.get(key).toString(), 'previous version content');
|
||||||
|
assert.equal(f.objects.size, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('checksum mismatch aborts before contacting S3', async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
writeFileSync(join(f.directory, f.metadata.filename), 'tampered');
|
||||||
|
await assert.rejects(publishMacos(f), /SHA-256/);
|
||||||
|
assert.equal(f.events.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const status of ['Enabled', 'Suspended']) {
|
||||||
|
test(`rejects ${status} bucket versioning before any write`, async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
f.run = () => JSON.stringify({ Status: status });
|
||||||
|
await assert.rejects(publishMacos(f), /unversioned/);
|
||||||
|
assert.equal(f.objects.size, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const operation of ['get-bucket-versioning', 'put-object', 'get-object']) {
|
||||||
|
test(`${operation} access or network failure cannot be mistaken for a missing object`, async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
await publishMacos(f);
|
||||||
|
const run = f.run;
|
||||||
|
f.events.length = 0;
|
||||||
|
f.run = (command, args) => {
|
||||||
|
if (args.includes(operation)) {
|
||||||
|
throw new Error('AccessDenied');
|
||||||
|
}
|
||||||
|
return run(command, args);
|
||||||
|
};
|
||||||
|
await assert.rejects(publishMacos(f), /AccessDenied/);
|
||||||
|
assert(!f.events.some((event) => event.key.endsWith('/release.json')));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const failure of ['private', 'wrong-content', 'network']) {
|
||||||
|
test(`public download ${failure} prevents completion record`, async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
f.fetchPublic = async () => {
|
||||||
|
if (failure === 'network') {
|
||||||
|
throw new Error('network failure');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: failure !== 'private',
|
||||||
|
arrayBuffer: async () => Buffer.from('wrong'),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
await assert.rejects(publishMacos(f));
|
||||||
|
assert(
|
||||||
|
![...f.objects.keys()].some((key) => key.endsWith('/release.json')),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a partial upload can resume with the same files', async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const run = f.run;
|
||||||
|
f.run = (command, args) => {
|
||||||
|
if (
|
||||||
|
args.includes('put-object') &&
|
||||||
|
args[args.indexOf('--key') + 1].endsWith('.sha256')
|
||||||
|
) {
|
||||||
|
throw new Error('upload interrupted');
|
||||||
|
}
|
||||||
|
return run(command, args);
|
||||||
|
};
|
||||||
|
await assert.rejects(publishMacos(f), /interrupted/);
|
||||||
|
assert.equal(f.objects.size, 1);
|
||||||
|
f.run = run;
|
||||||
|
await publishMacos(f);
|
||||||
|
assert.equal(f.objects.size, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const overrides of [
|
||||||
|
{ S3_REGION: '' },
|
||||||
|
{ S3_REGION: 'eu-central-1' },
|
||||||
|
{ S3_BUCKET: '' },
|
||||||
|
{ S3_BUCKET: 'other/../bucket' },
|
||||||
|
{ S3_PREFIX: '../other' },
|
||||||
|
{ S3_PUBLIC_BASE_URL: 'http://downloads.example.com' },
|
||||||
|
{ S3_PUBLIC_BASE_URL: 'https://secret@downloads.example.com' },
|
||||||
|
{ S3_PUBLIC_BASE_URL: 'https://downloads.example.com?token=secret' },
|
||||||
|
{ AWS_ACCESS_KEY_ID: '' },
|
||||||
|
{ AWS_SECRET_ACCESS_KEY: '' },
|
||||||
|
]) {
|
||||||
|
test(`invalid storage configuration is rejected before S3 access: ${JSON.stringify(overrides)}`, async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
f.env = { ...f.env, ...overrides };
|
||||||
|
await assert.rejects(publishMacos(f));
|
||||||
|
assert.equal(f.events.length, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('derives Hetzner endpoints and supports an explicit public proxy URL', () => {
|
||||||
|
for (const region of ['nbg1', 'fsn1', 'hel1']) {
|
||||||
|
const config = storageConfiguration({
|
||||||
|
S3_REGION: region,
|
||||||
|
S3_BUCKET: 'release-bucket',
|
||||||
|
S3_PUBLIC_BASE_URL: 'https://downloads.example.com/',
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
config.endpoint,
|
||||||
|
`https://${region}.your-objectstorage.com`,
|
||||||
|
);
|
||||||
|
assert.equal(config.publicBase, 'https://downloads.example.com');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -19,6 +19,7 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { useFormProtection } from '@/composables/useFormProtection';
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
|
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
|
||||||
|
import { copyText } from '@/lib/clipboard';
|
||||||
import { confirm } from '@/routes/two-factor';
|
import { confirm } from '@/routes/two-factor';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -94,7 +95,7 @@ async function copyKey(): Promise<void> {
|
|||||||
}
|
}
|
||||||
copyError.value = false;
|
copyError.value = false;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(key);
|
await copyText(key);
|
||||||
if (isOpen.value && manualSetupKey.value === key) {
|
if (isOpen.value && manualSetupKey.value === key) {
|
||||||
copied.value = true;
|
copied.value = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/** Copy only after a user action; WebViews may not expose the async Clipboard API. */
|
||||||
|
export async function copyText(text: string): Promise<void> {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// Embedded browsers can deny the async API while allowing a selected copy.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = document.activeElement;
|
||||||
|
const selection = window.getSelection();
|
||||||
|
const ranges = selection
|
||||||
|
? Array.from({ length: selection.rangeCount }, (_, index) =>
|
||||||
|
selection.getRangeAt(index).cloneRange(),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const inputSelection =
|
||||||
|
active instanceof HTMLInputElement ||
|
||||||
|
active instanceof HTMLTextAreaElement
|
||||||
|
? ([
|
||||||
|
active.selectionStart,
|
||||||
|
active.selectionEnd,
|
||||||
|
active.selectionDirection,
|
||||||
|
] as const)
|
||||||
|
: null;
|
||||||
|
const field = document.createElement('textarea');
|
||||||
|
field.value = text;
|
||||||
|
field.readOnly = true;
|
||||||
|
field.style.cssText =
|
||||||
|
'position:fixed;opacity:0;pointer-events:none;font-size:16px';
|
||||||
|
(active?.closest('[role="dialog"]') ?? document.body).append(field);
|
||||||
|
try {
|
||||||
|
field.focus({ preventScroll: true });
|
||||||
|
field.select();
|
||||||
|
field.setSelectionRange(0, text.length);
|
||||||
|
if (!document.execCommand('copy')) {
|
||||||
|
throw new Error('Clipboard access unavailable');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
field.remove();
|
||||||
|
if (active instanceof HTMLElement) {
|
||||||
|
active.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
if (selection) {
|
||||||
|
selection.removeAllRanges();
|
||||||
|
ranges.forEach((range) => selection.addRange(range));
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
inputSelection &&
|
||||||
|
inputSelection[0] !== null &&
|
||||||
|
inputSelection[1] !== null &&
|
||||||
|
(active instanceof HTMLInputElement ||
|
||||||
|
active instanceof HTMLTextAreaElement)
|
||||||
|
) {
|
||||||
|
active.setSelectionRange(
|
||||||
|
inputSelection[0],
|
||||||
|
inputSelection[1],
|
||||||
|
inputSelection[2] ?? undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { useFormProtection } from '@/composables/useFormProtection';
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
|
import { copyText } from '@/lib/clipboard';
|
||||||
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
||||||
import { edit } from '@/routes/applications/index';
|
import { edit } from '@/routes/applications/index';
|
||||||
import type { BreadcrumbItem, SettingsApplicationsPageProps } from '@/types';
|
import type { BreadcrumbItem, SettingsApplicationsPageProps } from '@/types';
|
||||||
@@ -83,7 +84,7 @@ async function copyKey(): Promise<void> {
|
|||||||
}
|
}
|
||||||
copyError.value = false;
|
copyError.value = false;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(key);
|
await copyText(key);
|
||||||
if (props.applicationKey === key) {
|
if (props.applicationKey === key) {
|
||||||
copied.value = true;
|
copied.value = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
|
||||||
|
const { chromium } = await import(
|
||||||
|
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
|
||||||
|
);
|
||||||
|
const server = await createServer({
|
||||||
|
configFile: false,
|
||||||
|
optimizeDeps: { noDiscovery: true, include: [] },
|
||||||
|
server: { host: '127.0.0.1', port: 0, hmr: false },
|
||||||
|
appType: 'custom',
|
||||||
|
});
|
||||||
|
server.middlewares.use((request, response, next) => {
|
||||||
|
if (request.url !== '/') {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
response.setHeader('Content-Type', 'text/html');
|
||||||
|
response.end(`<!doctype html><html><body>
|
||||||
|
<div role="dialog"><input id="draft" value="unsaved draft"><button id="copy">Copy</button></div>
|
||||||
|
<script type="module">
|
||||||
|
import { copyText } from '/resources/js/lib/clipboard.ts';
|
||||||
|
window.originalClipboard = navigator.clipboard;
|
||||||
|
window.copyText = copyText;
|
||||||
|
document.querySelector('#copy').onclick = async () => {
|
||||||
|
window.result = 'pending';
|
||||||
|
const input = document.querySelector('#draft');
|
||||||
|
input.focus(); input.setSelectionRange(1, 5);
|
||||||
|
try { await copyText('kan55 selected text'); window.result = 'copied'; }
|
||||||
|
catch { window.result = 'failed'; }
|
||||||
|
};
|
||||||
|
</script></body></html>`);
|
||||||
|
});
|
||||||
|
await server.listen();
|
||||||
|
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||||
|
const context = await browser.newContext({
|
||||||
|
permissions: ['clipboard-read', 'clipboard-write'],
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
try {
|
||||||
|
await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/`);
|
||||||
|
await page.waitForFunction(() => typeof window.copyText === 'function');
|
||||||
|
for (const mode of ['modern', 'absent', 'denied']) {
|
||||||
|
await page.evaluate(async (mode) => {
|
||||||
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
|
configurable: true,
|
||||||
|
value:
|
||||||
|
mode === 'modern'
|
||||||
|
? window.originalClipboard
|
||||||
|
: mode === 'absent'
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
writeText: () =>
|
||||||
|
Promise.reject(
|
||||||
|
new DOMException(
|
||||||
|
'Denied',
|
||||||
|
'NotAllowedError',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await window.originalClipboard.writeText('previous');
|
||||||
|
// Simulate the modal's focus boundary used by the two-factor setup dialog.
|
||||||
|
document.onfocusin = (event) => {
|
||||||
|
if (!event.target.closest('[role="dialog"]')) {
|
||||||
|
document.querySelector('#draft').focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, mode);
|
||||||
|
await page.getByRole('button', { name: 'Copy', exact: true }).click();
|
||||||
|
await page.waitForFunction(() => window.result === 'copied');
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(() => window.originalClipboard.readText()),
|
||||||
|
'kan55 selected text',
|
||||||
|
mode,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const input = document.querySelector('#draft');
|
||||||
|
return [
|
||||||
|
document.activeElement === input,
|
||||||
|
input.value,
|
||||||
|
input.selectionStart,
|
||||||
|
input.selectionEnd,
|
||||||
|
document.querySelectorAll('textarea').length,
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
[true, 'unsaved draft', 1, 5, 0],
|
||||||
|
mode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.execCommand = () => false;
|
||||||
|
});
|
||||||
|
await page.getByRole('button', { name: 'Copy', exact: true }).click();
|
||||||
|
await page.waitForFunction(() => window.result === 'failed');
|
||||||
|
assert.equal(await page.locator('textarea').count(), 0);
|
||||||
|
assert.equal(await page.locator('#draft').inputValue(), 'unsaved draft');
|
||||||
|
console.log(
|
||||||
|
'Clipboard browser checks passed: async API, unavailable/denied API fallback, modal focus, input selection and failure cleanup.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
@@ -82,6 +82,25 @@ try {
|
|||||||
),
|
),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.savedClipboard = navigator.clipboard;
|
||||||
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
|
configurable: true,
|
||||||
|
value: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await button('Copy key').click();
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(() => window.savedClipboard.readText()),
|
||||||
|
'MANUALSETUPTESTKEY',
|
||||||
|
);
|
||||||
|
assert.equal(await dialog().locator('textarea').count(), 0);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
|
configurable: true,
|
||||||
|
value: window.savedClipboard,
|
||||||
|
});
|
||||||
|
});
|
||||||
await button('Continue').click();
|
await button('Continue').click();
|
||||||
const code = page.getByLabel('Authentication code', {
|
const code = page.getByLabel('Authentication code', {
|
||||||
exact: true,
|
exact: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user