Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8ca4975e5 | ||
|
|
f2e1bd6a0d | ||
|
|
b0cc9dad14 | ||
|
|
52304c7eb2 | ||
|
|
b4cd6a5808 | ||
|
|
f52cffeaeb | ||
|
|
1c620654a3 | ||
|
|
fcba7673c2 | ||
|
|
2f99b8e65c | ||
|
|
2b85517965 | ||
|
|
7001776c48 | ||
|
|
9d472b5091 | ||
|
|
8bed02a484 | ||
|
|
d07b17ebdb | ||
|
|
859229fa21 | ||
|
|
32dac91620 |
@@ -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
|
||||||
@@ -31,4 +31,13 @@ yarn-error.log
|
|||||||
|
|
||||||
/native/node_modules
|
/native/node_modules
|
||||||
/native/src-tauri/target
|
/native/src-tauri/target
|
||||||
/native/src-tauri/gen
|
/native/src-tauri/gen/*
|
||||||
|
!/native/src-tauri/gen/android/
|
||||||
|
!/native/src-tauri/gen/apple/
|
||||||
|
/native/src-tauri/gen/apple/build/
|
||||||
|
/native/src-tauri/gen/apple/Externals/
|
||||||
|
/native/src-tauri/gen/apple/assets/
|
||||||
|
/native/src-tauri/gen/apple/Pods/
|
||||||
|
/native/src-tauri/gen/apple/ExportOptions.plist
|
||||||
|
/native/src-tauri/gen/apple/**/*.xcuserstate
|
||||||
|
/native/src-tauri/gen/apple/**/xcuserdata/
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
# Working Agreements
|
||||||
|
|
||||||
|
## Missing programs and SDKs
|
||||||
|
|
||||||
|
If implementation or verification requires a missing program, SDK, runtime, toolchain, or platform component, do not download or install a temporary copy in `/tmp` or another temporary directory. Do not use an ad hoc toolchain as a workaround. Tell the user which prerequisites are missing, ask them to install them permanently and confirm when installation is complete, and wait for that confirmation before continuing dependent work. This rule applies to every implementation task in this project.
|
||||||
|
|
||||||
<laravel-boost-guidelines>
|
<laravel-boost-guidelines>
|
||||||
=== foundation rules ===
|
=== foundation rules ===
|
||||||
|
|
||||||
|
|||||||
@@ -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,9 +7,18 @@
|
|||||||
"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",
|
||||||
"smoke:macos": "cargo test --manifest-path src-tauri/Cargo.toml --locked --features smoke-test,custom-protocol --test macos_smoke"
|
"dev:android": "tauri android dev",
|
||||||
|
"build:android": "tauri android build --debug --apk --target aarch64",
|
||||||
|
"smoke:macos": "cargo test --manifest-path src-tauri/Cargo.toml --locked --features smoke-test,custom-protocol --test macos_smoke",
|
||||||
|
"dev:ios": "tauri ios dev",
|
||||||
|
"build:ios:simulator": "rm -rf src-tauri/gen/apple/build/arm64-sim/KanbanGit.app && tauri ios build --debug --target aarch64-sim --no-sign --ci",
|
||||||
|
"build:ios:device": "tauri ios build --debug --target aarch64 --export-method debugging --archive-only",
|
||||||
|
"open:ios": "tauri ios dev --open --no-dev-server",
|
||||||
|
"prepare:ios": "xcodegen generate --spec src-tauri/gen/apple/project.yml",
|
||||||
|
"test:ios": "xcodebuild -project src-tauri/gen/apple/kanbangit-native.xcodeproj -scheme kanbangit-native_iOS -configuration debug -destination \"platform=iOS Simulator,id=${IOS_SIMULATOR_ID:?Set IOS_SIMULATOR_ID to an installed simulator UUID}\" -parallel-testing-enabled NO CODE_SIGNING_ALLOWED=NO test"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "2.11.4"
|
"@tauri-apps/cli": "2.11.4"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# EditorConfig is awesome: https://EditorConfig.org
|
||||||
|
|
||||||
|
# top-most EditorConfig file
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
end_of_line = lf
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
insert_final_newline = false
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
*.iml
|
||||||
|
.gradle
|
||||||
|
/local.properties
|
||||||
|
/.idea/caches
|
||||||
|
/.idea/libraries
|
||||||
|
/.idea/modules.xml
|
||||||
|
/.idea/workspace.xml
|
||||||
|
/.idea/navEditor.xml
|
||||||
|
/.idea/assetWizardSettings.xml
|
||||||
|
.DS_Store
|
||||||
|
build
|
||||||
|
/captures
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx
|
||||||
|
local.properties
|
||||||
|
key.properties
|
||||||
|
keystore.properties
|
||||||
|
|
||||||
|
/.tauri
|
||||||
|
/tauri.settings.gradle
|
||||||
|
/app/tauri.build.gradle.kts
|
||||||
|
/app/src/main/jniLibs
|
||||||
|
/app/src/main/java/de/kanbangit/desktop/generated
|
||||||
|
|
||||||
|
.kotlin
|
||||||
|
/app/tauri.properties
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/src/main/**/generated
|
||||||
|
/src/main/jniLibs/**/*.so
|
||||||
|
/src/main/assets/tauri.conf.json
|
||||||
|
/tauri.build.gradle.kts
|
||||||
|
/proguard-tauri.pro
|
||||||
|
/tauri.properties
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
id("rust")
|
||||||
|
}
|
||||||
|
|
||||||
|
val tauriProperties = Properties().apply {
|
||||||
|
val propFile = file("tauri.properties")
|
||||||
|
if (propFile.exists()) {
|
||||||
|
propFile.inputStream().use { load(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileSdk = 36
|
||||||
|
buildToolsVersion = "36.0.0"
|
||||||
|
namespace = "de.kanbangit.desktop"
|
||||||
|
defaultConfig {
|
||||||
|
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||||
|
applicationId = "de.kanbangit.desktop"
|
||||||
|
minSdk = 24
|
||||||
|
targetSdk = 36
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
// Tauri exits the process when its last activity is destroyed; report results first.
|
||||||
|
testInstrumentationRunnerArguments["waitForActivitiesToComplete"] = "false"
|
||||||
|
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
|
||||||
|
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
getByName("debug") {
|
||||||
|
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||||
|
isDebuggable = true
|
||||||
|
isJniDebuggable = true
|
||||||
|
isMinifyEnabled = false
|
||||||
|
packaging {
|
||||||
|
jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so")
|
||||||
|
jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so")
|
||||||
|
jniLibs.keepDebugSymbols.add("*/x86/*.so")
|
||||||
|
jniLibs.keepDebugSymbols.add("*/x86_64/*.so")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getByName("release") {
|
||||||
|
isMinifyEnabled = true
|
||||||
|
proguardFiles(
|
||||||
|
*fileTree(".") { include("**/*.pro") }
|
||||||
|
.plus(getDefaultProguardFile("proguard-android-optimize.txt"))
|
||||||
|
.toList().toTypedArray()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "1.8"
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rust {
|
||||||
|
rootDirRel = "../../../"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("androidx.webkit:webkit:1.14.0")
|
||||||
|
implementation("androidx.appcompat:appcompat:1.7.1")
|
||||||
|
implementation("androidx.activity:activity-ktx:1.10.1")
|
||||||
|
implementation("com.google.android.material:material:1.12.0")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-process:2.10.0")
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
androidTestImplementation("androidx.test.ext:junit:1.1.4")
|
||||||
|
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
apply(from = "tauri.build.gradle.kts")
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Uncomment this to preserve the line number information for
|
||||||
|
# debugging stack traces.
|
||||||
|
#-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# If you keep the line number information, uncomment this to
|
||||||
|
# hide the original source file name.
|
||||||
|
#-renamesourcefileattribute SourceFile
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package de.kanbangit.desktop
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.view.InputDevice
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import android.view.View
|
||||||
|
import android.webkit.CookieManager
|
||||||
|
import android.webkit.WebView
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.test.core.app.ActivityScenario
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class PlatformTest {
|
||||||
|
private val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||||
|
|
||||||
|
private fun evaluate(view: WebView, script: String): String {
|
||||||
|
val result = AtomicReference<String>()
|
||||||
|
val latch = CountDownLatch(1)
|
||||||
|
instrumentation.runOnMainSync { view.evaluateJavascript(script) { result.set(it); latch.countDown() } }
|
||||||
|
assertTrue("JavaScript callback timed out", latch.await(10, TimeUnit.SECONDS))
|
||||||
|
return result.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun waitFor(condition: () -> Boolean) {
|
||||||
|
val end = System.currentTimeMillis() + 25000
|
||||||
|
while (System.currentTimeMillis() < end) {
|
||||||
|
if (condition()) return
|
||||||
|
Thread.sleep(100)
|
||||||
|
}
|
||||||
|
fail("Platform condition timed out")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tapCenter(view: View) {
|
||||||
|
val position = IntArray(2)
|
||||||
|
instrumentation.runOnMainSync {
|
||||||
|
view.getLocationOnScreen(position)
|
||||||
|
position[0] += view.width / 2
|
||||||
|
position[1] += view.height / 2
|
||||||
|
}
|
||||||
|
val downTime = SystemClock.uptimeMillis()
|
||||||
|
for (action in listOf(MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP)) {
|
||||||
|
val event = MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), action, position[0].toFloat(), position[1].toFloat(), 0)
|
||||||
|
event.source = InputDevice.SOURCE_TOUCHSCREEN
|
||||||
|
try {
|
||||||
|
instrumentation.sendPointerSync(event)
|
||||||
|
} finally {
|
||||||
|
event.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
instrumentation.waitForIdleSync()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun workspacePreservesFormsAndIsolatesServers() {
|
||||||
|
instrumentation.targetContext.getSharedPreferences("workspace", Context.MODE_PRIVATE).edit().clear().commit()
|
||||||
|
val scenario = ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
Thread.sleep(1500)
|
||||||
|
scenario.onActivity { it.openWorkspace("https://kanbangit.de/") }
|
||||||
|
val remote = AtomicReference<WebView>()
|
||||||
|
waitFor {
|
||||||
|
var visible = false
|
||||||
|
scenario.onActivity { remote.set(it.workspace); visible = it.workspace?.visibility == View.VISIBLE }
|
||||||
|
visible && remote.get() != null && evaluate(remote.get(), "document.readyState === 'complete' && document.querySelector('#app')?.innerText.trim().length > 100") == "true"
|
||||||
|
}
|
||||||
|
val view = remote.get()
|
||||||
|
assertEquals("true", evaluate(view, "typeof window.nativeLauncher === 'undefined' && typeof window.__TAURI_INTERNALS__ === 'undefined'"))
|
||||||
|
assertEquals("true", evaluate(view, """
|
||||||
|
(() => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.id = 'kan53-input';
|
||||||
|
input.value = 'retained form';
|
||||||
|
document.body.append(input);
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;z-index:2147483647';
|
||||||
|
button.textContent = 'Navigate';
|
||||||
|
button.onclick = () => {
|
||||||
|
history.pushState({}, '', '/kan53-history');
|
||||||
|
button.remove();
|
||||||
|
};
|
||||||
|
document.body.append(button);
|
||||||
|
return true;
|
||||||
|
})()
|
||||||
|
""".trimIndent()))
|
||||||
|
tapCenter(view)
|
||||||
|
waitFor { evaluate(view, "location.pathname === '/kan53-history'") == "true" }
|
||||||
|
waitFor {
|
||||||
|
var canGoBack = false
|
||||||
|
scenario.onActivity { canGoBack = view.canGoBack() }
|
||||||
|
canGoBack
|
||||||
|
}
|
||||||
|
scenario.moveToState(Lifecycle.State.CREATED)
|
||||||
|
scenario.moveToState(Lifecycle.State.RESUMED)
|
||||||
|
assertEquals("\"retained form\"", evaluate(view, "document.querySelector('#kan53-input').value"))
|
||||||
|
scenario.onActivity { it.onBackPressedDispatcher.onBackPressed() }
|
||||||
|
waitFor { evaluate(view, "location.pathname !== '/kan53-history'") == "true" }
|
||||||
|
assertEquals("\"retained form\"", evaluate(view, "document.querySelector('#kan53-input').value"))
|
||||||
|
evaluate(view, "document.cookie = 'kan53_fixture=secret; Path=/; Secure; SameSite=Lax'; localStorage.setItem('kan53_fixture', 'secret')")
|
||||||
|
scenario.onActivity { it.openWorkspace("https://127.0.0.1:9/") }
|
||||||
|
waitFor {
|
||||||
|
val cookie = AtomicReference<String>()
|
||||||
|
instrumentation.runOnMainSync { cookie.set(CookieManager.getInstance().getCookie("https://kanbangit.de/")) }
|
||||||
|
cookie.get()?.contains("kan53_fixture") != true
|
||||||
|
}
|
||||||
|
scenario.onActivity { assertNotSame(view, it.workspace) }
|
||||||
|
// Let instrumentation finish the process after reporting, rather than exiting via Tauri.
|
||||||
|
scenario.moveToState(Lifecycle.State.CREATED)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:icon="@drawable/kanbangit"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:theme="@style/Theme.kanbangit_native"
|
||||||
|
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
||||||
|
<activity
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:label="@string/main_activity_title"
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:windowSoftInputMode="adjustResize"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
package de.kanbangit.desktop
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.net.Uri
|
||||||
|
import android.net.http.SslError
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.os.Message
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.webkit.CookieManager
|
||||||
|
import android.webkit.SslErrorHandler
|
||||||
|
import android.webkit.ValueCallback
|
||||||
|
import android.webkit.WebChromeClient
|
||||||
|
import android.webkit.WebResourceError
|
||||||
|
import android.webkit.WebResourceRequest
|
||||||
|
import android.webkit.WebResourceResponse
|
||||||
|
import android.webkit.WebSettings
|
||||||
|
import android.webkit.WebStorage
|
||||||
|
import android.webkit.WebView
|
||||||
|
import android.webkit.WebViewClient
|
||||||
|
import android.widget.Button
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.OnBackPressedCallback
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.webkit.WebViewCompat
|
||||||
|
import androidx.webkit.WebViewFeature
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
class MainActivity : TauriActivity() {
|
||||||
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
private val preferences by lazy { getSharedPreferences("workspace", MODE_PRIVATE) }
|
||||||
|
private lateinit var launcher: WebView
|
||||||
|
private lateinit var root: LinearLayout
|
||||||
|
private lateinit var content: FrameLayout
|
||||||
|
private lateinit var settings: Button
|
||||||
|
internal var workspace: WebView? = null
|
||||||
|
private set
|
||||||
|
private var server = NavigationPolicy.DEFAULT_SERVER
|
||||||
|
private var phase = "idle"
|
||||||
|
private var ready = false
|
||||||
|
private var revision = 0
|
||||||
|
private var launcherVisible = true
|
||||||
|
private var keyboardVisible = 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?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||||
|
override fun handleOnBackPressed() {
|
||||||
|
when (NavigationPolicy.backAction(keyboardVisible, launcherVisible, ready, workspace?.canGoBack() == true)) {
|
||||||
|
"keyboard" -> WindowCompat.getInsetsController(window, window.decorView).hide(WindowInsetsCompat.Type.ime())
|
||||||
|
"resume" -> showWorkspace()
|
||||||
|
"history" -> workspace?.goBack()
|
||||||
|
"settings" -> showLauncher()
|
||||||
|
else -> moveTaskToBack(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onWebViewCreate(webView: WebView) {
|
||||||
|
launcher = webView
|
||||||
|
if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
|
||||||
|
WebViewCompat.addWebMessageListener(launcher, "nativeLauncher", setOf(NavigationPolicy.LAUNCHER_ORIGIN)) {
|
||||||
|
_, message, origin, mainFrame, _ ->
|
||||||
|
if (mainFrame && origin.toString().trimEnd('/') == NavigationPolicy.LAUNCHER_ORIGIN) {
|
||||||
|
launcher.post { handleAction(message.data.orEmpty()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
launcher.post { attachShell() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun attachShell() {
|
||||||
|
if (::root.isInitialized) return
|
||||||
|
(launcher.parent as? ViewGroup)?.removeView(launcher)
|
||||||
|
root = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
|
||||||
|
settings = Button(this).apply {
|
||||||
|
text = getString(R.string.server_settings)
|
||||||
|
isAllCaps = false
|
||||||
|
visibility = View.GONE
|
||||||
|
setOnClickListener { showLauncher() }
|
||||||
|
}
|
||||||
|
root.addView(settings, LinearLayout.LayoutParams(-1, -2))
|
||||||
|
content = FrameLayout(this)
|
||||||
|
content.addView(launcher, FrameLayout.LayoutParams(-1, -1))
|
||||||
|
root.addView(content, LinearLayout.LayoutParams(-1, 0, 1f))
|
||||||
|
setContentView(root)
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets ->
|
||||||
|
val safe = insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
|
||||||
|
val keyboard = insets.getInsets(WindowInsetsCompat.Type.ime())
|
||||||
|
keyboardVisible = insets.isVisible(WindowInsetsCompat.Type.ime())
|
||||||
|
view.setPadding(safe.left, safe.top, safe.right, maxOf(safe.bottom, keyboard.bottom))
|
||||||
|
WindowInsetsCompat.CONSUMED
|
||||||
|
}
|
||||||
|
ViewCompat.requestApplyInsets(root)
|
||||||
|
if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
|
||||||
|
content.removeAllViews()
|
||||||
|
content.addView(TextView(this).apply { text = getString(R.string.update_webview) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleAction(value: String) {
|
||||||
|
attachShell()
|
||||||
|
val url = Uri.parse(value)
|
||||||
|
if (url.scheme != "https" || url.host != "tauri.localhost" || url.port != -1 || url.userInfo != null) return
|
||||||
|
when (url.path) {
|
||||||
|
"/action/ready" -> {
|
||||||
|
if (initialized) return
|
||||||
|
initialized = true
|
||||||
|
val saved = preferences.getString("server", null)
|
||||||
|
server = saved?.let(NavigationPolicy::installation) ?: NavigationPolicy.DEFAULT_SERVER
|
||||||
|
updateLauncher()
|
||||||
|
if (saved != null) openWorkspace(server)
|
||||||
|
}
|
||||||
|
"/action/open" -> openWorkspace(url.getQueryParameter("server").orEmpty())
|
||||||
|
"/action/resume" -> if (ready) showWorkspace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun openWorkspace(input: String) {
|
||||||
|
val address = NavigationPolicy.installation(input)
|
||||||
|
if (address == null) {
|
||||||
|
phase = "invalidServer"
|
||||||
|
showLauncher()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (phase == "connecting") return
|
||||||
|
if (address == server && ready) {
|
||||||
|
if (phase == "connectionError") {
|
||||||
|
phase = "connecting"
|
||||||
|
workspace?.let { startTimeout(it); it.reload() }
|
||||||
|
updateLauncher()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showWorkspace()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cancelFileSelection()
|
||||||
|
val changed = address != preferences.getString("server", null)
|
||||||
|
val clearSession = changed || preferences.getBoolean("clearing", false)
|
||||||
|
if (!preferences.edit().putString("server", address).putBoolean("clearing", clearSession).commit()) {
|
||||||
|
phase = "storageError"
|
||||||
|
showLauncher()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
server = address
|
||||||
|
ready = false
|
||||||
|
phase = "connecting"
|
||||||
|
revision++
|
||||||
|
workspace?.let { old ->
|
||||||
|
content.removeView(old)
|
||||||
|
old.stopLoading()
|
||||||
|
if (clearSession) old.clearCache(true)
|
||||||
|
old.destroy()
|
||||||
|
}
|
||||||
|
workspace = null
|
||||||
|
updateLauncher()
|
||||||
|
if (clearSession) {
|
||||||
|
WebStorage.getInstance().deleteAllData()
|
||||||
|
CookieManager.getInstance().removeAllCookies {
|
||||||
|
CookieManager.getInstance().flush()
|
||||||
|
if (!preferences.edit().putBoolean("clearing", false).commit()) {
|
||||||
|
phase = "storageError"
|
||||||
|
showLauncher()
|
||||||
|
} else if (!isDestroyed) {
|
||||||
|
createWorkspace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
createWorkspace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
private fun createWorkspace() {
|
||||||
|
val remote = WebView(this)
|
||||||
|
workspace = remote
|
||||||
|
remote.settings.apply {
|
||||||
|
javaScriptEnabled = true
|
||||||
|
domStorageEnabled = true
|
||||||
|
allowFileAccess = false
|
||||||
|
allowContentAccess = true
|
||||||
|
mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
|
||||||
|
javaScriptCanOpenWindowsAutomatically = false
|
||||||
|
setSupportMultipleWindows(true)
|
||||||
|
}
|
||||||
|
CookieManager.getInstance().setAcceptThirdPartyCookies(remote, false)
|
||||||
|
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() {
|
||||||
|
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
|
||||||
|
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?) {
|
||||||
|
if (workspace === view) cancelFileSelection()
|
||||||
|
if (!NavigationPolicy.allows(server, url)) {
|
||||||
|
view.stopLoading()
|
||||||
|
fail(view)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
phase = "connecting"
|
||||||
|
startTimeout(view)
|
||||||
|
updateLauncher()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPageFinished(view: WebView, url: String) {
|
||||||
|
if (workspace !== view || phase != "connecting" || !NavigationPolicy.allows(server, url)) return
|
||||||
|
revision++
|
||||||
|
phase = "connected"
|
||||||
|
val firstLoad = !ready
|
||||||
|
ready = true
|
||||||
|
CookieManager.getInstance().flush()
|
||||||
|
updateLauncher()
|
||||||
|
if (firstLoad) showWorkspace()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) {
|
||||||
|
if (request.isForMainFrame) fail(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceivedHttpError(view: WebView, request: WebResourceRequest, response: WebResourceResponse) {
|
||||||
|
if (request.isForMainFrame && response.statusCode >= 500) fail(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceivedSslError(view: WebView, sslHandler: SslErrorHandler, error: SslError) {
|
||||||
|
sslHandler.cancel()
|
||||||
|
fail(view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remote.visibility = View.GONE
|
||||||
|
content.addView(remote, 0, FrameLayout.LayoutParams(-1, -1))
|
||||||
|
startTimeout(remote)
|
||||||
|
remote.loadUrl(server)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startTimeout(view: WebView) {
|
||||||
|
val current = ++revision
|
||||||
|
handler.postDelayed({
|
||||||
|
if (current == revision && phase == "connecting" && workspace === view) {
|
||||||
|
view.stopLoading()
|
||||||
|
fail(view)
|
||||||
|
}
|
||||||
|
}, 15000)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fail(view: WebView) {
|
||||||
|
if (workspace !== view) return
|
||||||
|
revision++
|
||||||
|
phase = "connectionError"
|
||||||
|
showLauncher()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateLauncher() {
|
||||||
|
val query = Uri.Builder().appendQueryParameter("server", server)
|
||||||
|
.appendQueryParameter("phase", phase).appendQueryParameter("ready", ready.toString())
|
||||||
|
.appendQueryParameter("platform", "android").build().encodedQuery.orEmpty()
|
||||||
|
launcher.evaluateJavascript("window.updateLauncher?.(${JSONObject.quote(query)})", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showLauncher() {
|
||||||
|
updateLauncher()
|
||||||
|
launcherVisible = true
|
||||||
|
launcher.visibility = View.VISIBLE
|
||||||
|
workspace?.visibility = View.GONE
|
||||||
|
settings.visibility = View.GONE
|
||||||
|
launcher.requestFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showWorkspace() {
|
||||||
|
launcherVisible = false
|
||||||
|
launcher.visibility = View.GONE
|
||||||
|
workspace?.visibility = View.VISIBLE
|
||||||
|
settings.visibility = View.VISIBLE
|
||||||
|
workspace?.requestFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
workspace?.onPause()
|
||||||
|
CookieManager.getInstance().flush()
|
||||||
|
super.onPause()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
workspace?.onResume()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||||
|
super.onConfigurationChanged(newConfig)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
if (::root.isInitialized) ViewCompat.requestApplyInsets(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
handler.removeCallbacksAndMessages(null)
|
||||||
|
cancelFileSelection()
|
||||||
|
popups.forEach { it.destroy() }
|
||||||
|
popups.clear()
|
||||||
|
workspace?.destroy()
|
||||||
|
workspace = null
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package de.kanbangit.desktop
|
||||||
|
|
||||||
|
import java.net.URI
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
object NavigationPolicy {
|
||||||
|
const val DEFAULT_SERVER = "https://kanbangit.de/"
|
||||||
|
const val LAUNCHER_ORIGIN = "https://tauri.localhost"
|
||||||
|
|
||||||
|
fun installation(input: String): String? = runCatching {
|
||||||
|
val trimmed = input.trim()
|
||||||
|
require(trimmed.none { it.isISOControl() || it == '\\' })
|
||||||
|
val uri = URI(trimmed)
|
||||||
|
require(uri.scheme.equals("https", ignoreCase = true))
|
||||||
|
require(!uri.host.isNullOrBlank() && uri.rawUserInfo == null)
|
||||||
|
require(uri.rawQuery == null && uri.rawFragment == null)
|
||||||
|
require(uri.port == -1 || uri.port in 1..65535)
|
||||||
|
require(!uri.host.equals("tauri.localhost", ignoreCase = true))
|
||||||
|
val port = if (uri.port == -1 || uri.port == 443) "" else ":${uri.port}"
|
||||||
|
val path = uri.rawPath.orEmpty().let { if (it.endsWith('/')) it else "$it/" }
|
||||||
|
"https://${uri.host.lowercase(Locale.ROOT)}$port$path"
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
fun allows(server: String, destination: String): Boolean = runCatching {
|
||||||
|
val base = URI(server)
|
||||||
|
val target = URI(destination)
|
||||||
|
target.scheme.equals("https", ignoreCase = true) &&
|
||||||
|
target.host.equals(base.host, ignoreCase = true) &&
|
||||||
|
(if (target.port == -1) 443 else target.port) ==
|
||||||
|
(if (base.port == -1) 443 else base.port) &&
|
||||||
|
target.rawUserInfo == null && !destination.contains('\\')
|
||||||
|
}.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 {
|
||||||
|
keyboard -> "keyboard"
|
||||||
|
launcher && ready -> "resume"
|
||||||
|
!launcher && history -> "history"
|
||||||
|
!launcher -> "settings"
|
||||||
|
else -> "background"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="48dp" android:height="48dp"
|
||||||
|
android:viewportWidth="64" android:viewportHeight="64">
|
||||||
|
<path android:fillColor="#22c55e" android:pathData="M12,0 H52 Q64,0 64,12 V52 Q64,64 52,64 H12 Q0,64 0,52 V12 Q0,0 12,0 Z" />
|
||||||
|
<path android:fillColor="@android:color/transparent" android:strokeColor="#004b1e"
|
||||||
|
android:strokeWidth="3" android:strokeLineCap="round" android:strokeLineJoin="round"
|
||||||
|
android:pathData="M19,16 H45 Q48,16 48,19 V45 Q48,48 45,48 H19 Q16,48 16,45 V19 Q16,16 19,16 Z M27,16 V48 M37,16 V48" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<!-- Base application theme. -->
|
||||||
|
<style name="Theme.kanbangit_native" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||||
|
<!-- Customize your theme here. -->
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">"KanbanGit"</string>
|
||||||
|
<string name="main_activity_title">"KanbanGit"</string>
|
||||||
|
<string name="server_settings">Server settings</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>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<!-- Base application theme. -->
|
||||||
|
<style name="Theme.kanbangit_native" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||||
|
<!-- Customize your theme here. -->
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<data-extraction-rules>
|
||||||
|
<cloud-backup>
|
||||||
|
<exclude domain="root" path="." />
|
||||||
|
<exclude domain="sharedpref" path="." />
|
||||||
|
<exclude domain="database" path="." />
|
||||||
|
<exclude domain="file" path="." />
|
||||||
|
<exclude domain="external" path="." />
|
||||||
|
</cloud-backup>
|
||||||
|
<device-transfer>
|
||||||
|
<exclude domain="root" path="." />
|
||||||
|
<exclude domain="sharedpref" path="." />
|
||||||
|
<exclude domain="database" path="." />
|
||||||
|
<exclude domain="file" path="." />
|
||||||
|
<exclude domain="external" path="." />
|
||||||
|
</device-transfer>
|
||||||
|
</data-extraction-rules>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<external-path name="my_images" path="." />
|
||||||
|
<cache-path name="my_cache_images" path="." />
|
||||||
|
</paths>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package de.kanbangit.desktop
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class NavigationPolicyTest {
|
||||||
|
@Test fun normalizesAddressesAndRejectsCredentialsAndUnsafeSchemes() {
|
||||||
|
assertEquals("https://example.com/kanban/", NavigationPolicy.installation(" HTTPS://Example.com:443/kanban "))
|
||||||
|
assertEquals("https://[::1]:8443/", NavigationPolicy.installation("https://[::1]:8443"))
|
||||||
|
for (value in listOf("http://example.com", "https:example.com", "https://user:pass@example.com", "https://example.com/?token=x", "https://example.com/#x", "https://exa\nmple.com", "https://example.com\\other", "https://example.com:99999", "https://tauri.localhost/", "file:///etc/passwd", "")) {
|
||||||
|
assertNull(value, NavigationPolicy.installation(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun allowsLoginTwoFactorAndHistoryOnlyOnTheSelectedOrigin() {
|
||||||
|
val server = "https://example.com:8443/app/"
|
||||||
|
for (path in listOf("/login", "/two-factor-challenge", "/projects/1?ticket=53#details")) {
|
||||||
|
assertTrue(NavigationPolicy.allows(server, "https://example.com:8443$path"))
|
||||||
|
}
|
||||||
|
for (value in listOf("https://example.com/", "http://example.com:8443/", "https://other.example.com:8443/", "https://user@example.com:8443/", "https://tauri.localhost/action/open", "javascript:alert(1)", "intent://example.com", "file:///etc/passwd")) {
|
||||||
|
assertFalse(value, NavigationPolicy.allows(server, value))
|
||||||
|
}
|
||||||
|
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() {
|
||||||
|
assertEquals("keyboard", NavigationPolicy.backAction(true, false, true, true))
|
||||||
|
assertEquals("resume", NavigationPolicy.backAction(false, true, true, true))
|
||||||
|
assertEquals("history", NavigationPolicy.backAction(false, false, true, true))
|
||||||
|
assertEquals("settings", NavigationPolicy.backAction(false, false, true, false))
|
||||||
|
assertEquals("background", NavigationPolicy.backAction(false, true, false, false))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
buildscript {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
classpath("com.android.tools.build:gradle:8.11.0")
|
||||||
|
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register("clean").configure {
|
||||||
|
delete("build")
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
plugins {
|
||||||
|
`kotlin-dsl`
|
||||||
|
}
|
||||||
|
|
||||||
|
gradlePlugin {
|
||||||
|
plugins {
|
||||||
|
create("pluginsForCoolKids") {
|
||||||
|
id = "rust"
|
||||||
|
implementationClass = "RustPlugin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
compileOnly(gradleApi())
|
||||||
|
implementation("com.android.tools.build:gradle:8.11.0")
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import java.io.File
|
||||||
|
import org.apache.tools.ant.taskdefs.condition.Os
|
||||||
|
import org.gradle.api.DefaultTask
|
||||||
|
import org.gradle.api.GradleException
|
||||||
|
import org.gradle.api.logging.LogLevel
|
||||||
|
import org.gradle.api.tasks.Input
|
||||||
|
import org.gradle.api.tasks.TaskAction
|
||||||
|
|
||||||
|
open class BuildTask : DefaultTask() {
|
||||||
|
@Input
|
||||||
|
var rootDirRel: String? = null
|
||||||
|
@Input
|
||||||
|
var target: String? = null
|
||||||
|
@Input
|
||||||
|
var release: Boolean? = null
|
||||||
|
|
||||||
|
@TaskAction
|
||||||
|
fun assemble() {
|
||||||
|
val executable = """pnpm""";
|
||||||
|
try {
|
||||||
|
runTauriCli(executable)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (Os.isFamily(Os.FAMILY_WINDOWS)) {
|
||||||
|
// Try different Windows-specific extensions
|
||||||
|
val fallbacks = listOf(
|
||||||
|
"$executable.exe",
|
||||||
|
"$executable.cmd",
|
||||||
|
"$executable.bat",
|
||||||
|
)
|
||||||
|
|
||||||
|
var lastException: Exception = e
|
||||||
|
for (fallback in fallbacks) {
|
||||||
|
try {
|
||||||
|
runTauriCli(fallback)
|
||||||
|
return
|
||||||
|
} catch (fallbackException: Exception) {
|
||||||
|
lastException = fallbackException
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastException
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun runTauriCli(executable: String) {
|
||||||
|
val rootDirRel = rootDirRel ?: throw GradleException("rootDirRel cannot be null")
|
||||||
|
val target = target ?: throw GradleException("target cannot be null")
|
||||||
|
val release = release ?: throw GradleException("release cannot be null")
|
||||||
|
val args = listOf("tauri", "android", "android-studio-script");
|
||||||
|
|
||||||
|
project.exec {
|
||||||
|
workingDir(File(project.projectDir, rootDirRel))
|
||||||
|
executable(executable)
|
||||||
|
args(args)
|
||||||
|
if (project.logger.isEnabled(LogLevel.DEBUG)) {
|
||||||
|
args("-vv")
|
||||||
|
} else if (project.logger.isEnabled(LogLevel.INFO)) {
|
||||||
|
args("-v")
|
||||||
|
}
|
||||||
|
if (release) {
|
||||||
|
args("--release")
|
||||||
|
}
|
||||||
|
args(listOf("--target", target))
|
||||||
|
}.assertNormalExitValue()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import com.android.build.api.dsl.ApplicationExtension
|
||||||
|
import org.gradle.api.DefaultTask
|
||||||
|
import org.gradle.api.Plugin
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.kotlin.dsl.configure
|
||||||
|
import org.gradle.kotlin.dsl.get
|
||||||
|
|
||||||
|
const val TASK_GROUP = "rust"
|
||||||
|
|
||||||
|
open class Config {
|
||||||
|
lateinit var rootDirRel: String
|
||||||
|
}
|
||||||
|
|
||||||
|
open class RustPlugin : Plugin<Project> {
|
||||||
|
private lateinit var config: Config
|
||||||
|
|
||||||
|
override fun apply(project: Project) = with(project) {
|
||||||
|
config = extensions.create("rust", Config::class.java)
|
||||||
|
|
||||||
|
val defaultAbiList = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64");
|
||||||
|
val abiList = (findProperty("abiList") as? String)?.split(',') ?: defaultAbiList
|
||||||
|
|
||||||
|
val defaultArchList = listOf("arm64", "arm", "x86", "x86_64");
|
||||||
|
val archList = (findProperty("archList") as? String)?.split(',') ?: defaultArchList
|
||||||
|
|
||||||
|
val targetsList = (findProperty("targetList") as? String)?.split(',') ?: listOf("aarch64", "armv7", "i686", "x86_64")
|
||||||
|
|
||||||
|
extensions.configure<ApplicationExtension> {
|
||||||
|
@Suppress("UnstableApiUsage")
|
||||||
|
flavorDimensions.add("abi")
|
||||||
|
productFlavors {
|
||||||
|
create("universal") {
|
||||||
|
dimension = "abi"
|
||||||
|
ndk {
|
||||||
|
abiFilters += abiList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defaultArchList.forEachIndexed { index, arch ->
|
||||||
|
create(arch) {
|
||||||
|
dimension = "abi"
|
||||||
|
ndk {
|
||||||
|
abiFilters.add(defaultAbiList[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEvaluate {
|
||||||
|
for (profile in listOf("debug", "release")) {
|
||||||
|
val profileCapitalized = profile.replaceFirstChar { it.uppercase() }
|
||||||
|
val buildTask = tasks.maybeCreate(
|
||||||
|
"rustBuildUniversal$profileCapitalized",
|
||||||
|
DefaultTask::class.java
|
||||||
|
).apply {
|
||||||
|
group = TASK_GROUP
|
||||||
|
description = "Build dynamic library in $profile mode for all targets"
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks["mergeUniversal${profileCapitalized}JniLibFolders"].dependsOn(buildTask)
|
||||||
|
|
||||||
|
for (targetPair in targetsList.withIndex()) {
|
||||||
|
val targetName = targetPair.value
|
||||||
|
val targetArch = archList[targetPair.index]
|
||||||
|
val targetArchCapitalized = targetArch.replaceFirstChar { it.uppercase() }
|
||||||
|
val targetBuildTask = project.tasks.maybeCreate(
|
||||||
|
"rustBuild$targetArchCapitalized$profileCapitalized",
|
||||||
|
BuildTask::class.java
|
||||||
|
).apply {
|
||||||
|
group = TASK_GROUP
|
||||||
|
description = "Build dynamic library in $profile mode for $targetArch"
|
||||||
|
rootDirRel = config.rootDirRel
|
||||||
|
target = targetName
|
||||||
|
release = profile == "release"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTask.dependsOn(targetBuildTask)
|
||||||
|
tasks["merge$targetArchCapitalized${profileCapitalized}JniLibFolders"].dependsOn(
|
||||||
|
targetBuildTask
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Project-wide Gradle settings.
|
||||||
|
# IDE (e.g. Android Studio) users:
|
||||||
|
# Gradle settings configured through the IDE *will override*
|
||||||
|
# any settings specified in this file.
|
||||||
|
# For more details on how to configure your build environment visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||||
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
|
# The setting is particularly useful for tweaking memory settings.
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
|
# This option should only be used with decoupled projects. More details, visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||||
|
# org.gradle.parallel=true
|
||||||
|
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||||
|
# Android operating system, and which are packaged with your app"s APK
|
||||||
|
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||||
|
android.useAndroidX=true
|
||||||
|
# Kotlin code style for this project: "official" or "obsolete":
|
||||||
|
kotlin.code.style=official
|
||||||
|
# Enables namespacing of each library's R class so that its R class includes only the
|
||||||
|
# resources declared in the library itself and none from the library's dependencies,
|
||||||
|
# thereby reducing the size of the R class for that library
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
|
android.nonFinalResIds=false
|
||||||
|
android.builder.sdkDownload=false
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#Tue May 10 19:22:52 CST 2022
|
||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright 2015 the original author or authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
##
|
||||||
|
## Gradle start up script for UN*X
|
||||||
|
##
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
PRG="$0"
|
||||||
|
# Need this for relative symlinks.
|
||||||
|
while [ -h "$PRG" ] ; do
|
||||||
|
ls=`ls -ld "$PRG"`
|
||||||
|
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||||
|
if expr "$link" : '/.*' > /dev/null; then
|
||||||
|
PRG="$link"
|
||||||
|
else
|
||||||
|
PRG=`dirname "$PRG"`"/$link"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
SAVED="`pwd`"
|
||||||
|
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||||
|
APP_HOME="`pwd -P`"
|
||||||
|
cd "$SAVED" >/dev/null
|
||||||
|
|
||||||
|
APP_NAME="Gradle"
|
||||||
|
APP_BASE_NAME=`basename "$0"`
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD="maximum"
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "`uname`" in
|
||||||
|
CYGWIN* )
|
||||||
|
cygwin=true
|
||||||
|
;;
|
||||||
|
Darwin* )
|
||||||
|
darwin=true
|
||||||
|
;;
|
||||||
|
MINGW* )
|
||||||
|
msys=true
|
||||||
|
;;
|
||||||
|
NONSTOP* )
|
||||||
|
nonstop=true
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||||
|
else
|
||||||
|
JAVACMD="$JAVA_HOME/bin/java"
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD="java"
|
||||||
|
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||||
|
MAX_FD_LIMIT=`ulimit -H -n`
|
||||||
|
if [ $? -eq 0 ] ; then
|
||||||
|
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||||
|
MAX_FD="$MAX_FD_LIMIT"
|
||||||
|
fi
|
||||||
|
ulimit -n $MAX_FD
|
||||||
|
if [ $? -ne 0 ] ; then
|
||||||
|
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# For Darwin, add options to specify how the application appears in the dock
|
||||||
|
if $darwin; then
|
||||||
|
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
||||||
|
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||||
|
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||||
|
|
||||||
|
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||||
|
|
||||||
|
# We build the pattern for arguments to be converted via cygpath
|
||||||
|
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||||
|
SEP=""
|
||||||
|
for dir in $ROOTDIRSRAW ; do
|
||||||
|
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||||
|
SEP="|"
|
||||||
|
done
|
||||||
|
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||||
|
# Add a user-defined pattern to the cygpath arguments
|
||||||
|
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||||
|
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||||
|
fi
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
i=0
|
||||||
|
for arg in "$@" ; do
|
||||||
|
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||||
|
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||||
|
|
||||||
|
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||||
|
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||||
|
else
|
||||||
|
eval `echo args$i`="\"$arg\""
|
||||||
|
fi
|
||||||
|
i=`expr $i + 1`
|
||||||
|
done
|
||||||
|
case $i in
|
||||||
|
0) set -- ;;
|
||||||
|
1) set -- "$args0" ;;
|
||||||
|
2) set -- "$args0" "$args1" ;;
|
||||||
|
3) set -- "$args0" "$args1" "$args2" ;;
|
||||||
|
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||||
|
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||||
|
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||||
|
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||||
|
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||||
|
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Escape application args
|
||||||
|
save () {
|
||||||
|
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||||
|
echo " "
|
||||||
|
}
|
||||||
|
APP_ARGS=`save "$@"`
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||||
|
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%" == "" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%" == "" set DIRNAME=.
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if "%ERRORLEVEL%" == "0" goto execute
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||||
|
exit /b 1
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
include ':app'
|
||||||
|
|
||||||
|
apply from: 'tauri.settings.gradle'
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
xcuserdata/
|
||||||
|
build/
|
||||||
|
Externals/
|
||||||
|
After Width: | Height: | Size: 422 B |
|
After Width: | Height: | Size: 548 B |
|
After Width: | Height: | Size: 548 B |
|
After Width: | Height: | Size: 800 B |
|
After Width: | Height: | Size: 415 B |
|
After Width: | Height: | Size: 845 B |
|
After Width: | Height: | Size: 845 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 548 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 955 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,116 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"size" : "20x20",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "AppIcon-20x20@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "20x20",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "AppIcon-20x20@3x.png",
|
||||||
|
"scale" : "3x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "29x29",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "AppIcon-29x29@2x-1.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "29x29",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "AppIcon-29x29@3x.png",
|
||||||
|
"scale" : "3x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "40x40",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "AppIcon-40x40@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "40x40",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "AppIcon-40x40@3x.png",
|
||||||
|
"scale" : "3x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "60x60",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "AppIcon-60x60@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "60x60",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "AppIcon-60x60@3x.png",
|
||||||
|
"scale" : "3x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "20x20",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "AppIcon-20x20@1x.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "20x20",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "AppIcon-20x20@2x-1.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "29x29",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "AppIcon-29x29@1x.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "29x29",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "AppIcon-29x29@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "40x40",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "AppIcon-40x40@1x.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "40x40",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "AppIcon-40x40@2x-1.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "76x76",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "AppIcon-76x76@1x.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "76x76",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "AppIcon-76x76@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "83.5x83.5",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "AppIcon-83.5x83.5@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "1024x1024",
|
||||||
|
"idiom" : "ios-marketing",
|
||||||
|
"filename" : "AppIcon-512@2x.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"version" : 1,
|
||||||
|
"author" : "xcode"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"version" : 1,
|
||||||
|
"author" : "xcode"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17150" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="Y6W-OH-hqX">
|
||||||
|
<dependencies>
|
||||||
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17122"/>
|
||||||
|
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||||
|
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||||
|
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||||
|
</dependencies>
|
||||||
|
<scenes>
|
||||||
|
<!--View Controller-->
|
||||||
|
<scene sceneID="s0d-6b-0kx">
|
||||||
|
<objects>
|
||||||
|
<viewController id="Y6W-OH-hqX" sceneMemberID="viewController">
|
||||||
|
<view key="view" contentMode="scaleToFill" id="5EZ-qb-Rvc">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
|
||||||
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
|
<viewLayoutGuide key="safeArea" id="vDu-zF-Fre"/>
|
||||||
|
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||||
|
</view>
|
||||||
|
</viewController>
|
||||||
|
<placeholder placeholderIdentifier="IBFirstResponder" id="Ief-a0-LHa" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||||
|
</objects>
|
||||||
|
</scene>
|
||||||
|
</scenes>
|
||||||
|
<resources>
|
||||||
|
<systemColor name="systemBackgroundColor">
|
||||||
|
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
|
</systemColor>
|
||||||
|
</resources>
|
||||||
|
</document>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
platform :ios, '17.0'
|
||||||
|
|
||||||
|
target 'kanbangit-native_iOS' do
|
||||||
|
# Pods for kanbangit-native_iOS
|
||||||
|
end
|
||||||
|
|
||||||
|
# Inherit the deployment target from the Podfile.
|
||||||
|
post_install do |installer|
|
||||||
|
installer.pods_project.targets.each do |target|
|
||||||
|
target.build_configurations.each do |config|
|
||||||
|
config.build_settings.delete 'IPHONEOS_DEPLOYMENT_TARGET'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct WorkspaceServer: Codable, Equatable {
|
||||||
|
let url: URL
|
||||||
|
let profile: UUID
|
||||||
|
|
||||||
|
static func normalize(_ input: String) -> URL? {
|
||||||
|
let input = input.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard input.lowercased().hasPrefix("https://"),
|
||||||
|
!input.contains("\\"),
|
||||||
|
input.rangeOfCharacter(from: .controlCharacters) == nil,
|
||||||
|
var components = URLComponents(string: input),
|
||||||
|
components.scheme?.lowercased() == "https",
|
||||||
|
let host = components.host, !host.isEmpty,
|
||||||
|
components.user == nil, components.password == nil,
|
||||||
|
components.query == nil, components.fragment == nil,
|
||||||
|
components.port.map({ (1...65535).contains($0) }) ?? true else { return nil }
|
||||||
|
components.scheme = "https"
|
||||||
|
components.host = host.lowercased()
|
||||||
|
if components.port == 443 { components.port = nil }
|
||||||
|
if !components.path.hasSuffix("/") { components.path += "/" }
|
||||||
|
return components.url
|
||||||
|
}
|
||||||
|
|
||||||
|
func allows(_ destination: URL) -> Bool {
|
||||||
|
destination.scheme?.lowercased() == "https"
|
||||||
|
&& destination.host?.lowercased() == url.host?.lowercased()
|
||||||
|
&& (destination.port ?? 443) == (url.port ?? 443)
|
||||||
|
&& 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 {
|
||||||
|
current?.url == url ? current! : WorkspaceServer(url: url, profile: UUID())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WorkspaceConnection {
|
||||||
|
private(set) var revision = 0
|
||||||
|
private(set) var ready = false
|
||||||
|
private(set) var connecting = false
|
||||||
|
|
||||||
|
mutating func begin() -> Int {
|
||||||
|
revision += 1
|
||||||
|
connecting = true
|
||||||
|
return revision
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func finish() {
|
||||||
|
connecting = false
|
||||||
|
ready = true
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func fail(_ expectedRevision: Int) -> Bool {
|
||||||
|
guard expectedRevision == revision, connecting else { return false }
|
||||||
|
connecting = false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
import UIKit
|
||||||
|
import WebKit
|
||||||
|
|
||||||
|
@_cdecl("install_ios_workspace")
|
||||||
|
func installIOSWorkspace() {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
guard let root = UIApplication.shared.connectedScenes
|
||||||
|
.compactMap({ $0 as? UIWindowScene }).flatMap(\.windows)
|
||||||
|
.first(where: { $0.isKeyWindow })?.rootViewController
|
||||||
|
?? UIApplication.shared.windows.first?.rootViewController else { return }
|
||||||
|
guard !root.children.contains(where: { $0 is WorkspaceViewController }) else { return }
|
||||||
|
let controller = WorkspaceViewController()
|
||||||
|
root.view.subviews.forEach { $0.isHidden = true }
|
||||||
|
root.addChild(controller)
|
||||||
|
root.view.addSubview(controller.view)
|
||||||
|
controller.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
controller.view.topAnchor.constraint(equalTo: root.view.topAnchor),
|
||||||
|
controller.view.bottomAnchor.constraint(equalTo: root.view.bottomAnchor),
|
||||||
|
controller.view.leadingAnchor.constraint(equalTo: root.view.leadingAnchor),
|
||||||
|
controller.view.trailingAnchor.constraint(equalTo: root.view.trailingAnchor),
|
||||||
|
])
|
||||||
|
controller.didMove(toParent: root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKUIDelegate, UITextFieldDelegate {
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
private let serverKey = "ios.activeServer"
|
||||||
|
private(set) var server: WorkspaceServer?
|
||||||
|
private(set) var workspace: WKWebView?
|
||||||
|
private var connection = WorkspaceConnection()
|
||||||
|
private var pendingNavigation: WKNavigation?
|
||||||
|
private var timeout: DispatchWorkItem?
|
||||||
|
private let launcher = UIScrollView()
|
||||||
|
private let stack = UIStackView()
|
||||||
|
private let address = UITextField()
|
||||||
|
private let status = UILabel()
|
||||||
|
private let connectButton = UIButton(type: .system)
|
||||||
|
private let resumeButton = UIButton(type: .system)
|
||||||
|
private let toolbar = UIToolbar()
|
||||||
|
private var historyObservation: NSKeyValueObservation?
|
||||||
|
private var backButton: UIBarButtonItem!
|
||||||
|
private var appearance: UISegmentedControl!
|
||||||
|
|
||||||
|
init(defaults: UserDefaults = .standard) {
|
||||||
|
self.defaults = defaults
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
private func text(_ key: String) -> String {
|
||||||
|
NSLocalizedString(key, comment: "KanbanGit iOS workspace")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
view.backgroundColor = .systemBackground
|
||||||
|
view.tintColor = UIColor(red: 0, green: 0.43, blue: 0.18, alpha: 1)
|
||||||
|
configureLauncher()
|
||||||
|
configureToolbar()
|
||||||
|
if let data = defaults.data(forKey: serverKey) {
|
||||||
|
if let saved = try? JSONDecoder().decode(WorkspaceServer.self, from: data),
|
||||||
|
WorkspaceServer.normalize(saved.url.absoluteString) == saved.url {
|
||||||
|
server = saved
|
||||||
|
} else {
|
||||||
|
status.text = text("storageError")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
address.text = server?.url.absoluteString ?? "https://kanbangit.de/"
|
||||||
|
showSettings()
|
||||||
|
if let server { open(server.url) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func label(_ key: String, style: UIFont.TextStyle = .body) -> UILabel {
|
||||||
|
let label = UILabel()
|
||||||
|
label.text = text(key)
|
||||||
|
label.font = .preferredFont(forTextStyle: style)
|
||||||
|
label.adjustsFontForContentSizeCategory = true
|
||||||
|
label.numberOfLines = 0
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureLauncher() {
|
||||||
|
launcher.accessibilityIdentifier = "serverSettings"
|
||||||
|
launcher.keyboardDismissMode = .interactive
|
||||||
|
launcher.alwaysBounceVertical = true
|
||||||
|
view.addSubview(launcher)
|
||||||
|
launcher.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
launcher.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||||
|
launcher.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
|
||||||
|
launcher.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
||||||
|
launcher.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
||||||
|
])
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.spacing = 18
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
launcher.addSubview(stack)
|
||||||
|
let width = stack.widthAnchor.constraint(equalTo: launcher.frameLayoutGuide.widthAnchor, constant: -40)
|
||||||
|
width.priority = .defaultHigh
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
stack.topAnchor.constraint(equalTo: launcher.contentLayoutGuide.topAnchor, constant: 32),
|
||||||
|
stack.bottomAnchor.constraint(equalTo: launcher.contentLayoutGuide.bottomAnchor, constant: -32),
|
||||||
|
stack.centerXAnchor.constraint(equalTo: launcher.frameLayoutGuide.centerXAnchor),
|
||||||
|
stack.leadingAnchor.constraint(greaterThanOrEqualTo: launcher.contentLayoutGuide.leadingAnchor, constant: 20),
|
||||||
|
stack.trailingAnchor.constraint(lessThanOrEqualTo: launcher.contentLayoutGuide.trailingAnchor, constant: -20),
|
||||||
|
stack.widthAnchor.constraint(lessThanOrEqualToConstant: 480), width,
|
||||||
|
])
|
||||||
|
stack.addArrangedSubview(label("appName", style: .headline))
|
||||||
|
stack.addArrangedSubview(label("title", style: .largeTitle))
|
||||||
|
stack.addArrangedSubview(label("description"))
|
||||||
|
stack.addArrangedSubview(label("serverLabel", style: .headline))
|
||||||
|
address.borderStyle = .roundedRect
|
||||||
|
address.keyboardType = .URL
|
||||||
|
address.textContentType = .URL
|
||||||
|
address.autocorrectionType = .no
|
||||||
|
address.autocapitalizationType = .none
|
||||||
|
address.clearButtonMode = .whileEditing
|
||||||
|
address.returnKeyType = .go
|
||||||
|
address.delegate = self
|
||||||
|
address.accessibilityLabel = text("serverLabel")
|
||||||
|
address.accessibilityIdentifier = "serverAddress"
|
||||||
|
address.heightAnchor.constraint(greaterThanOrEqualToConstant: 48).isActive = true
|
||||||
|
stack.addArrangedSubview(address)
|
||||||
|
stack.addArrangedSubview(label("serverHelp", style: .footnote))
|
||||||
|
connectButton.configuration = .filled()
|
||||||
|
connectButton.setTitle(text("openWorkspace"), for: .normal)
|
||||||
|
connectButton.accessibilityIdentifier = "openWorkspace"
|
||||||
|
connectButton.addTarget(self, action: #selector(connect), for: .touchUpInside)
|
||||||
|
stack.addArrangedSubview(connectButton)
|
||||||
|
status.numberOfLines = 0
|
||||||
|
status.accessibilityIdentifier = "connectionStatus"
|
||||||
|
status.font = .preferredFont(forTextStyle: .body)
|
||||||
|
status.adjustsFontForContentSizeCategory = true
|
||||||
|
stack.addArrangedSubview(status)
|
||||||
|
resumeButton.setTitle(text("resume"), for: .normal)
|
||||||
|
resumeButton.accessibilityIdentifier = "resumeWorkspace"
|
||||||
|
resumeButton.addTarget(self, action: #selector(showWorkspace), for: .touchUpInside)
|
||||||
|
stack.addArrangedSubview(resumeButton)
|
||||||
|
stack.addArrangedSubview(label("appearance", style: .headline))
|
||||||
|
appearance = UISegmentedControl(items: [text("system"), text("light"), text("dark")])
|
||||||
|
appearance.selectedSegmentIndex = min(2, max(0, defaults.integer(forKey: "ios.appearance")))
|
||||||
|
appearance.accessibilityIdentifier = "appearance"
|
||||||
|
appearance.addTarget(self, action: #selector(changeAppearance), for: .valueChanged)
|
||||||
|
stack.addArrangedSubview(appearance)
|
||||||
|
[connectButton, resumeButton, appearance].forEach {
|
||||||
|
$0.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).isActive = true
|
||||||
|
}
|
||||||
|
changeAppearance()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureToolbar() {
|
||||||
|
toolbar.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(toolbar)
|
||||||
|
backButton = UIBarButtonItem(title: text("back"), style: .plain, target: self, action: #selector(goBack))
|
||||||
|
backButton.accessibilityIdentifier = "workspaceBack"
|
||||||
|
let settings = UIBarButtonItem(title: text("serverSettings"), style: .plain, target: self, action: #selector(showSettings))
|
||||||
|
settings.accessibilityIdentifier = "showServerSettings"
|
||||||
|
toolbar.items = [backButton, UIBarButtonItem(systemItem: .flexibleSpace), settings]
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
toolbar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||||
|
toolbar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
||||||
|
toolbar.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func changeAppearance() {
|
||||||
|
defaults.set(appearance.selectedSegmentIndex, forKey: "ios.appearance")
|
||||||
|
overrideUserInterfaceStyle = [.unspecified, .light, .dark][appearance.selectedSegmentIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
func textFieldShouldReturn(_ textField: UITextField) -> Bool { connect(); return true }
|
||||||
|
|
||||||
|
@objc private func connect() {
|
||||||
|
guard let url = WorkspaceServer.normalize(address.text ?? "") else {
|
||||||
|
status.text = text("invalidServer")
|
||||||
|
UIAccessibility.post(notification: .announcement, argument: status.text)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let server, server.url != url {
|
||||||
|
let alert = UIAlertController(title: text("confirmSwitch"), message: text("switchWarning"), preferredStyle: .alert)
|
||||||
|
alert.addAction(UIAlertAction(title: text("cancel"), style: .cancel))
|
||||||
|
alert.addAction(UIAlertAction(title: text("confirmSwitch"), style: .destructive) { [weak self] _ in self?.open(url) })
|
||||||
|
present(alert, animated: true)
|
||||||
|
} else { open(url) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func open(_ url: URL) {
|
||||||
|
if server?.url == url, connection.ready { showWorkspace(); return }
|
||||||
|
let selected = WorkspaceServer.selecting(url, current: server)
|
||||||
|
guard let data = try? JSONEncoder().encode(selected) else { status.text = text("storageError"); return }
|
||||||
|
defaults.set(data, forKey: serverKey)
|
||||||
|
timeout?.cancel()
|
||||||
|
historyObservation = nil
|
||||||
|
workspace?.stopLoading()
|
||||||
|
workspace?.navigationDelegate = nil
|
||||||
|
workspace?.removeFromSuperview()
|
||||||
|
connection = WorkspaceConnection()
|
||||||
|
server = selected
|
||||||
|
let configuration = WKWebViewConfiguration()
|
||||||
|
configuration.websiteDataStore = WKWebsiteDataStore(forIdentifier: selected.profile)
|
||||||
|
// Remote content receives a plain WKWebView, with no native message handlers or Tauri bridge.
|
||||||
|
let webview = WKWebView(frame: .zero, configuration: configuration)
|
||||||
|
webview.navigationDelegate = self
|
||||||
|
webview.uiDelegate = self
|
||||||
|
webview.allowsBackForwardNavigationGestures = true
|
||||||
|
webview.accessibilityIdentifier = "workspace"
|
||||||
|
webview.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
webview.isHidden = true
|
||||||
|
view.insertSubview(webview, belowSubview: launcher)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
webview.topAnchor.constraint(equalTo: toolbar.bottomAnchor),
|
||||||
|
webview.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
|
||||||
|
webview.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
||||||
|
webview.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
||||||
|
])
|
||||||
|
workspace = webview
|
||||||
|
historyObservation = webview.observe(\.canGoBack, options: [.initial, .new]) { [weak self] view, _ in
|
||||||
|
self?.backButton.isEnabled = view.canGoBack
|
||||||
|
}
|
||||||
|
view.endEditing(true)
|
||||||
|
address.text = url.absoluteString
|
||||||
|
beginNavigation()
|
||||||
|
pendingNavigation = webview.load(URLRequest(url: url))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func beginNavigation() {
|
||||||
|
timeout?.cancel()
|
||||||
|
let revision = connection.begin()
|
||||||
|
status.text = text("connecting")
|
||||||
|
connectButton.isEnabled = false
|
||||||
|
resumeButton.isHidden = !connection.ready
|
||||||
|
let work = DispatchWorkItem { [weak self] in self?.fail(revision) }
|
||||||
|
timeout = work
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 15, execute: work)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fail(_ revision: Int) {
|
||||||
|
guard connection.fail(revision) else { return }
|
||||||
|
timeout?.cancel()
|
||||||
|
status.text = text("connectionError")
|
||||||
|
connectButton.isEnabled = true
|
||||||
|
connectButton.setTitle(text("retry"), for: .normal)
|
||||||
|
showSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func showSettings() {
|
||||||
|
view.endEditing(true)
|
||||||
|
launcher.isHidden = false
|
||||||
|
toolbar.isHidden = true
|
||||||
|
workspace?.isHidden = true
|
||||||
|
resumeButton.isHidden = !connection.ready
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func showWorkspace() {
|
||||||
|
guard connection.ready else { return }
|
||||||
|
view.endEditing(true)
|
||||||
|
launcher.isHidden = true
|
||||||
|
toolbar.isHidden = false
|
||||||
|
workspace?.isHidden = false
|
||||||
|
backButton.isEnabled = workspace?.canGoBack == true
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func goBack() { if workspace?.canGoBack == true { workspace?.goBack() } }
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||||
|
guard webView === workspace, let url = action.request.url 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 {
|
||||||
|
decisionHandler(.cancel)
|
||||||
|
webView.load(action.request)
|
||||||
|
} else { decisionHandler(.allow) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, decidePolicyFor response: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
|
||||||
|
guard webView === workspace, let url = response.response.url,
|
||||||
|
server?.allows(url) == true else { decisionHandler(.cancel); return }
|
||||||
|
decisionHandler(.allow)
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||||
|
guard webView === workspace else { return }
|
||||||
|
pendingNavigation = navigation
|
||||||
|
beginNavigation()
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
|
guard webView === workspace, navigation === pendingNavigation else { return }
|
||||||
|
let firstLoad = !connection.ready
|
||||||
|
timeout?.cancel()
|
||||||
|
connection.finish()
|
||||||
|
connectButton.isEnabled = true
|
||||||
|
connectButton.setTitle(text("openWorkspace"), for: .normal)
|
||||||
|
status.text = text("connected")
|
||||||
|
resumeButton.isHidden = false
|
||||||
|
backButton.isEnabled = webView.canGoBack
|
||||||
|
if firstLoad { showWorkspace() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||||
|
navigationFailed(webView, navigation, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||||
|
navigationFailed(webView, navigation, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func navigationFailed(_ webView: WKWebView, _ navigation: WKNavigation?, _ error: Error) {
|
||||||
|
guard webView === workspace, navigation === pendingNavigation,
|
||||||
|
(error as NSError).code != NSURLErrorCancelled else { return }
|
||||||
|
fail(connection.revision)
|
||||||
|
}
|
||||||
|
|
||||||
|
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
||||||
|
guard webView === workspace else { return }
|
||||||
|
connection = WorkspaceConnection()
|
||||||
|
status.text = text("processTerminated")
|
||||||
|
connectButton.isEnabled = true
|
||||||
|
showSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
namespace ffi {
|
||||||
|
extern "C" {
|
||||||
|
void start_app();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"NSCameraUsageDescription" = "Choose a profile picture using your camera.";
|
||||||
|
"NSPhotoLibraryUsageDescription" = "Choose a profile picture from your photo library.";
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"appName" = "KanbanGit";
|
||||||
|
"title" = "Choose your workspace.";
|
||||||
|
"description" = "Connect to your KanbanGit installation over HTTPS.";
|
||||||
|
"serverLabel" = "Server address";
|
||||||
|
"serverHelp" = "Use the installation address without login details, a query or a fragment.";
|
||||||
|
"openWorkspace" = "Open workspace";
|
||||||
|
"retry" = "Try again";
|
||||||
|
"resume" = "Return to workspace";
|
||||||
|
"switchWarning" = "Switching servers closes the current workspace and discards unsaved inputs. You will sign in separately on the new server.";
|
||||||
|
"confirmSwitch" = "Switch server";
|
||||||
|
"cancel" = "Cancel";
|
||||||
|
"connecting" = "Connecting to your server…";
|
||||||
|
"connected" = "Your workspace is open. Use Server settings to change servers.";
|
||||||
|
"connectionError" = "The server did not finish loading. Check your connection, server address and HTTPS certificate, then try again. An already open workspace and its inputs are kept.";
|
||||||
|
"invalidServer" = "Enter a valid HTTPS installation address without login details, a query or a fragment.";
|
||||||
|
"storageError" = "The saved server setting could not be read. Enter your server address again.";
|
||||||
|
"processTerminated" = "iOS closed the workspace to free memory. Open it again to continue. Unsaved inputs may have been lost.";
|
||||||
|
"appearance" = "Appearance";
|
||||||
|
"system" = "System";
|
||||||
|
"light" = "Light";
|
||||||
|
"dark" = "Dark";
|
||||||
|
"back" = "Back";
|
||||||
|
"serverSettings" = "Server settings";
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
#include "bindings/bindings.h"
|
||||||
|
#import <UIKit/UIKit.h>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
extern "C" void install_ios_workspace(void);
|
||||||
|
|
||||||
|
int main(int argc, char * argv[]) {
|
||||||
|
@autoreleasepool {
|
||||||
|
// Xcode mirrors os_log to stderr. Tauri 2.11 redirects stderr back to os_log,
|
||||||
|
// which can fill its pipe and block UIKit/XCTest. Keep Xcode's original streams.
|
||||||
|
const bool debuggerLogging = getenv("OS_ACTIVITY_DT_MODE") != nullptr
|
||||||
|
|| getenv("XCTestConfigurationFilePath") != nullptr;
|
||||||
|
__block int originalOut = debuggerLogging ? dup(STDOUT_FILENO) : -1;
|
||||||
|
__block int originalError = debuggerLogging ? dup(STDERR_FILENO) : -1;
|
||||||
|
[[NSNotificationCenter defaultCenter]
|
||||||
|
addObserverForName:UIApplicationDidBecomeActiveNotification
|
||||||
|
object:nil
|
||||||
|
queue:NSOperationQueue.mainQueue
|
||||||
|
usingBlock:^(NSNotification *notification) {
|
||||||
|
if (originalOut >= 0) {
|
||||||
|
dup2(originalOut, STDOUT_FILENO);
|
||||||
|
close(originalOut);
|
||||||
|
originalOut = -1;
|
||||||
|
}
|
||||||
|
if (originalError >= 0) {
|
||||||
|
dup2(originalError, STDERR_FILENO);
|
||||||
|
close(originalError);
|
||||||
|
originalError = -1;
|
||||||
|
}
|
||||||
|
install_ios_workspace();
|
||||||
|
}];
|
||||||
|
ffi::start_app();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import XCTest
|
||||||
|
import WebKit
|
||||||
|
|
||||||
|
final class WorkspaceTests: XCTestCase {
|
||||||
|
func testServerValidationAndOriginBoundary() throws {
|
||||||
|
let url = try XCTUnwrap(WorkspaceServer.normalize(" HTTPS://Example.com:443/kanban "))
|
||||||
|
XCTAssertEqual(url.absoluteString, "https://example.com/kanban/")
|
||||||
|
let server = WorkspaceServer(url: url, profile: UUID())
|
||||||
|
for value in ["http://example.com/", "https://user:secret@example.com/", "https://example.com/?token=1", "https://example.com/#x", "https://exa\nmple.com", "https://example.com\\evil", "file:///etc/passwd", "javascript:alert(1)", "https://example.com:99999/"] {
|
||||||
|
XCTAssertNil(WorkspaceServer.normalize(value), value)
|
||||||
|
}
|
||||||
|
for value in ["https://example.com/login", "https://example.com/two-factor-challenge", "https://example.com/projects/1?ticket=54#details"] {
|
||||||
|
XCTAssertTrue(server.allows(try XCTUnwrap(URL(string: value))), value)
|
||||||
|
}
|
||||||
|
for value in ["http://example.com/", "https://example.com:8443/", "https://evil.example.com/", "https://user@example.com/", "file:///tmp/index.html", "tauri://localhost/action/open"] {
|
||||||
|
XCTAssertFalse(server.allows(try XCTUnwrap(URL(string: value))), value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
let firstURL = try XCTUnwrap(WorkspaceServer.normalize("https://example.com/"))
|
||||||
|
let otherURL = try XCTUnwrap(WorkspaceServer.normalize("https://example.com/other/"))
|
||||||
|
let first = WorkspaceServer.selecting(firstURL, current: nil)
|
||||||
|
XCTAssertEqual(WorkspaceServer.selecting(firstURL, current: first), first)
|
||||||
|
let second = WorkspaceServer.selecting(otherURL, current: first)
|
||||||
|
XCTAssertNotEqual(second.profile, first.profile)
|
||||||
|
XCTAssertNotEqual(WorkspaceServer.selecting(firstURL, current: second).profile, first.profile)
|
||||||
|
XCTAssertEqual(try JSONDecoder().decode(WorkspaceServer.self, from: JSONEncoder().encode(first)), first)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTimeoutDoesNotDiscardLoadedWorkspaceOrAffectLaterNavigation() {
|
||||||
|
var state = WorkspaceConnection()
|
||||||
|
let first = state.begin()
|
||||||
|
let second = state.begin()
|
||||||
|
XCTAssertFalse(state.fail(first))
|
||||||
|
state.finish()
|
||||||
|
XCTAssertFalse(state.fail(second))
|
||||||
|
let third = state.begin()
|
||||||
|
XCTAssertTrue(state.fail(third))
|
||||||
|
XCTAssertTrue(state.ready)
|
||||||
|
XCTAssertFalse(state.fail(third))
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testWKWebViewPersistsCookiesAcrossViewsAndIsolatesServerChanges() async throws {
|
||||||
|
let profile = UUID()
|
||||||
|
let first = WKWebViewConfiguration()
|
||||||
|
first.websiteDataStore = WKWebsiteDataStore(forIdentifier: profile)
|
||||||
|
let cookie = try XCTUnwrap(HTTPCookie(properties: [
|
||||||
|
.domain: "example.com", .path: "/", .name: "laravel_session", .value: "test-session",
|
||||||
|
.secure: "TRUE", .expires: Date().addingTimeInterval(3600),
|
||||||
|
]))
|
||||||
|
await first.websiteDataStore.httpCookieStore.setCookie(cookie)
|
||||||
|
let restored = WKWebViewConfiguration()
|
||||||
|
restored.websiteDataStore = WKWebsiteDataStore(forIdentifier: profile)
|
||||||
|
let restoredCookies = await restored.websiteDataStore.httpCookieStore.allCookies()
|
||||||
|
XCTAssertTrue(restoredCookies.contains { $0.name == "laravel_session" && $0.value == "test-session" })
|
||||||
|
let other = WKWebsiteDataStore(forIdentifier: UUID())
|
||||||
|
let otherCookies = await other.httpCookieStore.allCookies()
|
||||||
|
XCTAssertTrue(otherCookies.isEmpty)
|
||||||
|
await restored.websiteDataStore.httpCookieStore.deleteCookie(cookie)
|
||||||
|
let loggedOut = await first.websiteDataStore.httpCookieStore.allCookies()
|
||||||
|
XCTAssertFalse(loggedOut.contains { $0.name == "laravel_session" })
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testControllerRestoresServerAndCreatesIsolatedUnprivilegedWebView() throws {
|
||||||
|
let name = "KAN54-tests-\(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 server = try XCTUnwrap(controller.server)
|
||||||
|
XCTAssertEqual(controller.workspace?.configuration.websiteDataStore.identifier, server.profile)
|
||||||
|
XCTAssertTrue(controller.workspace?.configuration.userContentController.userScripts.isEmpty == true)
|
||||||
|
let restored = WorkspaceViewController(defaults: defaults)
|
||||||
|
restored.loadViewIfNeeded()
|
||||||
|
XCTAssertEqual(restored.server, server)
|
||||||
|
let other = try XCTUnwrap(WorkspaceServer.normalize("https://other.invalid/"))
|
||||||
|
restored.open(other)
|
||||||
|
XCTAssertNotEqual(restored.server?.profile, server.profile)
|
||||||
|
}
|
||||||
|
@MainActor
|
||||||
|
func testConnectionFailureKeepsLoadedFormAndAllowsResume() async throws {
|
||||||
|
let name = "KAN54-form-\(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()
|
||||||
|
let navigation = try XCTUnwrap(webview.loadHTMLString("<html><body><input id='draft' value='unsaved ticket'></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 ticket" { loaded = true; break }
|
||||||
|
try await Task.sleep(for: .milliseconds(100))
|
||||||
|
}
|
||||||
|
XCTAssertTrue(loaded)
|
||||||
|
controller.webView(webview, didFinish: navigation)
|
||||||
|
controller.webView(webview, didStartProvisionalNavigation: navigation)
|
||||||
|
controller.webView(webview, didFailProvisionalNavigation: navigation,
|
||||||
|
withError: NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet))
|
||||||
|
XCTAssertTrue(webview.isHidden)
|
||||||
|
controller.open(url)
|
||||||
|
XCTAssertTrue(controller.workspace === webview)
|
||||||
|
XCTAssertFalse(webview.isHidden)
|
||||||
|
let value = try await webview.evaluateJavaScript("document.getElementById('draft').value")
|
||||||
|
XCTAssertEqual(value as? String, "unsaved ticket")
|
||||||
|
let bridge = try await webview.evaluateJavaScript("typeof window.__TAURI_INTERNALS__")
|
||||||
|
XCTAssertEqual(bridge as? String, "undefined")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class WorkspaceUITests: XCTestCase {
|
||||||
|
@MainActor
|
||||||
|
private func reveal(_ element: XCUIElement, in scrollView: XCUIElement) {
|
||||||
|
for _ in 0..<8 {
|
||||||
|
if scrollView.frame.contains(CGPoint(x: element.frame.midX, y: element.frame.midY)), element.isHittable { return }
|
||||||
|
let start = scrollView.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
|
||||||
|
let direction: CGFloat = element.frame.midY > scrollView.frame.midY ? 0.2 : 0.8
|
||||||
|
let end = scrollView.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: direction))
|
||||||
|
start.press(forDuration: 0.05, thenDragTo: end)
|
||||||
|
}
|
||||||
|
XCTAssertTrue(element.isHittable)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testLauncherKeyboardValidationRotationAndAppearance() {
|
||||||
|
continueAfterFailure = false
|
||||||
|
XCUIDevice.shared.orientation = .portrait
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launch()
|
||||||
|
let settings = app.buttons["showServerSettings"]
|
||||||
|
if settings.waitForExistence(timeout: 3) { settings.tap() }
|
||||||
|
let address = app.textFields["serverAddress"]
|
||||||
|
XCTAssertTrue(address.waitForExistence(timeout: 15))
|
||||||
|
address.tap()
|
||||||
|
let clear = address.buttons.firstMatch
|
||||||
|
if clear.exists { clear.tap() }
|
||||||
|
address.typeText("http://invalid.example")
|
||||||
|
app.buttons["openWorkspace"].tap()
|
||||||
|
XCTAssertTrue(app.staticTexts["connectionStatus"].label.contains("valid HTTPS"))
|
||||||
|
XCUIDevice.shared.orientation = .landscapeLeft
|
||||||
|
let launcher = app.scrollViews["serverSettings"]
|
||||||
|
reveal(address, in: launcher)
|
||||||
|
let open = app.buttons["openWorkspace"]
|
||||||
|
reveal(open, in: launcher)
|
||||||
|
let dark = app.segmentedControls["appearance"].buttons["Dark"]
|
||||||
|
reveal(dark, in: launcher)
|
||||||
|
dark.tap()
|
||||||
|
XCTAssertTrue(dark.isSelected)
|
||||||
|
app.segmentedControls["appearance"].buttons["System"].tap()
|
||||||
|
XCUIDevice.shared.orientation = .portrait
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,701 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 77;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
009F8FEC2765B2F19DC12CA8 /* WorkspaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */; };
|
||||||
|
0FAB3CC7BB466BFF871C0C30 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 31630170C358EA8657E34854 /* Assets.xcassets */; };
|
||||||
|
205D0CBA33F7D3C59AAD5300 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 63685B1189795CAEA85F7AB7 /* QuartzCore.framework */; };
|
||||||
|
333E7761EA87AE29CE04F1AC /* main.mm in Sources */ = {isa = PBXBuildFile; fileRef = F46963153D20DB21D97843E9 /* main.mm */; };
|
||||||
|
34C189B72A1B9EBE33D700D1 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F63BC1BDE65F5AEBE5B13519 /* LaunchScreen.storyboard */; };
|
||||||
|
5E764E27CA85A3746845F417 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8243D0C3821DAC731FECF105 /* UIKit.framework */; };
|
||||||
|
6514B0DB3A5CCCDF7385AC18 /* WorkspaceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE63F98563975EF4FF2A10FB /* WorkspaceViewController.swift */; };
|
||||||
|
70EBA05ACBA5402617A3371C /* WorkspacePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 554AB059B4228085924F4959 /* WorkspacePolicy.swift */; };
|
||||||
|
7245AD23F39C4620BAA3AA92 /* WorkspaceUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED199829CC1B1598F58EDC9B /* WorkspaceUITests.swift */; };
|
||||||
|
73954B7AADE8C37663BEF288 /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CA65E67046326EA008175045 /* Metal.framework */; };
|
||||||
|
98D5B23C9BE2F62C23DF397A /* WorkspacePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 554AB059B4228085924F4959 /* WorkspacePolicy.swift */; };
|
||||||
|
A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C8C02BC0E757897278EB954A /* Localizable.strings */; };
|
||||||
|
B939982700E800053A9C7663 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE3B71153677F722CAC6DCF7 /* Security.framework */; };
|
||||||
|
C362C102D4950A81D89BB8C8 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */; };
|
||||||
|
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 */; };
|
||||||
|
D8772A5E508092D6F831058A /* libapp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 91991DFDB0C064B06DF854ED /* libapp.a */; };
|
||||||
|
EB37EAF992C1A156795CAEC0 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
8665A25D07C5F1A295164797 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = AA6AD2A91A9E86D319D86D59 /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = 721CF221CEDDC28FF2C5EBD5;
|
||||||
|
remoteInfo = "kanbangit-native_iOS";
|
||||||
|
};
|
||||||
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
01E10FB645809173AF1D4C2D /* bindings.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = bindings.h; sourceTree = "<group>"; };
|
||||||
|
05A753935B6CB0316DA56D96 /* desktop.rs */ = {isa = PBXFileReference; path = desktop.rs; sourceTree = "<group>"; };
|
||||||
|
08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTests.swift; sourceTree = "<group>"; };
|
||||||
|
0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; };
|
||||||
|
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>"; };
|
||||||
|
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>"; };
|
||||||
|
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; };
|
||||||
|
7440C7F0426E6B3977DD1D94 /* main.rs */ = {isa = PBXFileReference; path = main.rs; sourceTree = "<group>"; };
|
||||||
|
8243D0C3821DAC731FECF105 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; };
|
||||||
|
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; };
|
||||||
|
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; };
|
||||||
|
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>"; };
|
||||||
|
CA65E67046326EA008175045 /* Metal.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Metal.framework; path = System/Library/Frameworks/Metal.framework; sourceTree = SDKROOT; };
|
||||||
|
CAF08AD60D2F29A879CDAEBB /* ios.rs */ = {isa = PBXFileReference; path = ios.rs; sourceTree = "<group>"; };
|
||||||
|
D13B8D854BEB7757AB95231B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
D581BB90601D7A89F8F188E2 /* MetalKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MetalKit.framework; path = System/Library/Frameworks/MetalKit.framework; sourceTree = SDKROOT; };
|
||||||
|
DE3B71153677F722CAC6DCF7 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; };
|
||||||
|
E8BA104C81071623E1FF367D /* server.rs */ = {isa = PBXFileReference; path = server.rs; sourceTree = "<group>"; };
|
||||||
|
ED199829CC1B1598F58EDC9B /* WorkspaceUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceUITests.swift; sourceTree = "<group>"; };
|
||||||
|
ED4469441C3E0F37B1123572 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
EE63F98563975EF4FF2A10FB /* WorkspaceViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceViewController.swift; sourceTree = "<group>"; };
|
||||||
|
F46963153D20DB21D97843E9 /* main.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = main.mm; sourceTree = "<group>"; };
|
||||||
|
F63BC1BDE65F5AEBE5B13519 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
32E70C778E91CB2E8CD86715 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
D8772A5E508092D6F831058A /* libapp.a in Frameworks */,
|
||||||
|
EB37EAF992C1A156795CAEC0 /* CoreGraphics.framework in Frameworks */,
|
||||||
|
73954B7AADE8C37663BEF288 /* Metal.framework in Frameworks */,
|
||||||
|
CD64D99EBD87E6FFC0C9001C /* MetalKit.framework in Frameworks */,
|
||||||
|
205D0CBA33F7D3C59AAD5300 /* QuartzCore.framework in Frameworks */,
|
||||||
|
B939982700E800053A9C7663 /* Security.framework in Frameworks */,
|
||||||
|
5E764E27CA85A3746845F417 /* UIKit.framework in Frameworks */,
|
||||||
|
C362C102D4950A81D89BB8C8 /* WebKit.framework in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
140A7DB7E2461E64B1FBFA70 /* Tests */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */,
|
||||||
|
);
|
||||||
|
path = Tests;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
157AF88201BDE4D99AA2E14C = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
31630170C358EA8657E34854 /* Assets.xcassets */,
|
||||||
|
F63BC1BDE65F5AEBE5B13519 /* LaunchScreen.storyboard */,
|
||||||
|
4058655E6F37D054E25D1F37 /* kanbangit-native_iOS */,
|
||||||
|
8606F3EE5A736385061692EF /* Sources */,
|
||||||
|
335B1262A9019C70C7F887A5 /* src */,
|
||||||
|
140A7DB7E2461E64B1FBFA70 /* Tests */,
|
||||||
|
3C9F31BB065F210267ECA390 /* UITests */,
|
||||||
|
3028C3786B437115B6486F1C /* Frameworks */,
|
||||||
|
59D2DE9FD94E6D2D64CB0117 /* Products */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
2A11A26F0082A5075CD710C6 /* bindings */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
01E10FB645809173AF1D4C2D /* bindings.h */,
|
||||||
|
);
|
||||||
|
path = bindings;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
3028C3786B437115B6486F1C /* Frameworks */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */,
|
||||||
|
91991DFDB0C064B06DF854ED /* libapp.a */,
|
||||||
|
CA65E67046326EA008175045 /* Metal.framework */,
|
||||||
|
D581BB90601D7A89F8F188E2 /* MetalKit.framework */,
|
||||||
|
63685B1189795CAEA85F7AB7 /* QuartzCore.framework */,
|
||||||
|
DE3B71153677F722CAC6DCF7 /* Security.framework */,
|
||||||
|
8243D0C3821DAC731FECF105 /* UIKit.framework */,
|
||||||
|
92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */,
|
||||||
|
);
|
||||||
|
name = Frameworks;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
335B1262A9019C70C7F887A5 /* src */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
9C56BE6890F0452F69D64795 /* android.rs */,
|
||||||
|
05A753935B6CB0316DA56D96 /* desktop.rs */,
|
||||||
|
CAF08AD60D2F29A879CDAEBB /* ios.rs */,
|
||||||
|
2B96972F58CC158AB3DFF94A /* lib.rs */,
|
||||||
|
AEBAD9ABE758897C50BC1F2E /* locale.rs */,
|
||||||
|
7440C7F0426E6B3977DD1D94 /* main.rs */,
|
||||||
|
E8BA104C81071623E1FF367D /* server.rs */,
|
||||||
|
);
|
||||||
|
name = src;
|
||||||
|
path = ../../src;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
3C9F31BB065F210267ECA390 /* UITests */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
ED199829CC1B1598F58EDC9B /* WorkspaceUITests.swift */,
|
||||||
|
);
|
||||||
|
path = UITests;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
4058655E6F37D054E25D1F37 /* kanbangit-native_iOS */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
D13B8D854BEB7757AB95231B /* Info.plist */,
|
||||||
|
946BA5CC3FD90AD90D101A36 /* kanbangit-native_iOS.entitlements */,
|
||||||
|
);
|
||||||
|
path = "kanbangit-native_iOS";
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
59D2DE9FD94E6D2D64CB0117 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */,
|
||||||
|
A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */,
|
||||||
|
9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
8606F3EE5A736385061692EF /* Sources */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
89AA023B8C28CB860513C685 /* kanbangit-native */,
|
||||||
|
);
|
||||||
|
path = Sources;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
89AA023B8C28CB860513C685 /* kanbangit-native */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
4C76059D31C50B198779A5F7 /* en.lproj */,
|
||||||
|
F46963153D20DB21D97843E9 /* main.mm */,
|
||||||
|
554AB059B4228085924F4959 /* WorkspacePolicy.swift */,
|
||||||
|
EE63F98563975EF4FF2A10FB /* WorkspaceViewController.swift */,
|
||||||
|
2A11A26F0082A5075CD710C6 /* bindings */,
|
||||||
|
2AB6F9398EA3CB62A68287D2 /* InfoPlist.strings */,
|
||||||
|
C8C02BC0E757897278EB954A /* Localizable.strings */,
|
||||||
|
);
|
||||||
|
path = "kanbangit-native";
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = F0C7EA9610FB69A5EA27B950 /* Build configuration list for PBXNativeTarget "kanbangit-native_iOS" */;
|
||||||
|
buildPhases = (
|
||||||
|
C74F06A7ECCC2C6C79C74A2D /* Build Rust Code */,
|
||||||
|
F39C45598E4345705F3820FA /* Sources */,
|
||||||
|
4A4C7FC1B57785DDD6B301FB /* Resources */,
|
||||||
|
32E70C778E91CB2E8CD86715 /* Frameworks */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = "kanbangit-native_iOS";
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = "kanbangit-native_iOS";
|
||||||
|
productReference = 23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
988CFF304CB81C8F3A4D6612 /* WorkspaceUITests */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = E6F418AEF58E5F3D739C68F1 /* Build configuration list for PBXNativeTarget "WorkspaceUITests" */;
|
||||||
|
buildPhases = (
|
||||||
|
0258B26407CD2D8C61079F84 /* Sources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
5658F3B2915DC3A46531EDCE /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
name = WorkspaceUITests;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = WorkspaceUITests;
|
||||||
|
productReference = 9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */;
|
||||||
|
productType = "com.apple.product-type.bundle.ui-testing";
|
||||||
|
};
|
||||||
|
CBC4561F8018D37DD49A0598 /* WorkspaceTests */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 26F675D59EF454AE0D263F35 /* Build configuration list for PBXNativeTarget "WorkspaceTests" */;
|
||||||
|
buildPhases = (
|
||||||
|
2A0467365B852D44D1A9FD5B /* Sources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = WorkspaceTests;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = WorkspaceTests;
|
||||||
|
productReference = A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */;
|
||||||
|
productType = "com.apple.product-type.bundle.unit-test";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
AA6AD2A91A9E86D319D86D59 /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = YES;
|
||||||
|
LastUpgradeCheck = 1430;
|
||||||
|
TargetAttributes = {
|
||||||
|
721CF221CEDDC28FF2C5EBD5 = {
|
||||||
|
DevelopmentTeam = "";
|
||||||
|
ProvisioningStyle = Automatic;
|
||||||
|
};
|
||||||
|
988CFF304CB81C8F3A4D6612 = {
|
||||||
|
TestTargetID = 721CF221CEDDC28FF2C5EBD5;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = A9CA551DD3F3EBCCF8C41CC1 /* Build configuration list for PBXProject "kanbangit-native" */;
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
Base,
|
||||||
|
en,
|
||||||
|
);
|
||||||
|
mainGroup = 157AF88201BDE4D99AA2E14C;
|
||||||
|
minimizedProjectReferenceProxies = 1;
|
||||||
|
preferredProjectObjectVersion = 77;
|
||||||
|
productRefGroup = 59D2DE9FD94E6D2D64CB0117 /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */,
|
||||||
|
CBC4561F8018D37DD49A0598 /* WorkspaceTests */,
|
||||||
|
988CFF304CB81C8F3A4D6612 /* WorkspaceUITests */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
4A4C7FC1B57785DDD6B301FB /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
0FAB3CC7BB466BFF871C0C30 /* Assets.xcassets in Resources */,
|
||||||
|
C53E7EC983C008DF98244C74 /* InfoPlist.strings in Resources */,
|
||||||
|
34C189B72A1B9EBE33D700D1 /* LaunchScreen.storyboard in Resources */,
|
||||||
|
A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXShellScriptBuildPhase section */
|
||||||
|
C74F06A7ECCC2C6C79C74A2D /* Build Rust Code */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
alwaysOutOfDate = 1;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
|
name = "Build Rust Code";
|
||||||
|
outputFileListPaths = (
|
||||||
|
);
|
||||||
|
outputPaths = (
|
||||||
|
"$(SRCROOT)/Externals/x86_64/${CONFIGURATION}/libapp.a",
|
||||||
|
"$(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a",
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "export PATH=\"$(dirname \"$(rustup which cargo)\"):$PATH\"\nexport RUSTC=\"$(rustup which rustc)\"\ncd \"$SRCROOT/../../..\"\n./node_modules/.bin/tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths \"${FRAMEWORK_SEARCH_PATHS:?}\" --header-search-paths \"${HEADER_SEARCH_PATHS:?}\" --gcc-preprocessor-definitions \"${GCC_PREPROCESSOR_DEFINITIONS:-}\" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}\n";
|
||||||
|
};
|
||||||
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
0258B26407CD2D8C61079F84 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
7245AD23F39C4620BAA3AA92 /* WorkspaceUITests.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
2A0467365B852D44D1A9FD5B /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
98D5B23C9BE2F62C23DF397A /* WorkspacePolicy.swift in Sources */,
|
||||||
|
009F8FEC2765B2F19DC12CA8 /* WorkspaceTests.swift in Sources */,
|
||||||
|
6514B0DB3A5CCCDF7385AC18 /* WorkspaceViewController.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
F39C45598E4345705F3820FA /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
70EBA05ACBA5402617A3371C /* WorkspacePolicy.swift in Sources */,
|
||||||
|
C44BDCDEA454DBD106D51C9C /* WorkspaceViewController.swift in Sources */,
|
||||||
|
333E7761EA87AE29CE04F1AC /* main.mm in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
5658F3B2915DC3A46531EDCE /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = 721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */;
|
||||||
|
targetProxy = 8665A25D07C5F1A295164797 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
|
/* Begin PBXVariantGroup section */
|
||||||
|
2AB6F9398EA3CB62A68287D2 /* InfoPlist.strings */ = {
|
||||||
|
isa = PBXVariantGroup;
|
||||||
|
children = (
|
||||||
|
47C531812A31207F97A89C5F /* en */,
|
||||||
|
);
|
||||||
|
name = InfoPlist.strings;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
C8C02BC0E757897278EB954A /* Localizable.strings */ = {
|
||||||
|
isa = PBXVariantGroup;
|
||||||
|
children = (
|
||||||
|
ED4469441C3E0F37B1123572 /* en */,
|
||||||
|
);
|
||||||
|
name = Localizable.strings;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXVariantGroup section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
0C46162024E99BEFF872CABF /* release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||||
|
ARCHS = (
|
||||||
|
arm64,
|
||||||
|
);
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
|
||||||
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
DEVELOPMENT_TEAM = "";
|
||||||
|
ENABLE_BITCODE = NO;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
|
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
|
||||||
|
FRAMEWORK_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"\".\"",
|
||||||
|
);
|
||||||
|
INFOPLIST_FILE = "kanbangit-native_iOS/Info.plist";
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||||
|
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.desktop;
|
||||||
|
PRODUCT_MODULE_NAME = KanbanGitNative;
|
||||||
|
PRODUCT_NAME = "KanbanGit";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
VALID_ARCHS = arm64;
|
||||||
|
};
|
||||||
|
name = release;
|
||||||
|
};
|
||||||
|
390A27695F28BF3AA9A049C9 /* debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_OPTIMIZATION_LEVEL = 0;
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"DEBUG=1",
|
||||||
|
);
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
};
|
||||||
|
name = debug;
|
||||||
|
};
|
||||||
|
5110A47C3B60906E18EE09D2 /* debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@loader_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.uitests;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_TARGET_NAME = "kanbangit-native_iOS";
|
||||||
|
};
|
||||||
|
name = debug;
|
||||||
|
};
|
||||||
|
C81211E40BBB158A79AEBE3D /* release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
};
|
||||||
|
name = release;
|
||||||
|
};
|
||||||
|
D41150B2CAE4512D04C26759 /* debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||||
|
ARCHS = (
|
||||||
|
arm64,
|
||||||
|
);
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
|
||||||
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
DEVELOPMENT_TEAM = "";
|
||||||
|
ENABLE_BITCODE = NO;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
|
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
|
||||||
|
FRAMEWORK_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"\".\"",
|
||||||
|
);
|
||||||
|
INFOPLIST_FILE = "kanbangit-native_iOS/Info.plist";
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||||
|
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.desktop;
|
||||||
|
PRODUCT_MODULE_NAME = KanbanGitNative;
|
||||||
|
PRODUCT_NAME = "KanbanGit";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
VALID_ARCHS = arm64;
|
||||||
|
};
|
||||||
|
name = debug;
|
||||||
|
};
|
||||||
|
EF104E75BD04AFEF735BAA52 /* release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@loader_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.uitests;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_TARGET_NAME = "kanbangit-native_iOS";
|
||||||
|
};
|
||||||
|
name = release;
|
||||||
|
};
|
||||||
|
FB6E824120F9ED3CFE30E713 /* debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@loader_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.tests;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = debug;
|
||||||
|
};
|
||||||
|
FF1553DD6765EEA42FF50CDD /* release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@loader_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.tests;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
26F675D59EF454AE0D263F35 /* Build configuration list for PBXNativeTarget "WorkspaceTests" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
FB6E824120F9ED3CFE30E713 /* debug */,
|
||||||
|
FF1553DD6765EEA42FF50CDD /* release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = debug;
|
||||||
|
};
|
||||||
|
A9CA551DD3F3EBCCF8C41CC1 /* Build configuration list for PBXProject "kanbangit-native" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
390A27695F28BF3AA9A049C9 /* debug */,
|
||||||
|
C81211E40BBB158A79AEBE3D /* release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = debug;
|
||||||
|
};
|
||||||
|
E6F418AEF58E5F3D739C68F1 /* Build configuration list for PBXNativeTarget "WorkspaceUITests" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
5110A47C3B60906E18EE09D2 /* debug */,
|
||||||
|
EF104E75BD04AFEF735BAA52 /* release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = debug;
|
||||||
|
};
|
||||||
|
F0C7EA9610FB69A5EA27B950 /* Build configuration list for PBXNativeTarget "kanbangit-native_iOS" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
D41150B2CAE4512D04C26759 /* debug */,
|
||||||
|
0C46162024E99BEFF872CABF /* release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = debug;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
};
|
||||||
|
rootObject = AA6AD2A91A9E86D319D86D59 /* Project object */;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>BuildSystemType</key>
|
||||||
|
<string>Original</string>
|
||||||
|
<key>DisableBuildSystemDeprecationDiagnostic</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1430"
|
||||||
|
version = "1.3">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "721CF221CEDDC28FF2C5EBD5"
|
||||||
|
BuildableName = "KanbanGit.app"
|
||||||
|
BlueprintName = "kanbangit-native_iOS"
|
||||||
|
ReferencedContainer = "container:kanbangit-native.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "NO">
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "721CF221CEDDC28FF2C5EBD5"
|
||||||
|
BuildableName = "KanbanGit.app"
|
||||||
|
BlueprintName = "kanbangit-native_iOS"
|
||||||
|
ReferencedContainer = "container:kanbangit-native.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
<EnvironmentVariables>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "RUST_BACKTRACE"
|
||||||
|
value = "full"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "RUST_LOG"
|
||||||
|
value = "info"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
</EnvironmentVariables>
|
||||||
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "CBC4561F8018D37DD49A0598"
|
||||||
|
BuildableName = "WorkspaceTests.xctest"
|
||||||
|
BlueprintName = "WorkspaceTests"
|
||||||
|
ReferencedContainer = "container:kanbangit-native.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "988CFF304CB81C8F3A4D6612"
|
||||||
|
BuildableName = "WorkspaceUITests.xctest"
|
||||||
|
BlueprintName = "WorkspaceUITests"
|
||||||
|
ReferencedContainer = "container:kanbangit-native.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "721CF221CEDDC28FF2C5EBD5"
|
||||||
|
BuildableName = "KanbanGit.app"
|
||||||
|
BlueprintName = "kanbangit-native_iOS"
|
||||||
|
ReferencedContainer = "container:kanbangit-native.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
<EnvironmentVariables>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "RUST_BACKTRACE"
|
||||||
|
value = "full"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "RUST_LOG"
|
||||||
|
value = "info"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
</EnvironmentVariables>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "NO"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "721CF221CEDDC28FF2C5EBD5"
|
||||||
|
BuildableName = "KanbanGit.app"
|
||||||
|
BlueprintName = "kanbangit-native_iOS"
|
||||||
|
ReferencedContainer = "container:kanbangit-native.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
<EnvironmentVariables>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "RUST_BACKTRACE"
|
||||||
|
value = "full"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "RUST_LOG"
|
||||||
|
value = "info"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
</EnvironmentVariables>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>0.1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>0.1.0</string>
|
||||||
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
<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>
|
||||||
|
<string>LaunchScreen</string>
|
||||||
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
|
<array>
|
||||||
|
<string>arm64</string>
|
||||||
|
<string>metal</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict/>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
name: kanbangit-native
|
||||||
|
options:
|
||||||
|
bundleIdPrefix: de.kanbangit.desktop
|
||||||
|
deploymentTarget:
|
||||||
|
iOS: 17.0
|
||||||
|
fileGroups: [../../src]
|
||||||
|
configs:
|
||||||
|
debug: debug
|
||||||
|
release: release
|
||||||
|
settingGroups:
|
||||||
|
app:
|
||||||
|
base:
|
||||||
|
PRODUCT_NAME: KanbanGit
|
||||||
|
SWIFT_VERSION: "5.0"
|
||||||
|
PRODUCT_MODULE_NAME: KanbanGitNative
|
||||||
|
TARGETED_DEVICE_FAMILY: "1,2"
|
||||||
|
CODE_SIGN_STYLE: Automatic
|
||||||
|
DEVELOPMENT_TEAM: ""
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING: false
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.desktop
|
||||||
|
targetTemplates:
|
||||||
|
app:
|
||||||
|
type: application
|
||||||
|
sources:
|
||||||
|
- path: Sources
|
||||||
|
scheme:
|
||||||
|
environmentVariables:
|
||||||
|
RUST_BACKTRACE: full
|
||||||
|
RUST_LOG: info
|
||||||
|
settings:
|
||||||
|
groups: [app]
|
||||||
|
targets:
|
||||||
|
kanbangit-native_iOS:
|
||||||
|
type: application
|
||||||
|
platform: iOS
|
||||||
|
sources:
|
||||||
|
- path: Sources
|
||||||
|
- path: Assets.xcassets
|
||||||
|
- path: kanbangit-native_iOS
|
||||||
|
- path: LaunchScreen.storyboard
|
||||||
|
info:
|
||||||
|
path: kanbangit-native_iOS/Info.plist
|
||||||
|
properties:
|
||||||
|
NSCameraUsageDescription: Choose a profile picture using your camera.
|
||||||
|
NSPhotoLibraryUsageDescription: Choose a profile picture from your photo library.
|
||||||
|
LSRequiresIPhoneOS: true
|
||||||
|
UILaunchStoryboardName: LaunchScreen
|
||||||
|
UIRequiredDeviceCapabilities: [arm64, metal]
|
||||||
|
UISupportedInterfaceOrientations:
|
||||||
|
- UIInterfaceOrientationPortrait
|
||||||
|
- UIInterfaceOrientationLandscapeLeft
|
||||||
|
- UIInterfaceOrientationLandscapeRight
|
||||||
|
UISupportedInterfaceOrientations~ipad:
|
||||||
|
- UIInterfaceOrientationPortrait
|
||||||
|
- UIInterfaceOrientationPortraitUpsideDown
|
||||||
|
- UIInterfaceOrientationLandscapeLeft
|
||||||
|
- UIInterfaceOrientationLandscapeRight
|
||||||
|
CFBundleShortVersionString: 0.1.0
|
||||||
|
CFBundleVersion: "0.1.0"
|
||||||
|
entitlements:
|
||||||
|
path: kanbangit-native_iOS/kanbangit-native_iOS.entitlements
|
||||||
|
scheme:
|
||||||
|
environmentVariables:
|
||||||
|
RUST_BACKTRACE: full
|
||||||
|
RUST_LOG: info
|
||||||
|
testTargets:
|
||||||
|
- WorkspaceTests
|
||||||
|
- WorkspaceUITests
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
ENABLE_BITCODE: false
|
||||||
|
ARCHS: [arm64]
|
||||||
|
VALID_ARCHS: arm64
|
||||||
|
LIBRARY_SEARCH_PATHS[arch=x86_64]: $(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)
|
||||||
|
LIBRARY_SEARCH_PATHS[arch=arm64]: $(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)
|
||||||
|
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES: true
|
||||||
|
EXCLUDED_ARCHS[sdk=iphoneos*]: x86_64
|
||||||
|
groups: [app]
|
||||||
|
dependencies:
|
||||||
|
- framework: libapp.a
|
||||||
|
embed: false
|
||||||
|
- sdk: CoreGraphics.framework
|
||||||
|
- sdk: Metal.framework
|
||||||
|
- sdk: MetalKit.framework
|
||||||
|
- sdk: QuartzCore.framework
|
||||||
|
- sdk: Security.framework
|
||||||
|
- sdk: UIKit.framework
|
||||||
|
- sdk: WebKit.framework
|
||||||
|
preBuildScripts:
|
||||||
|
- script: |
|
||||||
|
export PATH="$(dirname "$(rustup which cargo)"):$PATH"
|
||||||
|
export RUSTC="$(rustup which rustc)"
|
||||||
|
cd "$SRCROOT/../../.."
|
||||||
|
./node_modules/.bin/tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths "${FRAMEWORK_SEARCH_PATHS:?}" --header-search-paths "${HEADER_SEARCH_PATHS:?}" --gcc-preprocessor-definitions "${GCC_PREPROCESSOR_DEFINITIONS:-}" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}
|
||||||
|
name: Build Rust Code
|
||||||
|
basedOnDependencyAnalysis: false
|
||||||
|
outputFiles:
|
||||||
|
- $(SRCROOT)/Externals/x86_64/${CONFIGURATION}/libapp.a
|
||||||
|
- $(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a
|
||||||
|
WorkspaceTests:
|
||||||
|
type: bundle.unit-test
|
||||||
|
platform: iOS
|
||||||
|
sources:
|
||||||
|
- path: Tests
|
||||||
|
- path: Sources/kanbangit-native/WorkspacePolicy.swift
|
||||||
|
- path: Sources/kanbangit-native/WorkspaceViewController.swift
|
||||||
|
- path: Sources/kanbangit-native/en.lproj
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.tests
|
||||||
|
SWIFT_VERSION: "5.0"
|
||||||
|
WorkspaceUITests:
|
||||||
|
type: bundle.ui-testing
|
||||||
|
platform: iOS
|
||||||
|
sources: [UITests]
|
||||||
|
dependencies:
|
||||||
|
- target: kanbangit-native_iOS
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.uitests
|
||||||
|
SWIFT_VERSION: "5.0"
|
||||||
|
TEST_TARGET_NAME: kanbangit-native_iOS
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
use tauri::{WebviewUrl, WebviewWindowBuilder};
|
||||||
|
|
||||||
|
pub fn context<R: tauri::Runtime>() -> tauri::Context<R> {
|
||||||
|
tauri::generate_context!()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn builder() -> tauri::Builder<tauri::Wry> {
|
||||||
|
tauri::Builder::default().setup(|app| {
|
||||||
|
WebviewWindowBuilder::new(app, "launcher", WebviewUrl::App("index.html".into()))
|
||||||
|
.use_https_scheme(true)
|
||||||
|
.on_navigation(|url| {
|
||||||
|
url.scheme() == "https"
|
||||||
|
&& url.host_str() == Some("tauri.localhost")
|
||||||
|
&& url.port().is_none()
|
||||||
|
&& url.username().is_empty()
|
||||||
|
&& url.password().is_none()
|
||||||
|
&& matches!(url.path(), "/" | "/index.html")
|
||||||
|
})
|
||||||
|
.build()?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,529 @@
|
|||||||
|
use crate::locale::en;
|
||||||
|
use crate::server::{validate_url, Server, DEFAULT_SERVER};
|
||||||
|
use std::{path::PathBuf, sync::Mutex, time::Duration};
|
||||||
|
use tauri::{
|
||||||
|
menu::{MenuBuilder, SubmenuBuilder},
|
||||||
|
webview::{NewWindowResponse, PageLoadEvent},
|
||||||
|
AppHandle, Manager, Url, WebviewUrl, WebviewWindowBuilder, WindowEvent,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Connection {
|
||||||
|
server: Option<Server>,
|
||||||
|
label: Option<String>,
|
||||||
|
revision: u64,
|
||||||
|
ready: bool,
|
||||||
|
phase: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Connection {
|
||||||
|
fn begin_navigation(&mut self) -> u64 {
|
||||||
|
self.revision += 1;
|
||||||
|
self.phase = "connecting";
|
||||||
|
self.revision
|
||||||
|
}
|
||||||
|
|
||||||
|
fn timeout(&mut self, revision: u64) -> bool {
|
||||||
|
if self.revision != revision || self.phase != "connecting" {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.phase = "connectionError";
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allows_server_navigation(server: &Url, destination: &Url) -> bool {
|
||||||
|
destination.scheme() == "https"
|
||||||
|
&& destination.origin() == server.origin()
|
||||||
|
&& destination.username().is_empty()
|
||||||
|
&& 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 {
|
||||||
|
destination.scheme() == "tauri"
|
||||||
|
&& destination.host_str() == Some("localhost")
|
||||||
|
&& destination.port().is_none()
|
||||||
|
&& destination.username().is_empty()
|
||||||
|
&& destination.password().is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn configuration_path(app: &AppHandle) -> tauri::Result<PathBuf> {
|
||||||
|
Ok(app.path().app_config_dir()?.join("active-server"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_launcher(app: &AppHandle) {
|
||||||
|
let state = app.state::<Mutex<Connection>>();
|
||||||
|
let state = state.lock().unwrap();
|
||||||
|
let mut message = Url::parse("tauri://localhost/").unwrap();
|
||||||
|
message
|
||||||
|
.query_pairs_mut()
|
||||||
|
.append_pair(
|
||||||
|
"server",
|
||||||
|
state
|
||||||
|
.server
|
||||||
|
.as_ref()
|
||||||
|
.map_or(DEFAULT_SERVER, |server| server.url.as_str()),
|
||||||
|
)
|
||||||
|
.append_pair("phase", state.phase)
|
||||||
|
.append_pair("ready", if state.ready { "true" } else { "false" });
|
||||||
|
if let Some(window) = app.get_webview_window("launcher") {
|
||||||
|
let _ = window.eval(format!(
|
||||||
|
"window.updateLauncher?.('{}')",
|
||||||
|
message.query().unwrap()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_launcher(app: &AppHandle) {
|
||||||
|
update_launcher(app);
|
||||||
|
if let Some(window) = app.get_webview_window("launcher") {
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn begin_navigation(app: &AppHandle, label: &str) {
|
||||||
|
let state = app.state::<Mutex<Connection>>();
|
||||||
|
let revision = {
|
||||||
|
let mut state = state.lock().unwrap();
|
||||||
|
if state.label.as_deref() != Some(label) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.begin_navigation()
|
||||||
|
};
|
||||||
|
update_launcher(app);
|
||||||
|
let handle = app.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
std::thread::sleep(Duration::from_secs(15));
|
||||||
|
let state = handle.state::<Mutex<Connection>>();
|
||||||
|
let timed_out = state.lock().unwrap().timeout(revision);
|
||||||
|
if timed_out {
|
||||||
|
show_launcher(&handle);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
|
||||||
|
let url = validate_url(input)?;
|
||||||
|
let state = app.state::<Mutex<Connection>>();
|
||||||
|
let (server, old_label, label) = {
|
||||||
|
let mut state = state.lock().unwrap();
|
||||||
|
let same_server = state
|
||||||
|
.server
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|server| server.url == url);
|
||||||
|
if same_server && state.ready {
|
||||||
|
if let Some(window) = state
|
||||||
|
.label
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|label| app.get_webview_window(label))
|
||||||
|
{
|
||||||
|
window.show().map_err(|_| "connectionError")?;
|
||||||
|
window.set_focus().map_err(|_| "connectionError")?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let server = if same_server {
|
||||||
|
state.server.clone().unwrap()
|
||||||
|
} else {
|
||||||
|
Server::new(url).map_err(|_| "storageError")?
|
||||||
|
};
|
||||||
|
server
|
||||||
|
.save(&configuration_path(app).map_err(|_| "storageError")?)
|
||||||
|
.map_err(|_| "storageError")?;
|
||||||
|
let old_label = state.label.take();
|
||||||
|
state.server = Some(server.clone());
|
||||||
|
state.ready = false;
|
||||||
|
state.revision += 1;
|
||||||
|
let label = format!("workspace-{}", state.revision);
|
||||||
|
state.label = Some(label.clone());
|
||||||
|
(server, old_label, label)
|
||||||
|
};
|
||||||
|
if let Some(window) = old_label.and_then(|label| app.get_webview_window(&label)) {
|
||||||
|
window.close().map_err(|_| "connectionError")?;
|
||||||
|
}
|
||||||
|
begin_navigation(app, &label);
|
||||||
|
let handle = app.clone();
|
||||||
|
let navigation_label = label.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))
|
||||||
|
.title(en::APP_NAME)
|
||||||
|
.inner_size(1280.0, 850.0)
|
||||||
|
.min_inner_size(375.0, 600.0)
|
||||||
|
.center()
|
||||||
|
.visible(false)
|
||||||
|
.data_store_identifier(server.profile)
|
||||||
|
.on_navigation(move |destination| {
|
||||||
|
if !allows_server_navigation(&server_url, destination) {
|
||||||
|
open_external(destination);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
begin_navigation(&handle, &navigation_label);
|
||||||
|
true
|
||||||
|
})
|
||||||
|
.on_new_window(move |destination, _| {
|
||||||
|
if allows_server_navigation(&popup_server, &destination) {
|
||||||
|
if let Some(window) = popup_handle.get_webview_window(&popup_label) {
|
||||||
|
let _ = window.navigate(destination);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
open_external(&destination);
|
||||||
|
}
|
||||||
|
NewWindowResponse::Deny
|
||||||
|
})
|
||||||
|
.on_page_load(|window, payload| {
|
||||||
|
if payload.event() != PageLoadEvent::Finished {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let app = window.app_handle();
|
||||||
|
let state = app.state::<Mutex<Connection>>();
|
||||||
|
let should_show = {
|
||||||
|
let mut state = state.lock().unwrap();
|
||||||
|
if state.label.as_deref() != Some(window.label()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let first_load = !state.ready;
|
||||||
|
state.ready = true;
|
||||||
|
state.phase = "connected";
|
||||||
|
first_load
|
||||||
|
};
|
||||||
|
update_launcher(app);
|
||||||
|
if should_show {
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
if let Some(launcher) = app.get_webview_window("launcher") {
|
||||||
|
let _ = launcher.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
.map_err(|_| "connectionError")?;
|
||||||
|
let handle = app.clone();
|
||||||
|
window.on_window_event(move |event| {
|
||||||
|
if matches!(event, WindowEvent::Destroyed) {
|
||||||
|
let state = handle.state::<Mutex<Connection>>();
|
||||||
|
let current = {
|
||||||
|
let mut state = state.lock().unwrap();
|
||||||
|
if state.label.as_deref() == Some(&label) {
|
||||||
|
state.label = None;
|
||||||
|
state.ready = false;
|
||||||
|
state.revision += 1;
|
||||||
|
state.phase = "idle";
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if current {
|
||||||
|
show_launcher(&handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_launcher_action(app: &AppHandle, destination: &Url) {
|
||||||
|
let result = match destination.path() {
|
||||||
|
"/action/open" => {
|
||||||
|
let input = destination
|
||||||
|
.query_pairs()
|
||||||
|
.find(|(key, _)| key == "server")
|
||||||
|
.map(|(_, value)| value.into_owned())
|
||||||
|
.unwrap_or_default();
|
||||||
|
open_workspace(app, &input)
|
||||||
|
}
|
||||||
|
"/action/resume" => {
|
||||||
|
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.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
_ => Ok(()),
|
||||||
|
};
|
||||||
|
if let Err(error) = result {
|
||||||
|
app.state::<Mutex<Connection>>().lock().unwrap().phase = error;
|
||||||
|
show_launcher(app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn context<R: tauri::Runtime>() -> tauri::Context<R> {
|
||||||
|
tauri::generate_context!()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn builder() -> tauri::Builder<tauri::Wry> {
|
||||||
|
tauri::Builder::default().setup(|app| {
|
||||||
|
let (server, phase) = match Server::load(&configuration_path(app.handle())?) {
|
||||||
|
Ok(server) => (server, "idle"),
|
||||||
|
Err(_) => (None, "storageError"),
|
||||||
|
};
|
||||||
|
app.manage(Mutex::new(Connection {
|
||||||
|
server,
|
||||||
|
phase,
|
||||||
|
..Default::default()
|
||||||
|
}));
|
||||||
|
let menu = MenuBuilder::new(app)
|
||||||
|
.item(
|
||||||
|
&SubmenuBuilder::new(app, en::APP_NAME)
|
||||||
|
.text("server-settings", en::SERVER_SETTINGS)
|
||||||
|
.separator()
|
||||||
|
.quit()
|
||||||
|
.build()?,
|
||||||
|
)
|
||||||
|
.item(
|
||||||
|
&SubmenuBuilder::new(app, en::EDIT)
|
||||||
|
.undo()
|
||||||
|
.redo()
|
||||||
|
.separator()
|
||||||
|
.cut()
|
||||||
|
.copy()
|
||||||
|
.paste()
|
||||||
|
.select_all()
|
||||||
|
.build()?,
|
||||||
|
)
|
||||||
|
.item(
|
||||||
|
&SubmenuBuilder::new(app, en::NAVIGATION)
|
||||||
|
.text("workspace-back", en::BACK)
|
||||||
|
.text("workspace-forward", en::FORWARD)
|
||||||
|
.build()?,
|
||||||
|
)
|
||||||
|
.build()?;
|
||||||
|
app.set_menu(menu)?;
|
||||||
|
app.on_menu_event(|app, event| {
|
||||||
|
if event.id().as_ref() == "server-settings" {
|
||||||
|
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 launcher =
|
||||||
|
WebviewWindowBuilder::new(app, "launcher", WebviewUrl::App("index.html".into()))
|
||||||
|
.title(en::APP_NAME)
|
||||||
|
.inner_size(560.0, 720.0)
|
||||||
|
.min_inner_size(375.0, 640.0)
|
||||||
|
.center()
|
||||||
|
.on_navigation(move |destination| {
|
||||||
|
if !allows_launcher_navigation(destination) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if destination.path().starts_with("/action/") {
|
||||||
|
let handle = handle.clone();
|
||||||
|
let destination = destination.clone();
|
||||||
|
let _ = handle.clone().run_on_main_thread(move || {
|
||||||
|
handle_launcher_action(&handle, &destination)
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
matches!(destination.path(), "" | "/" | "/index.html")
|
||||||
|
})
|
||||||
|
.on_new_window(|_, _| NewWindowResponse::Deny)
|
||||||
|
.on_page_load(|window, payload| {
|
||||||
|
if payload.event() == PageLoadEvent::Finished {
|
||||||
|
let app = window.app_handle().clone();
|
||||||
|
update_launcher(&app);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let handle = app.clone();
|
||||||
|
let _ = app.run_on_main_thread(move || {
|
||||||
|
let server = handle
|
||||||
|
.state::<Mutex<Connection>>()
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.server
|
||||||
|
.clone();
|
||||||
|
if let Some(server) = server {
|
||||||
|
if let Err(error) = open_workspace(&handle, server.url.as_str())
|
||||||
|
{
|
||||||
|
handle.state::<Mutex<Connection>>().lock().unwrap().phase =
|
||||||
|
error;
|
||||||
|
show_launcher(&handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build()?;
|
||||||
|
let window = launcher.clone();
|
||||||
|
launcher.on_window_event(move |event| {
|
||||||
|
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||||
|
api.prevent_close();
|
||||||
|
let _ = window.hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
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]
|
||||||
|
fn navigation_is_limited_to_the_selected_https_origin() {
|
||||||
|
let server = validate_url("https://example.com:8443/app/").unwrap();
|
||||||
|
for path in [
|
||||||
|
"/login",
|
||||||
|
"/two-factor-challenge",
|
||||||
|
"/projects/1?ticket=52#details",
|
||||||
|
] {
|
||||||
|
assert!(allows_server_navigation(
|
||||||
|
&server,
|
||||||
|
&server.join(path).unwrap()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for destination in [
|
||||||
|
"http://example.com:8443/",
|
||||||
|
"https://example.com/",
|
||||||
|
"https://other.example.com:8443/",
|
||||||
|
"https://user:secret@example.com:8443/",
|
||||||
|
"tauri://localhost/action/open?server=https://evil.test",
|
||||||
|
"file:///etc/passwd",
|
||||||
|
"data:text/html,hello",
|
||||||
|
"javascript:alert(1)",
|
||||||
|
] {
|
||||||
|
assert!(!allows_server_navigation(
|
||||||
|
&server,
|
||||||
|
&Url::parse(destination).unwrap()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn launcher_actions_are_restricted_to_the_bundled_origin() {
|
||||||
|
assert!(allows_launcher_navigation(
|
||||||
|
&Url::parse("tauri://localhost/action/open").unwrap()
|
||||||
|
));
|
||||||
|
for input in [
|
||||||
|
DEFAULT_SERVER,
|
||||||
|
"https://localhost/",
|
||||||
|
"tauri://other/index.html",
|
||||||
|
"tauri://user@localhost/index.html",
|
||||||
|
] {
|
||||||
|
assert!(!allows_launcher_navigation(&Url::parse(input).unwrap()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stale_timeouts_do_not_replace_newer_connections_or_loaded_forms() {
|
||||||
|
let mut state = Connection::default();
|
||||||
|
let first = state.begin_navigation();
|
||||||
|
let second = state.begin_navigation();
|
||||||
|
assert!(!state.timeout(first));
|
||||||
|
state.ready = true;
|
||||||
|
assert!(state.timeout(second));
|
||||||
|
assert!(state.ready);
|
||||||
|
assert!(!state.timeout(second));
|
||||||
|
let third = state.begin_navigation();
|
||||||
|
state.phase = "connected";
|
||||||
|
assert!(!state.timeout(third));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remote_pages_cannot_invoke_native_commands() {
|
||||||
|
let app = tauri::test::mock_builder().build(context()).unwrap();
|
||||||
|
let window = WebviewWindowBuilder::new(
|
||||||
|
&app,
|
||||||
|
"workspace-1",
|
||||||
|
WebviewUrl::External(Url::parse(DEFAULT_SERVER).unwrap()),
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
for command in [
|
||||||
|
"plugin:window|close",
|
||||||
|
"plugin:webview|create_webview_window",
|
||||||
|
"plugin:path|resolve_directory",
|
||||||
|
] {
|
||||||
|
let response = tauri::test::get_ipc_response(
|
||||||
|
&window,
|
||||||
|
tauri::webview::InvokeRequest {
|
||||||
|
cmd: command.into(),
|
||||||
|
callback: tauri::ipc::CallbackFn(0),
|
||||||
|
error: tauri::ipc::CallbackFn(1),
|
||||||
|
url: Url::parse(DEFAULT_SERVER).unwrap(),
|
||||||
|
body: tauri::ipc::InvokeBody::default(),
|
||||||
|
headers: Default::default(),
|
||||||
|
invoke_key: tauri::test::INVOKE_KEY.into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(response
|
||||||
|
.unwrap_err()
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("not allowed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn packaged_app_exposes_no_native_capabilities_or_global_api() {
|
||||||
|
let config: serde_json::Value =
|
||||||
|
serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
|
||||||
|
assert_eq!(config["bundle"]["macOS"]["minimumSystemVersion"], "14.0");
|
||||||
|
assert_eq!(config["app"]["withGlobalTauri"], false);
|
||||||
|
assert_eq!(
|
||||||
|
config["app"]["security"]["capabilities"],
|
||||||
|
serde_json::json!([])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
use tauri::{WebviewUrl, WebviewWindowBuilder};
|
||||||
|
|
||||||
|
pub fn context<R: tauri::Runtime>() -> tauri::Context<R> {
|
||||||
|
tauri::generate_context!()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn builder() -> tauri::Builder<tauri::Wry> {
|
||||||
|
tauri::Builder::default().setup(|app| {
|
||||||
|
WebviewWindowBuilder::new(app, "host", WebviewUrl::App("index.html".into()))
|
||||||
|
.on_navigation(|_| false)
|
||||||
|
.build()?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,464 +1,22 @@
|
|||||||
|
#[cfg(target_os = "android")]
|
||||||
|
mod android;
|
||||||
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
|
mod desktop;
|
||||||
|
#[cfg(target_os = "ios")]
|
||||||
|
mod ios;
|
||||||
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
mod locale;
|
mod locale;
|
||||||
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
mod server;
|
mod server;
|
||||||
|
|
||||||
use locale::en;
|
#[cfg(target_os = "android")]
|
||||||
use server::{validate_url, Server, DEFAULT_SERVER};
|
pub use android::{builder, context};
|
||||||
use std::{path::PathBuf, sync::Mutex, time::Duration};
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
use tauri::{
|
pub use desktop::{builder, context};
|
||||||
menu::{MenuBuilder, SubmenuBuilder},
|
#[cfg(target_os = "ios")]
|
||||||
webview::{NewWindowResponse, PageLoadEvent},
|
pub use ios::{builder, context};
|
||||||
AppHandle, Manager, Url, WebviewUrl, WebviewWindowBuilder, WindowEvent,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct Connection {
|
|
||||||
server: Option<Server>,
|
|
||||||
label: Option<String>,
|
|
||||||
revision: u64,
|
|
||||||
ready: bool,
|
|
||||||
phase: &'static str,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Connection {
|
|
||||||
fn begin_navigation(&mut self) -> u64 {
|
|
||||||
self.revision += 1;
|
|
||||||
self.phase = "connecting";
|
|
||||||
self.revision
|
|
||||||
}
|
|
||||||
|
|
||||||
fn timeout(&mut self, revision: u64) -> bool {
|
|
||||||
if self.revision != revision || self.phase != "connecting" {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
self.phase = "connectionError";
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn allows_server_navigation(server: &Url, destination: &Url) -> bool {
|
|
||||||
destination.scheme() == "https"
|
|
||||||
&& destination.origin() == server.origin()
|
|
||||||
&& destination.username().is_empty()
|
|
||||||
&& destination.password().is_none()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn allows_launcher_navigation(destination: &Url) -> bool {
|
|
||||||
destination.scheme() == "tauri"
|
|
||||||
&& destination.host_str() == Some("localhost")
|
|
||||||
&& destination.port().is_none()
|
|
||||||
&& destination.username().is_empty()
|
|
||||||
&& destination.password().is_none()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn configuration_path(app: &AppHandle) -> tauri::Result<PathBuf> {
|
|
||||||
Ok(app.path().app_config_dir()?.join("active-server"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update_launcher(app: &AppHandle) {
|
|
||||||
let state = app.state::<Mutex<Connection>>();
|
|
||||||
let state = state.lock().unwrap();
|
|
||||||
let mut message = Url::parse("tauri://localhost/").unwrap();
|
|
||||||
message
|
|
||||||
.query_pairs_mut()
|
|
||||||
.append_pair(
|
|
||||||
"server",
|
|
||||||
state
|
|
||||||
.server
|
|
||||||
.as_ref()
|
|
||||||
.map_or(DEFAULT_SERVER, |server| server.url.as_str()),
|
|
||||||
)
|
|
||||||
.append_pair("phase", state.phase)
|
|
||||||
.append_pair("ready", if state.ready { "true" } else { "false" });
|
|
||||||
if let Some(window) = app.get_webview_window("launcher") {
|
|
||||||
let _ = window.eval(format!(
|
|
||||||
"window.updateLauncher?.('{}')",
|
|
||||||
message.query().unwrap()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn show_launcher(app: &AppHandle) {
|
|
||||||
update_launcher(app);
|
|
||||||
if let Some(window) = app.get_webview_window("launcher") {
|
|
||||||
let _ = window.show();
|
|
||||||
let _ = window.set_focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn begin_navigation(app: &AppHandle, label: &str) {
|
|
||||||
let state = app.state::<Mutex<Connection>>();
|
|
||||||
let revision = {
|
|
||||||
let mut state = state.lock().unwrap();
|
|
||||||
if state.label.as_deref() != Some(label) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
state.begin_navigation()
|
|
||||||
};
|
|
||||||
update_launcher(app);
|
|
||||||
let handle = app.clone();
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
std::thread::sleep(Duration::from_secs(15));
|
|
||||||
let state = handle.state::<Mutex<Connection>>();
|
|
||||||
let timed_out = state.lock().unwrap().timeout(revision);
|
|
||||||
if timed_out {
|
|
||||||
show_launcher(&handle);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
|
|
||||||
let url = validate_url(input)?;
|
|
||||||
let state = app.state::<Mutex<Connection>>();
|
|
||||||
let (server, old_label, label) = {
|
|
||||||
let mut state = state.lock().unwrap();
|
|
||||||
let same_server = state
|
|
||||||
.server
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|server| server.url == url);
|
|
||||||
if same_server && state.ready {
|
|
||||||
if let Some(window) = state
|
|
||||||
.label
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|label| app.get_webview_window(label))
|
|
||||||
{
|
|
||||||
window.show().map_err(|_| "connectionError")?;
|
|
||||||
window.set_focus().map_err(|_| "connectionError")?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let server = if same_server {
|
|
||||||
state.server.clone().unwrap()
|
|
||||||
} else {
|
|
||||||
Server::new(url).map_err(|_| "storageError")?
|
|
||||||
};
|
|
||||||
server
|
|
||||||
.save(&configuration_path(app).map_err(|_| "storageError")?)
|
|
||||||
.map_err(|_| "storageError")?;
|
|
||||||
let old_label = state.label.take();
|
|
||||||
state.server = Some(server.clone());
|
|
||||||
state.ready = false;
|
|
||||||
state.revision += 1;
|
|
||||||
let label = format!("workspace-{}", state.revision);
|
|
||||||
state.label = Some(label.clone());
|
|
||||||
(server, old_label, label)
|
|
||||||
};
|
|
||||||
if let Some(window) = old_label.and_then(|label| app.get_webview_window(&label)) {
|
|
||||||
window.close().map_err(|_| "connectionError")?;
|
|
||||||
}
|
|
||||||
begin_navigation(app, &label);
|
|
||||||
let handle = app.clone();
|
|
||||||
let navigation_label = label.clone();
|
|
||||||
let server_url = server.url.clone();
|
|
||||||
let window = WebviewWindowBuilder::new(app, &label, WebviewUrl::External(server.url))
|
|
||||||
.title(en::APP_NAME)
|
|
||||||
.inner_size(1280.0, 850.0)
|
|
||||||
.min_inner_size(375.0, 600.0)
|
|
||||||
.center()
|
|
||||||
.visible(false)
|
|
||||||
.data_store_identifier(server.profile)
|
|
||||||
.on_navigation(move |destination| {
|
|
||||||
if !allows_server_navigation(&server_url, destination) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
begin_navigation(&handle, &navigation_label);
|
|
||||||
true
|
|
||||||
})
|
|
||||||
.on_new_window(|_, _| NewWindowResponse::Deny)
|
|
||||||
.on_page_load(|window, payload| {
|
|
||||||
if payload.event() != PageLoadEvent::Finished {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let app = window.app_handle();
|
|
||||||
let state = app.state::<Mutex<Connection>>();
|
|
||||||
let should_show = {
|
|
||||||
let mut state = state.lock().unwrap();
|
|
||||||
if state.label.as_deref() != Some(window.label()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let first_load = !state.ready;
|
|
||||||
state.ready = true;
|
|
||||||
state.phase = "connected";
|
|
||||||
first_load
|
|
||||||
};
|
|
||||||
update_launcher(app);
|
|
||||||
if should_show {
|
|
||||||
let _ = window.show();
|
|
||||||
let _ = window.set_focus();
|
|
||||||
if let Some(launcher) = app.get_webview_window("launcher") {
|
|
||||||
let _ = launcher.hide();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.build()
|
|
||||||
.map_err(|_| "connectionError")?;
|
|
||||||
let handle = app.clone();
|
|
||||||
window.on_window_event(move |event| {
|
|
||||||
if matches!(event, WindowEvent::Destroyed) {
|
|
||||||
let state = handle.state::<Mutex<Connection>>();
|
|
||||||
let current = {
|
|
||||||
let mut state = state.lock().unwrap();
|
|
||||||
if state.label.as_deref() == Some(&label) {
|
|
||||||
state.label = None;
|
|
||||||
state.ready = false;
|
|
||||||
state.revision += 1;
|
|
||||||
state.phase = "idle";
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if current {
|
|
||||||
show_launcher(&handle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_launcher_action(app: &AppHandle, destination: &Url) {
|
|
||||||
let result = match destination.path() {
|
|
||||||
"/action/open" => {
|
|
||||||
let input = destination
|
|
||||||
.query_pairs()
|
|
||||||
.find(|(key, _)| key == "server")
|
|
||||||
.map(|(_, value)| value.into_owned())
|
|
||||||
.unwrap_or_default();
|
|
||||||
open_workspace(app, &input)
|
|
||||||
}
|
|
||||||
"/action/resume" => {
|
|
||||||
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.show();
|
|
||||||
let _ = window.set_focus();
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
_ => Ok(()),
|
|
||||||
};
|
|
||||||
if let Err(error) = result {
|
|
||||||
app.state::<Mutex<Connection>>().lock().unwrap().phase = error;
|
|
||||||
show_launcher(app);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn context<R: tauri::Runtime>() -> tauri::Context<R> {
|
|
||||||
tauri::generate_context!()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn builder() -> tauri::Builder<tauri::Wry> {
|
|
||||||
tauri::Builder::default().setup(|app| {
|
|
||||||
let (server, phase) = match Server::load(&configuration_path(app.handle())?) {
|
|
||||||
Ok(server) => (server, "idle"),
|
|
||||||
Err(_) => (None, "storageError"),
|
|
||||||
};
|
|
||||||
app.manage(Mutex::new(Connection {
|
|
||||||
server,
|
|
||||||
phase,
|
|
||||||
..Default::default()
|
|
||||||
}));
|
|
||||||
let menu = MenuBuilder::new(app)
|
|
||||||
.item(
|
|
||||||
&SubmenuBuilder::new(app, en::APP_NAME)
|
|
||||||
.text("server-settings", en::SERVER_SETTINGS)
|
|
||||||
.separator()
|
|
||||||
.quit()
|
|
||||||
.build()?,
|
|
||||||
)
|
|
||||||
.item(
|
|
||||||
&SubmenuBuilder::new(app, en::EDIT)
|
|
||||||
.undo()
|
|
||||||
.redo()
|
|
||||||
.separator()
|
|
||||||
.cut()
|
|
||||||
.copy()
|
|
||||||
.paste()
|
|
||||||
.select_all()
|
|
||||||
.build()?,
|
|
||||||
)
|
|
||||||
.build()?;
|
|
||||||
app.set_menu(menu)?;
|
|
||||||
app.on_menu_event(|app, event| {
|
|
||||||
if event.id().as_ref() == "server-settings" {
|
|
||||||
show_launcher(app);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let handle = app.handle().clone();
|
|
||||||
let launcher =
|
|
||||||
WebviewWindowBuilder::new(app, "launcher", WebviewUrl::App("index.html".into()))
|
|
||||||
.title(en::APP_NAME)
|
|
||||||
.inner_size(560.0, 720.0)
|
|
||||||
.min_inner_size(375.0, 640.0)
|
|
||||||
.center()
|
|
||||||
.on_navigation(move |destination| {
|
|
||||||
if !allows_launcher_navigation(destination) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if destination.path().starts_with("/action/") {
|
|
||||||
let handle = handle.clone();
|
|
||||||
let destination = destination.clone();
|
|
||||||
let _ = handle.clone().run_on_main_thread(move || {
|
|
||||||
handle_launcher_action(&handle, &destination)
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
matches!(destination.path(), "" | "/" | "/index.html")
|
|
||||||
})
|
|
||||||
.on_new_window(|_, _| NewWindowResponse::Deny)
|
|
||||||
.on_page_load(|window, payload| {
|
|
||||||
if payload.event() == PageLoadEvent::Finished {
|
|
||||||
let app = window.app_handle().clone();
|
|
||||||
update_launcher(&app);
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
let handle = app.clone();
|
|
||||||
let _ = app.run_on_main_thread(move || {
|
|
||||||
let server = handle
|
|
||||||
.state::<Mutex<Connection>>()
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.server
|
|
||||||
.clone();
|
|
||||||
if let Some(server) = server {
|
|
||||||
if let Err(error) = open_workspace(&handle, server.url.as_str())
|
|
||||||
{
|
|
||||||
handle.state::<Mutex<Connection>>().lock().unwrap().phase =
|
|
||||||
error;
|
|
||||||
show_launcher(&handle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.build()?;
|
|
||||||
let window = launcher.clone();
|
|
||||||
launcher.on_window_event(move |event| {
|
|
||||||
if let WindowEvent::CloseRequested { api, .. } = event {
|
|
||||||
api.prevent_close();
|
|
||||||
let _ = window.hide();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
builder().run(context()).expect("Could not start KanbanGit");
|
builder().run(context()).expect("Could not start KanbanGit");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn navigation_is_limited_to_the_selected_https_origin() {
|
|
||||||
let server = validate_url("https://example.com:8443/app/").unwrap();
|
|
||||||
for path in [
|
|
||||||
"/login",
|
|
||||||
"/two-factor-challenge",
|
|
||||||
"/projects/1?ticket=52#details",
|
|
||||||
] {
|
|
||||||
assert!(allows_server_navigation(
|
|
||||||
&server,
|
|
||||||
&server.join(path).unwrap()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
for destination in [
|
|
||||||
"http://example.com:8443/",
|
|
||||||
"https://example.com/",
|
|
||||||
"https://other.example.com:8443/",
|
|
||||||
"https://user:secret@example.com:8443/",
|
|
||||||
"tauri://localhost/action/open?server=https://evil.test",
|
|
||||||
"file:///etc/passwd",
|
|
||||||
"data:text/html,hello",
|
|
||||||
"javascript:alert(1)",
|
|
||||||
] {
|
|
||||||
assert!(!allows_server_navigation(
|
|
||||||
&server,
|
|
||||||
&Url::parse(destination).unwrap()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn launcher_actions_are_restricted_to_the_bundled_origin() {
|
|
||||||
assert!(allows_launcher_navigation(
|
|
||||||
&Url::parse("tauri://localhost/action/open").unwrap()
|
|
||||||
));
|
|
||||||
for input in [
|
|
||||||
DEFAULT_SERVER,
|
|
||||||
"https://localhost/",
|
|
||||||
"tauri://other/index.html",
|
|
||||||
"tauri://user@localhost/index.html",
|
|
||||||
] {
|
|
||||||
assert!(!allows_launcher_navigation(&Url::parse(input).unwrap()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn stale_timeouts_do_not_replace_newer_connections_or_loaded_forms() {
|
|
||||||
let mut state = Connection::default();
|
|
||||||
let first = state.begin_navigation();
|
|
||||||
let second = state.begin_navigation();
|
|
||||||
assert!(!state.timeout(first));
|
|
||||||
state.ready = true;
|
|
||||||
assert!(state.timeout(second));
|
|
||||||
assert!(state.ready);
|
|
||||||
assert!(!state.timeout(second));
|
|
||||||
let third = state.begin_navigation();
|
|
||||||
state.phase = "connected";
|
|
||||||
assert!(!state.timeout(third));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remote_pages_cannot_invoke_native_commands() {
|
|
||||||
let app = tauri::test::mock_builder().build(context()).unwrap();
|
|
||||||
let window = WebviewWindowBuilder::new(
|
|
||||||
&app,
|
|
||||||
"workspace-1",
|
|
||||||
WebviewUrl::External(Url::parse(DEFAULT_SERVER).unwrap()),
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
for command in [
|
|
||||||
"plugin:window|close",
|
|
||||||
"plugin:webview|create_webview_window",
|
|
||||||
"plugin:path|resolve_directory",
|
|
||||||
] {
|
|
||||||
let response = tauri::test::get_ipc_response(
|
|
||||||
&window,
|
|
||||||
tauri::webview::InvokeRequest {
|
|
||||||
cmd: command.into(),
|
|
||||||
callback: tauri::ipc::CallbackFn(0),
|
|
||||||
error: tauri::ipc::CallbackFn(1),
|
|
||||||
url: Url::parse(DEFAULT_SERVER).unwrap(),
|
|
||||||
body: tauri::ipc::InvokeBody::default(),
|
|
||||||
headers: Default::default(),
|
|
||||||
invoke_key: tauri::test::INVOKE_KEY.into(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
assert!(response
|
|
||||||
.unwrap_err()
|
|
||||||
.as_str()
|
|
||||||
.unwrap()
|
|
||||||
.contains("not allowed"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn packaged_app_exposes_no_native_capabilities_or_global_api() {
|
|
||||||
let config: serde_json::Value =
|
|
||||||
serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
|
|
||||||
assert_eq!(config["bundle"]["macOS"]["minimumSystemVersion"], "14.0");
|
|
||||||
assert_eq!(config["app"]["withGlobalTauri"], false);
|
|
||||||
assert_eq!(
|
|
||||||
config["app"]["security"]["capabilities"],
|
|
||||||
serde_json::json!([])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,6 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"bundle": {
|
||||||
|
"iOS": { "minimumSystemVersion": "17.0" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,89 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
class Element {
|
||||||
|
constructor() {
|
||||||
|
this.events = {};
|
||||||
|
this.attributes = {};
|
||||||
|
this.dataset = {};
|
||||||
|
this.hidden = true;
|
||||||
|
this.value = '';
|
||||||
|
}
|
||||||
|
addEventListener(name, callback) {
|
||||||
|
this.events[name] = callback;
|
||||||
|
}
|
||||||
|
setAttribute(name, value) {
|
||||||
|
this.attributes[name] = value;
|
||||||
|
}
|
||||||
|
removeAttribute(name) {
|
||||||
|
delete this.attributes[name];
|
||||||
|
}
|
||||||
|
focus() {}
|
||||||
|
fire(name) {
|
||||||
|
this.events[name]({ preventDefault() {}, target: this });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Android launcher sends only explicit local actions and preserves switch confirmation', async () => {
|
||||||
|
const elements = new Map();
|
||||||
|
const element = (selector) => {
|
||||||
|
if (!elements.has(selector)) {
|
||||||
|
elements.set(selector, new Element());
|
||||||
|
}
|
||||||
|
return elements.get(selector);
|
||||||
|
};
|
||||||
|
const messages = [];
|
||||||
|
globalThis.document = {
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
querySelector: element,
|
||||||
|
documentElement: new Element(),
|
||||||
|
};
|
||||||
|
globalThis.window = {
|
||||||
|
nativeLauncher: {
|
||||||
|
postMessage: (value) => messages.push(new URL(value)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
globalThis.location = { href: 'https://tauri.localhost/index.html' };
|
||||||
|
try {
|
||||||
|
await import('../ui/main.js');
|
||||||
|
assert.equal(messages.shift().pathname, '/action/ready');
|
||||||
|
window.updateLauncher(
|
||||||
|
'server=https%3A%2F%2Fexample.com%2F&phase=idle&ready=false&platform=android',
|
||||||
|
);
|
||||||
|
assert.equal(element('#server-url').value, 'https://example.com/');
|
||||||
|
element('#server-url').value = 'http://example.com';
|
||||||
|
element('#server-form').fire('submit');
|
||||||
|
assert.equal(messages.length, 0);
|
||||||
|
assert.equal(element('#server-url').attributes['aria-invalid'], 'true');
|
||||||
|
element('#server-url').value = 'https://example.com/';
|
||||||
|
element('#server-form').fire('submit');
|
||||||
|
assert.equal(messages[0].pathname, '/action/open');
|
||||||
|
assert.equal(
|
||||||
|
messages.shift().searchParams.get('server'),
|
||||||
|
'https://example.com/',
|
||||||
|
);
|
||||||
|
window.updateLauncher(
|
||||||
|
'server=https%3A%2F%2Fexample.com%2F&phase=connected&ready=true&platform=android',
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
element('#connection-status').textContent,
|
||||||
|
/Use Server settings/,
|
||||||
|
);
|
||||||
|
element('#resume').fire('click');
|
||||||
|
assert.equal(messages.shift().pathname, '/action/resume');
|
||||||
|
element('#server-url').value = 'https://other.example.com/';
|
||||||
|
element('#server-form').fire('submit');
|
||||||
|
assert.equal(messages.length, 0);
|
||||||
|
assert.equal(element('#switch-confirmation').hidden, false);
|
||||||
|
element('#confirm-switch').fire('click');
|
||||||
|
assert.equal(
|
||||||
|
messages.shift().searchParams.get('server'),
|
||||||
|
'https://other.example.com/',
|
||||||
|
);
|
||||||
|
assert.equal(location.href, 'https://tauri.localhost/index.html');
|
||||||
|
} finally {
|
||||||
|
delete globalThis.window;
|
||||||
|
delete globalThis.document;
|
||||||
|
delete globalThis.location;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -16,6 +16,8 @@ export const en = {
|
|||||||
connecting: 'Connecting to your server…',
|
connecting: 'Connecting to your server…',
|
||||||
connected:
|
connected:
|
||||||
'Your workspace is open. Use KanbanGit → Server settings to change servers.',
|
'Your workspace is open. Use KanbanGit → Server settings to change servers.',
|
||||||
|
connectedMobile:
|
||||||
|
'Your workspace is open. Use Server settings to change servers.',
|
||||||
connectionError:
|
connectionError:
|
||||||
'The server did not finish loading. Check your connection, server address and HTTPS certificate, then try again. An already open workspace and its inputs are kept.',
|
'The server did not finish loading. Check your connection, server address and HTTPS certificate, then try again. An already open workspace and its inputs are kept.',
|
||||||
invalidServer:
|
invalidServer:
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ function action(name, server) {
|
|||||||
if (server) {
|
if (server) {
|
||||||
url.searchParams.set('server', server);
|
url.searchParams.set('server', server);
|
||||||
}
|
}
|
||||||
|
if (window.nativeLauncher) {
|
||||||
|
window.nativeLauncher.postMessage(url.href);
|
||||||
|
} else {
|
||||||
location.href = url.href;
|
location.href = url.href;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.updateLauncher = (query) => {
|
window.updateLauncher = (query) => {
|
||||||
@@ -34,7 +38,12 @@ window.updateLauncher = (query) => {
|
|||||||
if (phase === 'connecting' || phase === 'connected') {
|
if (phase === 'connecting' || phase === 'connected') {
|
||||||
activeServer = server;
|
activeServer = server;
|
||||||
}
|
}
|
||||||
status.textContent = en[phase] ?? '';
|
status.textContent =
|
||||||
|
en[
|
||||||
|
phase === 'connected' && state.get('platform') === 'android'
|
||||||
|
? 'connectedMobile'
|
||||||
|
: phase
|
||||||
|
] ?? '';
|
||||||
status.dataset.state = phase;
|
status.dataset.state = phase;
|
||||||
open.disabled = phase === 'connecting';
|
open.disabled = phase === 'connecting';
|
||||||
open.textContent =
|
open.textContent =
|
||||||
@@ -85,3 +94,7 @@ resume.addEventListener('click', () => action('resume'));
|
|||||||
document.querySelector('#appearance').addEventListener('change', (event) => {
|
document.querySelector('#appearance').addEventListener('change', (event) => {
|
||||||
document.documentElement.dataset.appearance = event.target.value;
|
document.documentElement.dataset.appearance = event.target.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (window.nativeLauncher) {
|
||||||
|
action('ready');
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||