Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba12e65e81 | ||
|
|
85b9409e3c | ||
|
|
f862a6a226 | ||
|
|
c8f494fe7c | ||
|
|
f7c3873c19 | ||
|
|
d8e1659f4b | ||
|
|
704fe5d241 | ||
|
|
73f6ab6918 | ||
|
|
6d4a823fe5 | ||
|
|
6054690607 | ||
|
|
723285f820 | ||
|
|
393d074bb7 | ||
|
|
3ec59ef367 | ||
|
|
10f88f6029 | ||
|
|
3b110f08ae | ||
|
|
1a022c257f | ||
|
|
ce36060ba1 | ||
|
|
f4434a3203 | ||
|
|
912cfc28c2 | ||
|
|
752ba2cd38 | ||
|
|
1cf7524a7b | ||
|
|
386b25858b | ||
|
|
aa14068c66 | ||
|
|
4afaa79834 | ||
|
|
0662a6970e | ||
|
|
6820e0b25e | ||
|
|
b124a77cd1 | ||
|
|
2725816273 | ||
|
|
32a6756419 | ||
|
|
2a637affdf | ||
|
|
188aa07059 | ||
|
|
fa8a0cc512 | ||
|
|
d18fffe665 | ||
|
|
aece653970 | ||
|
|
2b1cf6ec13 | ||
|
|
38355c9507 | ||
|
|
d0c5d65f0e | ||
|
|
4f9bf2dec4 | ||
|
|
4ee047311f | ||
|
|
41b6dc8f10 | ||
|
|
3d5ee223e8 | ||
|
|
9dbb491dc9 | ||
|
|
40b66bf7ce | ||
|
|
dc9f400f34 | ||
|
|
48f5fc410b | ||
|
|
f6725ac025 | ||
|
|
8aa7bf6860 | ||
|
|
ba9c4fd2e8 | ||
|
|
74bcf5d30c | ||
|
|
20f9314659 | ||
|
|
3e5c316b8f | ||
|
|
4dd73ec583 |
@@ -1,78 +1,18 @@
|
||||
name: "Lint and Test APP"
|
||||
description: "Run Pint and the Pest test suite"
|
||||
name: test_and_check_app
|
||||
description: Build the test image and run all application checks
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 #v2.37.2
|
||||
with:
|
||||
php-version: "8.5"
|
||||
extensions: mbstring, pdo_sqlite
|
||||
coverage: none
|
||||
tools: composer:v2
|
||||
|
||||
- name: Verify Composer lockfile
|
||||
- name: Test and check app
|
||||
shell: bash
|
||||
run: |
|
||||
if [ ! -f composer.lock ]; then
|
||||
echo "::error::composer.lock not found"
|
||||
exit 1
|
||||
fi
|
||||
shell: bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
- name: Get Composer cache directory
|
||||
id: composer_cache
|
||||
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
|
||||
shell: bash
|
||||
|
||||
- name: Cache Composer packages
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: ${{ steps.composer_cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-php-8.5-${{ hashFiles('composer.lock') }}
|
||||
|
||||
- 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 dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
shell: bash
|
||||
|
||||
- name: Prepare environment file
|
||||
run: cp .env.example .env
|
||||
shell: bash
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --no-interaction --prefer-dist --no-progress
|
||||
shell: bash
|
||||
|
||||
- name: Generate application key
|
||||
run: php artisan key:generate
|
||||
shell: bash
|
||||
|
||||
- name: Generate Wayfinder routes and actions
|
||||
run: php artisan wayfinder:generate --with-form
|
||||
shell: bash
|
||||
|
||||
- name: Run independent application checks
|
||||
run: |
|
||||
pnpm exec concurrently \
|
||||
--names frontend,backend \
|
||||
--prefix-colors cyan,magenta \
|
||||
--kill-others-on-fail \
|
||||
"composer run ci:frontend" \
|
||||
"composer run ci:backend"
|
||||
shell: bash
|
||||
|
||||
- name: Run Laravel tests
|
||||
run: composer run ci:test
|
||||
shell: bash
|
||||
docker buildx build \
|
||||
--file Dockerfile \
|
||||
--target test \
|
||||
--tag "local/kanbangit-test:${GITHUB_SHA}" \
|
||||
--progress plain \
|
||||
--load \
|
||||
.
|
||||
|
||||
@@ -17,6 +17,8 @@ permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
# Partial release files may be replaced until the feed is promoted.
|
||||
# Keep every publisher in this group to prevent overlapping rebuilds.
|
||||
group: macos-release
|
||||
cancel-in-progress: false
|
||||
|
||||
@@ -98,6 +100,7 @@ jobs:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
S3_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
|
||||
S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
|
||||
run: |
|
||||
@@ -110,7 +113,7 @@ jobs:
|
||||
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
|
||||
for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_PASSWORD TAURI_SIGNING_PRIVATE_KEY S3_ACCESS_KEY_ID S3_SECRET_ACCESS_KEY; do
|
||||
[[ -n "${!name}" ]] || { echo "Missing repository secret: $name" >&2; exit 1; }
|
||||
done
|
||||
|
||||
@@ -175,14 +178,19 @@ jobs:
|
||||
- name: Build, sign and notarize with Tauri
|
||||
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
|
||||
env:
|
||||
# The DMG bundler reads these variables separately from --ci.
|
||||
CI: "true"
|
||||
TAURI_BUNDLER_DMG_IGNORE_CI: "false"
|
||||
APPLE_ID: dev@petsch-studios.de
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
with:
|
||||
projectPath: native
|
||||
tauriScript: pnpm exec tauri
|
||||
args: >-
|
||||
--ci --target ${{ env.MACOS_RELEASE_TARGET }} --bundles app
|
||||
--config '{"bundle":{"createUpdaterArtifacts":false,"macOS":{"hardenedRuntime":true}}}'
|
||||
--ci --target ${{ env.MACOS_RELEASE_TARGET }} --bundles app,dmg
|
||||
--config '{"bundle":{"createUpdaterArtifacts":true,"macOS":{"hardenedRuntime":true}}}'
|
||||
-- --locked
|
||||
uploadUpdaterJson: false
|
||||
uploadUpdaterSignatures: false
|
||||
@@ -192,7 +200,7 @@ jobs:
|
||||
id: package
|
||||
run: node native/macos-ci.mjs package
|
||||
|
||||
- name: Publish immutable files and verify public Hetzner downloads
|
||||
- name: Upload release files and promote the verified update feed
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Working Agreements
|
||||
|
||||
## Native app versions and build numbers
|
||||
|
||||
For every delivered change under `native/`, increment the app version and bundle/build number for the affected platforms in the same change before committing. Do this for fixes and follow-up changes too; do not wait for a separate release or a user reminder.
|
||||
|
||||
- macOS changes require a higher macOS app version and `bundle.macOS.bundleVersion`.
|
||||
- iOS changes require a higher iOS app version and `bundle.iOS.bundleVersion`, including the matching Xcode `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION` in both `project.yml` and `project.pbxproj`.
|
||||
- Shared changes require a higher general native app version and higher bundle/build numbers for every affected platform. Android changes also require a higher `bundle.android.versionCode`; this metadata rule does not imply Android build support.
|
||||
- The app version is currently shared: keep `native/package.json`, `native/src-tauri/tauri.conf.json`, the native package entries in `Cargo.toml` and `Cargo.lock`, and the Xcode marketing versions consistent. Use a patch increment unless the requested release calls for a minor or major increment. Never decrease or reuse an existing build number.
|
||||
- Update the native release notes and run the existing version consistency test. One coordinated increment per delivered change is sufficient; intermediate edits and test reruns do not need additional increments.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Models\Project;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DeleteProject
|
||||
{
|
||||
@@ -10,7 +11,13 @@ class DeleteProject
|
||||
{
|
||||
$projectName = $project->name;
|
||||
|
||||
$project->delete();
|
||||
DB::transaction(function () use ($project): void {
|
||||
$baseTemplate = $project->baseTemplate()->first();
|
||||
|
||||
$project->ticketStatuses()->delete();
|
||||
$project->delete();
|
||||
$baseTemplate?->delete();
|
||||
});
|
||||
|
||||
return $projectName;
|
||||
}
|
||||
|
||||
@@ -17,18 +17,21 @@ class StoreProjectTicketLink
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{linked_ticket_id: int, ticket_link_type_id: int} $validated
|
||||
* @param array{linked_ticket_id: int, ticket_link_type_id: int, link_direction?: 'source'|'counterpart'} $validated
|
||||
*/
|
||||
public function handle(Project $project, Ticket $ticket, User $user, array $validated): TicketLink
|
||||
{
|
||||
$linkedTicketId = Arr::get($validated, 'linked_ticket_id');
|
||||
$ticketLinkTypeId = Arr::get($validated, 'ticket_link_type_id');
|
||||
$isSourceTicket = Arr::get($validated, 'link_direction', 'source') === 'source';
|
||||
|
||||
$linkedTicket = Ticket::query()
|
||||
->where('project_id', $project->id)
|
||||
->findOrFail($linkedTicketId);
|
||||
$sourceTicket = $isSourceTicket ? $ticket : $linkedTicket;
|
||||
$targetTicket = $isSourceTicket ? $linkedTicket : $ticket;
|
||||
|
||||
return DB::transaction(function () use ($linkedTicket, $user, $ticketLinkTypeId, $ticket): TicketLink {
|
||||
return DB::transaction(function () use ($linkedTicket, $user, $ticketLinkTypeId, $ticket, $sourceTicket, $targetTicket, $isSourceTicket): TicketLink {
|
||||
Ticket::query()
|
||||
->whereKey([$ticket->id, $linkedTicket->id])
|
||||
->orderBy('id')
|
||||
@@ -50,31 +53,31 @@ class StoreProjectTicketLink
|
||||
|
||||
if ($ticketLink === null) {
|
||||
$ticketLink = TicketLink::query()->create([
|
||||
'source_ticket_id' => $ticket->id,
|
||||
'target_ticket_id' => $linkedTicket->id,
|
||||
'source_ticket_id' => $sourceTicket->id,
|
||||
'target_ticket_id' => $targetTicket->id,
|
||||
'ticket_link_type_id' => $ticketLinkTypeId,
|
||||
]);
|
||||
} else {
|
||||
$ticketLink->update([
|
||||
'source_ticket_id' => $ticket->id,
|
||||
'target_ticket_id' => $linkedTicket->id,
|
||||
'source_ticket_id' => $sourceTicket->id,
|
||||
'target_ticket_id' => $targetTicket->id,
|
||||
'ticket_link_type_id' => $ticketLinkTypeId,
|
||||
]);
|
||||
}
|
||||
|
||||
$ticketLink->load('ticketLinkType');
|
||||
|
||||
$sourceLabel = $ticketLink->ticketLinkType->labelForSource(true);
|
||||
$targetLabel = $ticketLink->ticketLinkType->labelForSource(false);
|
||||
$ticketLabel = $ticketLink->ticketLinkType->labelForSource($isSourceTicket);
|
||||
$linkedTicketLabel = $ticketLink->ticketLinkType->labelForSource(! $isSourceTicket);
|
||||
|
||||
$this->createTicketWorklogEntry->handle(
|
||||
$ticket,
|
||||
$user,
|
||||
TicketWorklogEntryType::LinkUpdated,
|
||||
"Linked {$linkedTicket->ticket_key} as {$sourceLabel}.",
|
||||
"Linked {$linkedTicket->ticket_key} as {$ticketLabel}.",
|
||||
[
|
||||
'linked_ticket_key' => $linkedTicket->ticket_key,
|
||||
'ticket_link_type' => $sourceLabel,
|
||||
'ticket_link_type' => $ticketLabel,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -82,10 +85,10 @@ class StoreProjectTicketLink
|
||||
$linkedTicket,
|
||||
$user,
|
||||
TicketWorklogEntryType::LinkUpdated,
|
||||
"Linked {$ticket->ticket_key} as {$targetLabel}.",
|
||||
"Linked {$ticket->ticket_key} as {$linkedTicketLabel}.",
|
||||
[
|
||||
'linked_ticket_key' => $ticket->ticket_key,
|
||||
'ticket_link_type' => $targetLabel,
|
||||
'ticket_link_type' => $linkedTicketLabel,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -36,11 +36,12 @@ class ProjectTicketLinkController extends ApiController
|
||||
|
||||
$ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated());
|
||||
$ticketLink->setAttribute('current_ticket_id', $ticket->id);
|
||||
$label = $ticketLink->ticketLinkType->labelForSource($ticketLink->source_ticket_id === $ticket->id);
|
||||
|
||||
return TicketLinkResource::make($ticketLink->load(['sourceTicket', 'targetTicket', 'ticketLinkType']))
|
||||
->additional([
|
||||
'meta' => [
|
||||
'message' => "{$ticketLink->ticketLinkType->label} link was saved.",
|
||||
'message' => "{$label} link was saved.",
|
||||
],
|
||||
])
|
||||
->response()
|
||||
|
||||
@@ -34,9 +34,10 @@ class ProjectTicketLinkController extends Controller
|
||||
);
|
||||
|
||||
$ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated());
|
||||
$label = $ticketLink->ticketLinkType->labelForSource($ticketLink->source_ticket_id === $ticket->id);
|
||||
|
||||
return to_route('projects.tickets.show', [$project, $ticket])
|
||||
->with('status', "{$ticketLink->ticketLinkType->label} link was saved.");
|
||||
->with('status', "{$label} link was saved.");
|
||||
}
|
||||
|
||||
public function destroy(
|
||||
|
||||
@@ -23,6 +23,7 @@ class RequirePasswordChange
|
||||
|
||||
if ($request->routeIs([
|
||||
'logout',
|
||||
'native-app.edit',
|
||||
'password.force-change.edit',
|
||||
'user-password.update',
|
||||
])) {
|
||||
|
||||
@@ -11,7 +11,7 @@ class DeleteProjectRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('access-admin') ?? false;
|
||||
return $this->user()?->can('admin-project', $this->route('project')) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -42,6 +42,7 @@ class StoreTicketLinkRequest extends FormRequest
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
),
|
||||
],
|
||||
'link_direction' => ['sometimes', 'required', Rule::in(['source', 'counterpart'])],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1451,20 +1451,20 @@
|
||||
},
|
||||
{
|
||||
"name": "laravel/framework",
|
||||
"version": "v13.26.1",
|
||||
"version": "v13.34.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/framework.git",
|
||||
"reference": "e4a1bc52ef551d52e60244bb004256d6861da7ab"
|
||||
"reference": "c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/framework/zipball/e4a1bc52ef551d52e60244bb004256d6861da7ab",
|
||||
"reference": "e4a1bc52ef551d52e60244bb004256d6861da7ab",
|
||||
"url": "https://api.github.com/repos/laravel/framework/zipball/c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4",
|
||||
"reference": "c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19",
|
||||
"brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0",
|
||||
"composer-runtime-api": "^2.2",
|
||||
"doctrine/inflector": "^2.0.5",
|
||||
"dragonmantank/cron-expression": "^3.4",
|
||||
@@ -1576,18 +1576,20 @@
|
||||
"league/flysystem-sftp-v3": "^3.25.1",
|
||||
"mockery/mockery": "^1.6.10",
|
||||
"opis/json-schema": "^2.4.1",
|
||||
"orchestra/testbench-core": "^11.0.0",
|
||||
"orchestra/testbench-core": "^11.5.0",
|
||||
"pda/pheanstalk": "^7.0.0 || ^8.0.0",
|
||||
"php-http/discovery": "^1.15",
|
||||
"phpstan/phpstan": "^2.0",
|
||||
"phpstan/phpstan": "2.2.15",
|
||||
"phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0.3",
|
||||
"predis/predis": "^2.3 || ^3.0",
|
||||
"rector/rector": "^2.3",
|
||||
"rector/rector": "2.6.3",
|
||||
"resend/resend-php": "^1.0",
|
||||
"symfony/cache": "^7.4.0 || ^8.0.0",
|
||||
"symfony/http-client": "^7.4.0 || ^8.0.0",
|
||||
"symfony/mercure": "^0.8.0",
|
||||
"symfony/psr-http-message-bridge": "^7.4.0 || ^8.0.0",
|
||||
"symfony/translation": "^7.4.0 || ^8.0.0"
|
||||
"symfony/translation": "^7.4.0 || ^8.0.0",
|
||||
"web-token/jwt-library": "^4.1"
|
||||
},
|
||||
"suggest": {
|
||||
"ably/ably-php": "Required to use the Ably broadcast driver (^1.0).",
|
||||
@@ -1623,8 +1625,10 @@
|
||||
"symfony/filesystem": "Required to enable support for relative symbolic links (^7.4 || ^8.0).",
|
||||
"symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.4 || ^8.0).",
|
||||
"symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.4 || ^8.0).",
|
||||
"symfony/mercure": "Required to use the Mercure broadcast driver (^0.8).",
|
||||
"symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.4 || ^8.0).",
|
||||
"symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0)."
|
||||
"symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0).",
|
||||
"web-token/jwt-library": "Required to sign Mercure JWTs and use end-to-end encrypted channels (^4.1)."
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
@@ -1674,7 +1678,7 @@
|
||||
"issues": "https://github.com/laravel/framework/issues",
|
||||
"source": "https://github.com/laravel/framework"
|
||||
},
|
||||
"time": "2026-08-18T20:31:28+00:00"
|
||||
"time": "2026-09-29T12:46:21+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/mcp",
|
||||
@@ -2003,16 +2007,16 @@
|
||||
},
|
||||
{
|
||||
"name": "league/commonmark",
|
||||
"version": "2.10.0",
|
||||
"version": "2.10.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/commonmark.git",
|
||||
"reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4"
|
||||
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
|
||||
"reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
|
||||
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6efbd9c472b91db0a3350fcd601c8332c2382e1f",
|
||||
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -2106,7 +2110,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-08-11T16:06:25+00:00"
|
||||
"time": "2026-09-21T13:07:34+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/config",
|
||||
@@ -2192,16 +2196,16 @@
|
||||
},
|
||||
{
|
||||
"name": "league/flysystem",
|
||||
"version": "3.35.2",
|
||||
"version": "3.36.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/flysystem.git",
|
||||
"reference": "b277b5dc3d56650b68904117124e79c851e12376"
|
||||
"reference": "f7fb152932f30072d573510cbd4dd657d6475b25"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/b277b5dc3d56650b68904117124e79c851e12376",
|
||||
"reference": "b277b5dc3d56650b68904117124e79c851e12376",
|
||||
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f7fb152932f30072d573510cbd4dd657d6475b25",
|
||||
"reference": "f7fb152932f30072d573510cbd4dd657d6475b25",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -2269,9 +2273,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/thephpleague/flysystem/issues",
|
||||
"source": "https://github.com/thephpleague/flysystem/tree/3.35.2"
|
||||
"source": "https://github.com/thephpleague/flysystem/tree/3.36.0"
|
||||
},
|
||||
"time": "2026-07-06T14:42:07+00:00"
|
||||
"time": "2026-09-02T08:00:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/flysystem-aws-s3-v3",
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, isAbsolute, join, relative } from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { parseArgs } from 'node:util';
|
||||
|
||||
export const appleTeamId = 'ZZX2KZGTHU';
|
||||
export const bundleIdentifier = 'de.kanbangit.ios';
|
||||
|
||||
const plistReader = `import base64, datetime, json, plistlib, sys
|
||||
def encode(value):
|
||||
if isinstance(value, bytes): return base64.b64encode(value).decode()
|
||||
if isinstance(value, datetime.datetime): return value.isoformat() + 'Z'
|
||||
raise TypeError(type(value).__name__)
|
||||
print(json.dumps(plistlib.loads(sys.stdin.buffer.read()), default=encode))`;
|
||||
|
||||
function execute(command, args, options) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
input: options.input,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
stdio: options.inherit ? 'inherit' : 'pipe',
|
||||
});
|
||||
if (result.error || result.status !== 0) {
|
||||
throw new Error(
|
||||
`${options.step} failed: ${result.error?.message || result.stderr?.trim() || `exit ${result.status ?? result.signal}`}`,
|
||||
);
|
||||
}
|
||||
return result.stdout?.trim() || '';
|
||||
}
|
||||
|
||||
export function validateProfile(profile, mode, now = new Date()) {
|
||||
const entitlements = profile.Entitlements ?? {};
|
||||
if (
|
||||
profile.TeamIdentifier?.length !== 1 ||
|
||||
profile.TeamIdentifier[0] !== appleTeamId ||
|
||||
entitlements['com.apple.developer.team-identifier'] !== appleTeamId ||
|
||||
entitlements['application-identifier'] !==
|
||||
`${appleTeamId}.${bundleIdentifier}`
|
||||
) {
|
||||
throw new Error(
|
||||
`The profile must explicitly match ${bundleIdentifier} in team ${appleTeamId}.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
!/^[a-f\d]{8}-(?:[a-f\d]{4}-){3}[a-f\d]{12}$/i.test(profile.UUID) ||
|
||||
!(new Date(profile.ExpirationDate) > now) ||
|
||||
!profile.DeveloperCertificates?.length ||
|
||||
!profile.Platform?.includes('iOS')
|
||||
) {
|
||||
throw new Error(
|
||||
'The iOS profile is invalid or expired. Download a current profile from your Apple account.',
|
||||
);
|
||||
}
|
||||
if (mode === 'testflight') {
|
||||
if (
|
||||
entitlements['get-task-allow'] !== false ||
|
||||
entitlements['beta-reports-active'] !== true ||
|
||||
'ProvisionedDevices' in profile ||
|
||||
profile.ProvisionsAllDevices
|
||||
) {
|
||||
throw new Error(
|
||||
'TestFlight requires an App Store Connect profile, not a development, Ad Hoc or enterprise profile.',
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
entitlements['get-task-allow'] !== true ||
|
||||
!profile.ProvisionedDevices?.length ||
|
||||
profile.ProvisionsAllDevices
|
||||
) {
|
||||
throw new Error(
|
||||
'Device development requires an iOS App Development profile containing your registered device.',
|
||||
);
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
export function selectIdentity(profile, identities, mode) {
|
||||
const fingerprints = profile.DeveloperCertificates.map((certificate) =>
|
||||
createHash('sha1')
|
||||
.update(Buffer.from(certificate, 'base64'))
|
||||
.digest('hex')
|
||||
.toUpperCase(),
|
||||
);
|
||||
const prefix =
|
||||
mode === 'testflight' ? 'Apple Distribution: ' : 'Apple Development: ';
|
||||
const matches = [
|
||||
...identities.matchAll(/\) ([A-Fa-f\d]{40}) "([^"]+)"/g),
|
||||
].filter(
|
||||
([, fingerprint, name]) =>
|
||||
fingerprints.includes(fingerprint.toUpperCase()) &&
|
||||
name.startsWith(prefix),
|
||||
);
|
||||
if (matches.length !== 1) {
|
||||
throw new Error(
|
||||
`The profile must match exactly one usable ${prefix.replace(': ', '')} certificate with its private key in your local keychain. Select a profile containing the intended certificate.`,
|
||||
);
|
||||
}
|
||||
return matches[0][1].toUpperCase();
|
||||
}
|
||||
|
||||
export function buildIos({
|
||||
mode = 'testflight',
|
||||
env = process.env,
|
||||
platform = process.platform,
|
||||
directory = dirname(fileURLToPath(import.meta.url)),
|
||||
home = homedir(),
|
||||
run = execute,
|
||||
log = console.log,
|
||||
now = new Date(),
|
||||
} = {}) {
|
||||
if (!['testflight', 'development', 'simulator'].includes(mode)) {
|
||||
throw new Error('Choose --mode testflight, development or simulator.');
|
||||
}
|
||||
if (platform !== 'darwin') {
|
||||
throw new Error(
|
||||
'iOS builds require a Mac with Xcode, XcodeGen and the matching Rust target installed permanently.',
|
||||
);
|
||||
}
|
||||
if (
|
||||
env.APPLE_DEVELOPMENT_TEAM &&
|
||||
env.APPLE_DEVELOPMENT_TEAM !== appleTeamId
|
||||
) {
|
||||
throw new Error(`APPLE_DEVELOPMENT_TEAM must be ${appleTeamId}.`);
|
||||
}
|
||||
if (env.TAURI_CONFIG) {
|
||||
throw new Error(
|
||||
'Remove TAURI_CONFIG overrides before building the configured iOS app.',
|
||||
);
|
||||
}
|
||||
const config = JSON.parse(
|
||||
readFileSync(join(directory, 'src-tauri/tauri.conf.json'), 'utf8'),
|
||||
);
|
||||
const ios = JSON.parse(
|
||||
readFileSync(join(directory, 'src-tauri/tauri.ios.conf.json'), 'utf8'),
|
||||
);
|
||||
const version = config.version;
|
||||
const buildNumber = ios.bundle?.iOS?.bundleVersion;
|
||||
if (
|
||||
ios.identifier !== bundleIdentifier ||
|
||||
ios.bundle?.iOS?.developmentTeam !== appleTeamId ||
|
||||
config.identifier !== 'de.kanbangit.desktop' ||
|
||||
config.productName !== 'KanbanGit' ||
|
||||
!/^\d+\.\d+\.\d+$/.test(version) ||
|
||||
!/^[1-9]\d*$/.test(buildNumber)
|
||||
) {
|
||||
throw new Error(
|
||||
'Check the iOS team, bundle identifier, version and positive integer bundleVersion in the Tauri configuration.',
|
||||
);
|
||||
}
|
||||
const simulator = mode === 'simulator';
|
||||
const configuration = mode === 'testflight' ? 'release' : 'debug';
|
||||
const target = simulator ? 'aarch64-apple-ios-sim' : 'aarch64-apple-ios';
|
||||
const sdk = simulator ? 'iphonesimulator' : 'iphoneos';
|
||||
const project = join(directory, 'src-tauri/gen/apple');
|
||||
const targetDirectory = join(directory, 'src-tauri/target');
|
||||
const outputRoot = join(targetDirectory, `ios-${mode}`);
|
||||
const destination = join(outputRoot, `${version}-${buildNumber}`);
|
||||
if (mode === 'testflight' && existsSync(destination)) {
|
||||
throw new Error(
|
||||
`Build output already exists: ${destination}. Move it aside, or increment bundle.iOS.bundleVersion for a new TestFlight upload.`,
|
||||
);
|
||||
}
|
||||
const buildEnv = Object.fromEntries(
|
||||
Object.entries(env).filter(
|
||||
([key]) =>
|
||||
!key.startsWith('APPLE_') &&
|
||||
!key.startsWith('IOS_') &&
|
||||
!key.startsWith('TAURI_') &&
|
||||
![
|
||||
'SDKROOT',
|
||||
'CARGO_ENCODED_RUSTFLAGS',
|
||||
'RUSTFLAGS',
|
||||
'RUSTC_WRAPPER',
|
||||
'RUSTC_WORKSPACE_WRAPPER',
|
||||
'XCODE_XCCONFIG_FILE',
|
||||
].includes(key),
|
||||
),
|
||||
);
|
||||
function step(label, command, args, options = {}) {
|
||||
log(label);
|
||||
return run(command, args, {
|
||||
cwd: directory,
|
||||
env: buildEnv,
|
||||
step: label,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
function parsePlist(xml) {
|
||||
return JSON.parse(
|
||||
step(
|
||||
'Reading local plist metadata',
|
||||
'python3',
|
||||
['-c', plistReader],
|
||||
{ input: xml },
|
||||
),
|
||||
);
|
||||
}
|
||||
function readProfile(path) {
|
||||
return validateProfile(
|
||||
parsePlist(
|
||||
step('Reading provisioning profile', 'security', [
|
||||
'cms',
|
||||
'-D',
|
||||
'-i',
|
||||
path,
|
||||
]),
|
||||
),
|
||||
mode,
|
||||
now,
|
||||
);
|
||||
}
|
||||
step(
|
||||
'Checking XcodeGen (install permanently with brew install xcodegen if missing)',
|
||||
'xcodegen',
|
||||
['--version'],
|
||||
);
|
||||
step('Checking Xcode', 'xcodebuild', ['-version']);
|
||||
const installedTargets = step('Checking installed Rust targets', 'rustup', [
|
||||
'target',
|
||||
'list',
|
||||
'--installed',
|
||||
]);
|
||||
if (!installedTargets.split(/\s+/).includes(target)) {
|
||||
throw new Error(
|
||||
`Install the missing target permanently with rustup target add ${target}, then retry.`,
|
||||
);
|
||||
}
|
||||
const cargo = step('Selecting the rustup Cargo toolchain', 'rustup', [
|
||||
'which',
|
||||
'cargo',
|
||||
]);
|
||||
buildEnv.RUSTC = step('Selecting the matching Rust compiler', 'rustup', [
|
||||
'which',
|
||||
'rustc',
|
||||
]);
|
||||
buildEnv.PATH = `${dirname(cargo)}:${buildEnv.PATH ?? ''}`;
|
||||
buildEnv.SDKROOT = step('Checking the iOS SDK', 'xcrun', [
|
||||
'--sdk',
|
||||
sdk,
|
||||
'--show-sdk-path',
|
||||
]);
|
||||
buildEnv.IPHONEOS_DEPLOYMENT_TARGET = ios.bundle.iOS.minimumSystemVersion;
|
||||
buildEnv.CARGO_TARGET_DIR = targetDirectory;
|
||||
let profile;
|
||||
let profilePath;
|
||||
let identity;
|
||||
if (!simulator) {
|
||||
if (
|
||||
!env.IOS_PROVISIONING_PROFILE ||
|
||||
!existsSync(env.IOS_PROVISIONING_PROFILE)
|
||||
) {
|
||||
throw new Error(
|
||||
'Set IOS_PROVISIONING_PROFILE to your downloaded .mobileprovision file outside the repository. Apple account setup is manual; this command never creates or downloads profiles.',
|
||||
);
|
||||
}
|
||||
profilePath = realpathSync(env.IOS_PROVISIONING_PROFILE);
|
||||
const withinProject = relative(
|
||||
realpathSync(join(directory, '..')),
|
||||
profilePath,
|
||||
);
|
||||
if (
|
||||
!withinProject.startsWith(
|
||||
`..${process.platform === 'win32' ? '\\' : '/'}`,
|
||||
) &&
|
||||
!isAbsolute(withinProject)
|
||||
) {
|
||||
throw new Error(
|
||||
'Keep provisioning profiles outside the repository.',
|
||||
);
|
||||
}
|
||||
profile = readProfile(profilePath);
|
||||
identity = selectIdentity(
|
||||
profile,
|
||||
step('Checking local signing identities', 'security', [
|
||||
'find-identity',
|
||||
'-v',
|
||||
'-p',
|
||||
'codesigning',
|
||||
]),
|
||||
mode,
|
||||
);
|
||||
}
|
||||
mkdirSync(outputRoot, { recursive: true });
|
||||
const working = mkdtempSync(join(outputRoot, '.building-'));
|
||||
let installedProfile;
|
||||
try {
|
||||
if (profile) {
|
||||
const installedPath = join(
|
||||
home,
|
||||
'Library/Developer/Xcode/UserData/Provisioning Profiles',
|
||||
`${profile.UUID}.mobileprovision`,
|
||||
);
|
||||
mkdirSync(dirname(installedPath), { recursive: true });
|
||||
if (existsSync(installedPath)) {
|
||||
if (
|
||||
!readFileSync(installedPath).equals(
|
||||
readFileSync(profilePath),
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'A different provisioning profile already exists with the same UUID. Resolve it in Xcode before retrying.',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
copyFileSync(profilePath, installedPath);
|
||||
installedProfile = installedPath;
|
||||
}
|
||||
}
|
||||
step('Generating the Xcode project', 'xcodegen', [
|
||||
'generate',
|
||||
'--spec',
|
||||
join(project, 'project.yml'),
|
||||
]);
|
||||
step(
|
||||
'Building the iOS Rust library',
|
||||
cargo,
|
||||
[
|
||||
'build',
|
||||
'--manifest-path',
|
||||
join(directory, 'src-tauri/Cargo.toml'),
|
||||
'--lib',
|
||||
'--locked',
|
||||
'--target',
|
||||
target,
|
||||
'--features',
|
||||
'custom-protocol',
|
||||
...(configuration === 'release' ? ['--release'] : []),
|
||||
],
|
||||
{ inherit: true },
|
||||
);
|
||||
const library = join(
|
||||
targetDirectory,
|
||||
target,
|
||||
configuration,
|
||||
'libkanbangit_native.a',
|
||||
);
|
||||
if (!existsSync(library)) {
|
||||
throw new Error(
|
||||
`The iOS Rust library was not produced: ${library}`,
|
||||
);
|
||||
}
|
||||
buildEnv.KANBANGIT_IOS_LIBRARY = library;
|
||||
const archive = join(working, 'KanbanGit.xcarchive');
|
||||
step(
|
||||
'Archiving locally without Apple provisioning updates',
|
||||
'xcodebuild',
|
||||
[
|
||||
'-project',
|
||||
join(project, 'kanbangit-native.xcodeproj'),
|
||||
'-scheme',
|
||||
'kanbangit-native_iOS',
|
||||
'-configuration',
|
||||
configuration,
|
||||
'-sdk',
|
||||
sdk,
|
||||
'-destination',
|
||||
`generic/platform=${simulator ? 'iOS Simulator' : 'iOS'}`,
|
||||
'-archivePath',
|
||||
archive,
|
||||
'-derivedDataPath',
|
||||
join(targetDirectory, `ios-derived-${mode}`),
|
||||
'archive',
|
||||
`MARKETING_VERSION=${version}`,
|
||||
`CURRENT_PROJECT_VERSION=${buildNumber}`,
|
||||
`DEVELOPMENT_TEAM=${appleTeamId}`,
|
||||
`PRODUCT_BUNDLE_IDENTIFIER=${bundleIdentifier}`,
|
||||
'CODE_SIGN_STYLE=Manual',
|
||||
'ARCHS=arm64',
|
||||
...(simulator
|
||||
? ['CODE_SIGNING_ALLOWED=NO', 'CODE_SIGNING_REQUIRED=NO']
|
||||
: [
|
||||
`CODE_SIGN_IDENTITY=${identity}`,
|
||||
`PROVISIONING_PROFILE_SPECIFIER=${profile.UUID}`,
|
||||
]),
|
||||
],
|
||||
{ inherit: true },
|
||||
);
|
||||
function verifyApp(app) {
|
||||
const info = parsePlist(readFileSync(join(app, 'Info.plist')));
|
||||
if (
|
||||
info.CFBundleIdentifier !== bundleIdentifier ||
|
||||
info.CFBundleShortVersionString !== version ||
|
||||
info.CFBundleVersion !== buildNumber
|
||||
) {
|
||||
throw new Error(
|
||||
'The built app has an unexpected identifier, version or build number.',
|
||||
);
|
||||
}
|
||||
if (!simulator) {
|
||||
step('Verifying the Apple signature and team', 'codesign', [
|
||||
'--verify',
|
||||
'--deep',
|
||||
'--strict',
|
||||
'-R',
|
||||
`=anchor apple generic and identifier "${bundleIdentifier}" and certificate leaf[subject.OU] = "${appleTeamId}" and certificate leaf = H"${identity}" and entitlement["application-identifier"] = "${appleTeamId}.${bundleIdentifier}" and entitlement["com.apple.developer.team-identifier"] = "${appleTeamId}" and ${mode === 'testflight' ? '! ' : ''}entitlement["get-task-allow"] exists${mode === 'testflight' ? ' and entitlement["beta-reports-active"] exists' : ''}`,
|
||||
app,
|
||||
]);
|
||||
if (
|
||||
readProfile(join(app, 'embedded.mobileprovision')).UUID !==
|
||||
profile.UUID
|
||||
) {
|
||||
throw new Error(
|
||||
'The app was signed with an unexpected provisioning profile.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
verifyApp(join(archive, 'Products/Applications/KanbanGit.app'));
|
||||
if (!simulator) {
|
||||
const exportOptions = join(working, 'ExportOptions.plist');
|
||||
writeFileSync(
|
||||
exportOptions,
|
||||
JSON.stringify({
|
||||
method:
|
||||
mode === 'testflight'
|
||||
? 'app-store-connect'
|
||||
: 'debugging',
|
||||
destination: 'export',
|
||||
signingStyle: 'manual',
|
||||
teamID: appleTeamId,
|
||||
signingCertificate: identity,
|
||||
provisioningProfiles: { [bundleIdentifier]: profile.UUID },
|
||||
manageAppVersionAndBuildNumber: false,
|
||||
}),
|
||||
);
|
||||
step('Preparing local export options', 'plutil', [
|
||||
'-convert',
|
||||
'xml1',
|
||||
exportOptions,
|
||||
]);
|
||||
step(
|
||||
'Exporting the signed IPA locally (no upload)',
|
||||
'xcodebuild',
|
||||
[
|
||||
'-exportArchive',
|
||||
'-archivePath',
|
||||
archive,
|
||||
'-exportPath',
|
||||
join(working, 'export'),
|
||||
'-exportOptionsPlist',
|
||||
exportOptions,
|
||||
],
|
||||
{ inherit: true },
|
||||
);
|
||||
const ipa = join(working, 'export/KanbanGit.ipa');
|
||||
if (!existsSync(ipa)) {
|
||||
throw new Error('Xcode did not produce the signed IPA.');
|
||||
}
|
||||
const extracted = join(working, 'verify');
|
||||
step('Verifying the exported IPA contents', 'ditto', [
|
||||
'-x',
|
||||
'-k',
|
||||
ipa,
|
||||
extracted,
|
||||
]);
|
||||
verifyApp(join(extracted, 'Payload/KanbanGit.app'));
|
||||
rmSync(extracted, { recursive: true, force: true });
|
||||
writeFileSync(
|
||||
`${ipa}.sha256`,
|
||||
`${createHash('sha256').update(readFileSync(ipa)).digest('hex')} KanbanGit.ipa\n`,
|
||||
);
|
||||
}
|
||||
if (mode !== 'testflight') {
|
||||
rmSync(destination, { recursive: true, force: true });
|
||||
}
|
||||
renameSync(working, destination);
|
||||
log(`Verified local ${mode} build: ${destination}`);
|
||||
return destination;
|
||||
} catch (error) {
|
||||
log(`Build did not complete. Diagnostic output remains in ${working}`);
|
||||
throw error;
|
||||
} finally {
|
||||
if (installedProfile) {
|
||||
rmSync(installedProfile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
) {
|
||||
try {
|
||||
const { values } = parseArgs({
|
||||
options: { mode: { type: 'string', default: 'testflight' } },
|
||||
});
|
||||
buildIos({ mode: values.mode });
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
appendFileSync,
|
||||
copyFileSync,
|
||||
readlinkSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
} from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { updateSource } from './update-manifest.mjs';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import {
|
||||
appleTeamId,
|
||||
@@ -28,9 +31,10 @@ export function releaseMetadata(
|
||||
const config = JSON.parse(
|
||||
readFileSync(join(directory, 'src-tauri/tauri.conf.json'), 'utf8'),
|
||||
);
|
||||
const packageVersion = JSON.parse(
|
||||
const nativePackage = JSON.parse(
|
||||
readFileSync(join(directory, 'package.json'), 'utf8'),
|
||||
).version;
|
||||
);
|
||||
const packageVersion = nativePackage.version;
|
||||
const cargoVersion = readFileSync(
|
||||
join(directory, 'src-tauri/Cargo.toml'),
|
||||
'utf8',
|
||||
@@ -59,6 +63,23 @@ export function releaseMetadata(
|
||||
'The macOS product name and bundle identifier must stay unchanged.',
|
||||
);
|
||||
}
|
||||
if (config.plugins?.updater?.pubkey !== updateSource.publicKey) {
|
||||
throw new Error(
|
||||
'The app updater key must match the permanent release key.',
|
||||
);
|
||||
}
|
||||
if (
|
||||
!/^\d+(?:\.\d+){0,2}$/.test(config.bundle?.macOS?.bundleVersion || '')
|
||||
) {
|
||||
throw new Error(
|
||||
'Set a numeric macOS bundleVersion for the About build number.',
|
||||
);
|
||||
}
|
||||
if (!nativePackage.releaseNotes?.trim()) {
|
||||
throw new Error(
|
||||
'Add releaseNotes to native/package.json before publishing.',
|
||||
);
|
||||
}
|
||||
const target = env.MACOS_RELEASE_TARGET || 'aarch64-apple-darwin';
|
||||
const arch = {
|
||||
'aarch64-apple-darwin': 'arm64',
|
||||
@@ -81,9 +102,11 @@ export function releaseMetadata(
|
||||
}
|
||||
return {
|
||||
version: config.version,
|
||||
notes: nativePackage.releaseNotes,
|
||||
target,
|
||||
arch,
|
||||
filename: `KanbanGit-${config.version}-${target}.zip`,
|
||||
filename: `KanbanGit-${config.version}-${target}.dmg`,
|
||||
updaterFilename: `KanbanGit-${config.version}-${target}.app.tar.gz`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -146,25 +169,65 @@ export function packageNotarizedApp({
|
||||
const staging = mkdtempSync(join(releaseRoot, '.pending-'));
|
||||
try {
|
||||
const archive = join(staging, metadata.filename);
|
||||
run('ditto', [
|
||||
'-c',
|
||||
'-k',
|
||||
'--sequesterRsrc',
|
||||
'--keepParent',
|
||||
app,
|
||||
const bundle = join(root, metadata.target, 'release/bundle');
|
||||
copyFileSync(
|
||||
join(
|
||||
bundle,
|
||||
'dmg',
|
||||
`KanbanGit_${metadata.version}_${metadata.arch === 'arm64' ? 'aarch64' : 'x64'}.dmg`,
|
||||
),
|
||||
archive,
|
||||
);
|
||||
const mounted = join(staging, 'mounted');
|
||||
mkdirSync(mounted);
|
||||
run('hdiutil', ['verify', archive]);
|
||||
run('hdiutil', [
|
||||
'attach',
|
||||
archive,
|
||||
'-readonly',
|
||||
'-nobrowse',
|
||||
'-mountpoint',
|
||||
mounted,
|
||||
]);
|
||||
try {
|
||||
if (
|
||||
readlinkSync(join(mounted, 'Applications')) !== '/Applications'
|
||||
) {
|
||||
throw new Error(
|
||||
'DMG must contain the Applications installation link.',
|
||||
);
|
||||
}
|
||||
verifyNotarizedApp(join(mounted, 'KanbanGit.app'), metadata, run);
|
||||
} finally {
|
||||
run('hdiutil', ['detach', mounted]);
|
||||
}
|
||||
rmSync(mounted, { recursive: true, force: true });
|
||||
const updateArchive = join(staging, metadata.updaterFilename);
|
||||
copyFileSync(join(bundle, 'macos/KanbanGit.app.tar.gz'), updateArchive);
|
||||
copyFileSync(
|
||||
join(bundle, 'macos/KanbanGit.app.tar.gz.sig'),
|
||||
`${updateArchive}.sig`,
|
||||
);
|
||||
if (!readFileSync(`${updateArchive}.sig`, 'utf8').trim()) {
|
||||
throw new Error('The updater signature is missing.');
|
||||
}
|
||||
const extracted = join(staging, 'extracted');
|
||||
mkdirSync(extracted);
|
||||
run('tar', ['-xzf', updateArchive, '-C', extracted]);
|
||||
verifyNotarizedApp(join(extracted, 'KanbanGit.app'), metadata, run);
|
||||
rmSync(extracted, { recursive: true, force: true });
|
||||
const checksum = createHash('sha256')
|
||||
.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 });
|
||||
for (const name of [metadata.filename, metadata.updaterFilename]) {
|
||||
const hash = createHash('sha256')
|
||||
.update(readFileSync(join(staging, name)))
|
||||
.digest('hex');
|
||||
writeFileSync(
|
||||
join(staging, `${name}.sha256`),
|
||||
`${hash} ${name}\n`,
|
||||
);
|
||||
}
|
||||
renameSync(staging, destination);
|
||||
return { ...metadata, directory: destination, checksum };
|
||||
} finally {
|
||||
@@ -191,6 +254,7 @@ if (
|
||||
appendFileSync(
|
||||
process.env.GITHUB_OUTPUT,
|
||||
Object.entries(output)
|
||||
.filter(([key]) => key !== 'notes')
|
||||
.map(([key, value]) => `${key}=${value}\n`)
|
||||
.join(''),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { mkdirSync, rmSync, symlinkSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
export function createDiskImage({ app, archive, identity, run }) {
|
||||
const contents = join(dirname(archive), 'dmg-contents');
|
||||
mkdirSync(contents);
|
||||
try {
|
||||
run('ditto', [app, join(contents, 'KanbanGit.app')]);
|
||||
symlinkSync('/Applications', join(contents, 'Applications'));
|
||||
run('hdiutil', [
|
||||
'create',
|
||||
'-volname',
|
||||
'KanbanGit',
|
||||
'-srcfolder',
|
||||
contents,
|
||||
'-format',
|
||||
'UDZO',
|
||||
archive,
|
||||
]);
|
||||
run('codesign', ['--sign', identity, '--timestamp', archive]);
|
||||
run('hdiutil', ['verify', archive]);
|
||||
} finally {
|
||||
rmSync(contents, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kanbangit-native",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -14,13 +14,16 @@
|
||||
"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",
|
||||
"build:ios:simulator": "node build-ios.mjs --mode simulator",
|
||||
"build:ios:device": "node build-ios.mjs --mode development",
|
||||
"release:ios": "node build-ios.mjs --mode testflight",
|
||||
"open:ios": "tauri ios dev --open --no-dev-server",
|
||||
"prepare:ios": "xcodegen generate --spec src-tauri/gen/apple/project.yml",
|
||||
"test:ios": "xcodebuild -project src-tauri/gen/apple/kanbangit-native.xcodeproj -scheme kanbangit-native_iOS -configuration debug -destination \"platform=iOS Simulator,id=${IOS_SIMULATOR_ID:?Set IOS_SIMULATOR_ID to an installed simulator UUID}\" -parallel-testing-enabled NO CODE_SIGNING_ALLOWED=NO test"
|
||||
"test:ios": "xcodebuild -project src-tauri/gen/apple/kanbangit-native.xcodeproj -scheme kanbangit-native_iOS -configuration debug -destination \"platform=iOS Simulator,id=${IOS_SIMULATOR_ID:?Set IOS_SIMULATOR_ID to an installed simulator UUID}\" -parallel-testing-enabled NO CODE_SIGNING_ALLOWED=NO test",
|
||||
"smoke:updater": "cargo test --manifest-path src-tauri/Cargo.toml --locked --features smoke-test,custom-protocol --test updater_smoke"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "2.11.4"
|
||||
}
|
||||
},
|
||||
"releaseNotes": "Manage local workspace settings in the web app on macOS and iOS, with shared macOS update controls and native recovery for older or unavailable servers."
|
||||
}
|
||||
|
||||
@@ -10,8 +10,15 @@ import {
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { execute, releaseMetadata } from './macos-ci.mjs';
|
||||
import {
|
||||
compareVersions,
|
||||
updateManifest,
|
||||
updateSource,
|
||||
verifyUpdateSignature,
|
||||
} from './update-manifest.mjs';
|
||||
|
||||
export function storageConfiguration(env = process.env) {
|
||||
const region = env.S3_REGION;
|
||||
@@ -56,14 +63,47 @@ export function storageConfiguration(env = process.env) {
|
||||
|
||||
const sha256 = (data) => createHash('sha256').update(data).digest('hex');
|
||||
|
||||
export function conditionalEtag(etag) {
|
||||
const value =
|
||||
typeof etag === 'string' && etag.startsWith('"') && etag.endsWith('"')
|
||||
? etag.slice(1, -1)
|
||||
: etag;
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!/^[a-f\d]{32}(?:-[1-9]\d*)?$/i.test(value)
|
||||
) {
|
||||
throw new Error(
|
||||
'Cannot atomically replace an object without a valid S3 ETag.',
|
||||
);
|
||||
}
|
||||
// Hetzner returns quoted ETags on GET but compares bare values on conditional PUT.
|
||||
return value;
|
||||
}
|
||||
|
||||
function requiresExistingObjectCheck(error) {
|
||||
// AWS CLI can crash while formatting Hetzner's empty error Message.
|
||||
// This only permits a readback; content checks and conditional writes still apply.
|
||||
return /\(PreconditionFailed\)|\(412\)|argument of type 'NoneType' is not (?:a container or )?iterable/.test(
|
||||
error.message,
|
||||
);
|
||||
}
|
||||
|
||||
export async function publishMacos({
|
||||
env = process.env,
|
||||
directory,
|
||||
metadata,
|
||||
run = execute,
|
||||
fetchPublic = fetch,
|
||||
wait = setTimeout,
|
||||
publicKey = updateSource.publicKey,
|
||||
feedBase = updateSource.baseUrl,
|
||||
} = {}) {
|
||||
const storage = storageConfiguration(env);
|
||||
if (`${storage.publicBase}/${storage.prefix}` !== feedBase) {
|
||||
throw new Error(
|
||||
'Release storage does not match the update source bundled in the app.',
|
||||
);
|
||||
}
|
||||
if (!env.AWS_ACCESS_KEY_ID || !env.AWS_SECRET_ACCESS_KEY) {
|
||||
throw new Error(
|
||||
'Set the dedicated S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY release secrets.',
|
||||
@@ -77,6 +117,21 @@ export async function publishMacos({
|
||||
if (checksumFile.toString() !== `${checksum} ${metadata.filename}\n`) {
|
||||
throw new Error('Release archive does not match its SHA-256 file.');
|
||||
}
|
||||
const updater = readFileSync(join(directory, metadata.updaterFilename));
|
||||
const signature = readFileSync(
|
||||
join(directory, `${metadata.updaterFilename}.sig`),
|
||||
'utf8',
|
||||
);
|
||||
verifyUpdateSignature(updater, signature, publicKey);
|
||||
const updateChecksum = readFileSync(
|
||||
join(directory, `${metadata.updaterFilename}.sha256`),
|
||||
);
|
||||
if (
|
||||
updateChecksum.toString() !==
|
||||
`${sha256(updater)} ${metadata.updaterFilename}\n`
|
||||
) {
|
||||
throw new Error('Updater archive does not match its SHA-256 file.');
|
||||
}
|
||||
const common = [
|
||||
'--endpoint-url',
|
||||
storage.endpoint,
|
||||
@@ -86,13 +141,21 @@ export async function publishMacos({
|
||||
's3api',
|
||||
];
|
||||
function s3(operation, args) {
|
||||
return run('aws', [
|
||||
...common,
|
||||
operation,
|
||||
'--bucket',
|
||||
storage.bucket,
|
||||
...args,
|
||||
]);
|
||||
try {
|
||||
return run('aws', [
|
||||
...common,
|
||||
operation,
|
||||
'--bucket',
|
||||
storage.bucket,
|
||||
...args,
|
||||
]);
|
||||
} catch (error) {
|
||||
const keyIndex = args.indexOf('--key');
|
||||
const key = keyIndex >= 0 ? ` ${args[keyIndex + 1]}` : '';
|
||||
throw new Error(`S3 ${operation}${key}: ${error.message}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
const versioningOutput = s3('get-bucket-versioning', [
|
||||
'--output',
|
||||
@@ -122,55 +185,150 @@ export async function publishMacos({
|
||||
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}.`);
|
||||
const latestPrefix = `${storage.prefix}/${metadata.target}`;
|
||||
const latestKey = `${latestPrefix}/latest.json`;
|
||||
async function verifyPublic(name, contents, prefix = publicPrefix) {
|
||||
for (let attempt = 1; attempt <= 5; attempt++) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetchPublic(`${prefix}/${name}`, {
|
||||
redirect: 'error',
|
||||
cache: 'no-store',
|
||||
signal: AbortSignal.timeout(60000),
|
||||
});
|
||||
} catch (error) {
|
||||
if (attempt === 5) {
|
||||
throw new Error(
|
||||
`Public download request failed for ${name}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
await wait(2000);
|
||||
continue;
|
||||
}
|
||||
if (response.ok) {
|
||||
if (
|
||||
sha256(Buffer.from(await response.arrayBuffer())) !==
|
||||
sha256(contents)
|
||||
) {
|
||||
throw new Error(
|
||||
`Public download verification failed for ${name}: SHA-256 mismatch.`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await response.body?.cancel();
|
||||
const retryable = [403, 404, 408, 429, 500, 502, 503, 504].includes(
|
||||
response.status,
|
||||
);
|
||||
if (!retryable || attempt === 5) {
|
||||
throw new Error(
|
||||
`Public download verification failed for ${name}: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
await wait(2000);
|
||||
}
|
||||
}
|
||||
function putImmutable(name, contents, contentType) {
|
||||
function assertUnpublishedRelease(allowSameVersion = false) {
|
||||
const file = join(staging, 'published-latest.json');
|
||||
try {
|
||||
s3('get-object', ['--key', latestKey, file]);
|
||||
} catch (error) {
|
||||
if (/\(NoSuchKey\)/.test(error.message)) {
|
||||
return;
|
||||
}
|
||||
if (requiresExistingObjectCheck(error)) {
|
||||
// A broken CLI error message cannot distinguish missing from denied.
|
||||
// Only an authenticated, explicitly empty listing proves absence.
|
||||
const listing = JSON.parse(
|
||||
s3('list-objects-v2', [
|
||||
'--prefix',
|
||||
latestKey,
|
||||
'--max-keys',
|
||||
'1',
|
||||
'--output',
|
||||
'json',
|
||||
]),
|
||||
);
|
||||
if (
|
||||
listing?.KeyCount === 0 &&
|
||||
listing.IsTruncated === false &&
|
||||
Array.isArray(listing.Contents ?? []) &&
|
||||
(listing.Contents ?? []).length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const published = JSON.parse(readFileSync(file, 'utf8'));
|
||||
const comparison = compareVersions(metadata.version, published.version);
|
||||
if (allowSameVersion && comparison < 0) {
|
||||
throw new Error('Refusing to downgrade the stable download.');
|
||||
}
|
||||
if (comparison < 0 || (!allowSameVersion && comparison === 0)) {
|
||||
throw new Error(
|
||||
'This version has already been released through the update feed; bump the app version. Published objects were not overwritten.',
|
||||
);
|
||||
}
|
||||
}
|
||||
// The macos-release concurrency group serializes the entire publication.
|
||||
// Never run independent publishers against the same release destination.
|
||||
function putReleaseFile(name, contents, contentType) {
|
||||
const file = join(staging, name);
|
||||
writeFileSync(file, contents);
|
||||
try {
|
||||
function put(condition) {
|
||||
s3('put-object', [
|
||||
'--key',
|
||||
`${keyPrefix}/${name}`,
|
||||
'--body',
|
||||
file,
|
||||
'--if-none-match',
|
||||
'*',
|
||||
...condition,
|
||||
'--content-type',
|
||||
contentType,
|
||||
'--cache-control',
|
||||
'public, max-age=31536000, immutable',
|
||||
'no-cache, max-age=0, must-revalidate',
|
||||
'--content-md5',
|
||||
createHash('md5').update(contents).digest('base64'),
|
||||
'--metadata',
|
||||
`sha256=${sha256(contents)}`,
|
||||
]);
|
||||
}
|
||||
try {
|
||||
put(['--if-none-match', '*']);
|
||||
} catch (error) {
|
||||
if (!/\(PreconditionFailed\)|\(412\)/.test(error.message)) {
|
||||
if (!requiresExistingObjectCheck(error)) {
|
||||
throw error;
|
||||
}
|
||||
const existing = join(staging, `existing-${name}`);
|
||||
s3('get-object', ['--key', `${keyPrefix}/${name}`, existing]);
|
||||
const response = JSON.parse(
|
||||
s3('get-object', [
|
||||
'--key',
|
||||
`${keyPrefix}/${name}`,
|
||||
'--output',
|
||||
'json',
|
||||
existing,
|
||||
]),
|
||||
);
|
||||
if (sha256(readFileSync(existing)) !== sha256(contents)) {
|
||||
throw new Error(
|
||||
`Published ${name} differs; bump the app version. Existing objects were not overwritten.`,
|
||||
);
|
||||
assertUnpublishedRelease();
|
||||
put(['--if-match', conditionalEtag(response.ETag)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
const files = [
|
||||
[metadata.filename, archive, 'application/zip'],
|
||||
[metadata.filename, archive, 'application/x-apple-diskimage'],
|
||||
[metadata.updaterFilename, updater, 'application/gzip'],
|
||||
[
|
||||
`${metadata.updaterFilename}.sig`,
|
||||
Buffer.from(signature),
|
||||
'text/plain; charset=utf-8',
|
||||
],
|
||||
[
|
||||
`${metadata.updaterFilename}.sha256`,
|
||||
updateChecksum,
|
||||
'text/plain; charset=utf-8',
|
||||
],
|
||||
[
|
||||
`${metadata.filename}.sha256`,
|
||||
checksumFile,
|
||||
@@ -178,10 +336,10 @@ export async function publishMacos({
|
||||
],
|
||||
];
|
||||
for (const [name, contents, type] of files) {
|
||||
putImmutable(name, contents, type);
|
||||
putReleaseFile(name, contents, type);
|
||||
await verifyPublic(name, contents);
|
||||
}
|
||||
// This completion record is written only after both public downloads match.
|
||||
// Completion metadata is written only after every public artifact matches.
|
||||
const manifest = Buffer.from(
|
||||
JSON.stringify(
|
||||
{
|
||||
@@ -195,8 +353,128 @@ export async function publishMacos({
|
||||
2,
|
||||
) + '\n',
|
||||
);
|
||||
putImmutable('release.json', manifest, 'application/json');
|
||||
putReleaseFile('release.json', manifest, 'application/json');
|
||||
await verifyPublic('release.json', manifest);
|
||||
const latest = Buffer.from(
|
||||
JSON.stringify(
|
||||
updateManifest(
|
||||
metadata,
|
||||
publicPrefix,
|
||||
signature,
|
||||
metadata.notes,
|
||||
),
|
||||
null,
|
||||
2,
|
||||
) + '\n',
|
||||
);
|
||||
putReleaseFile('update.json', latest, 'application/json');
|
||||
await verifyPublic('update.json', latest);
|
||||
// An older release must not populate an absent download alias either.
|
||||
assertUnpublishedRelease(true);
|
||||
const downloadName = 'KanbanGit.dmg';
|
||||
const downloadKey = `${latestPrefix}/${downloadName}`;
|
||||
const downloadFile = join(staging, downloadName);
|
||||
writeFileSync(downloadFile, archive);
|
||||
function publishDownload(condition) {
|
||||
s3('put-object', [
|
||||
'--key',
|
||||
downloadKey,
|
||||
'--body',
|
||||
downloadFile,
|
||||
...condition,
|
||||
'--content-type',
|
||||
'application/x-apple-diskimage',
|
||||
'--cache-control',
|
||||
'no-cache, max-age=0, must-revalidate',
|
||||
'--content-md5',
|
||||
createHash('md5').update(archive).digest('base64'),
|
||||
'--metadata',
|
||||
`sha256=${checksum}`,
|
||||
]);
|
||||
}
|
||||
try {
|
||||
publishDownload(['--if-none-match', '*']);
|
||||
} catch (error) {
|
||||
if (!requiresExistingObjectCheck(error)) {
|
||||
throw error;
|
||||
}
|
||||
const previousFile = join(staging, 'previous-download.dmg');
|
||||
const response = JSON.parse(
|
||||
s3('get-object', [
|
||||
'--key',
|
||||
downloadKey,
|
||||
'--output',
|
||||
'json',
|
||||
previousFile,
|
||||
]),
|
||||
);
|
||||
if (sha256(readFileSync(previousFile)) !== checksum) {
|
||||
assertUnpublishedRelease();
|
||||
publishDownload(['--if-match', conditionalEtag(response.ETag)]);
|
||||
}
|
||||
}
|
||||
await verifyPublic(
|
||||
downloadName,
|
||||
archive,
|
||||
`${storage.publicBase}/${latestPrefix}`,
|
||||
);
|
||||
const latestFile = join(staging, 'latest.json');
|
||||
writeFileSync(latestFile, latest);
|
||||
function publishLatest(condition) {
|
||||
s3('put-object', [
|
||||
'--key',
|
||||
latestKey,
|
||||
'--body',
|
||||
latestFile,
|
||||
...condition,
|
||||
'--content-type',
|
||||
'application/json',
|
||||
'--cache-control',
|
||||
'no-cache, max-age=0, must-revalidate',
|
||||
'--content-md5',
|
||||
createHash('md5').update(latest).digest('base64'),
|
||||
]);
|
||||
}
|
||||
try {
|
||||
publishLatest(['--if-none-match', '*']);
|
||||
} catch (error) {
|
||||
if (!requiresExistingObjectCheck(error)) {
|
||||
throw error;
|
||||
}
|
||||
const previousFile = join(staging, 'previous-latest.json');
|
||||
const response = JSON.parse(
|
||||
s3('get-object', [
|
||||
'--key',
|
||||
latestKey,
|
||||
'--output',
|
||||
'json',
|
||||
previousFile,
|
||||
]),
|
||||
);
|
||||
const previousBytes = readFileSync(previousFile);
|
||||
const previous = JSON.parse(previousBytes);
|
||||
const comparison = compareVersions(
|
||||
metadata.version,
|
||||
previous.version,
|
||||
);
|
||||
if (comparison < 0) {
|
||||
throw new Error('Refusing to downgrade the update feed.');
|
||||
}
|
||||
if (comparison === 0) {
|
||||
if (!previousBytes.equals(latest)) {
|
||||
throw new Error(
|
||||
'Published update metadata differs; bump the app version.',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
publishLatest(['--if-match', conditionalEtag(response.ETag)]);
|
||||
}
|
||||
}
|
||||
await verifyPublic(
|
||||
'latest.json',
|
||||
latest,
|
||||
`${storage.publicBase}/${latestPrefix}`,
|
||||
);
|
||||
return `${publicPrefix}/${metadata.filename}`;
|
||||
} finally {
|
||||
rmSync(staging, { recursive: true, force: true });
|
||||
|
||||
@@ -13,6 +13,7 @@ import { dirname, join, resolve } from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { parseArgs } from 'node:util';
|
||||
import { createDiskImage } from './macos-dmg.mjs';
|
||||
|
||||
export const appleTeamId = 'ZZX2KZGTHU';
|
||||
export const bundleIdentifier = 'de.kanbangit.desktop';
|
||||
@@ -105,6 +106,11 @@ export function releaseMacos({
|
||||
throw new Error('--resume requires the Apple submission UUID.');
|
||||
}
|
||||
|
||||
if (!env.TAURI_SIGNING_PRIVATE_KEY?.trim()) {
|
||||
throw new Error(
|
||||
'Set TAURI_SIGNING_PRIVATE_KEY to the permanent updater signing key.',
|
||||
);
|
||||
}
|
||||
const identity = env.APPLE_SIGNING_IDENTITY?.trim();
|
||||
const profile = env.APPLE_NOTARY_KEYCHAIN_PROFILE?.trim();
|
||||
|
||||
@@ -405,20 +411,42 @@ export function releaseMacos({
|
||||
app,
|
||||
]);
|
||||
|
||||
const name = `KanbanGit-${config.version}-${target}.zip`;
|
||||
const name = `KanbanGit-${config.version}-${target}.dmg`;
|
||||
const archive = join(staging, name);
|
||||
step('Packaging notarized download', 'ditto', [
|
||||
'-c',
|
||||
'-k',
|
||||
'--sequesterRsrc',
|
||||
'--keepParent',
|
||||
createDiskImage({
|
||||
app,
|
||||
archive,
|
||||
]);
|
||||
identity,
|
||||
run: (command, args) =>
|
||||
step('Packaging notarized download', command, args),
|
||||
});
|
||||
const checksum = createHash('sha256')
|
||||
.update(readFileSync(archive))
|
||||
.digest('hex');
|
||||
writeFileSync(`${archive}.sha256`, `${checksum} ${name}\n`);
|
||||
const updaterName = `KanbanGit-${config.version}-${target}.app.tar.gz`;
|
||||
const updaterArchive = join(staging, updaterName);
|
||||
step('Packaging updater archive', 'tar', [
|
||||
'-czf',
|
||||
updaterArchive,
|
||||
'-C',
|
||||
dirname(app),
|
||||
'KanbanGit.app',
|
||||
]);
|
||||
step('Signing updater archive', 'pnpm', [
|
||||
'exec',
|
||||
'tauri',
|
||||
'signer',
|
||||
'sign',
|
||||
updaterArchive,
|
||||
]);
|
||||
const updaterChecksum = createHash('sha256')
|
||||
.update(readFileSync(updaterArchive))
|
||||
.digest('hex');
|
||||
writeFileSync(
|
||||
`${updaterArchive}.sha256`,
|
||||
`${updaterChecksum} ${updaterName}\n`,
|
||||
);
|
||||
rmSync(submission, { force: true });
|
||||
renameSync(staging, destination);
|
||||
log(`Verified release: ${join(destination, name)}`);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "kanbangit-native"
|
||||
version = "0.1.0"
|
||||
version = "0.2.6"
|
||||
description = "KanbanGit native application"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
@@ -13,6 +13,9 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
semver = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri = { version = "2", features = [] }
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -27,3 +30,11 @@ custom-protocol = ["tauri/custom-protocol"]
|
||||
name = "macos_smoke"
|
||||
harness = false
|
||||
required-features = ["smoke-test"]
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
tauri-plugin-updater = "=2.12.0"
|
||||
|
||||
[[test]]
|
||||
name = "updater_smoke"
|
||||
harness = false
|
||||
required-features = ["smoke-test"]
|
||||
|
||||
@@ -1,3 +1,74 @@
|
||||
fn main() {
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") {
|
||||
build_navigation();
|
||||
}
|
||||
tauri_build::build();
|
||||
}
|
||||
|
||||
fn build_navigation() {
|
||||
use std::{env, path::PathBuf, process::Command};
|
||||
|
||||
println!("cargo:rerun-if-changed=src/navigation.m");
|
||||
println!("cargo:rerun-if-changed=src/settings.m");
|
||||
let output = PathBuf::from(env::var_os("OUT_DIR").unwrap());
|
||||
let object = output.join("navigation.o");
|
||||
let settings_object = output.join("settings.o");
|
||||
let archive = output.join("libkanban_navigation.a");
|
||||
let arch = match env::var("CARGO_CFG_TARGET_ARCH").unwrap().as_str() {
|
||||
"aarch64" => "arm64",
|
||||
"x86_64" => "x86_64",
|
||||
_ => panic!("Unsupported macOS architecture"),
|
||||
};
|
||||
assert!(Command::new("xcrun")
|
||||
.args([
|
||||
"--sdk",
|
||||
"macosx",
|
||||
"clang",
|
||||
"-fobjc-arc",
|
||||
"-fblocks",
|
||||
"-Wall",
|
||||
"-Werror",
|
||||
"-mmacosx-version-min=14.0",
|
||||
"-arch",
|
||||
arch,
|
||||
"-c",
|
||||
"src/navigation.m",
|
||||
"-o"
|
||||
])
|
||||
.arg(&object)
|
||||
.status()
|
||||
.expect("Xcode clang is required")
|
||||
.success());
|
||||
assert!(Command::new("xcrun")
|
||||
.args([
|
||||
"--sdk",
|
||||
"macosx",
|
||||
"clang",
|
||||
"-fobjc-arc",
|
||||
"-fblocks",
|
||||
"-Wall",
|
||||
"-Werror",
|
||||
"-mmacosx-version-min=14.0",
|
||||
"-arch",
|
||||
arch,
|
||||
"-c",
|
||||
"src/settings.m",
|
||||
"-o"
|
||||
])
|
||||
.arg(&settings_object)
|
||||
.status()
|
||||
.expect("Xcode clang is required")
|
||||
.success());
|
||||
assert!(Command::new("xcrun")
|
||||
.args(["ar", "crs"])
|
||||
.arg(&archive)
|
||||
.arg(&object)
|
||||
.arg(&settings_object)
|
||||
.status()
|
||||
.expect("Xcode ar is required")
|
||||
.success());
|
||||
println!("cargo:rustc-link-search=native={}", output.display());
|
||||
println!("cargo:rustc-link-lib=static=kanban_navigation");
|
||||
println!("cargo:rustc-link-lib=framework=AppKit");
|
||||
println!("cargo:rustc-link-lib=framework=WebKit");
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.os.SystemClock
|
||||
import android.view.InputDevice
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
@@ -59,6 +60,59 @@ class PlatformTest {
|
||||
instrumentation.waitForIdleSync()
|
||||
}
|
||||
|
||||
@Test fun coldStartRestoresWorkspaceWithoutSelectionOrLauncherHistory() {
|
||||
val mode = InstrumentationRegistry.getArguments().getString("startupConfig", "saved")
|
||||
val preferences = instrumentation.targetContext.getSharedPreferences("workspace", Context.MODE_PRIVATE)
|
||||
preferences.edit().clear().apply {
|
||||
if (mode == "saved") putString("server", "https://127.0.0.1:9/app/")
|
||||
if (mode == "invalid") putString("server", "http://invalid.example/")
|
||||
}.commit()
|
||||
val scenario = ActivityScenario.launch(MainActivity::class.java)
|
||||
val launcher = AtomicReference<WebView>()
|
||||
fun findLauncher(view: View): WebView? {
|
||||
if (view is WebView && view.url?.startsWith(NavigationPolicy.LAUNCHER_ORIGIN) == true) return view
|
||||
if (view is ViewGroup) for (index in 0 until view.childCount) {
|
||||
findLauncher(view.getChildAt(index))?.let { return it }
|
||||
}
|
||||
return null
|
||||
}
|
||||
waitFor {
|
||||
scenario.onActivity { launcher.set(findLauncher(it.window.decorView)) }
|
||||
launcher.get() != null && evaluate(launcher.get(), "typeof window.updateLauncher === 'function'") == "true"
|
||||
}
|
||||
if (mode == "saved") {
|
||||
waitFor {
|
||||
val state = evaluate(launcher.get(), "document.querySelector('#connection-status').dataset.state")
|
||||
if (state == "\"connecting\"") {
|
||||
assertEquals("true", evaluate(launcher.get(), "document.querySelector('#workspace-settings').hidden"))
|
||||
}
|
||||
state == "\"connectionError\""
|
||||
}
|
||||
assertEquals("https://127.0.0.1:9/app/", preferences.getString("server", null))
|
||||
assertEquals("\"Try again\"", evaluate(launcher.get(), "document.querySelector('.open-workspace').textContent"))
|
||||
scenario.onActivity {
|
||||
assertEquals("https://127.0.0.1:9/app/dashboard", it.workspace?.originalUrl)
|
||||
assertFalse(it.workspace!!.canGoBack())
|
||||
it.openWorkspace("https://127.0.0.1:9/app/")
|
||||
}
|
||||
waitFor { evaluate(launcher.get(), "document.querySelector('#connection-status').dataset.state") == "\"connectionError\"" }
|
||||
} else {
|
||||
waitFor { evaluate(launcher.get(), "document.querySelector('#workspace-settings').hidden") == "false" }
|
||||
scenario.onActivity { assertNull(it.workspace) }
|
||||
}
|
||||
scenario.onActivity { it.openWorkspace("https://kanbangit.de/") }
|
||||
val remote = AtomicReference<WebView>()
|
||||
waitFor {
|
||||
var visible = false
|
||||
scenario.onActivity { remote.set(it.workspace); visible = it.workspace?.visibility == View.VISIBLE }
|
||||
visible && remote.get() != null
|
||||
}
|
||||
assertEquals("\"/login\"", evaluate(remote.get(), "location.pathname"))
|
||||
assertEquals("1", evaluate(remote.get(), "history.length"))
|
||||
assertEquals("https://kanbangit.de/", preferences.getString("server", null))
|
||||
scenario.moveToState(Lifecycle.State.CREATED)
|
||||
}
|
||||
|
||||
@Test fun workspacePreservesFormsAndIsolatesServers() {
|
||||
instrumentation.targetContext.getSharedPreferences("workspace", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
val scenario = ActivityScenario.launch(MainActivity::class.java)
|
||||
|
||||
@@ -164,9 +164,9 @@ class MainActivity : TauriActivity() {
|
||||
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)
|
||||
val restored = saved?.let(NavigationPolicy::installation)
|
||||
server = restored ?: NavigationPolicy.DEFAULT_SERVER
|
||||
if (restored != null) openWorkspace(restored) else updateLauncher()
|
||||
}
|
||||
"/action/open" -> openWorkspace(url.getQueryParameter("server").orEmpty())
|
||||
"/action/resume" -> if (ready) showWorkspace()
|
||||
@@ -313,8 +313,8 @@ class MainActivity : TauriActivity() {
|
||||
val firstLoad = !ready
|
||||
ready = true
|
||||
CookieManager.getInstance().flush()
|
||||
updateLauncher()
|
||||
if (firstLoad) showWorkspace()
|
||||
updateLauncher()
|
||||
}
|
||||
|
||||
override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) {
|
||||
@@ -333,7 +333,7 @@ class MainActivity : TauriActivity() {
|
||||
remote.visibility = View.GONE
|
||||
content.addView(remote, 0, FrameLayout.LayoutParams(-1, -1))
|
||||
startTimeout(remote)
|
||||
remote.loadUrl(server)
|
||||
remote.loadUrl(NavigationPolicy.dashboard(server))
|
||||
}
|
||||
|
||||
private fun startTimeout(view: WebView) {
|
||||
|
||||
@@ -21,6 +21,8 @@ object NavigationPolicy {
|
||||
"https://${uri.host.lowercase(Locale.ROOT)}$port$path"
|
||||
}.getOrNull()
|
||||
|
||||
fun dashboard(server: String): String = URI(requireNotNull(installation(server))).resolve("dashboard").toString()
|
||||
|
||||
fun allows(server: String, destination: String): Boolean = runCatching {
|
||||
val base = URI(server)
|
||||
val target = URI(destination)
|
||||
|
||||
@@ -12,6 +12,11 @@ class NavigationPolicyTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun startupTargetsDashboardWithinTheInstallation() {
|
||||
assertEquals("https://example.com/dashboard", NavigationPolicy.dashboard("https://example.com/"))
|
||||
assertEquals("https://example.com:8443/kanban/dashboard", NavigationPolicy.dashboard("https://example.com:8443/kanban/"))
|
||||
}
|
||||
|
||||
@Test fun allowsLoginTwoFactorAndHistoryOnlyOnTheSelectedOrigin() {
|
||||
val server = "https://example.com:8443/app/"
|
||||
for (path in listOf("/login", "/two-factor-challenge", "/projects/1?ticket=53#details")) {
|
||||
|
||||
|
Before Width: | Height: | Size: 422 B After Width: | Height: | Size: 638 B |
|
Before Width: | Height: | Size: 548 B After Width: | Height: | Size: 774 B |
|
Before Width: | Height: | Size: 548 B After Width: | Height: | Size: 774 B |
|
Before Width: | Height: | Size: 800 B After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 415 B After Width: | Height: | Size: 658 B |
|
Before Width: | Height: | Size: 845 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 845 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 548 B After Width: | Height: | Size: 774 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 955 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.4 KiB |
@@ -22,6 +22,8 @@ struct WorkspaceServer: Codable, Equatable {
|
||||
return components.url
|
||||
}
|
||||
|
||||
var dashboardURL: URL { url.appendingPathComponent("dashboard") }
|
||||
|
||||
func allows(_ destination: URL) -> Bool {
|
||||
destination.scheme?.lowercased() == "https"
|
||||
&& destination.host?.lowercased() == url.host?.lowercased()
|
||||
@@ -63,3 +65,32 @@ struct WorkspaceConnection {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
enum NativeSettingsError: Error { case invalidRequest, incompatibleVersion, invalidServer }
|
||||
|
||||
struct NativeSettingsRequest {
|
||||
let id: Int
|
||||
let action: String
|
||||
let payload: [String: Any]
|
||||
|
||||
static func parse(_ body: String) throws -> NativeSettingsRequest {
|
||||
guard body.utf8.count <= 8192,
|
||||
let value = try JSONSerialization.jsonObject(with: Data(body.utf8)) as? [String: Any],
|
||||
Set(value.keys) == Set(["version", "id", "action", "payload"]),
|
||||
let version = value["version"] as? NSNumber, CFGetTypeID(version) != CFBooleanGetTypeID(),
|
||||
version == 1 else { throw NativeSettingsError.incompatibleVersion }
|
||||
guard let id = value["id"] as? NSNumber, CFGetTypeID(id) != CFBooleanGetTypeID(),
|
||||
id.doubleValue >= 0, id.doubleValue <= 9007199254740991, id.doubleValue.rounded() == id.doubleValue,
|
||||
let action = value["action"] as? String,
|
||||
let payload = value["payload"] as? [String: Any] else { throw NativeSettingsError.invalidRequest }
|
||||
switch action {
|
||||
case "getState", "settingsReady", "openSettings":
|
||||
guard payload.isEmpty else { throw NativeSettingsError.invalidRequest }
|
||||
case "setWorkspace":
|
||||
guard Set(payload.keys) == Set(["server"]), payload["server"] is String else { throw NativeSettingsError.invalidRequest }
|
||||
default: throw NativeSettingsError.invalidRequest
|
||||
}
|
||||
return NativeSettingsRequest(id: id.intValue, action: action, payload: payload)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
private var connection = WorkspaceConnection()
|
||||
private var pendingNavigation: WKNavigation?
|
||||
private var timeout: DispatchWorkItem?
|
||||
private let loading = UIActivityIndicatorView(style: .large)
|
||||
private let launcher = UIScrollView()
|
||||
private let stack = UIStackView()
|
||||
private let address = UITextField()
|
||||
@@ -39,8 +40,8 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
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 settingsAvailable = false
|
||||
private var toolbarHeight: NSLayoutConstraint!
|
||||
private var appearance: UISegmentedControl!
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
@@ -58,6 +59,14 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemBackground
|
||||
view.tintColor = UIColor(red: 0, green: 0.43, blue: 0.18, alpha: 1)
|
||||
loading.accessibilityIdentifier = "workspaceLoading"
|
||||
loading.accessibilityLabel = text("connecting")
|
||||
loading.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(loading)
|
||||
NSLayoutConstraint.activate([
|
||||
loading.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
loading.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||
])
|
||||
configureLauncher()
|
||||
configureToolbar()
|
||||
if let data = defaults.data(forKey: serverKey) {
|
||||
@@ -69,8 +78,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
}
|
||||
}
|
||||
address.text = server?.url.absoluteString ?? "https://kanbangit.de/"
|
||||
showSettings()
|
||||
if let server { open(server.url) }
|
||||
if let server { open(server.url) } else { showSettings() }
|
||||
}
|
||||
|
||||
private func label(_ key: String, style: UIFont.TextStyle = .body) -> UILabel {
|
||||
@@ -83,9 +91,10 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
}
|
||||
|
||||
private func configureLauncher() {
|
||||
launcher.isHidden = true
|
||||
launcher.accessibilityIdentifier = "serverSettings"
|
||||
launcher.keyboardDismissMode = .interactive
|
||||
launcher.alwaysBounceVertical = true
|
||||
launcher.bounces = false
|
||||
view.addSubview(launcher)
|
||||
launcher.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
@@ -152,14 +161,15 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
}
|
||||
|
||||
private func configureToolbar() {
|
||||
toolbar.isHidden = true
|
||||
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]
|
||||
toolbar.items = [UIBarButtonItem(systemItem: .flexibleSpace), settings]
|
||||
toolbarHeight = toolbar.heightAnchor.constraint(equalToConstant: 44)
|
||||
NSLayoutConstraint.activate([
|
||||
toolbarHeight,
|
||||
toolbar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||
toolbar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
||||
toolbar.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
||||
@@ -193,19 +203,27 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
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?.configuration.userContentController.removeScriptMessageHandler(forName: "kanbanSettings", contentWorld: .page)
|
||||
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 scriptURL = Bundle(for: WorkspaceViewController.self).url(forResource: "workspace-navigation", withExtension: "js")!
|
||||
let script = try! String(contentsOf: scriptURL, encoding: .utf8)
|
||||
configuration.userContentController.addUserScript(WKUserScript(source: script, injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
||||
let settingsURL = Bundle(for: WorkspaceViewController.self).url(forResource: "workspace-settings", withExtension: "js")!
|
||||
let settingsScript = try! String(contentsOf: settingsURL, encoding: .utf8)
|
||||
configuration.userContentController.addUserScript(WKUserScript(source: settingsScript, injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
||||
configuration.userContentController.addScriptMessageHandler(NativeSettingsHandler(controller: self), contentWorld: .page, name: "kanbanSettings")
|
||||
setSettingsAvailable(false)
|
||||
let webview = WKWebView(frame: .zero, configuration: configuration)
|
||||
webview.navigationDelegate = self
|
||||
webview.uiDelegate = self
|
||||
webview.allowsBackForwardNavigationGestures = true
|
||||
webview.scrollView.bounces = false
|
||||
webview.accessibilityIdentifier = "workspace"
|
||||
webview.translatesAutoresizingMaskIntoConstraints = false
|
||||
webview.isHidden = true
|
||||
@@ -217,13 +235,13 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
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
|
||||
launcher.isHidden = true
|
||||
toolbar.isHidden = true
|
||||
loading.startAnimating()
|
||||
beginNavigation()
|
||||
pendingNavigation = webview.load(URLRequest(url: url))
|
||||
pendingNavigation = webview.load(URLRequest(url: selected.dashboardURL))
|
||||
}
|
||||
|
||||
private func beginNavigation() {
|
||||
@@ -247,6 +265,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
}
|
||||
|
||||
@objc private func showSettings() {
|
||||
loading.stopAnimating()
|
||||
view.endEditing(true)
|
||||
launcher.isHidden = false
|
||||
toolbar.isHidden = true
|
||||
@@ -256,14 +275,50 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
|
||||
@objc private func showWorkspace() {
|
||||
guard connection.ready else { return }
|
||||
loading.stopAnimating()
|
||||
view.endEditing(true)
|
||||
launcher.isHidden = true
|
||||
toolbar.isHidden = false
|
||||
toolbar.isHidden = settingsAvailable
|
||||
workspace?.isHidden = false
|
||||
backButton.isEnabled = workspace?.canGoBack == true
|
||||
}
|
||||
|
||||
@objc private func goBack() { if workspace?.canGoBack == true { workspace?.goBack() } }
|
||||
private func setSettingsAvailable(_ available: Bool) {
|
||||
settingsAvailable = available
|
||||
toolbarHeight.constant = available ? 0 : 44
|
||||
toolbar.isHidden = available || !launcher.isHidden || !connection.ready
|
||||
}
|
||||
|
||||
func handleSettings(_ message: WKScriptMessage, reply: @escaping (Any?, String?) -> Void) {
|
||||
guard message.webView === workspace, message.frameInfo.isMainFrame,
|
||||
let source = message.frameInfo.request.url, server?.allows(source) == true,
|
||||
let current = workspace?.url, server?.allows(current) == true,
|
||||
let body = message.body as? String else { reply(nil, "untrustedFrame"); return }
|
||||
do {
|
||||
let request = try NativeSettingsRequest.parse(body)
|
||||
var result: Any = NSNull()
|
||||
switch request.action {
|
||||
case "getState":
|
||||
result = ["apiVersion": 1, "platform": "ios", "workspace": server!.url.absoluteString,
|
||||
"version": Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown",
|
||||
"capabilities": ["workspace"]] as [String: Any]
|
||||
case "settingsReady": setSettingsAvailable(true)
|
||||
case "openSettings": showSettings()
|
||||
case "setWorkspace":
|
||||
guard let address = request.payload["server"] as? String,
|
||||
let url = WorkspaceServer.normalize(address) else { throw NativeSettingsError.invalidServer }
|
||||
let owner = workspace
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.workspace === owner else { return }
|
||||
self.open(url)
|
||||
}
|
||||
default: throw NativeSettingsError.invalidRequest
|
||||
}
|
||||
let data = try JSONSerialization.data(withJSONObject: ["id": request.id, "result": result])
|
||||
reply(String(decoding: data, as: UTF8.self), nil)
|
||||
} catch {
|
||||
reply(nil, String(describing: error))
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
guard webView === workspace, let url = action.request.url else { decisionHandler(.cancel); return }
|
||||
@@ -291,12 +346,14 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
|
||||
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||
guard webView === workspace else { return }
|
||||
setSettingsAvailable(false)
|
||||
pendingNavigation = navigation
|
||||
beginNavigation()
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
guard webView === workspace, navigation === pendingNavigation else { return }
|
||||
guard webView === workspace, navigation === pendingNavigation, connection.connecting,
|
||||
let url = webView.url, server?.allows(url) == true else { return }
|
||||
let firstLoad = !connection.ready
|
||||
timeout?.cancel()
|
||||
connection.finish()
|
||||
@@ -304,7 +361,6 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
connectButton.setTitle(text("openWorkspace"), for: .normal)
|
||||
status.text = text("connected")
|
||||
resumeButton.isHidden = false
|
||||
backButton.isEnabled = webView.canGoBack
|
||||
if firstLoad { showWorkspace() }
|
||||
}
|
||||
|
||||
@@ -330,3 +386,13 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
showSettings()
|
||||
}
|
||||
}
|
||||
|
||||
private final class NativeSettingsHandler: NSObject, WKScriptMessageHandlerWithReply {
|
||||
weak var controller: WorkspaceViewController?
|
||||
init(controller: WorkspaceViewController) { self.controller = controller }
|
||||
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage,
|
||||
replyHandler: @escaping (Any?, String?) -> Void) {
|
||||
guard let controller else { replyHandler(nil, "bridgeUnavailable"); return }
|
||||
controller.handleSettings(message, reply: replyHandler)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,71 @@ final class WorkspaceTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
func testDashboardAddressPreservesTheInstallationPath() throws {
|
||||
let url = try XCTUnwrap(WorkspaceServer.normalize("https://example.com:8443/kanban/"))
|
||||
XCTAssertEqual(WorkspaceServer(url: url, profile: UUID()).dashboardURL.absoluteString,
|
||||
"https://example.com:8443/kanban/dashboard")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testColdStartHidesSelectionUntilFailureAndPreservesSavedWorkspace() throws {
|
||||
let name = "KAN69-startup-\(UUID())"
|
||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
||||
defer { defaults.removePersistentDomain(forName: name) }
|
||||
let saved = WorkspaceServer(url: URL(string: "https://example.invalid/kanban/")!, profile: UUID())
|
||||
let data = try JSONEncoder().encode(saved)
|
||||
defaults.set(data, forKey: "ios.activeServer")
|
||||
let controller = WorkspaceViewController(defaults: defaults)
|
||||
controller.loadViewIfNeeded()
|
||||
let launcher = try XCTUnwrap(controller.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" })
|
||||
let loading = try XCTUnwrap(controller.view.subviews.first { $0.accessibilityIdentifier == "workspaceLoading" } as? UIActivityIndicatorView)
|
||||
XCTAssertTrue(launcher.isHidden)
|
||||
XCTAssertTrue(loading.isAnimating)
|
||||
XCTAssertEqual(controller.server, saved)
|
||||
let webview = try XCTUnwrap(controller.workspace)
|
||||
XCTAssertFalse(webview.canGoBack)
|
||||
XCTAssertTrue(webview.backForwardList.backList.isEmpty)
|
||||
webview.stopLoading()
|
||||
let navigation = try XCTUnwrap(webview.loadSimulatedRequest(URLRequest(url: saved.dashboardURL), responseHTML: "<html></html>"))
|
||||
controller.webView(webview, didStartProvisionalNavigation: navigation)
|
||||
XCTAssertTrue(launcher.isHidden)
|
||||
controller.webView(webview, didFailProvisionalNavigation: navigation,
|
||||
withError: NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet))
|
||||
XCTAssertFalse(launcher.isHidden)
|
||||
XCTAssertFalse(loading.isAnimating)
|
||||
XCTAssertEqual(try JSONDecoder().decode(WorkspaceServer.self, from: XCTUnwrap(defaults.data(forKey: "ios.activeServer"))), saved)
|
||||
controller.webView(webview, didFinish: navigation)
|
||||
XCTAssertFalse(launcher.isHidden, "A late finish must not hide recovery after a failure")
|
||||
controller.open(saved.url)
|
||||
XCTAssertTrue(launcher.isHidden)
|
||||
XCTAssertTrue(loading.isAnimating)
|
||||
XCTAssertEqual(controller.server?.profile, saved.profile)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testMissingOrInvalidConfigurationShowsSetupAndServerChangesAreRestored() throws {
|
||||
let name = "KAN69-setup-\(UUID())"
|
||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
||||
defer { defaults.removePersistentDomain(forName: name) }
|
||||
for data in [nil, Data("invalid".utf8), try JSONEncoder().encode(WorkspaceServer(url: URL(string: "http://example.com/")!, profile: UUID()))] {
|
||||
defaults.set(data, forKey: "ios.activeServer")
|
||||
let controller = WorkspaceViewController(defaults: defaults)
|
||||
controller.loadViewIfNeeded()
|
||||
XCTAssertNil(controller.workspace)
|
||||
let launcher = try XCTUnwrap(controller.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" })
|
||||
XCTAssertFalse(launcher.isHidden)
|
||||
}
|
||||
let controller = WorkspaceViewController(defaults: defaults)
|
||||
controller.loadViewIfNeeded()
|
||||
controller.open(URL(string: "https://first.invalid/")!)
|
||||
controller.open(URL(string: "https://second.invalid/app/")!)
|
||||
let restored = WorkspaceViewController(defaults: defaults)
|
||||
restored.loadViewIfNeeded()
|
||||
XCTAssertEqual(restored.server, controller.server)
|
||||
XCTAssertEqual(restored.server?.dashboardURL.absoluteString, "https://second.invalid/app/dashboard")
|
||||
XCTAssertTrue(restored.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" }?.isHidden == true)
|
||||
}
|
||||
|
||||
func testExternalLinksStayOutsideTheSelectedServer() throws {
|
||||
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"] {
|
||||
@@ -30,39 +95,130 @@ final class WorkspaceTests: XCTestCase {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testBackButtonTracksSameDocumentHistory() async throws {
|
||||
let name = "KAN55-history-\(UUID())"
|
||||
func testWebKitOwnsInteractiveGesturesAndKeepsScrollBounds() async throws {
|
||||
let name = "KAN71-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)
|
||||
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||
let url = URL(string: "https://example.invalid/dashboard")!
|
||||
controller.open(URL(string: "https://example.invalid/")!)
|
||||
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")
|
||||
webview.frame = controller.view.bounds
|
||||
webview.loadSimulatedRequest(URLRequest(url: url), responseHTML: """
|
||||
<html><head><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
||||
<body style="margin:0"><p>Workspace</p><input id="draft" value="unsaved"></body></html>
|
||||
""")
|
||||
try await waitFor(webview, "document.querySelector('#draft')?.value === 'unsaved'")
|
||||
XCTAssertFalse(webview.canGoBack)
|
||||
XCTAssertNil(webview.goBack())
|
||||
XCTAssertNil(webview.goForward())
|
||||
try await waitFor(webview, "location.pathname === '/dashboard'")
|
||||
XCTAssertTrue(webview.allowsBackForwardNavigationGestures)
|
||||
XCTAssertFalse(webview.scrollView.bounces)
|
||||
XCTAssertFalse(controller.responds(to: NSSelectorFromString("navigateGesture:")))
|
||||
let customNavigation = try await webview.evaluateJavaScript("typeof window.__kanbanNavigation")
|
||||
XCTAssertEqual(customNavigation as? String, "undefined")
|
||||
let draft = try await webview.evaluateJavaScript("document.querySelector('#draft').value")
|
||||
XCTAssertEqual(draft as? String, "unsaved")
|
||||
let toolbar = try XCTUnwrap(controller.view.subviews.compactMap { $0 as? UIToolbar }.first)
|
||||
XCTAssertFalse(toolbar.items!.contains { $0.accessibilityIdentifier == "workspaceBack" })
|
||||
XCTAssertTrue(toolbar.items!.contains { $0.accessibilityIdentifier == "showServerSettings" })
|
||||
let scroll = try await webview.evaluateJavaScript("getComputedStyle(document.documentElement).overscrollBehaviorY")
|
||||
XCTAssertEqual(scroll as? String, "none")
|
||||
let horizontal = try await webview.evaluateJavaScript("getComputedStyle(document.documentElement).overscrollBehaviorX")
|
||||
XCTAssertEqual(horizontal as? String, "auto")
|
||||
controller.open(URL(string: "https://other.invalid/")!)
|
||||
XCTAssertTrue(try XCTUnwrap(controller.workspace).allowsBackForwardNavigationGestures,
|
||||
"Changing workspaces must retain native gesture navigation")
|
||||
}
|
||||
|
||||
func testSettingsProtocolRejectsUnsupportedActionsAndInvalidPayloads() throws {
|
||||
for body in [
|
||||
#"{"version":2,"id":1,"action":"getState","payload":{}}"#,
|
||||
#"{"version":true,"id":1,"action":"getState","payload":{}}"#,
|
||||
#"{"version":1,"id":1,"action":"installUpdate","payload":{"version":"1"}}"#,
|
||||
#"{"version":1,"id":1,"action":"setWorkspace","payload":{"server":12}}"#,
|
||||
#"{"version":1,"id":1,"action":"getState","payload":{"extra":1}}"#,
|
||||
#"{"version":1,"id":1,"action":"shell","payload":{}}"#,
|
||||
] { XCTAssertThrowsError(try NativeSettingsRequest.parse(body), body) }
|
||||
XCTAssertThrowsError(try NativeSettingsRequest.parse(String(repeating: "x", count: 8193)))
|
||||
XCTAssertEqual(try NativeSettingsRequest.parse(#"{"version":1,"id":2,"action":"getState","payload":{}}"#).id, 2)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testSettingsBridgeHidesToolbarOnlyAfterHandshakeAndRejectsFramesAndOldWorkspaces() async throws {
|
||||
let name = "KAN70-settings-\(UUID())"
|
||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
||||
defer { defaults.removePersistentDomain(forName: name) }
|
||||
let controller = WorkspaceViewController(defaults: defaults)
|
||||
controller.loadViewIfNeeded()
|
||||
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||
let url = URL(string: "https://example.invalid/")!
|
||||
controller.open(url)
|
||||
let originalProfile = controller.server!.profile
|
||||
let webview = try XCTUnwrap(controller.workspace)
|
||||
webview.stopLoading()
|
||||
webview.loadSimulatedRequest(URLRequest(url: url.appendingPathComponent("login")), responseHTML: "<html><body>Login<iframe srcdoc='<p>frame</p>'></iframe></body></html>")
|
||||
try await waitFor(webview, "document.readyState === 'complete' && typeof window.kanbanNative === 'object'")
|
||||
let state = try await callScript(webview, "return await window.kanbanNative.request('getState')") as? [String: Any]
|
||||
XCTAssertEqual(state?["platform"] as? String, "ios")
|
||||
XCTAssertEqual(state?["workspace"] as? String, url.absoluteString)
|
||||
XCTAssertEqual(state?["capabilities"] as? [String], ["workspace"])
|
||||
XCTAssertNil(state?["updates"])
|
||||
let toolbar = try XCTUnwrap(controller.view.subviews.compactMap { $0 as? UIToolbar }.first)
|
||||
XCTAssertFalse(toolbar.isHidden, "Older pages must retain native recovery")
|
||||
let rejected = try await callScript(webview, """
|
||||
try {
|
||||
await window.frames[0].webkit.messageHandlers.kanbanSettings.postMessage(JSON.stringify({version:1,id:2,action:'settingsReady',payload:{}}));
|
||||
return false;
|
||||
} catch { return true; }
|
||||
""")
|
||||
XCTAssertEqual(rejected as? Bool, true)
|
||||
XCTAssertFalse(toolbar.isHidden)
|
||||
_ = try await callScript(webview, "return await window.kanbanNative.request('settingsReady')")
|
||||
controller.view.layoutIfNeeded()
|
||||
XCTAssertTrue(toolbar.isHidden)
|
||||
XCTAssertEqual(toolbar.frame.height, 0)
|
||||
XCTAssertEqual(webview.frame.minY, controller.view.safeAreaLayoutGuide.layoutFrame.minY, accuracy: 1)
|
||||
let invalid = try await callScript(webview, "try { await window.kanbanNative.request('setWorkspace', {server:'http://bad.test'}); return false; } catch { return true; }")
|
||||
XCTAssertEqual(invalid as? Bool, true)
|
||||
XCTAssertEqual(controller.server?.url, url)
|
||||
_ = try await callScript(webview, "return await window.kanbanNative.request('openSettings')")
|
||||
XCTAssertFalse(controller.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" }!.isHidden)
|
||||
_ = try await callScript(webview, "return await window.kanbanNative.request('setWorkspace', {server:'https://other.invalid/app/'})")
|
||||
for _ in 0..<50 {
|
||||
if controller.workspace !== webview { break }
|
||||
try await Task.sleep(for: .milliseconds(20))
|
||||
}
|
||||
XCTAssertFalse(controller.workspace === webview)
|
||||
XCTAssertNotEqual(controller.server?.profile, originalProfile)
|
||||
let restored = WorkspaceViewController(defaults: defaults)
|
||||
restored.loadViewIfNeeded()
|
||||
XCTAssertEqual(restored.server?.url.absoluteString, "https://other.invalid/app/")
|
||||
let stale = try await callScript(webview, "try { await window.kanbanNative.request('setWorkspace', {server:'https://stale.invalid/'}); return false; } catch { return true; }")
|
||||
XCTAssertEqual(stale as? Bool, true)
|
||||
XCTAssertEqual(controller.server?.url.absoluteString, "https://other.invalid/app/")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func callScript(_ webview: WKWebView, _ script: String) async throws -> Any {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
webview.callAsyncJavaScript(script, arguments: [:], in: nil, in: .page) { result in
|
||||
continuation.resume(with: result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func waitFor(_ webview: WKWebView, _ expression: String) async throws {
|
||||
for _ in 0..<100 {
|
||||
if let result = try? await webview.evaluateJavaScript(expression), result as? Bool == true { return }
|
||||
try await Task.sleep(for: .milliseconds(50))
|
||||
}
|
||||
XCTFail("WebView condition timed out: \(expression)")
|
||||
}
|
||||
|
||||
func testServerChangesNeverReusePreviousProfile() throws {
|
||||
@@ -122,7 +278,7 @@ final class WorkspaceTests: XCTestCase {
|
||||
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)
|
||||
XCTAssertEqual(controller.workspace?.configuration.userContentController.userScripts.count, 2)
|
||||
let restored = WorkspaceViewController(defaults: defaults)
|
||||
restored.loadViewIfNeeded()
|
||||
XCTAssertEqual(restored.server, server)
|
||||
|
||||
@@ -13,11 +13,50 @@ final class WorkspaceUITests: XCTestCase {
|
||||
XCTAssertTrue(element.isHittable)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testSavedWorkspaceColdStartNeverShowsSelectionBeforeRecovery() {
|
||||
continueAfterFailure = false
|
||||
XCUIDevice.shared.orientation = .portrait
|
||||
defer { XCUIDevice.shared.orientation = .portrait }
|
||||
let app = XCUIApplication()
|
||||
app.launchEnvironment["OS_ACTIVITY_DT_MODE"] = "NO"
|
||||
app.launchEnvironment["OS_ACTIVITY_MODE"] = "disable"
|
||||
app.launch()
|
||||
let settings = app.buttons["showServerSettings"]
|
||||
if settings.waitForExistence(timeout: 3) { settings.tap() }
|
||||
let address = app.textFields["serverAddress"]
|
||||
XCTAssertTrue(address.waitForExistence(timeout: 20))
|
||||
address.tap()
|
||||
if address.buttons.firstMatch.exists { address.buttons.firstMatch.tap() }
|
||||
address.typeText("https://127.0.0.1:9/")
|
||||
app.buttons["openWorkspace"].tap()
|
||||
let confirmation = app.alerts.buttons["Switch server"]
|
||||
if confirmation.waitForExistence(timeout: 1) { confirmation.tap() }
|
||||
XCTAssertTrue(app.staticTexts["connectionStatus"].waitForExistence(timeout: 20))
|
||||
app.terminate()
|
||||
app.launch()
|
||||
// An unreachable saved server may fail immediately; only recovery may reveal setup.
|
||||
for _ in 0..<30 {
|
||||
if address.exists {
|
||||
XCTAssertTrue(app.staticTexts["connectionStatus"].label.contains("server did not finish loading"))
|
||||
break
|
||||
}
|
||||
XCTAssertFalse(app.staticTexts["Choose your workspace."].exists)
|
||||
Thread.sleep(forTimeInterval: 0.1)
|
||||
}
|
||||
XCTAssertTrue(address.waitForExistence(timeout: 20))
|
||||
XCTAssertEqual(address.value as? String, "https://127.0.0.1:9/")
|
||||
XCTAssertTrue(app.buttons["openWorkspace"].label.contains("Try again"))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testLauncherKeyboardValidationRotationAndAppearance() {
|
||||
continueAfterFailure = false
|
||||
XCUIDevice.shared.orientation = .portrait
|
||||
defer { XCUIDevice.shared.orientation = .portrait }
|
||||
let app = XCUIApplication()
|
||||
app.launchEnvironment["OS_ACTIVITY_DT_MODE"] = "NO"
|
||||
app.launchEnvironment["OS_ACTIVITY_MODE"] = "disable"
|
||||
app.launch()
|
||||
let settings = app.buttons["showServerSettings"]
|
||||
if settings.waitForExistence(timeout: 3) { settings.tap() }
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
009F8FEC2765B2F19DC12CA8 /* WorkspaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */; };
|
||||
0EC5CB2D7F774DBDCA69BCD8 /* workspace-navigation.js in Resources */ = {isa = PBXBuildFile; fileRef = 7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */; };
|
||||
0FAB3CC7BB466BFF871C0C30 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 31630170C358EA8657E34854 /* Assets.xcassets */; };
|
||||
205D0CBA33F7D3C59AAD5300 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 63685B1189795CAEA85F7AB7 /* QuartzCore.framework */; };
|
||||
333E7761EA87AE29CE04F1AC /* main.mm in Sources */ = {isa = PBXBuildFile; fileRef = F46963153D20DB21D97843E9 /* main.mm */; };
|
||||
@@ -18,6 +19,8 @@
|
||||
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 */; };
|
||||
99291BB0C1DE7714FB32F554 /* workspace-navigation.js in Resources */ = {isa = PBXBuildFile; fileRef = 7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */; };
|
||||
99D76D98A2416CAA713186C2 /* workspace-settings.js in Resources */ = {isa = PBXBuildFile; fileRef = BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */; };
|
||||
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 */; };
|
||||
@@ -25,6 +28,7 @@
|
||||
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 */; };
|
||||
EAD1D5A29503456CEED3B324 /* workspace-settings.js in Resources */ = {isa = PBXBuildFile; fileRef = BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */; };
|
||||
EB37EAF992C1A156795CAEC0 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
@@ -43,15 +47,21 @@
|
||||
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; };
|
||||
1CDE5C303A96423E2E6A194D /* settings.rs */ = {isa = PBXFileReference; path = settings.rs; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
5F196E34D1B9E14FBD7191F4 /* settings.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = settings.m; sourceTree = "<group>"; };
|
||||
5F71F06AB639140AB9714425 /* metadata.rs */ = {isa = PBXFileReference; path = metadata.rs; sourceTree = "<group>"; };
|
||||
63685B1189795CAEA85F7AB7 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; };
|
||||
6610D376CF901AC1733D394E /* updates.rs */ = {isa = PBXFileReference; path = updates.rs; sourceTree = "<group>"; };
|
||||
7440C7F0426E6B3977DD1D94 /* main.rs */ = {isa = PBXFileReference; path = main.rs; sourceTree = "<group>"; };
|
||||
7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "workspace-navigation.js"; sourceTree = "<group>"; };
|
||||
8243D0C3821DAC731FECF105 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; };
|
||||
8C0F963909020429B3D257D5 /* navigation.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = navigation.m; sourceTree = "<group>"; };
|
||||
91991DFDB0C064B06DF854ED /* libapp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libapp.a; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
@@ -59,6 +69,7 @@
|
||||
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>"; };
|
||||
BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "workspace-settings.js"; 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>"; };
|
||||
@@ -108,6 +119,7 @@
|
||||
8606F3EE5A736385061692EF /* Sources */,
|
||||
335B1262A9019C70C7F887A5 /* src */,
|
||||
140A7DB7E2461E64B1FBFA70 /* Tests */,
|
||||
709BE6DF191B953724A55555 /* ui */,
|
||||
3C9F31BB065F210267ECA390 /* UITests */,
|
||||
3028C3786B437115B6486F1C /* Frameworks */,
|
||||
59D2DE9FD94E6D2D64CB0117 /* Products */,
|
||||
@@ -146,7 +158,12 @@
|
||||
2B96972F58CC158AB3DFF94A /* lib.rs */,
|
||||
AEBAD9ABE758897C50BC1F2E /* locale.rs */,
|
||||
7440C7F0426E6B3977DD1D94 /* main.rs */,
|
||||
5F71F06AB639140AB9714425 /* metadata.rs */,
|
||||
8C0F963909020429B3D257D5 /* navigation.m */,
|
||||
E8BA104C81071623E1FF367D /* server.rs */,
|
||||
5F196E34D1B9E14FBD7191F4 /* settings.m */,
|
||||
1CDE5C303A96423E2E6A194D /* settings.rs */,
|
||||
6610D376CF901AC1733D394E /* updates.rs */,
|
||||
);
|
||||
name = src;
|
||||
path = ../../src;
|
||||
@@ -179,6 +196,16 @@
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
709BE6DF191B953724A55555 /* ui */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */,
|
||||
BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */,
|
||||
);
|
||||
name = ui;
|
||||
path = ../../../ui;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
8606F3EE5A736385061692EF /* Sources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -247,6 +274,7 @@
|
||||
buildConfigurationList = 26F675D59EF454AE0D263F35 /* Build configuration list for PBXNativeTarget "WorkspaceTests" */;
|
||||
buildPhases = (
|
||||
2A0467365B852D44D1A9FD5B /* Sources */,
|
||||
DC3295DE0893151786FEA37B /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -269,12 +297,18 @@
|
||||
LastUpgradeCheck = 1430;
|
||||
TargetAttributes = {
|
||||
721CF221CEDDC28FF2C5EBD5 = {
|
||||
DevelopmentTeam = "";
|
||||
ProvisioningStyle = Automatic;
|
||||
DevelopmentTeam = ZZX2KZGTHU;
|
||||
ProvisioningStyle = Manual;
|
||||
};
|
||||
988CFF304CB81C8F3A4D6612 = {
|
||||
DevelopmentTeam = ZZX2KZGTHU;
|
||||
ProvisioningStyle = Manual;
|
||||
TestTargetID = 721CF221CEDDC28FF2C5EBD5;
|
||||
};
|
||||
CBC4561F8018D37DD49A0598 = {
|
||||
DevelopmentTeam = ZZX2KZGTHU;
|
||||
ProvisioningStyle = Manual;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = A9CA551DD3F3EBCCF8C41CC1 /* Build configuration list for PBXProject "kanbangit-native" */;
|
||||
@@ -307,6 +341,17 @@
|
||||
C53E7EC983C008DF98244C74 /* InfoPlist.strings in Resources */,
|
||||
34C189B72A1B9EBE33D700D1 /* LaunchScreen.storyboard in Resources */,
|
||||
A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */,
|
||||
99291BB0C1DE7714FB32F554 /* workspace-navigation.js in Resources */,
|
||||
99D76D98A2416CAA713186C2 /* workspace-settings.js in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
DC3295DE0893151786FEA37B /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
0EC5CB2D7F774DBDCA69BCD8 /* workspace-navigation.js in Resources */,
|
||||
EAD1D5A29503456CEED3B324 /* workspace-settings.js in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -332,7 +377,7 @@
|
||||
);
|
||||
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";
|
||||
shellScript = "set -eu\nif [ -n \"${KANBANGIT_IOS_LIBRARY:-}\" ]; then\n mkdir -p \"$SRCROOT/Externals/arm64/$CONFIGURATION\"\n cp \"$KANBANGIT_IOS_LIBRARY\" \"$SRCROOT/Externals/arm64/$CONFIGURATION/libapp.a\"\n exit 0\nfi\nexport PATH=\"$(dirname \"$(rustup which cargo)\"):$PATH\"\nexport RUSTC=\"$(rustup which rustc)\"\ncd \"$SRCROOT/../../..\"\n./node_modules/.bin/tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths \"${FRAMEWORK_SEARCH_PATHS:?}\" --header-search-paths \"${HEADER_SEARCH_PATHS:?}\" --gcc-preprocessor-definitions \"${GCC_PREPROCESSOR_DEFINITIONS:-}\" --configuration ${CONFIGURATION:?} ${FORCE_COLOR:-} ${ARCHS:?}\n";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
@@ -404,9 +449,10 @@
|
||||
);
|
||||
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 = "";
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 6;
|
||||
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
|
||||
@@ -421,9 +467,10 @@
|
||||
);
|
||||
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.desktop;
|
||||
MARKETING_VERSION = 0.2.6;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
|
||||
PRODUCT_MODULE_NAME = KanbanGitNative;
|
||||
PRODUCT_NAME = "KanbanGit";
|
||||
PRODUCT_NAME = KanbanGit;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
@@ -498,13 +545,15 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.uitests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios.uitests;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
@@ -577,9 +626,10 @@
|
||||
);
|
||||
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 = "";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 6;
|
||||
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
|
||||
@@ -594,9 +644,10 @@
|
||||
);
|
||||
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.desktop;
|
||||
MARKETING_VERSION = 0.2.6;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
|
||||
PRODUCT_MODULE_NAME = KanbanGitNative;
|
||||
PRODUCT_NAME = "KanbanGit";
|
||||
PRODUCT_NAME = KanbanGit;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
@@ -608,13 +659,15 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.uitests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios.uitests;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
@@ -626,13 +679,15 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.tests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios.tests;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
@@ -643,13 +698,15 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.tests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios.tests;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.1.0</string>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.1.0</string>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
@@ -45,4 +45,4 @@
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
</plist>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: kanbangit-native
|
||||
options:
|
||||
bundleIdPrefix: de.kanbangit.desktop
|
||||
bundleIdPrefix: de.kanbangit.ios
|
||||
deploymentTarget:
|
||||
iOS: 17.0
|
||||
fileGroups: [../../src]
|
||||
@@ -14,10 +14,17 @@ settingGroups:
|
||||
SWIFT_VERSION: "5.0"
|
||||
PRODUCT_MODULE_NAME: KanbanGitNative
|
||||
TARGETED_DEVICE_FAMILY: "1,2"
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
DEVELOPMENT_TEAM: ""
|
||||
CODE_SIGN_STYLE: Manual
|
||||
DEVELOPMENT_TEAM: ZZX2KZGTHU
|
||||
ENABLE_USER_SCRIPT_SANDBOXING: false
|
||||
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.desktop
|
||||
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios
|
||||
MARKETING_VERSION: "0.2.6"
|
||||
CURRENT_PROJECT_VERSION: "6"
|
||||
configs:
|
||||
debug:
|
||||
CODE_SIGN_IDENTITY: Apple Development
|
||||
release:
|
||||
CODE_SIGN_IDENTITY: Apple Distribution
|
||||
targetTemplates:
|
||||
app:
|
||||
type: application
|
||||
@@ -35,6 +42,8 @@ targets:
|
||||
platform: iOS
|
||||
sources:
|
||||
- path: Sources
|
||||
- path: ../../../ui/workspace-navigation.js
|
||||
- path: ../../../ui/workspace-settings.js
|
||||
- path: Assets.xcassets
|
||||
- path: kanbangit-native_iOS
|
||||
- path: LaunchScreen.storyboard
|
||||
@@ -55,8 +64,8 @@ targets:
|
||||
- UIInterfaceOrientationPortraitUpsideDown
|
||||
- UIInterfaceOrientationLandscapeLeft
|
||||
- UIInterfaceOrientationLandscapeRight
|
||||
CFBundleShortVersionString: 0.1.0
|
||||
CFBundleVersion: "0.1.0"
|
||||
CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||
entitlements:
|
||||
path: kanbangit-native_iOS/kanbangit-native_iOS.entitlements
|
||||
scheme:
|
||||
@@ -88,10 +97,16 @@ targets:
|
||||
- sdk: WebKit.framework
|
||||
preBuildScripts:
|
||||
- script: |
|
||||
set -eu
|
||||
if [ -n "${KANBANGIT_IOS_LIBRARY:-}" ]; then
|
||||
mkdir -p "$SRCROOT/Externals/arm64/$CONFIGURATION"
|
||||
cp "$KANBANGIT_IOS_LIBRARY" "$SRCROOT/Externals/arm64/$CONFIGURATION/libapp.a"
|
||||
exit 0
|
||||
fi
|
||||
export PATH="$(dirname "$(rustup which cargo)"):$PATH"
|
||||
export 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:?}
|
||||
./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:
|
||||
@@ -101,6 +116,8 @@ targets:
|
||||
type: bundle.unit-test
|
||||
platform: iOS
|
||||
sources:
|
||||
- path: ../../../ui/workspace-navigation.js
|
||||
- path: ../../../ui/workspace-settings.js
|
||||
- path: Tests
|
||||
- path: Sources/kanbangit-native/WorkspacePolicy.swift
|
||||
- path: Sources/kanbangit-native/WorkspaceViewController.swift
|
||||
@@ -108,7 +125,9 @@ targets:
|
||||
settings:
|
||||
base:
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.tests
|
||||
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios.tests
|
||||
DEVELOPMENT_TEAM: ZZX2KZGTHU
|
||||
CODE_SIGN_STYLE: Manual
|
||||
SWIFT_VERSION: "5.0"
|
||||
WorkspaceUITests:
|
||||
type: bundle.ui-testing
|
||||
@@ -119,6 +138,8 @@ targets:
|
||||
settings:
|
||||
base:
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.uitests
|
||||
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios.uitests
|
||||
DEVELOPMENT_TEAM: ZZX2KZGTHU
|
||||
CODE_SIGN_STYLE: Manual
|
||||
SWIFT_VERSION: "5.0"
|
||||
TEST_TARGET_NAME: kanbangit-native_iOS
|
||||
|
||||
@@ -2,7 +2,7 @@ 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},
|
||||
menu::{AboutMetadata, MenuBuilder, MenuItem, SubmenuBuilder},
|
||||
webview::{NewWindowResponse, PageLoadEvent},
|
||||
AppHandle, Manager, Url, WebviewUrl, WebviewWindowBuilder, WindowEvent,
|
||||
};
|
||||
@@ -17,6 +17,15 @@ struct Connection {
|
||||
}
|
||||
|
||||
impl Connection {
|
||||
#[cfg(target_os = "macos")]
|
||||
fn allows_settings(&self, label: &str, url: &Url, main: bool) -> bool {
|
||||
main && self.label.as_deref() == Some(label)
|
||||
&& self
|
||||
.server
|
||||
.as_ref()
|
||||
.is_some_and(|server| allows_server_navigation(&server.url, url))
|
||||
}
|
||||
|
||||
fn begin_navigation(&mut self) -> u64 {
|
||||
self.revision += 1;
|
||||
self.phase = "connecting";
|
||||
@@ -169,84 +178,150 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
|
||||
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);
|
||||
let window =
|
||||
WebviewWindowBuilder::new(app, &label, WebviewUrl::External(server.dashboard_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)
|
||||
.initialization_script(include_str!("../../ui/workspace-navigation.js"))
|
||||
.on_navigation(move |destination| {
|
||||
if !allows_server_navigation(&server_url, destination) {
|
||||
open_external(destination);
|
||||
return false;
|
||||
}
|
||||
} 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()) {
|
||||
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 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();
|
||||
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())
|
||||
|| state.phase != "connecting"
|
||||
|| !state.server.as_ref().is_some_and(|server| {
|
||||
allows_server_navigation(&server.url, payload.url())
|
||||
})
|
||||
{
|
||||
return;
|
||||
}
|
||||
let first_load = !state.ready;
|
||||
state.ready = true;
|
||||
state.phase = "connected";
|
||||
first_load
|
||||
};
|
||||
if should_show {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
if let Some(launcher) = app.get_webview_window("launcher") {
|
||||
let _ = launcher.hide();
|
||||
}
|
||||
}
|
||||
update_launcher(app);
|
||||
})
|
||||
.build()
|
||||
.map_err(|_| "connectionError")?;
|
||||
#[cfg(target_os = "macos")]
|
||||
window
|
||||
.with_webview(|webview| unsafe {
|
||||
unsafe extern "C" {
|
||||
fn install_workspace_navigation(webview: *mut std::ffi::c_void);
|
||||
}
|
||||
install_workspace_navigation(webview.inner().cast());
|
||||
})
|
||||
.build()
|
||||
.map_err(|_| "connectionError")?;
|
||||
#[cfg(target_os = "macos")]
|
||||
crate::settings::install(app, &window).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 let WindowEvent::CloseRequested { api, .. } = event {
|
||||
let current = handle
|
||||
.state::<Mutex<Connection>>()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.label
|
||||
.as_deref()
|
||||
== Some(&label);
|
||||
if current {
|
||||
show_launcher(&handle);
|
||||
api.prevent_close();
|
||||
handle.exit(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn allows_settings(app: &AppHandle, label: &str, url: &Url, main: bool) -> bool {
|
||||
let state = app.state::<Mutex<Connection>>();
|
||||
let state = state.lock().unwrap();
|
||||
state.allows_settings(label, url, main)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn settings_action(
|
||||
app: &AppHandle,
|
||||
action: crate::settings::Action,
|
||||
) -> Result<serde_json::Value, &'static str> {
|
||||
use crate::settings::Action;
|
||||
match action {
|
||||
Action::GetState {} => {
|
||||
let workspace = app
|
||||
.state::<Mutex<Connection>>()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.server
|
||||
.as_ref()
|
||||
.map(|server| server.url.to_string())
|
||||
.ok_or("bridgeUnavailable")?;
|
||||
return Ok(
|
||||
serde_json::json!({"apiVersion":1, "platform":"macos", "workspace":workspace,
|
||||
"version":app.package_info().version.to_string(), "capabilities":["workspace", "updates"],
|
||||
"updates":crate::updates::snapshot(app)}),
|
||||
);
|
||||
}
|
||||
Action::SettingsReady {} => {}
|
||||
Action::OpenSettings {} => show_launcher(app),
|
||||
Action::SetWorkspace { server } => {
|
||||
if let Err(error) = open_workspace(app, &server) {
|
||||
app.state::<Mutex<Connection>>().lock().unwrap().phase = error;
|
||||
show_launcher(app);
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
Action::CheckForUpdates {} => crate::updates::check_inline(app),
|
||||
Action::SetUpdatePreferences {
|
||||
automatic,
|
||||
interval_hours,
|
||||
} => crate::updates::save_preferences(app, automatic, interval_hours)?,
|
||||
Action::InstallUpdate { version } => crate::updates::install(app, Some(&version)),
|
||||
}
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
fn handle_launcher_action(app: &AppHandle, destination: &Url) {
|
||||
let result = match destination.path() {
|
||||
"/action/updates" => {
|
||||
#[cfg(target_os = "macos")]
|
||||
crate::updates::show(app);
|
||||
Ok(())
|
||||
}
|
||||
"/action/open" => {
|
||||
let input = destination
|
||||
.query_pairs()
|
||||
@@ -280,12 +355,24 @@ pub fn context<R: tauri::Runtime>() -> tauri::Context<R> {
|
||||
tauri::generate_context!()
|
||||
}
|
||||
|
||||
fn about_metadata(version: String) -> AboutMetadata<'static> {
|
||||
AboutMetadata {
|
||||
name: Some(en::APP_NAME.into()),
|
||||
version: Some(version),
|
||||
#[cfg(target_os = "macos")]
|
||||
short_version: Some(crate::metadata::build_number().into()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn builder() -> tauri::Builder<tauri::Wry> {
|
||||
tauri::Builder::default().setup(|app| {
|
||||
let (server, phase) = match Server::load(&configuration_path(app.handle())?) {
|
||||
Ok(server) => (server, "idle"),
|
||||
Ok(Some(server)) => (Some(server), "connecting"),
|
||||
Ok(None) => (None, "idle"),
|
||||
Err(_) => (None, "storageError"),
|
||||
};
|
||||
let startup_server = server.clone();
|
||||
app.manage(Mutex::new(Connection {
|
||||
server,
|
||||
phase,
|
||||
@@ -293,7 +380,20 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
|
||||
}));
|
||||
let menu = MenuBuilder::new(app)
|
||||
.item(
|
||||
&SubmenuBuilder::new(app, en::APP_NAME)
|
||||
&SubmenuBuilder::with_id(app, "app-menu", en::APP_NAME)
|
||||
.about_with_text(
|
||||
en::ABOUT,
|
||||
Some(about_metadata(app.package_info().version.to_string())),
|
||||
)
|
||||
.text("check-updates", en::CHECK_UPDATES)
|
||||
.item(&MenuItem::with_id(
|
||||
app,
|
||||
"install-update",
|
||||
en::INSTALL_UPDATE,
|
||||
false,
|
||||
None::<&str>,
|
||||
)?)
|
||||
.separator()
|
||||
.text("server-settings", en::SERVER_SETTINGS)
|
||||
.separator()
|
||||
.quit()
|
||||
@@ -318,7 +418,21 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
|
||||
)
|
||||
.build()?;
|
||||
app.set_menu(menu)?;
|
||||
#[cfg(target_os = "macos")]
|
||||
crate::updates::setup(app.handle())?;
|
||||
app.on_menu_event(|app, event| {
|
||||
#[cfg(target_os = "macos")]
|
||||
match event.id().as_ref() {
|
||||
"check-updates" => {
|
||||
crate::updates::check(app, true);
|
||||
return;
|
||||
}
|
||||
"install-update" => {
|
||||
crate::updates::show(app);
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if event.id().as_ref() == "server-settings" {
|
||||
show_launcher(app);
|
||||
} else if let Some(script) = match event.id().as_ref() {
|
||||
@@ -343,6 +457,7 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
|
||||
.title(en::APP_NAME)
|
||||
.inner_size(560.0, 720.0)
|
||||
.min_inner_size(375.0, 640.0)
|
||||
.visible(false)
|
||||
.center()
|
||||
.on_navigation(move |destination| {
|
||||
if !allows_launcher_navigation(destination) {
|
||||
@@ -363,25 +478,17 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
#[cfg(target_os = "macos")]
|
||||
crate::updates::refresh(&app);
|
||||
let needs_setup = app
|
||||
.state::<Mutex<Connection>>()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.server
|
||||
.is_none();
|
||||
if needs_setup {
|
||||
show_launcher(&app);
|
||||
}
|
||||
}
|
||||
})
|
||||
.build()?;
|
||||
@@ -389,9 +496,25 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
|
||||
launcher.on_window_event(move |event| {
|
||||
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||
api.prevent_close();
|
||||
let _ = window.hide();
|
||||
let has_workspace = window
|
||||
.app_handle()
|
||||
.state::<Mutex<Connection>>()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.ready;
|
||||
if has_workspace {
|
||||
let _ = window.hide();
|
||||
} else {
|
||||
window.app_handle().exit(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(server) = startup_server {
|
||||
if let Err(error) = open_workspace(app.handle(), server.url.as_str()) {
|
||||
app.state::<Mutex<Connection>>().lock().unwrap().phase = error;
|
||||
show_launcher(app.handle());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -400,6 +523,42 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn settings_only_accept_the_current_workspace_main_frame() {
|
||||
let mut state = Connection {
|
||||
server: Some(Server::new(validate_url("https://example.com/app/").unwrap()).unwrap()),
|
||||
label: Some("workspace-1".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let url = Url::parse("https://example.com/settings/app").unwrap();
|
||||
assert!(state.allows_settings("workspace-1", &url, true));
|
||||
assert!(!state.allows_settings("workspace-1", &url, false));
|
||||
assert!(!state.allows_settings("workspace-0", &url, true));
|
||||
for address in [
|
||||
"https://evil.test",
|
||||
"http://example.com",
|
||||
"https://example.com:444/",
|
||||
"file:///tmp/page",
|
||||
] {
|
||||
assert!(!state.allows_settings("workspace-1", &Url::parse(address).unwrap(), true));
|
||||
}
|
||||
state.label = Some("workspace-2".into());
|
||||
assert!(!state.allows_settings("workspace-1", &url, true));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn about_shows_app_version_followed_by_the_actual_build_number() {
|
||||
let metadata = about_metadata("9.8.7".into());
|
||||
assert_eq!(metadata.version.as_deref(), Some("9.8.7"));
|
||||
assert_eq!(
|
||||
metadata.short_version.as_deref(),
|
||||
Some(crate::metadata::build_number())
|
||||
);
|
||||
assert_ne!(metadata.version, metadata.short_version);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_links_accept_only_web_urls_without_credentials() {
|
||||
for input in [
|
||||
@@ -482,7 +641,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn remote_pages_cannot_invoke_native_commands() {
|
||||
let app = tauri::test::mock_builder().build(context()).unwrap();
|
||||
let builder = tauri::test::mock_builder();
|
||||
#[cfg(target_os = "macos")]
|
||||
let builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
|
||||
let app = builder.build(context()).unwrap();
|
||||
let window = WebviewWindowBuilder::new(
|
||||
&app,
|
||||
"workspace-1",
|
||||
@@ -491,6 +653,8 @@ mod tests {
|
||||
.build()
|
||||
.unwrap();
|
||||
for command in [
|
||||
"plugin:updater|check",
|
||||
"plugin:updater|download_and_install",
|
||||
"plugin:window|close",
|
||||
"plugin:webview|create_webview_window",
|
||||
"plugin:path|resolve_directory",
|
||||
|
||||
@@ -6,8 +6,12 @@ mod desktop;
|
||||
mod ios;
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
mod locale;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod metadata;
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
mod server;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod updates;
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub use android::{builder, context};
|
||||
@@ -16,7 +20,13 @@ pub use desktop::{builder, context};
|
||||
#[cfg(target_os = "ios")]
|
||||
pub use ios::{builder, context};
|
||||
|
||||
#[cfg(all(target_os = "macos", feature = "smoke-test"))]
|
||||
pub use updates::refresh_menu as refresh_update_menu_for_smoke_test;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
builder().run(context()).expect("Could not start KanbanGit");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod settings;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
pub mod en {
|
||||
pub const ABOUT: &str = "About KanbanGit";
|
||||
pub const CHECK_UPDATES: &str = "Check For Updates…";
|
||||
pub const INSTALL_UPDATE: &str = "Install Update…";
|
||||
pub const UPDATES: &str = "KanbanGit Updates";
|
||||
pub const APP_NAME: &str = "KanbanGit";
|
||||
pub const SERVER_SETTINGS: &str = "Server settings…";
|
||||
pub const NAVIGATION: &str = "Navigation";
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
use std::{path::Path, sync::LazyLock};
|
||||
|
||||
fn build_from_executable(executable: &Path) -> Option<String> {
|
||||
let macos = executable.parent()?;
|
||||
let contents = macos.parent()?;
|
||||
if macos.file_name()? != "MacOS" || contents.file_name()? != "Contents" {
|
||||
return None;
|
||||
}
|
||||
let output = std::process::Command::new("/usr/bin/plutil")
|
||||
.args(["-extract", "CFBundleVersion", "raw", "-o", "-"])
|
||||
.arg(contents.join("Info.plist"))
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return Some("unknown".into());
|
||||
}
|
||||
let build = String::from_utf8(output.stdout).ok()?;
|
||||
Some(build.trim().to_owned())
|
||||
}
|
||||
|
||||
pub fn build_number() -> &'static str {
|
||||
static BUILD: LazyLock<String> = LazyLock::new(|| {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|executable| build_from_executable(&executable))
|
||||
.unwrap_or_else(|| {
|
||||
// Tauri omits macOS bundle settings from its generated runtime config.
|
||||
let config: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
|
||||
config["bundle"]["macOS"]["bundleVersion"]
|
||||
.as_str()
|
||||
.unwrap_or("unknown")
|
||||
.to_owned()
|
||||
})
|
||||
});
|
||||
&BUILD
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn installed_build_comes_from_the_app_plist_instead_of_the_app_version() {
|
||||
let root = std::env::temp_dir().join(format!("kanbangit-build-{}", std::process::id()));
|
||||
let contents = root.join("KanbanGit.app/Contents");
|
||||
std::fs::create_dir_all(contents.join("MacOS")).unwrap();
|
||||
let executable = contents.join("MacOS/kanbangit-native");
|
||||
std::fs::write(contents.join("Info.plist"), r#"<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict><key>CFBundleShortVersionString</key><string>0.1.3</string><key>CFBundleVersion</key><string>42</string></dict></plist>"#).unwrap();
|
||||
assert_eq!(build_from_executable(&executable).as_deref(), Some("42"));
|
||||
std::fs::remove_file(contents.join("Info.plist")).unwrap();
|
||||
assert_eq!(
|
||||
build_from_executable(&executable).as_deref(),
|
||||
Some("unknown")
|
||||
);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbundled_execution_uses_the_configured_build() {
|
||||
assert!(build_from_executable(Path::new("/tmp/target/debug/kanbangit-native")).is_none());
|
||||
let config: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
|
||||
assert_eq!(
|
||||
build_number(),
|
||||
config["bundle"]["macOS"]["bundleVersion"].as_str().unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#import <WebKit/WebKit.h>
|
||||
|
||||
void install_workspace_navigation(void *pointer) {
|
||||
WKWebView *webview = (__bridge WKWebView *)pointer;
|
||||
webview.allowsBackForwardNavigationGestures = YES;
|
||||
}
|
||||
@@ -37,6 +37,12 @@ pub fn validate_url(input: &str) -> Result<Url, &'static str> {
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub fn dashboard_url(&self) -> Url {
|
||||
self.url
|
||||
.join("dashboard")
|
||||
.expect("Validated installation URL")
|
||||
}
|
||||
|
||||
pub fn new(url: Url) -> io::Result<Self> {
|
||||
let mut profile = [0; 16];
|
||||
fs::File::open("/dev/urandom")?.read_exact(&mut profile)?;
|
||||
@@ -111,6 +117,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_targets_dashboard_within_the_installation() {
|
||||
for (base, expected) in [
|
||||
("https://example.com/", "https://example.com/dashboard"),
|
||||
(
|
||||
"https://example.com:8443/kanban/",
|
||||
"https://example.com:8443/kanban/dashboard",
|
||||
),
|
||||
] {
|
||||
let server = Server::new(validate_url(base).unwrap()).unwrap();
|
||||
assert_eq!(server.dashboard_url().as_str(), expected);
|
||||
assert_eq!(server.url.as_str(), base);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_round_trip_preserves_only_the_address_and_profile() {
|
||||
let server = Server::new(validate_url(DEFAULT_SERVER).unwrap()).unwrap();
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
#import <WebKit/WebKit.h>
|
||||
|
||||
typedef char *(*SettingsCallback)(const char *, const char *, const char *, bool);
|
||||
typedef void (*SettingsFree)(char *);
|
||||
|
||||
@interface KanbanSettingsHandler : NSObject <WKScriptMessageHandlerWithReply>
|
||||
@property(nonatomic, weak) WKWebView *webview;
|
||||
@property(nonatomic, copy) NSString *label;
|
||||
@property(nonatomic) SettingsCallback callback;
|
||||
@property(nonatomic) SettingsFree releaseReply;
|
||||
@end
|
||||
|
||||
@implementation KanbanSettingsHandler
|
||||
- (void)userContentController:(WKUserContentController *)controller
|
||||
didReceiveScriptMessage:(WKScriptMessage *)message
|
||||
replyHandler:(void (^)(id, NSString *))replyHandler {
|
||||
if (message.webView != self.webview || !message.frameInfo.isMainFrame ||
|
||||
![message.body isKindOfClass:NSString.class]) {
|
||||
replyHandler(nil, @"untrustedFrame");
|
||||
return;
|
||||
}
|
||||
char *reply = self.callback(self.label.UTF8String,
|
||||
message.frameInfo.request.URL.absoluteString.UTF8String,
|
||||
[(NSString *)message.body UTF8String], message.frameInfo.isMainFrame);
|
||||
if (!reply) { replyHandler(nil, @"bridgeUnavailable"); return; }
|
||||
NSString *value = [NSString stringWithUTF8String:reply];
|
||||
self.releaseReply(reply);
|
||||
replyHandler(value, nil);
|
||||
}
|
||||
@end
|
||||
|
||||
void install_workspace_settings(void *pointer, const char *label, const char *script,
|
||||
SettingsCallback callback, SettingsFree releaseReply) {
|
||||
WKWebView *webview = (__bridge WKWebView *)pointer;
|
||||
KanbanSettingsHandler *handler = [KanbanSettingsHandler new];
|
||||
handler.webview = webview;
|
||||
handler.label = [NSString stringWithUTF8String:label];
|
||||
handler.callback = callback;
|
||||
handler.releaseReply = releaseReply;
|
||||
[webview.configuration.userContentController addScriptMessageHandlerWithReply:handler
|
||||
contentWorld:WKContentWorld.pageWorld name:@"kanbanSettings"];
|
||||
[webview.configuration.userContentController addUserScript:[[WKUserScript alloc]
|
||||
initWithSource:[NSString stringWithUTF8String:script]
|
||||
injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:YES]];
|
||||
// The initial document may already have started loading before the handler is attached.
|
||||
[webview evaluateJavaScript:[NSString stringWithUTF8String:script] completionHandler:nil];
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::ffi::{c_char, CStr, CString};
|
||||
use std::sync::OnceLock;
|
||||
use tauri::{AppHandle, Url};
|
||||
|
||||
static APP: OnceLock<AppHandle> = OnceLock::new();
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Request {
|
||||
pub version: u8,
|
||||
pub id: u64,
|
||||
pub action: String,
|
||||
pub payload: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(
|
||||
tag = "action",
|
||||
content = "payload",
|
||||
rename_all = "camelCase",
|
||||
deny_unknown_fields
|
||||
)]
|
||||
pub enum Action {
|
||||
GetState {},
|
||||
SettingsReady {},
|
||||
OpenSettings {},
|
||||
SetWorkspace {
|
||||
server: String,
|
||||
},
|
||||
CheckForUpdates {},
|
||||
SetUpdatePreferences {
|
||||
automatic: bool,
|
||||
#[serde(rename = "intervalHours")]
|
||||
interval_hours: u64,
|
||||
},
|
||||
InstallUpdate {
|
||||
version: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn parse(value: &str) -> Result<(u64, Action), &'static str> {
|
||||
if value.len() > 8192 {
|
||||
return Err("invalidRequest");
|
||||
}
|
||||
let request: Request = serde_json::from_str(value).map_err(|_| "invalidRequest")?;
|
||||
if request.version != 1 {
|
||||
return Err("incompatibleVersion");
|
||||
}
|
||||
let action =
|
||||
serde_json::from_value(json!({"action": request.action, "payload": request.payload}))
|
||||
.map_err(|_| "invalidRequest")?;
|
||||
Ok((request.id, action))
|
||||
}
|
||||
|
||||
pub fn install(app: &AppHandle, window: &tauri::WebviewWindow) -> tauri::Result<()> {
|
||||
let _ = APP.set(app.clone());
|
||||
let label = CString::new(window.label()).unwrap();
|
||||
let script = CString::new(include_str!("../../ui/workspace-settings.js")).unwrap();
|
||||
window.with_webview(move |view| unsafe {
|
||||
unsafe extern "C" {
|
||||
fn install_workspace_settings(
|
||||
view: *mut std::ffi::c_void,
|
||||
label: *const c_char,
|
||||
script: *const c_char,
|
||||
callback: extern "C" fn(
|
||||
*const c_char,
|
||||
*const c_char,
|
||||
*const c_char,
|
||||
bool,
|
||||
) -> *mut c_char,
|
||||
free: extern "C" fn(*mut c_char),
|
||||
);
|
||||
}
|
||||
install_workspace_settings(
|
||||
view.inner().cast(),
|
||||
label.as_ptr(),
|
||||
script.as_ptr(),
|
||||
receive,
|
||||
release,
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
extern "C" fn release(value: *mut c_char) {
|
||||
if !value.is_null() {
|
||||
unsafe {
|
||||
drop(CString::from_raw(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn receive(
|
||||
label: *const c_char,
|
||||
url: *const c_char,
|
||||
body: *const c_char,
|
||||
main: bool,
|
||||
) -> *mut c_char {
|
||||
let result = std::panic::catch_unwind(|| -> Result<Value, &'static str> {
|
||||
if label.is_null() || url.is_null() || body.is_null() {
|
||||
return Err("invalidRequest");
|
||||
}
|
||||
let label = unsafe { CStr::from_ptr(label) }
|
||||
.to_str()
|
||||
.map_err(|_| "invalidRequest")?;
|
||||
let url = unsafe { CStr::from_ptr(url) }
|
||||
.to_str()
|
||||
.map_err(|_| "invalidRequest")?;
|
||||
let body = unsafe { CStr::from_ptr(body) }
|
||||
.to_str()
|
||||
.map_err(|_| "invalidRequest")?;
|
||||
let app = APP.get().ok_or("bridgeUnavailable")?;
|
||||
let url = Url::parse(url).map_err(|_| "untrustedFrame")?;
|
||||
if !crate::desktop::allows_settings(app, label, &url, main) {
|
||||
return Err("untrustedFrame");
|
||||
}
|
||||
let (id, action) = parse(body)?;
|
||||
let result = crate::desktop::settings_action(app, action)?;
|
||||
Ok(json!({"id": id, "result": result}))
|
||||
})
|
||||
.unwrap_or(Err("bridgeUnavailable"));
|
||||
let value = result.unwrap_or_else(|error| json!({"error": error}));
|
||||
CString::new(value.to_string()).unwrap().into_raw()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn protocol_rejects_unknown_versions_actions_and_malformed_payloads() {
|
||||
for value in [
|
||||
r#"{"version":2,"id":1,"action":"getState","payload":{}}"#,
|
||||
r#"{"version":1,"id":1,"action":"shell","payload":{}}"#,
|
||||
r#"{"version":1,"id":1,"action":"setWorkspace","payload":{"server":3}}"#,
|
||||
r#"{"version":1,"id":1,"action":"setUpdatePreferences","payload":{"automatic":"true","intervalHours":6}}"#,
|
||||
r#"{"version":1,"id":1,"action":"installUpdate","payload":{"version":"1.2.3","url":"https://evil.test"}}"#,
|
||||
] {
|
||||
assert!(parse(value).is_err(), "{value}");
|
||||
}
|
||||
assert!(parse(&"x".repeat(8193)).is_err());
|
||||
assert!(matches!(
|
||||
parse(r#"{"version":1,"id":1,"action":"getState","payload":{}}"#),
|
||||
Ok((1, Action::GetState {}))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,833 @@
|
||||
use semver::Version;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
fs,
|
||||
io::Read,
|
||||
path::Path,
|
||||
sync::Mutex,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tauri::{
|
||||
webview::{NewWindowResponse, PageLoadEvent},
|
||||
AppHandle, Manager, Url, WebviewUrl, WebviewWindowBuilder,
|
||||
};
|
||||
use tauri_plugin_updater::{Update, UpdaterExt};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Source {
|
||||
base_url: String,
|
||||
public_key: String,
|
||||
}
|
||||
fn source() -> &'static Source {
|
||||
static SOURCE: std::sync::LazyLock<Source> = std::sync::LazyLock::new(|| {
|
||||
serde_json::from_str(include_str!("../../update-source.json"))
|
||||
.expect("Bundled update source must be valid")
|
||||
});
|
||||
&SOURCE
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Preferences {
|
||||
automatic: bool,
|
||||
interval_hours: u64,
|
||||
}
|
||||
impl Default for Preferences {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
automatic: true,
|
||||
interval_hours: 6,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Preferences {
|
||||
fn valid(&self) -> bool {
|
||||
[1, 6, 24].contains(&self.interval_hours)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct View {
|
||||
phase: &'static str,
|
||||
current_version: String,
|
||||
build: String,
|
||||
version: Option<String>,
|
||||
notes: Option<String>,
|
||||
downloaded: u64,
|
||||
total: Option<u64>,
|
||||
error: Option<&'static str>,
|
||||
preferences: Preferences,
|
||||
}
|
||||
struct State {
|
||||
view: View,
|
||||
update: Option<Update>,
|
||||
last_check: Option<Instant>,
|
||||
}
|
||||
impl State {
|
||||
fn busy(&self) -> bool {
|
||||
matches!(
|
||||
self.view.phase,
|
||||
"checking" | "downloading" | "verifying" | "installing" | "restarting"
|
||||
)
|
||||
}
|
||||
fn begin_check(&mut self, manual: bool, now: Instant) -> bool {
|
||||
if self.busy()
|
||||
|| (!manual
|
||||
&& (!self.view.preferences.automatic
|
||||
|| self.last_check.is_some_and(|last| {
|
||||
now.duration_since(last)
|
||||
< Duration::from_secs(self.view.preferences.interval_hours * 3600)
|
||||
})))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.last_check = Some(now);
|
||||
self.view.phase = "checking";
|
||||
self.view.error = None;
|
||||
self.update = None;
|
||||
self.view.version = None;
|
||||
self.view.notes = None;
|
||||
true
|
||||
}
|
||||
}
|
||||
fn target() -> String {
|
||||
format!("{}-apple-darwin", std::env::consts::ARCH)
|
||||
}
|
||||
fn feed_url() -> Url {
|
||||
Url::parse(&format!("{}/{}/latest.json", source().base_url, target())).unwrap()
|
||||
}
|
||||
fn newer_stable(current: &str, candidate: &str) -> bool {
|
||||
match (Version::parse(current), Version::parse(candidate)) {
|
||||
(Ok(current), Ok(candidate)) => {
|
||||
candidate.pre.is_empty() && candidate.build.is_empty() && candidate > current
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
fn valid_download(current: &str, version: &str, url: &Url) -> bool {
|
||||
newer_stable(current, version)
|
||||
&& url.as_str()
|
||||
== format!(
|
||||
"{}/{}/{version}/KanbanGit-{version}-{}.app.tar.gz",
|
||||
source().base_url,
|
||||
target(),
|
||||
target()
|
||||
)
|
||||
}
|
||||
fn trusted_navigation(url: &Url) -> bool {
|
||||
url.scheme() == "tauri"
|
||||
&& url.host_str() == Some("localhost")
|
||||
&& url.port().is_none()
|
||||
&& url.username().is_empty()
|
||||
&& url.password().is_none()
|
||||
}
|
||||
pub fn refresh_menu(app: &AppHandle, available: bool, busy: bool) {
|
||||
if let Some(menu) = app
|
||||
.menu()
|
||||
.and_then(|menu| menu.get("app-menu"))
|
||||
.and_then(|item| {
|
||||
item.as_submenu()
|
||||
.and_then(|menu| menu.get("install-update"))
|
||||
})
|
||||
{
|
||||
if let Some(item) = menu.as_menuitem() {
|
||||
let _ = item.set_enabled(available && !busy);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn refresh(app: &AppHandle) {
|
||||
let (view, available, busy) = {
|
||||
let state = app.state::<Mutex<State>>();
|
||||
let state = state.lock().unwrap();
|
||||
(state.view.clone(), state.update.is_some(), state.busy())
|
||||
};
|
||||
refresh_menu(app, available, busy);
|
||||
if let Some(window) = app.get_webview_window("updates") {
|
||||
if let Ok(json) = serde_json::to_string(&view) {
|
||||
let _ = window.eval(format!("window.updateStatus?.({json})"));
|
||||
}
|
||||
}
|
||||
if let Some(window) = app.get_webview_window("launcher") {
|
||||
let _ = window.eval(format!("window.updateAvailable?.({available})"));
|
||||
}
|
||||
}
|
||||
pub fn show(app: &AppHandle) {
|
||||
if let Some(window) = app.get_webview_window("updates") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
refresh(app);
|
||||
}
|
||||
fn fail(app: &AppHandle, message: &'static str) {
|
||||
{
|
||||
let state = app.state::<Mutex<State>>();
|
||||
let mut state = state.lock().unwrap();
|
||||
state.view.phase = "error";
|
||||
state.view.error = Some(message);
|
||||
}
|
||||
refresh(app);
|
||||
}
|
||||
fn update_error(error: &tauri_plugin_updater::Error) -> &'static str {
|
||||
use tauri_plugin_updater::Error;
|
||||
match error {
|
||||
Error::Minisign(_)
|
||||
| Error::Base64(_)
|
||||
| Error::SignatureUtf8(_)
|
||||
| Error::SignedVersionMismatch { .. }
|
||||
| Error::MissingSignedVersion => "signatureError",
|
||||
Error::Serialization(_)
|
||||
| Error::Semver(_)
|
||||
| Error::TargetsNotFound(_)
|
||||
| Error::TargetNotFound(_) => "metadataError",
|
||||
_ => "networkError",
|
||||
}
|
||||
}
|
||||
pub fn check(app: &AppHandle, manual: bool) {
|
||||
if manual {
|
||||
show(app);
|
||||
}
|
||||
check_with_mode(app, manual);
|
||||
}
|
||||
pub fn check_inline(app: &AppHandle) {
|
||||
check_with_mode(app, true);
|
||||
}
|
||||
fn check_with_mode(app: &AppHandle, manual: bool) {
|
||||
let begin = app
|
||||
.state::<Mutex<State>>()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.begin_check(manual, Instant::now());
|
||||
if !begin {
|
||||
return;
|
||||
}
|
||||
refresh(app);
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let result = async {
|
||||
app.updater_builder()
|
||||
.pubkey(&source().public_key)
|
||||
.endpoints(vec![feed_url()])?
|
||||
.timeout(Duration::from_secs(60))
|
||||
.configure_client(|client| client.https_only(true))
|
||||
.version_comparator(|current, release| {
|
||||
newer_stable(¤t.to_string(), &release.version.to_string())
|
||||
})
|
||||
.build()?
|
||||
.check()
|
||||
.await
|
||||
}
|
||||
.await;
|
||||
match result {
|
||||
Ok(Some(mut update)) => {
|
||||
if !valid_download(
|
||||
&update.current_version,
|
||||
&update.version,
|
||||
&update.download_url,
|
||||
) || update.signature.trim().is_empty()
|
||||
{
|
||||
fail(&app, "metadataError");
|
||||
return;
|
||||
}
|
||||
update.timeout = Some(Duration::from_secs(600));
|
||||
let state = app.state::<Mutex<State>>();
|
||||
let mut state = state.lock().unwrap();
|
||||
state.view.phase = "available";
|
||||
state.view.version = Some(update.version.clone());
|
||||
state.view.notes = update.body.clone();
|
||||
state.update = Some(update);
|
||||
}
|
||||
Ok(None) => app.state::<Mutex<State>>().lock().unwrap().view.phase = "current",
|
||||
Err(error) => {
|
||||
fail(&app, update_error(&error));
|
||||
return;
|
||||
}
|
||||
}
|
||||
refresh(&app);
|
||||
});
|
||||
}
|
||||
fn command(program: &str, args: &[&str]) -> Result<String, &'static str> {
|
||||
let output = std::process::Command::new(program)
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|_| "verificationError")?;
|
||||
if !output.status.success() {
|
||||
return Err("verificationError");
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
|
||||
}
|
||||
fn matches_architecture(header: &[u8]) -> bool {
|
||||
let expected_cpu: u32 = if cfg!(target_arch = "aarch64") {
|
||||
0x0100_000c
|
||||
} else {
|
||||
0x0100_0007
|
||||
};
|
||||
header.len() >= 8
|
||||
&& header[..4] == [0xcf, 0xfa, 0xed, 0xfe]
|
||||
&& header[4..8] == expected_cpu.to_le_bytes()
|
||||
}
|
||||
fn verify_bundle(archive: &Path, directory: &Path, version: &str) -> Result<(), &'static str> {
|
||||
command(
|
||||
"/usr/bin/tar",
|
||||
&[
|
||||
"-xzf",
|
||||
archive.to_str().ok_or("verificationError")?,
|
||||
"-C",
|
||||
directory.to_str().ok_or("verificationError")?,
|
||||
],
|
||||
)?;
|
||||
let app = directory.join("KanbanGit.app");
|
||||
let plist = app.join("Contents/Info.plist");
|
||||
let plist = plist.to_str().ok_or("verificationError")?;
|
||||
for (field, expected) in [
|
||||
("CFBundleShortVersionString", version),
|
||||
("CFBundleIdentifier", "de.kanbangit.desktop"),
|
||||
] {
|
||||
if command(
|
||||
"/usr/bin/plutil",
|
||||
&["-extract", field, "raw", "-o", "-", plist],
|
||||
)? != expected
|
||||
{
|
||||
return Err("verificationError");
|
||||
}
|
||||
}
|
||||
let executable = app.join("Contents/MacOS/kanbangit-native");
|
||||
let mut header = [0u8; 8];
|
||||
fs::File::open(executable)
|
||||
.and_then(|mut file| file.read_exact(&mut header))
|
||||
.map_err(|_| "verificationError")?;
|
||||
if !matches_architecture(&header) {
|
||||
return Err("verificationError");
|
||||
}
|
||||
command("/usr/bin/codesign", &["--verify", "--deep", "--strict", "-R", "=anchor apple generic and identifier \"de.kanbangit.desktop\" and certificate leaf[subject.OU] = \"ZZX2KZGTHU\" and certificate leaf[field.1.2.840.113635.100.6.1.13] exists", app.to_str().ok_or("verificationError")?])?;
|
||||
Ok(())
|
||||
}
|
||||
pub fn install(app: &AppHandle, confirmed_version: Option<&str>) {
|
||||
let update = {
|
||||
let state = app.state::<Mutex<State>>();
|
||||
let mut state = state.lock().unwrap();
|
||||
if state.busy() {
|
||||
return;
|
||||
}
|
||||
let Some(update) = state.update.clone() else {
|
||||
return;
|
||||
};
|
||||
if confirmed_version != Some(update.version.as_str()) {
|
||||
return;
|
||||
}
|
||||
state.view.phase = "downloading";
|
||||
state.view.error = None;
|
||||
state.view.downloaded = 0;
|
||||
state.view.total = None;
|
||||
update
|
||||
};
|
||||
refresh(app);
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let bytes = update
|
||||
.download(
|
||||
|chunk, total| {
|
||||
{
|
||||
let state = app.state::<Mutex<State>>();
|
||||
let mut state = state.lock().unwrap();
|
||||
state.view.downloaded += chunk as u64;
|
||||
state.view.total = total;
|
||||
}
|
||||
refresh(&app);
|
||||
},
|
||||
|| {
|
||||
app.state::<Mutex<State>>().lock().unwrap().view.phase = "verifying";
|
||||
refresh(&app);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let bytes = match bytes {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => {
|
||||
fail(&app, update_error(&error));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let handle = app.clone();
|
||||
let result = tauri::async_runtime::spawn_blocking(move || -> Result<(), &'static str> {
|
||||
let cache = handle
|
||||
.path()
|
||||
.app_cache_dir()
|
||||
.map_err(|_| "installationError")?
|
||||
.join("verified-update");
|
||||
if cache.exists() {
|
||||
fs::remove_dir_all(&cache).map_err(|_| "installationError")?;
|
||||
}
|
||||
fs::create_dir_all(&cache).map_err(|_| "installationError")?;
|
||||
let archive = cache.join("update.tar.gz");
|
||||
let result = (|| {
|
||||
fs::write(&archive, &bytes).map_err(|_| "installationError")?;
|
||||
verify_bundle(&archive, &cache, &update.version)?;
|
||||
handle.state::<Mutex<State>>().lock().unwrap().view.phase = "installing";
|
||||
refresh(&handle);
|
||||
update.install(&bytes).map_err(|_| "installationError")
|
||||
})();
|
||||
let _ = fs::remove_dir_all(cache);
|
||||
result
|
||||
})
|
||||
.await;
|
||||
match result {
|
||||
Ok(Ok(())) => {
|
||||
app.state::<Mutex<State>>().lock().unwrap().view.phase = "restarting";
|
||||
refresh(&app);
|
||||
app.restart();
|
||||
}
|
||||
Ok(Err(message)) => fail(&app, message),
|
||||
Err(_) => fail(&app, "installationError"),
|
||||
}
|
||||
});
|
||||
}
|
||||
fn action(app: &AppHandle, url: &Url) {
|
||||
match url.path() {
|
||||
"/updates/check" => check(app, true),
|
||||
"/updates/install" => install(
|
||||
app,
|
||||
url.query_pairs()
|
||||
.find(|(key, _)| key == "version")
|
||||
.as_ref()
|
||||
.map(|(_, value)| value.as_ref()),
|
||||
),
|
||||
"/updates/preferences" => {
|
||||
let params: std::collections::HashMap<_, _> = url.query_pairs().collect();
|
||||
let preferences = Preferences {
|
||||
automatic: params.get("automatic").is_some_and(|v| v == "true"),
|
||||
interval_hours: params
|
||||
.get("interval")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0),
|
||||
};
|
||||
if !preferences.valid() {
|
||||
fail(app, "preferencesError");
|
||||
return;
|
||||
}
|
||||
if let Err(error) =
|
||||
save_preferences(app, preferences.automatic, preferences.interval_hours)
|
||||
{
|
||||
fail(app, error);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
pub fn snapshot(app: &AppHandle) -> serde_json::Value {
|
||||
serde_json::to_value(&app.state::<Mutex<State>>().lock().unwrap().view).unwrap()
|
||||
}
|
||||
pub fn save_preferences(
|
||||
app: &AppHandle,
|
||||
automatic: bool,
|
||||
interval_hours: u64,
|
||||
) -> Result<(), &'static str> {
|
||||
let preferences = Preferences {
|
||||
automatic,
|
||||
interval_hours,
|
||||
};
|
||||
if !preferences.valid() {
|
||||
return Err("preferencesError");
|
||||
}
|
||||
let state = app.state::<Mutex<State>>();
|
||||
let mut state = state.lock().unwrap();
|
||||
if state.busy() {
|
||||
return Err("updateBusy");
|
||||
}
|
||||
let saved = (|| -> Result<(), Box<dyn std::error::Error>> {
|
||||
let directory = app.path().app_config_dir()?;
|
||||
fs::create_dir_all(&directory)?;
|
||||
let pending = directory.join("update-preferences.pending");
|
||||
fs::write(&pending, serde_json::to_vec(&preferences)?)?;
|
||||
fs::rename(pending, directory.join("update-preferences.json"))?;
|
||||
Ok(())
|
||||
})();
|
||||
saved.map_err(|_| "preferencesError")?;
|
||||
state.view.preferences = preferences;
|
||||
drop(state);
|
||||
refresh(app);
|
||||
Ok(())
|
||||
}
|
||||
pub fn setup(app: &AppHandle) -> tauri::Result<()> {
|
||||
app.plugin(
|
||||
tauri_plugin_updater::Builder::new()
|
||||
.pubkey(&source().public_key)
|
||||
.build(),
|
||||
)?;
|
||||
let path = app.path().app_config_dir()?.join("update-preferences.json");
|
||||
let (preferences, error) = match fs::read(path) {
|
||||
Ok(bytes) => match serde_json::from_slice::<Preferences>(&bytes) {
|
||||
Ok(p) if p.valid() => (p, None),
|
||||
_ => (
|
||||
Preferences {
|
||||
automatic: false,
|
||||
..Default::default()
|
||||
},
|
||||
Some("preferencesError"),
|
||||
),
|
||||
},
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
(Preferences::default(), None)
|
||||
}
|
||||
Err(_) => (
|
||||
Preferences {
|
||||
automatic: false,
|
||||
..Default::default()
|
||||
},
|
||||
Some("preferencesError"),
|
||||
),
|
||||
};
|
||||
app.manage(Mutex::new(State {
|
||||
view: View {
|
||||
phase: if error.is_some() { "error" } else { "idle" },
|
||||
current_version: app.package_info().version.to_string(),
|
||||
build: crate::metadata::build_number().into(),
|
||||
version: None,
|
||||
notes: None,
|
||||
downloaded: 0,
|
||||
total: None,
|
||||
error,
|
||||
preferences,
|
||||
},
|
||||
update: None,
|
||||
last_check: None,
|
||||
}));
|
||||
let handle = app.clone();
|
||||
let window = WebviewWindowBuilder::new(app, "updates", WebviewUrl::App("updates.html".into()))
|
||||
.title(crate::locale::en::UPDATES)
|
||||
.inner_size(560.0, 690.0)
|
||||
.min_inner_size(420.0, 580.0)
|
||||
.center()
|
||||
.visible(false)
|
||||
.on_navigation(move |url| {
|
||||
if !trusted_navigation(url) {
|
||||
return false;
|
||||
}
|
||||
if url.path().starts_with("/updates/") {
|
||||
action(&handle, url);
|
||||
return false;
|
||||
}
|
||||
url.path() == "/updates.html"
|
||||
})
|
||||
.on_new_window(|_, _| NewWindowResponse::Deny)
|
||||
.on_page_load(|window, payload| {
|
||||
if payload.event() == PageLoadEvent::Finished {
|
||||
refresh(window.app_handle());
|
||||
}
|
||||
})
|
||||
.build()?;
|
||||
let hide = window.clone();
|
||||
window.on_window_event(move |event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
api.prevent_close();
|
||||
let _ = hide.hide();
|
||||
}
|
||||
});
|
||||
let handle = app.clone();
|
||||
std::thread::spawn(move || loop {
|
||||
check(&handle, false);
|
||||
std::thread::sleep(Duration::from_secs(30));
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn state() -> State {
|
||||
State {
|
||||
view: View {
|
||||
phase: "idle",
|
||||
current_version: "1.0.0".into(),
|
||||
build: "1".into(),
|
||||
version: None,
|
||||
notes: None,
|
||||
downloaded: 0,
|
||||
total: None,
|
||||
error: None,
|
||||
preferences: Preferences::default(),
|
||||
},
|
||||
update: None,
|
||||
last_check: None,
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn accepts_only_newer_stable_versions_and_exact_release_paths() {
|
||||
assert!(newer_stable("1.9.9", "1.10.0"));
|
||||
for version in ["0.9.0", "1.0.0", "1.2.0-beta", "1.2.0+build", "invalid"] {
|
||||
assert!(!newer_stable("1.0.0", version));
|
||||
}
|
||||
let good = format!(
|
||||
"{}/{}/1.1.0/KanbanGit-1.1.0-{}.app.tar.gz",
|
||||
source().base_url,
|
||||
target(),
|
||||
target()
|
||||
);
|
||||
assert!(valid_download(
|
||||
"1.0.0",
|
||||
"1.1.0",
|
||||
&Url::parse(&good).unwrap()
|
||||
));
|
||||
for input in [
|
||||
good.replace("https:", "http:"),
|
||||
good.replace(".app.tar.gz", ".dmg"),
|
||||
good.replace("1.1.0/", "0.9.0/"),
|
||||
good.replace(&target(), "foreign-apple-darwin"),
|
||||
format!("{good}?token=anything"),
|
||||
format!("{good}#fragment"),
|
||||
good.replace("https://", "https://user@"),
|
||||
"https://untrusted.example/update.app.tar.gz".into(),
|
||||
] {
|
||||
assert!(!valid_download(
|
||||
"1.0.0",
|
||||
"1.1.0",
|
||||
&Url::parse(&input).unwrap()
|
||||
));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn automatic_checks_start_once_then_respect_interval_and_opt_out() {
|
||||
let mut state = state();
|
||||
let now = Instant::now();
|
||||
assert!(state.begin_check(false, now));
|
||||
assert!(!state.begin_check(false, now));
|
||||
state.view.phase = "current";
|
||||
assert!(!state.begin_check(false, now + Duration::from_secs(6 * 3600 - 1)));
|
||||
assert!(state.begin_check(false, now + Duration::from_secs(6 * 3600)));
|
||||
state.view.phase = "current";
|
||||
state.view.preferences.automatic = false;
|
||||
assert!(!state.begin_check(false, now + Duration::from_secs(24 * 3600)));
|
||||
assert!(state.begin_check(true, now + Duration::from_secs(24 * 3600)));
|
||||
}
|
||||
#[test]
|
||||
fn manual_checks_retry_errors_but_never_overlap_downloads_or_installation() {
|
||||
let mut state = state();
|
||||
state.view.phase = "error";
|
||||
state.view.error = Some("networkError");
|
||||
assert!(state.begin_check(true, Instant::now()));
|
||||
assert!(state.view.error.is_none());
|
||||
for phase in [
|
||||
"checking",
|
||||
"downloading",
|
||||
"verifying",
|
||||
"installing",
|
||||
"restarting",
|
||||
] {
|
||||
state.view.phase = phase;
|
||||
assert!(!state.begin_check(true, Instant::now()));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn preferences_validate_and_round_trip_without_server_settings() {
|
||||
let p = Preferences {
|
||||
automatic: false,
|
||||
interval_hours: 24,
|
||||
};
|
||||
let json = serde_json::to_string(&p).unwrap();
|
||||
let loaded: Preferences = serde_json::from_str(&json).unwrap();
|
||||
assert!(!loaded.automatic);
|
||||
assert_eq!(loaded.interval_hours, 24);
|
||||
assert!(loaded.valid());
|
||||
for interval in [0, 2, 100, u64::MAX] {
|
||||
assert!(!Preferences {
|
||||
automatic: true,
|
||||
interval_hours: interval
|
||||
}
|
||||
.valid());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn update_actions_are_confined_to_the_local_trusted_window() {
|
||||
assert!(trusted_navigation(
|
||||
&Url::parse("tauri://localhost/updates/install").unwrap()
|
||||
));
|
||||
for address in [
|
||||
"https://kanbangit.de/updates/install",
|
||||
"https://localhost/updates/install",
|
||||
"tauri://user@localhost/updates/install",
|
||||
"tauri://localhost:443/updates/install",
|
||||
"tauri://other/updates/install",
|
||||
] {
|
||||
assert!(!trusted_navigation(&Url::parse(address).unwrap()));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn rejects_foreign_or_invalid_mach_o_headers_without_developer_tools() {
|
||||
let cpu: u32 = if cfg!(target_arch = "aarch64") {
|
||||
0x0100_000c
|
||||
} else {
|
||||
0x0100_0007
|
||||
};
|
||||
let mut header = vec![0xcf, 0xfa, 0xed, 0xfe];
|
||||
header.extend(cpu.to_le_bytes());
|
||||
assert!(matches_architecture(&header));
|
||||
header[4] ^= 1;
|
||||
assert!(!matches_architecture(&header));
|
||||
assert!(!matches_architecture(b"MZ"));
|
||||
assert!(!matches_architecture(&[0; 8]));
|
||||
}
|
||||
#[test]
|
||||
fn error_categories_offer_specific_retryable_messages() {
|
||||
use tauri_plugin_updater::Error;
|
||||
assert_eq!(
|
||||
update_error(&Error::Network("offline".into())),
|
||||
"networkError"
|
||||
);
|
||||
assert_eq!(
|
||||
update_error(&Error::TargetNotFound("darwin-other".into())),
|
||||
"metadataError"
|
||||
);
|
||||
assert_eq!(
|
||||
update_error(&Error::SignatureUtf8("invalid".into())),
|
||||
"signatureError"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod signed_upgrade_test {
|
||||
use super::*;
|
||||
use std::{io::Write, net::TcpListener};
|
||||
|
||||
#[test]
|
||||
#[ignore = "Requires KANBANGIT_UPGRADE_BASE and KANBANGIT_UPGRADE_ARCHIVE pointing to two Developer-ID-signed macOS versions"]
|
||||
fn installs_a_real_signed_upgrade_and_rejects_tampering() {
|
||||
let base = std::env::var("KANBANGIT_UPGRADE_BASE").expect("Set the signed base app path");
|
||||
let archive = std::env::var("KANBANGIT_UPGRADE_ARCHIVE")
|
||||
.expect("Set the signed updater archive path");
|
||||
let base_version = env!("CARGO_PKG_VERSION");
|
||||
let mut next_version = Version::parse(base_version).unwrap();
|
||||
next_version.patch += 1;
|
||||
let candidate_version =
|
||||
std::env::var("KANBANGIT_UPGRADE_VERSION").unwrap_or_else(|_| next_version.to_string());
|
||||
assert!(newer_stable(base_version, &candidate_version));
|
||||
let root = std::env::temp_dir().join(format!("kanbangit-upgrade-{}", std::process::id()));
|
||||
fs::create_dir(&root).unwrap();
|
||||
struct Cleanup(std::path::PathBuf);
|
||||
impl Drop for Cleanup {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
let _cleanup = Cleanup(root.clone());
|
||||
let installed = root.join("KanbanGit.app");
|
||||
command("/usr/bin/ditto", &[&base, installed.to_str().unwrap()]).unwrap();
|
||||
let data = root.join("user-data");
|
||||
fs::create_dir(&data).unwrap();
|
||||
fs::write(data.join("active-server"), "https://workspace.example/").unwrap();
|
||||
fs::write(data.join("session"), "existing-session").unwrap();
|
||||
let bytes = fs::read(&archive).unwrap();
|
||||
let signature = fs::read_to_string(format!("{archive}.sig")).unwrap();
|
||||
let verify_dir = root.join("verify");
|
||||
fs::create_dir(&verify_dir).unwrap();
|
||||
verify_bundle(Path::new(&archive), &verify_dir, &candidate_version).unwrap();
|
||||
assert!(verify_bundle(Path::new(&archive), &verify_dir, base_version).is_err());
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let download = bytes.clone();
|
||||
let announced_version = candidate_version.clone();
|
||||
let server = std::thread::spawn(move || {
|
||||
for index in 0..3 {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(10)))
|
||||
.unwrap();
|
||||
let mut request = [0; 4096];
|
||||
let _ = stream.read(&mut request).unwrap();
|
||||
let body = if index == 0 {
|
||||
serde_json::to_vec(&serde_json::json!({"version":announced_version, "platforms":{(format!("darwin-{}", std::env::consts::ARCH)): {"url":format!("http://{address}/update.tar.gz"), "signature":signature.trim()}}})).unwrap()
|
||||
} else if index == 1 {
|
||||
b"tampered update".to_vec()
|
||||
} else {
|
||||
download.clone()
|
||||
};
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
)
|
||||
.unwrap();
|
||||
stream.write_all(&body).unwrap();
|
||||
}
|
||||
});
|
||||
let mut context = crate::desktop::context();
|
||||
context.config_mut().plugins.0.insert("updater".into(), serde_json::json!({"pubkey":source().public_key, "dangerousInsecureTransportProtocol":true}));
|
||||
let app = tauri::test::mock_builder()
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.build(context)
|
||||
.unwrap();
|
||||
let update = tauri::async_runtime::block_on(async {
|
||||
app.updater_builder()
|
||||
.executable_path(installed.join("Contents/MacOS/kanbangit-native"))
|
||||
.endpoints(vec![
|
||||
Url::parse(&format!("http://{address}/latest.json")).unwrap()
|
||||
])
|
||||
.unwrap()
|
||||
.timeout(Duration::from_secs(20))
|
||||
.build()
|
||||
.unwrap()
|
||||
.check()
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
});
|
||||
assert_eq!(update.current_version, base_version);
|
||||
let rejected =
|
||||
tauri::async_runtime::block_on(update.download(|_, _| {}, || {})).unwrap_err();
|
||||
assert_eq!(update_error(&rejected), "signatureError");
|
||||
let plist = installed.join("Contents/Info.plist");
|
||||
assert_eq!(
|
||||
command(
|
||||
"/usr/bin/plutil",
|
||||
&[
|
||||
"-extract",
|
||||
"CFBundleShortVersionString",
|
||||
"raw",
|
||||
"-o",
|
||||
"-",
|
||||
plist.to_str().unwrap()
|
||||
]
|
||||
)
|
||||
.unwrap(),
|
||||
base_version
|
||||
);
|
||||
let verified = tauri::async_runtime::block_on(update.download(|_, _| {}, || {})).unwrap();
|
||||
assert_eq!(verified, bytes);
|
||||
update.install(verified).unwrap();
|
||||
assert_eq!(
|
||||
command(
|
||||
"/usr/bin/plutil",
|
||||
&[
|
||||
"-extract",
|
||||
"CFBundleShortVersionString",
|
||||
"raw",
|
||||
"-o",
|
||||
"-",
|
||||
plist.to_str().unwrap()
|
||||
]
|
||||
)
|
||||
.unwrap(),
|
||||
candidate_version
|
||||
);
|
||||
command(
|
||||
"/usr/bin/codesign",
|
||||
&[
|
||||
"--verify",
|
||||
"--deep",
|
||||
"--strict",
|
||||
installed.to_str().unwrap(),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
fs::read_to_string(data.join("active-server")).unwrap(),
|
||||
"https://workspace.example/"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(data.join("session")).unwrap(),
|
||||
"existing-session"
|
||||
);
|
||||
server.join().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "KanbanGit",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.6",
|
||||
"identifier": "de.kanbangit.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../ui"
|
||||
@@ -16,10 +16,38 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["app"],
|
||||
"icon": ["icons/icon.png", "icons/icon.icns"],
|
||||
"targets": [
|
||||
"app"
|
||||
],
|
||||
"icon": [
|
||||
"icons/icon.png",
|
||||
"icons/icon.icns"
|
||||
],
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "14.0"
|
||||
"minimumSystemVersion": "14.0",
|
||||
"bundleVersion": "11",
|
||||
"dmg": {
|
||||
"windowSize": {
|
||||
"width": 660,
|
||||
"height": 400
|
||||
},
|
||||
"appPosition": {
|
||||
"x": 180,
|
||||
"y": 170
|
||||
},
|
||||
"applicationFolderPosition": {
|
||||
"x": 480,
|
||||
"y": 170
|
||||
}
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"versionCode": 2004
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhBQzA0QjhENTI3OEE1QzAKUldUQXBYaFNqVXZBaXJVWmErWUZTNVp6QnllT25NWFMyemxqcFBnYnJHT3JBSWxrSkVYU1VVMFEK"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"identifier": "de.kanbangit.ios",
|
||||
"bundle": {
|
||||
"iOS": { "minimumSystemVersion": "17.0" }
|
||||
"iOS": {
|
||||
"minimumSystemVersion": "17.0",
|
||||
"developmentTeam": "ZZX2KZGTHU",
|
||||
"bundleVersion": "6"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,18 +6,65 @@ fn main() {
|
||||
};
|
||||
use tauri::{webview::PageLoadEvent, Manager};
|
||||
|
||||
let startup = std::env::var("KANBANGIT_SMOKE_STARTUP").unwrap_or_default();
|
||||
let online = startup == "online";
|
||||
let needs_setup = matches!(startup.as_str(), "missing" | "invalid");
|
||||
let passed = Arc::new(AtomicBool::new(false));
|
||||
let stage = Arc::new(AtomicUsize::new(0));
|
||||
let page_stage = stage.clone();
|
||||
let result_passed = passed.clone();
|
||||
let setup_passed = passed.clone();
|
||||
let plugin = tauri::plugin::Builder::<tauri::Wry>::new("macos-smoke")
|
||||
.js_init_script("window.addEventListener('error', event => { location.href = 'tauri://localhost/__native_smoke_result?passed=false&error=' + encodeURIComponent(event.message || event.target.src); }, true);")
|
||||
.setup(move |app, _| {
|
||||
let directory = app.path().app_config_dir()?;
|
||||
std::fs::create_dir_all(&directory)?;
|
||||
std::fs::write(directory.join("update-preferences.json"), r#"{"automatic":false,"intervalHours":6}"#)?;
|
||||
if needs_setup {
|
||||
if startup == "invalid" {
|
||||
std::fs::write(directory.join("active-server"), "invalid configuration")?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let server = if online { "https://kanbangit.de/" } else { "https://127.0.0.1:9/" };
|
||||
std::fs::write(directory.join("active-server"), format!("{server}\n0123456789abcdef0123456789abcdef"))?;
|
||||
Ok(())
|
||||
})
|
||||
.js_init_script(r#"
|
||||
if (location.protocol === 'tauri:') {
|
||||
window.__selectionFlashed = false;
|
||||
new MutationObserver(() => {
|
||||
const selection = document.querySelector('#workspace-settings');
|
||||
const phase = document.querySelector('#connection-status')?.dataset.state;
|
||||
if (selection && !selection.hidden && phase !== 'connectionError') window.__selectionFlashed = true;
|
||||
}).observe(document, { subtree: true, attributes: true, childList: true });
|
||||
}
|
||||
"#)
|
||||
.js_init_script("window.addEventListener('error', event => { location.href = 'tauri://localhost/?smoke=result&passed=false&error=' + encodeURIComponent(event.message || event.target.src); }, true);")
|
||||
.on_page_load(move |webview, payload| {
|
||||
|
||||
eprintln!("Smoke load: {} {:?} {}", webview.label(), payload.event(), payload.url());
|
||||
if payload.event() != PageLoadEvent::Finished {
|
||||
return;
|
||||
}
|
||||
if webview.label() == "launcher" {
|
||||
if needs_setup {
|
||||
let app = webview.app_handle().clone();
|
||||
let passed = setup_passed.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
let handle = app.clone();
|
||||
app.run_on_main_thread(move || {
|
||||
let launcher = handle.get_webview_window("launcher").unwrap();
|
||||
assert!(launcher.is_visible().unwrap(), "First launch and invalid configuration must show setup");
|
||||
assert!(!handle.webview_windows().keys().any(|label| label.starts_with("workspace-")));
|
||||
passed.store(true, Ordering::SeqCst);
|
||||
launcher.close().unwrap();
|
||||
}).unwrap();
|
||||
});
|
||||
return;
|
||||
}
|
||||
assert!(!webview.app_handle().get_webview_window("launcher").unwrap().is_visible().unwrap(), "Saved startup must not show the native launcher window");
|
||||
if online { return; }
|
||||
webview.eval(r#"
|
||||
(async () => {
|
||||
const wait = async predicate => {
|
||||
@@ -28,7 +75,9 @@ fn main() {
|
||||
throw new Error('Timed out waiting for local state');
|
||||
};
|
||||
try {
|
||||
await wait(() => document.querySelector('.open-workspace')?.textContent === 'Open workspace');
|
||||
await wait(() => document.querySelector('#connection-status')?.dataset.state === 'connectionError');
|
||||
if (window.__selectionFlashed) throw new Error('Workspace selection flashed during saved startup');
|
||||
if (document.querySelector('#server-url').value !== 'https://127.0.0.1:9/') throw new Error('Saved workspace was discarded');
|
||||
const input = document.querySelector('#server-url');
|
||||
const form = document.querySelector('#server-form');
|
||||
const status = document.querySelector('#connection-status');
|
||||
@@ -43,6 +92,7 @@ fn main() {
|
||||
}
|
||||
input.value = 'https://127.0.0.1:9/';
|
||||
form.requestSubmit();
|
||||
await wait(() => status.dataset.state === 'connecting');
|
||||
await wait(() => status.dataset.state === 'connectionError');
|
||||
if (input.value !== 'https://127.0.0.1:9/' || document.querySelector('.open-workspace').disabled) throw new Error('Offline recovery failed');
|
||||
input.value = 'https://kanbangit.de/';
|
||||
@@ -53,7 +103,7 @@ fn main() {
|
||||
form.requestSubmit();
|
||||
document.querySelector('#confirm-switch').click();
|
||||
} catch (error) {
|
||||
location.href = 'tauri://localhost/__native_smoke_result?passed=false&reason=' + encodeURIComponent(error.message);
|
||||
location.href = 'tauri://localhost/?smoke=result&passed=false&reason=' + encodeURIComponent(error.message);
|
||||
}
|
||||
})();
|
||||
"#).unwrap();
|
||||
@@ -69,16 +119,34 @@ fn main() {
|
||||
const rendered = document.querySelector('#app')?.innerText.trim().length > 100;
|
||||
let denied = true;
|
||||
for (const cmd of ['plugin:path|resolve_directory', 'plugin:window|get_all_windows']) {
|
||||
try { await window.__TAURI_INTERNALS__.invoke(cmd); denied = false; }
|
||||
try { await Promise.race([window.__TAURI_INTERNALS__.invoke(cmd), new Promise((_, reject) => setTimeout(() => reject(new Error('IPC timeout')), 3000))]); denied = false; }
|
||||
catch (error) { denied = denied && String(error).includes('not allowed'); }
|
||||
}
|
||||
const api = window.kanbanNative;
|
||||
if (api?.version !== 1) throw new Error('Versioned settings bridge missing');
|
||||
const initial = await api.request('getState');
|
||||
if (initial.platform !== 'macos' || initial.workspace !== 'https://kanbangit.de/' || !initial.capabilities.includes('updates')) throw new Error('Incorrect native settings');
|
||||
await api.request('setUpdatePreferences', {automatic:false, intervalHours:24});
|
||||
const changed = await api.request('getState');
|
||||
if (changed.updates.preferences.automatic || changed.updates.preferences.intervalHours !== 24) throw new Error('Update preferences did not persist');
|
||||
const invalid = await Promise.race([window.webkit.messageHandlers.kanbanSettings.postMessage(JSON.stringify({version:1,id:5,action:'shell',payload:{}})), new Promise((_, reject) => setTimeout(() => reject(new Error('Raw bridge timeout')), 3000))]);
|
||||
if (!JSON.parse(invalid).error) throw new Error('Unknown native action accepted');
|
||||
const frame = document.createElement('iframe');
|
||||
document.body.append(frame);
|
||||
if (!frame.contentWindow.webkit?.messageHandlers.kanbanSettings) throw new Error('Frame bridge fixture missing');
|
||||
let frameDenied = false;
|
||||
try { await Promise.race([frame.contentWindow.webkit.messageHandlers.kanbanSettings.postMessage(JSON.stringify({version:1,id:6,action:'getState',payload:{}})), new Promise((_, reject) => setTimeout(() => reject(new Error('Frame bridge timeout')), 3000))]); }
|
||||
catch (error) { frameDenied = String(error).includes('untrustedFrame'); }
|
||||
if (!frameDenied) throw new Error('Subframe settings access accepted');
|
||||
const input = document.createElement('input');
|
||||
input.id = 'kan52-unsaved'; input.value = 'unsaved input'; document.body.append(input);
|
||||
document.cookie = 'kan52_fixture=isolated; Path=/; Secure; SameSite=Lax';
|
||||
localStorage.setItem('kan52_fixture', 'isolated');
|
||||
const ok = rendered && denied && typeof window.__TAURI__ === 'undefined';
|
||||
if (typeof window.__kanbanNavigation !== 'undefined') throw new Error('Custom gesture bridge must be removed');
|
||||
if (getComputedStyle(document.documentElement).overscrollBehaviorY !== 'none' || getComputedStyle(document.documentElement).overscrollBehaviorX !== 'auto') throw new Error('Scroll boundary policy missing');
|
||||
const ok = rendered && denied && typeof window.__TAURI__ === 'undefined' && location.pathname === '/login' && history.length === 1;
|
||||
location.href = '/__native_smoke_result?stage=loaded&passed=' + ok;
|
||||
})();
|
||||
})().catch(error => { location.href = '/__native_smoke_result?passed=false&reason=' + encodeURIComponent(String(error)); });
|
||||
"#).unwrap();
|
||||
} else {
|
||||
webview.eval(r#"
|
||||
@@ -90,7 +158,8 @@ fn main() {
|
||||
})
|
||||
.on_navigation(move |webview, url| {
|
||||
|
||||
if url.path() != "/__native_smoke_result" {
|
||||
eprintln!("Smoke navigation: {} {}", webview.label(), url);
|
||||
if url.path() != "/__native_smoke_result" && !url.query_pairs().any(|(key, value)| key == "smoke" && value == "result") {
|
||||
return true;
|
||||
}
|
||||
let success = url.query_pairs().any(|(key, value)| key == "passed" && value == "true");
|
||||
@@ -109,17 +178,25 @@ fn main() {
|
||||
location.href = '/__native_smoke_result?passed=false&stage=preservation';
|
||||
}
|
||||
"#).unwrap();
|
||||
let launcher = app.get_webview_window("launcher").unwrap();
|
||||
launcher.show().unwrap();
|
||||
launcher.eval(r#"
|
||||
document.querySelector('#server-url').value = 'https://kanbangit.de/login/';
|
||||
document.querySelector('#server-form').requestSubmit();
|
||||
document.querySelector('#confirm-switch').click();
|
||||
window.eval(r#"
|
||||
window.kanbanNative.request('setWorkspace', {server:'https://kanbangit.de/login/'}).catch(error => {
|
||||
location.href = '/__native_smoke_result?passed=false&stage=settings-switch&reason=' + encodeURIComponent(error.message);
|
||||
});
|
||||
"#).unwrap();
|
||||
});
|
||||
} else {
|
||||
result_passed.store(success, Ordering::SeqCst);
|
||||
webview.app_handle().exit(if success { 0 } else { 1 });
|
||||
if success {
|
||||
let app = webview.app_handle().clone();
|
||||
let label = webview.label().to_string();
|
||||
let handle = app.clone();
|
||||
app.run_on_main_thread(move || {
|
||||
assert!(!handle.get_webview_window("launcher").unwrap().is_visible().unwrap());
|
||||
handle.get_webview_window(&label).unwrap().close().unwrap();
|
||||
}).unwrap();
|
||||
} else {
|
||||
webview.app_handle().exit(1);
|
||||
}
|
||||
}
|
||||
false
|
||||
})
|
||||
@@ -130,6 +207,25 @@ fn main() {
|
||||
.plugin(plugin)
|
||||
.build(context)
|
||||
.expect("The macOS application must start");
|
||||
let visibility_handle = app.handle().clone();
|
||||
let visibility_stage = stage.clone();
|
||||
let visibility_check = std::thread::spawn(move || {
|
||||
if needs_setup {
|
||||
return;
|
||||
}
|
||||
for _ in 0..30 {
|
||||
if visibility_stage.load(Ordering::SeqCst) > 0 {
|
||||
break;
|
||||
}
|
||||
if let Some(launcher) = visibility_handle.get_webview_window("launcher") {
|
||||
assert!(
|
||||
!launcher.is_visible().unwrap(),
|
||||
"Launcher flashed while the workspace was loading"
|
||||
);
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
}
|
||||
});
|
||||
let config_dir = app.path().app_config_dir().unwrap();
|
||||
let handle = app.handle().clone();
|
||||
std::thread::spawn(move || {
|
||||
@@ -137,7 +233,41 @@ fn main() {
|
||||
eprintln!("macOS smoke test timed out");
|
||||
handle.exit(1);
|
||||
});
|
||||
app.run_return(|_, _| {});
|
||||
let closed = Arc::new(AtomicBool::new(false));
|
||||
let closed_result = closed.clone();
|
||||
let exit_code = app.run_return(move |app, event| {
|
||||
if let tauri::RunEvent::ExitRequested { code: Some(0), .. } = event {
|
||||
closed_result.store(true, Ordering::SeqCst);
|
||||
assert!(
|
||||
needs_setup
|
||||
|| !app
|
||||
.get_webview_window("launcher")
|
||||
.unwrap()
|
||||
.is_visible()
|
||||
.unwrap(),
|
||||
"Closing the workspace must not reopen settings"
|
||||
);
|
||||
}
|
||||
});
|
||||
visibility_check.join().unwrap();
|
||||
assert_eq!(exit_code, 0);
|
||||
assert!(
|
||||
closed.load(Ordering::SeqCst),
|
||||
"The close button must quit the application"
|
||||
);
|
||||
if online {
|
||||
assert!(std::fs::read_to_string(config_dir.join("active-server"))
|
||||
.unwrap()
|
||||
.starts_with("https://kanbangit.de/login/\n"));
|
||||
let preferences: serde_json::Value = serde_json::from_str(
|
||||
&std::fs::read_to_string(config_dir.join("update-preferences.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
preferences,
|
||||
serde_json::json!({"automatic": false, "intervalHours": 24})
|
||||
);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(config_dir);
|
||||
assert!(
|
||||
passed.load(Ordering::SeqCst),
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tauri::command]
|
||||
fn updater_smoke_menu(app: tauri::AppHandle) -> Result<(), String> {
|
||||
let submenu = app.menu().unwrap().get("app-menu").unwrap();
|
||||
let item = submenu.as_submenu().unwrap().get("install-update").unwrap();
|
||||
let item = item.as_menuitem().unwrap();
|
||||
for (available, busy, expected) in [
|
||||
(false, false, false), // No update.
|
||||
(true, false, true), // Available update, also after a retryable failure.
|
||||
(true, true, false), // Download or installation in progress.
|
||||
(true, false, true), // Retry after a failed download.
|
||||
(false, true, false), // A new check clears the previous update.
|
||||
(false, false, false), // Up to date.
|
||||
] {
|
||||
kanbangit_native::refresh_update_menu_for_smoke_test(&app, available, busy);
|
||||
if item.is_enabled().map_err(|error| error.to_string())? != expected {
|
||||
return Err(format!(
|
||||
"Native install menu has wrong state: available={available}, busy={busy}"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tauri::command]
|
||||
fn updater_smoke_report(app: tauri::AppHandle, passed: bool, reason: String) {
|
||||
use tauri::Manager;
|
||||
println!("Updater WebView test: passed={passed}, {reason}");
|
||||
app.state::<std::sync::Arc<std::sync::atomic::AtomicBool>>()
|
||||
.store(passed, std::sync::atomic::Ordering::SeqCst);
|
||||
app.exit(if passed { 0 } else { 1 });
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn main() {
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
};
|
||||
use tauri::webview::PageLoadEvent;
|
||||
let passed = Arc::new(AtomicBool::new(false));
|
||||
let config: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
|
||||
let expected_installed = format!(
|
||||
"Installed version {} (build {})",
|
||||
config["version"].as_str().unwrap(),
|
||||
config["bundle"]["macOS"]["bundleVersion"].as_str().unwrap()
|
||||
);
|
||||
|
||||
let plugin = tauri::plugin::Builder::<tauri::Wry>::new("updater-smoke")
|
||||
.js_init_script(format!("window.expectedInstalled = {};", serde_json::to_string(&expected_installed).unwrap()))
|
||||
.on_page_load(|window, payload| {
|
||||
if window.label() != "updates" || payload.event() != PageLoadEvent::Finished { return; }
|
||||
window.eval(r#"
|
||||
(async () => {
|
||||
try {
|
||||
for (let i = 0; i < 100 && !window.updateStatus; i++) await new Promise(r => setTimeout(r, 50));
|
||||
if (!window.updateStatus) throw new Error('Update UI did not load');
|
||||
const $ = id => document.getElementById(id);
|
||||
for (let i = 0; i < 100 && !$('installed').textContent; i++) await new Promise(r => setTimeout(r, 50));
|
||||
if ($('installed').textContent !== window.expectedInstalled) throw new Error('Actual installed version/build failed: ' + $('installed').textContent);
|
||||
await window.__TAURI_INTERNALS__.invoke('updater_smoke_menu');
|
||||
const state = { phase: 'current', currentVersion: '0.1.2', build: '2', version: null, notes: null, downloaded: 0, total: null, error: null, preferences: { automatic: true, intervalHours: 6 } };
|
||||
window.updateStatus(state);
|
||||
if (!$('installed').textContent.includes('0.1.2 (build 2)') || !$('install').hidden || $('check').disabled) throw new Error('Current version state failed');
|
||||
Object.assign(state, { phase: 'available', version: '0.1.3', notes: '<script>unsafe()</script>\nRelease notes' });
|
||||
window.updateStatus(state);
|
||||
if ($('install').hidden || $('release-notes').querySelector('script') || !$('release-notes').textContent.includes('<script>')) throw new Error('Available state or text escaping failed');
|
||||
$('install').click();
|
||||
if ($('install-confirmation').hidden || !$('install-confirmation').textContent.includes('Unsaved inputs')) throw new Error('Missing restart confirmation');
|
||||
$('cancel-install').click();
|
||||
if (!$('install-confirmation').hidden) throw new Error('Cancel failed');
|
||||
Object.assign(state, { phase: 'downloading', downloaded: 500, total: 1000 }); window.updateStatus(state);
|
||||
if (!$('check').disabled || !$('install').hidden || $('download-progress').value !== 500) throw new Error('Progress state failed');
|
||||
Object.assign(state, { phase: 'error', error: 'signatureError' }); window.updateStatus(state);
|
||||
if ($('install').hidden || !$('update-error').textContent.includes('Nothing was installed')) throw new Error('Retry failed');
|
||||
state.preferences.automatic = false; window.updateStatus(state);
|
||||
if ($('automatic').checked || !$('interval').disabled) throw new Error('Opt out failed');
|
||||
await window.__TAURI_INTERNALS__.invoke('updater_smoke_report', { passed: true, reason: '' });
|
||||
} catch (error) {
|
||||
await window.__TAURI_INTERNALS__.invoke('updater_smoke_report', { passed: false, reason: error.message });
|
||||
}
|
||||
})();
|
||||
"#).unwrap();
|
||||
})
|
||||
.build();
|
||||
let mut context = kanbangit_native::context();
|
||||
context.config_mut().identifier = format!("de.kanbangit.smoke.updates.{}", std::process::id());
|
||||
let app = kanbangit_native::builder()
|
||||
.manage(passed.clone())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
updater_smoke_report,
|
||||
updater_smoke_menu
|
||||
])
|
||||
.plugin(plugin)
|
||||
.build(context)
|
||||
.expect("App must start");
|
||||
let handle = app.handle().clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_secs(30));
|
||||
handle.exit(1);
|
||||
});
|
||||
app.run_return(|_, _| {});
|
||||
assert!(passed.load(Ordering::SeqCst), "Updater UI test must pass");
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn main() {
|
||||
panic!("Requires macOS");
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import process from 'node:process';
|
||||
import test from 'node:test';
|
||||
import { conditionalEtag } from '../publish-macos.mjs';
|
||||
|
||||
test(
|
||||
'Hetzner conditional writes accept a bare ETag and reject a stale ETag',
|
||||
{
|
||||
skip: process.env.KANBANGIT_S3_INTEGRATION !== '1',
|
||||
},
|
||||
() => {
|
||||
const bucket = process.env.AWS_BUCKET;
|
||||
assert(bucket, 'Set AWS_BUCKET to a test bucket');
|
||||
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-s3-etag-'));
|
||||
const key = `kanbangit-integration-tests/${randomUUID()}.txt`;
|
||||
const file = join(directory, 'object.txt');
|
||||
const downloaded = join(directory, 'downloaded.txt');
|
||||
const run = (operation, args) => {
|
||||
const result = spawnSync(
|
||||
'aws',
|
||||
[
|
||||
'--debug',
|
||||
'--endpoint-url',
|
||||
process.env.AWS_ENDPOINT,
|
||||
'--region',
|
||||
process.env.AWS_DEFAULT_REGION,
|
||||
'--no-cli-pager',
|
||||
's3api',
|
||||
operation,
|
||||
'--bucket',
|
||||
bucket,
|
||||
'--key',
|
||||
key,
|
||||
...args,
|
||||
],
|
||||
{ encoding: 'utf8', timeout: 30000 },
|
||||
);
|
||||
// Never expose debug headers: they contain the signed Authorization header.
|
||||
return {
|
||||
status: result.status,
|
||||
stdout: result.stdout,
|
||||
codes: [
|
||||
...(result.stderr || '').matchAll(/<Code>([^<]*)<\/Code>/g),
|
||||
].map((match) => match[1]),
|
||||
};
|
||||
};
|
||||
let created = false;
|
||||
try {
|
||||
writeFileSync(file, 'first');
|
||||
const initial = run('put-object', [
|
||||
'--body',
|
||||
file,
|
||||
'--if-none-match',
|
||||
'*',
|
||||
]);
|
||||
assert.equal(initial.status, 0, `Create failed: ${initial.codes}`);
|
||||
created = true;
|
||||
const read = run('get-object', ['--output', 'json', downloaded]);
|
||||
assert.equal(read.status, 0, `Read failed: ${read.codes}`);
|
||||
const etag = JSON.parse(read.stdout).ETag;
|
||||
const quoted = run('put-object', [
|
||||
'--body',
|
||||
file,
|
||||
'--if-match',
|
||||
etag,
|
||||
]);
|
||||
console.log(
|
||||
`Quoted ETag result: exit ${quoted.status}, S3 ${quoted.codes.join(', ')}`,
|
||||
);
|
||||
const bare = conditionalEtag(etag);
|
||||
writeFileSync(file, 'second');
|
||||
const updated = run('put-object', [
|
||||
'--body',
|
||||
file,
|
||||
'--if-match',
|
||||
bare,
|
||||
]);
|
||||
assert.equal(
|
||||
updated.status,
|
||||
0,
|
||||
`Bare ETag replacement failed: ${updated.codes}`,
|
||||
);
|
||||
writeFileSync(file, 'stale writer');
|
||||
const stale = run('put-object', [
|
||||
'--body',
|
||||
file,
|
||||
'--if-match',
|
||||
bare,
|
||||
]);
|
||||
assert.notEqual(stale.status, 0);
|
||||
assert(
|
||||
stale.codes.includes('PreconditionFailed'),
|
||||
`Unexpected stale error: ${stale.codes}`,
|
||||
);
|
||||
assert.equal(run('get-object', [downloaded]).status, 0);
|
||||
assert.equal(readFileSync(downloaded, 'utf8'), 'second');
|
||||
} finally {
|
||||
try {
|
||||
if (created) {
|
||||
const cleanup = run('delete-object', []);
|
||||
assert.equal(
|
||||
cleanup.status,
|
||||
0,
|
||||
`Cleanup failed: ${cleanup.codes}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,534 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
appleTeamId,
|
||||
buildIos,
|
||||
bundleIdentifier,
|
||||
selectIdentity,
|
||||
validateProfile,
|
||||
} from '../build-ios.mjs';
|
||||
|
||||
const certificate = Buffer.from('synthetic test certificate');
|
||||
const fingerprint = createHash('sha1')
|
||||
.update(certificate)
|
||||
.digest('hex')
|
||||
.toUpperCase();
|
||||
|
||||
function profileFor(mode = 'testflight') {
|
||||
return {
|
||||
UUID: '00000000-1111-2222-3333-444444444444',
|
||||
TeamIdentifier: [appleTeamId],
|
||||
Platform: ['iOS'],
|
||||
ExpirationDate: '2099-01-01T00:00:00Z',
|
||||
DeveloperCertificates: [certificate.toString('base64')],
|
||||
...(mode === 'development'
|
||||
? { ProvisionedDevices: ['test-device'] }
|
||||
: {}),
|
||||
Entitlements: {
|
||||
'application-identifier': `${appleTeamId}.${bundleIdentifier}`,
|
||||
'com.apple.developer.team-identifier': appleTeamId,
|
||||
'get-task-allow': mode === 'development',
|
||||
'beta-reports-active': mode === 'testflight',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fixture(t, mode = 'testflight', overrides = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'kanbangit-ios-test-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const directory = join(root, 'repo/native');
|
||||
const profilePath = join(root, 'profile.mobileprovision');
|
||||
const config = {
|
||||
productName: 'KanbanGit',
|
||||
identifier: 'de.kanbangit.desktop',
|
||||
version: '1.2.3',
|
||||
};
|
||||
const ios = {
|
||||
identifier: bundleIdentifier,
|
||||
bundle: {
|
||||
iOS: {
|
||||
developmentTeam: appleTeamId,
|
||||
bundleVersion: '7',
|
||||
minimumSystemVersion: '17.0',
|
||||
},
|
||||
},
|
||||
};
|
||||
mkdirSync(join(directory, 'src-tauri'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(directory, 'src-tauri/tauri.conf.json'),
|
||||
JSON.stringify(config),
|
||||
);
|
||||
writeFileSync(
|
||||
join(directory, 'src-tauri/tauri.ios.conf.json'),
|
||||
JSON.stringify(ios),
|
||||
);
|
||||
writeFileSync(profilePath, 'external provisioning profile');
|
||||
const profile = profileFor(mode);
|
||||
const calls = [];
|
||||
const destination = join(directory, `src-tauri/target/ios-${mode}/1.2.3-7`);
|
||||
const home = join(root, 'home');
|
||||
const installedProfile = join(
|
||||
home,
|
||||
`Library/Developer/Xcode/UserData/Provisioning Profiles/${profile.UUID}.mobileprovision`,
|
||||
);
|
||||
const info = {
|
||||
CFBundleIdentifier: bundleIdentifier,
|
||||
CFBundleShortVersionString: '1.2.3',
|
||||
CFBundleVersion: '7',
|
||||
};
|
||||
function app(path) {
|
||||
mkdirSync(path, { recursive: true });
|
||||
writeFileSync(
|
||||
join(path, 'Info.plist'),
|
||||
JSON.stringify({ ...info, ...overrides.info }),
|
||||
);
|
||||
}
|
||||
const options = {
|
||||
directory,
|
||||
home,
|
||||
mode,
|
||||
platform: 'darwin',
|
||||
log() {},
|
||||
env: { PATH: '/test/bin', IOS_PROVISIONING_PROFILE: profilePath },
|
||||
run(command, args, settings) {
|
||||
calls.push({ command, args, settings });
|
||||
if (overrides.failStep === settings.step) {
|
||||
throw new Error('Simulated build failure');
|
||||
}
|
||||
if (command === 'python3') {
|
||||
return settings.input.toString();
|
||||
}
|
||||
if (command === 'security') {
|
||||
if (args[0] === 'cms') {
|
||||
const embedded = args
|
||||
.at(-1)
|
||||
.endsWith('embedded.mobileprovision');
|
||||
return JSON.stringify(
|
||||
embedded && overrides.embeddedProfile
|
||||
? overrides.embeddedProfile
|
||||
: profile,
|
||||
);
|
||||
}
|
||||
return `1) ${fingerprint} "Apple ${mode === 'development' ? 'Development' : 'Distribution'}: Example (${appleTeamId})"`;
|
||||
}
|
||||
if (command === 'rustup') {
|
||||
if (args[0] === 'target') {
|
||||
return (
|
||||
overrides.targets ??
|
||||
'aarch64-apple-ios\naarch64-apple-ios-sim'
|
||||
);
|
||||
}
|
||||
return `/permanent/toolchain/bin/${args[1]}`;
|
||||
}
|
||||
if (command === '/permanent/toolchain/bin/cargo') {
|
||||
const library = join(
|
||||
directory,
|
||||
'src-tauri/target',
|
||||
args[args.indexOf('--target') + 1],
|
||||
args.includes('--release') ? 'release' : 'debug',
|
||||
'libkanbangit_native.a',
|
||||
);
|
||||
mkdirSync(dirname(library), { recursive: true });
|
||||
writeFileSync(library, 'compiled library');
|
||||
}
|
||||
if (command === 'xcodebuild' && args.includes('archive')) {
|
||||
assert.ok(mode === 'simulator' || existsSync(installedProfile));
|
||||
app(
|
||||
join(
|
||||
args[args.indexOf('-archivePath') + 1],
|
||||
'Products/Applications/KanbanGit.app',
|
||||
),
|
||||
);
|
||||
}
|
||||
if (command === 'xcodebuild' && args.includes('-exportArchive')) {
|
||||
const output = args[args.indexOf('-exportPath') + 1];
|
||||
mkdirSync(output, { recursive: true });
|
||||
if (!overrides.missingIpa) {
|
||||
writeFileSync(
|
||||
join(output, 'KanbanGit.ipa'),
|
||||
'signed ipa bytes',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (command === 'ditto') {
|
||||
app(join(args.at(-1), 'Payload/KanbanGit.app'));
|
||||
}
|
||||
if (command === 'xcrun') {
|
||||
return '/permanent/Xcode/SDK';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
};
|
||||
return {
|
||||
options,
|
||||
calls,
|
||||
profile,
|
||||
profilePath,
|
||||
destination,
|
||||
installedProfile,
|
||||
ios,
|
||||
};
|
||||
}
|
||||
|
||||
test('TestFlight produces a verified local IPA with manual signing and no Apple mutation or upload flags', (t) => {
|
||||
const f = fixture(t);
|
||||
f.options.env.APPLE_API_KEY = 'must-not-reach-build';
|
||||
f.options.env.IOS_CERTIFICATE = 'must-not-reach-build';
|
||||
assert.equal(buildIos(f.options), f.destination);
|
||||
const options = JSON.parse(
|
||||
readFileSync(join(f.destination, 'ExportOptions.plist'), 'utf8'),
|
||||
);
|
||||
assert.equal(options.method, 'app-store-connect');
|
||||
assert.equal(options.destination, 'export');
|
||||
assert.equal(options.signingStyle, 'manual');
|
||||
assert.equal(options.manageAppVersionAndBuildNumber, false);
|
||||
assert.equal(options.signingCertificate, fingerprint);
|
||||
assert.deepEqual(options.provisioningProfiles, {
|
||||
[bundleIdentifier]: f.profile.UUID,
|
||||
});
|
||||
assert.equal(
|
||||
readFileSync(
|
||||
join(f.destination, 'export/KanbanGit.ipa.sha256'),
|
||||
'utf8',
|
||||
),
|
||||
`${createHash('sha256').update('signed ipa bytes').digest('hex')} KanbanGit.ipa\n`,
|
||||
);
|
||||
assert.equal(
|
||||
f.calls.filter(
|
||||
(call) =>
|
||||
call.command === 'codesign' && call.args.includes('--verify'),
|
||||
).length,
|
||||
2,
|
||||
);
|
||||
assert.equal(existsSync(f.installedProfile), false);
|
||||
const verification = f.calls.find((call) => call.command === 'codesign');
|
||||
const requirement = verification.args[verification.args.indexOf('-R') + 1];
|
||||
assert.ok(requirement.includes('! entitlement["get-task-allow"] exists'));
|
||||
assert.ok(
|
||||
requirement.includes('entitlement["beta-reports-active"] exists'),
|
||||
);
|
||||
assert.ok(requirement.includes(`certificate leaf = H"${fingerprint}"`));
|
||||
for (const call of f.calls) {
|
||||
assert.equal(call.settings.env.APPLE_API_KEY, undefined);
|
||||
assert.equal(call.settings.env.IOS_CERTIFICATE, undefined);
|
||||
assert.ok(
|
||||
!call.args.some((arg) =>
|
||||
/allowProvisioning|authenticationKey|upload|downloadPlatform/.test(
|
||||
arg,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
const archive = f.calls.find((call) => call.args.includes('archive'));
|
||||
assert.ok(archive.args.includes('CODE_SIGN_STYLE=Manual'));
|
||||
assert.ok(
|
||||
archive.args.includes(
|
||||
`PROVISIONING_PROFILE_SPECIFIER=${f.profile.UUID}`,
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
archive.settings.env.KANBANGIT_IOS_LIBRARY.endsWith(
|
||||
'/aarch64-apple-ios/release/libkanbangit_native.a',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('the simulator needs no certificate or profile and disables signing explicitly', (t) => {
|
||||
const f = fixture(t, 'simulator');
|
||||
delete f.options.env.IOS_PROVISIONING_PROFILE;
|
||||
buildIos(f.options);
|
||||
assert.ok(
|
||||
f.calls.some((call) => call.args.includes('CODE_SIGNING_ALLOWED=NO')),
|
||||
);
|
||||
assert.ok(
|
||||
f.calls.some((call) => call.args.includes('aarch64-apple-ios-sim')),
|
||||
);
|
||||
assert.ok(
|
||||
!f.calls.some(
|
||||
(call) =>
|
||||
['security', 'codesign'].includes(call.command) ||
|
||||
call.args.includes('-exportArchive'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('local device development uses a registered-device development profile', (t) => {
|
||||
const f = fixture(t, 'development');
|
||||
buildIos(f.options);
|
||||
const options = JSON.parse(
|
||||
readFileSync(join(f.destination, 'ExportOptions.plist'), 'utf8'),
|
||||
);
|
||||
assert.equal(options.method, 'debugging');
|
||||
assert.equal(options.destination, 'export');
|
||||
});
|
||||
|
||||
for (const [name, change, message] of [
|
||||
[
|
||||
'different team',
|
||||
(p) => {
|
||||
p.TeamIdentifier = ['OLDTEAM'];
|
||||
},
|
||||
/explicitly match/,
|
||||
],
|
||||
[
|
||||
'wildcard identifier',
|
||||
(p) => {
|
||||
p.Entitlements['application-identifier'] = `${appleTeamId}.*`;
|
||||
},
|
||||
/explicitly match/,
|
||||
],
|
||||
[
|
||||
'macOS identifier',
|
||||
(p) => {
|
||||
p.Entitlements['application-identifier'] =
|
||||
`${appleTeamId}.de.kanbangit.desktop`;
|
||||
},
|
||||
/explicitly match/,
|
||||
],
|
||||
[
|
||||
'expired profile',
|
||||
(p) => {
|
||||
p.ExpirationDate = '2000-01-01';
|
||||
},
|
||||
/expired/,
|
||||
],
|
||||
[
|
||||
'invalid expiry',
|
||||
(p) => {
|
||||
p.ExpirationDate = 'invalid';
|
||||
},
|
||||
/expired/,
|
||||
],
|
||||
[
|
||||
'invalid UUID',
|
||||
(p) => {
|
||||
p.UUID = '../unexpected';
|
||||
},
|
||||
/invalid/,
|
||||
],
|
||||
[
|
||||
'missing certificate',
|
||||
(p) => {
|
||||
p.DeveloperCertificates = [];
|
||||
},
|
||||
/invalid/,
|
||||
],
|
||||
[
|
||||
'wrong platform',
|
||||
(p) => {
|
||||
p.Platform = ['OSX'];
|
||||
},
|
||||
/iOS profile/,
|
||||
],
|
||||
[
|
||||
'Ad Hoc profile',
|
||||
(p) => {
|
||||
p.ProvisionedDevices = ['device'];
|
||||
},
|
||||
/App Store Connect/,
|
||||
],
|
||||
[
|
||||
'enterprise profile',
|
||||
(p) => {
|
||||
p.ProvisionsAllDevices = true;
|
||||
},
|
||||
/App Store Connect/,
|
||||
],
|
||||
[
|
||||
'development entitlement',
|
||||
(p) => {
|
||||
p.Entitlements['get-task-allow'] = true;
|
||||
},
|
||||
/App Store Connect/,
|
||||
],
|
||||
[
|
||||
'missing beta entitlement',
|
||||
(p) => {
|
||||
delete p.Entitlements['beta-reports-active'];
|
||||
},
|
||||
/App Store Connect/,
|
||||
],
|
||||
]) {
|
||||
test(`rejects ${name} before signing`, () => {
|
||||
const profile = profileFor();
|
||||
change(profile);
|
||||
assert.throws(() => validateProfile(profile, 'testflight'), message);
|
||||
});
|
||||
}
|
||||
|
||||
test('a distribution profile cannot be used for device development', () => {
|
||||
assert.throws(
|
||||
() => validateProfile(profileFor(), 'development'),
|
||||
/App Development/,
|
||||
);
|
||||
});
|
||||
|
||||
test('identity selection requires the profile certificate and a usable private key of the correct kind', () => {
|
||||
const profile = profileFor();
|
||||
for (const identities of [
|
||||
'0 valid identities found',
|
||||
`1) ${'A'.repeat(40)} "Apple Distribution: Other"`,
|
||||
`1) ${fingerprint} "Developer ID Application: Example"`,
|
||||
`1) ${fingerprint} "Apple Development: Example"`,
|
||||
]) {
|
||||
assert.throws(
|
||||
() => selectIdentity(profile, identities, 'testflight'),
|
||||
/private key/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
for (const step of [
|
||||
'Generating the Xcode project',
|
||||
'Building the iOS Rust library',
|
||||
'Archiving locally without Apple provisioning updates',
|
||||
'Verifying the Apple signature and team',
|
||||
'Exporting the signed IPA locally (no upload)',
|
||||
]) {
|
||||
test(`failure at ${step} does not publish a successful build and cleans its profile`, (t) => {
|
||||
const f = fixture(t, 'testflight', { failStep: step });
|
||||
assert.throws(() => buildIos(f.options), /Simulated build failure/);
|
||||
assert.equal(existsSync(f.destination), false);
|
||||
assert.equal(existsSync(f.installedProfile), false);
|
||||
});
|
||||
}
|
||||
|
||||
test('an existing identical local profile is preserved after the build', (t) => {
|
||||
const f = fixture(t);
|
||||
mkdirSync(dirname(f.installedProfile), { recursive: true });
|
||||
writeFileSync(f.installedProfile, readFileSync(f.profilePath));
|
||||
buildIos(f.options);
|
||||
assert.equal(existsSync(f.installedProfile), true);
|
||||
});
|
||||
|
||||
test('a conflicting local profile is never overwritten or deleted', (t) => {
|
||||
const f = fixture(t);
|
||||
mkdirSync(dirname(f.installedProfile), { recursive: true });
|
||||
writeFileSync(f.installedProfile, 'user profile');
|
||||
assert.throws(() => buildIos(f.options), /same UUID/);
|
||||
assert.equal(readFileSync(f.installedProfile, 'utf8'), 'user profile');
|
||||
});
|
||||
|
||||
test('missing toolchain targets fail before compilation without installing anything', (t) => {
|
||||
const f = fixture(t, 'testflight', { targets: 'aarch64-apple-darwin' });
|
||||
assert.throws(
|
||||
() => buildIos(f.options),
|
||||
/rustup target add aarch64-apple-ios/,
|
||||
);
|
||||
assert.ok(
|
||||
!f.calls.some(
|
||||
(call) => call.args.includes('build') || call.args.includes('add'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('missing profile and ambient team overrides fail clearly', (t) => {
|
||||
const f = fixture(t);
|
||||
delete f.options.env.IOS_PROVISIONING_PROFILE;
|
||||
assert.throws(() => buildIos(f.options), /IOS_PROVISIONING_PROFILE/);
|
||||
f.options.env.APPLE_DEVELOPMENT_TEAM = 'OLDTEAM';
|
||||
assert.throws(() => buildIos(f.options), /must be ZZX2KZGTHU/);
|
||||
delete f.options.env.APPLE_DEVELOPMENT_TEAM;
|
||||
f.options.env.TAURI_CONFIG = '{}';
|
||||
assert.throws(() => buildIos(f.options), /Remove TAURI_CONFIG/);
|
||||
});
|
||||
|
||||
for (const [name, overrides, message] of [
|
||||
[
|
||||
'wrong app version',
|
||||
{ info: { CFBundleVersion: '8' } },
|
||||
/unexpected identifier, version/,
|
||||
],
|
||||
[
|
||||
'changed embedded profile',
|
||||
{
|
||||
embeddedProfile: {
|
||||
...profileFor(),
|
||||
UUID: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||
},
|
||||
},
|
||||
/unexpected provisioning profile/,
|
||||
],
|
||||
['missing IPA', { missingIpa: true }, /did not produce/],
|
||||
]) {
|
||||
test(`rejects ${name} in the build output`, (t) => {
|
||||
const f = fixture(t, 'testflight', overrides);
|
||||
assert.throws(() => buildIos(f.options), message);
|
||||
assert.equal(existsSync(f.destination), false);
|
||||
});
|
||||
}
|
||||
|
||||
test('completed artifacts are not silently overwritten', (t) => {
|
||||
const f = fixture(t);
|
||||
buildIos(f.options);
|
||||
assert.throws(() => buildIos(f.options), /already exists/);
|
||||
});
|
||||
|
||||
for (const mode of ['simulator', 'development']) {
|
||||
test(`${mode} can rebuild the same version and preserves the previous build on failure`, (t) => {
|
||||
const overrides = {};
|
||||
const f = fixture(t, mode, overrides);
|
||||
buildIos(f.options);
|
||||
const previousBuild = join(f.destination, 'previous-build');
|
||||
writeFileSync(previousBuild, 'verified artifact');
|
||||
overrides.failStep = 'Building the iOS Rust library';
|
||||
assert.throws(() => buildIos(f.options), /Simulated build failure/);
|
||||
assert.equal(readFileSync(previousBuild, 'utf8'), 'verified artifact');
|
||||
delete overrides.failStep;
|
||||
buildIos(f.options);
|
||||
assert.equal(existsSync(previousBuild), false);
|
||||
assert.ok(existsSync(join(f.destination, 'KanbanGit.xcarchive')));
|
||||
assert.equal(existsSync(f.installedProfile), false);
|
||||
});
|
||||
}
|
||||
|
||||
test('every iOS icon has its declared dimensions and contains no alpha channel or transparency chunk', () => {
|
||||
const assets = fileURLToPath(
|
||||
new URL(
|
||||
'../src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/',
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const catalog = JSON.parse(
|
||||
readFileSync(join(assets, 'Contents.json'), 'utf8'),
|
||||
);
|
||||
assert.ok(
|
||||
catalog.images.some(
|
||||
(icon) =>
|
||||
icon.idiom === 'ios-marketing' && icon.size === '1024x1024',
|
||||
),
|
||||
);
|
||||
for (const icon of catalog.images) {
|
||||
const png = readFileSync(join(assets, icon.filename));
|
||||
assert.equal(png.subarray(0, 8).toString('hex'), '89504e470d0a1a0a');
|
||||
const [width, height] = icon.size.split('x').map(Number);
|
||||
const scale = Number.parseInt(icon.scale, 10);
|
||||
assert.equal(png.readUInt32BE(16), width * scale, icon.filename);
|
||||
assert.equal(png.readUInt32BE(20), height * scale, icon.filename);
|
||||
assert.equal(png[25], 2, `${icon.filename} must use RGB without alpha`);
|
||||
for (
|
||||
let offset = 8;
|
||||
offset < png.length;
|
||||
offset += png.readUInt32BE(offset) + 12
|
||||
) {
|
||||
assert.notEqual(
|
||||
png.subarray(offset + 4, offset + 8).toString(),
|
||||
'tRNS',
|
||||
icon.filename,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -62,6 +62,12 @@ const latestAction = async (name) => {
|
||||
};
|
||||
try {
|
||||
await page.goto(`http://127.0.0.1:${server.address().port}/`);
|
||||
assert.equal(await page.locator('#workspace-settings').isVisible(), false);
|
||||
await state('connecting');
|
||||
await page.waitForTimeout(500);
|
||||
assert.equal(await page.locator('#workspace-settings').isVisible(), false);
|
||||
assert.equal(await page.locator('#startup-loading').isVisible(), true);
|
||||
await page.reload();
|
||||
await state('idle');
|
||||
for (const width of [320, 390, 1440]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
@@ -98,12 +104,7 @@ try {
|
||||
'https://first.example.com/',
|
||||
);
|
||||
await state('connecting', false, 'https://first.example.com/');
|
||||
assert.equal(
|
||||
await page
|
||||
.getByRole('button', { name: 'Open workspace', exact: true })
|
||||
.isDisabled(),
|
||||
true,
|
||||
);
|
||||
assert.equal(await page.locator('.open-workspace').isDisabled(), true);
|
||||
await state('connectionError', false, 'https://first.example.com/');
|
||||
assert.equal(
|
||||
await page.getByLabel('Server address').inputValue(),
|
||||
|
||||
@@ -6,6 +6,11 @@ import { normalizeServer, needsConfirmation } from '../ui/server.js';
|
||||
|
||||
const html = readFileSync(new URL('../ui/index.html', import.meta.url), 'utf8');
|
||||
|
||||
test('workspace selection is hidden before native configuration is available', () => {
|
||||
assert.match(html, /<main id="workspace-settings" hidden>/);
|
||||
assert.match(html, /id="startup-loading" role="status"/);
|
||||
});
|
||||
|
||||
test('every launcher text resolves to a nonempty English translation', () => {
|
||||
for (const [, key] of html.matchAll(/data-i18n="([^"]+)"/g)) {
|
||||
assert.equal(typeof en[key], 'string', key);
|
||||
|
||||
@@ -4,12 +4,14 @@ import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
symlinkSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { updateSource } from '../update-manifest.mjs';
|
||||
import {
|
||||
packageNotarizedApp,
|
||||
releaseMetadata,
|
||||
@@ -22,6 +24,8 @@ function fixture(t) {
|
||||
mkdirSync(join(directory, 'src-tauri'));
|
||||
const config = {
|
||||
version: '1.2.3',
|
||||
plugins: { updater: { pubkey: updateSource.publicKey } },
|
||||
bundle: { macOS: { bundleVersion: '123' } },
|
||||
productName: 'KanbanGit',
|
||||
identifier: 'de.kanbangit.desktop',
|
||||
};
|
||||
@@ -31,7 +35,7 @@ function fixture(t) {
|
||||
);
|
||||
writeFileSync(
|
||||
join(directory, 'package.json'),
|
||||
JSON.stringify({ version: '1.2.3' }),
|
||||
JSON.stringify({ version: '1.2.3', releaseNotes: 'Release notes' }),
|
||||
);
|
||||
writeFileSync(
|
||||
join(directory, 'src-tauri/Cargo.toml'),
|
||||
@@ -42,6 +46,21 @@ function fixture(t) {
|
||||
APPLE_SIGNING_IDENTITY:
|
||||
'Developer ID Application: Example (ZZX2KZGTHU)',
|
||||
};
|
||||
const bundle = join(
|
||||
directory,
|
||||
'src-tauri/target/aarch64-apple-darwin/release/bundle',
|
||||
);
|
||||
mkdirSync(join(bundle, 'dmg'), { recursive: true });
|
||||
mkdirSync(join(bundle, 'macos'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(bundle, 'dmg/KanbanGit_1.2.3_aarch64.dmg'),
|
||||
'notarized archive',
|
||||
);
|
||||
writeFileSync(
|
||||
join(bundle, 'macos/KanbanGit.app.tar.gz'),
|
||||
'notarized updater',
|
||||
);
|
||||
writeFileSync(join(bundle, 'macos/KanbanGit.app.tar.gz.sig'), 'signature');
|
||||
const calls = [];
|
||||
const run = (command, args) => {
|
||||
calls.push({ command, args });
|
||||
@@ -54,8 +73,8 @@ function fixture(t) {
|
||||
if (command === 'lipo') {
|
||||
return 'arm64';
|
||||
}
|
||||
if (command === 'ditto' && args.includes('-c')) {
|
||||
writeFileSync(args.at(-1), 'notarized archive');
|
||||
if (command === 'hdiutil' && args[0] === 'attach') {
|
||||
symlinkSync('/Applications', join(args.at(-1), 'Applications'));
|
||||
}
|
||||
return '';
|
||||
};
|
||||
@@ -66,9 +85,11 @@ test('release metadata binds tag, all app versions, identity and architecture',
|
||||
const f = fixture(t);
|
||||
assert.deepEqual(releaseMetadata(f.env, f.directory), {
|
||||
version: '1.2.3',
|
||||
notes: 'Release notes',
|
||||
target: 'aarch64-apple-darwin',
|
||||
arch: 'arm64',
|
||||
filename: 'KanbanGit-1.2.3-aarch64-apple-darwin.zip',
|
||||
filename: 'KanbanGit-1.2.3-aarch64-apple-darwin.dmg',
|
||||
updaterFilename: 'KanbanGit-1.2.3-aarch64-apple-darwin.app.tar.gz',
|
||||
});
|
||||
assert.equal(
|
||||
releaseMetadata(
|
||||
@@ -127,8 +148,8 @@ test('packages only a verified app and checks the extracted download again', (t)
|
||||
),
|
||||
`${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.equal(f.calls.filter((call) => call.command === 'spctl').length, 3);
|
||||
assert.equal(f.calls.filter((call) => call.command === 'xcrun').length, 3);
|
||||
assert.match(
|
||||
f.calls.findLast((call) => call.command === 'spctl').args.at(-1),
|
||||
/extracted\/KanbanGit\.app$/,
|
||||
@@ -136,7 +157,14 @@ test('packages only a verified app and checks the extracted download again', (t)
|
||||
assert.throws(() => packageNotarizedApp(f), /already exists/);
|
||||
});
|
||||
|
||||
for (const failure of ['codesign', 'lipo', 'xcrun', 'spctl', 'ditto']) {
|
||||
for (const failure of [
|
||||
'codesign',
|
||||
'lipo',
|
||||
'xcrun',
|
||||
'spctl',
|
||||
'hdiutil',
|
||||
'tar',
|
||||
]) {
|
||||
test(`does not publish local output when ${failure} verification fails`, (t) => {
|
||||
const f = fixture(t);
|
||||
const run = f.run;
|
||||
|
||||
@@ -40,6 +40,7 @@ function fixture(t, overrides = {}) {
|
||||
arch: 'arm64',
|
||||
log() {},
|
||||
env: {
|
||||
TAURI_SIGNING_PRIVATE_KEY: 'test-private-key',
|
||||
APPLE_SIGNING_IDENTITY: identity,
|
||||
APPLE_NOTARY_KEYCHAIN_PROFILE: 'kanbangit-notary',
|
||||
},
|
||||
@@ -57,7 +58,11 @@ function fixture(t, overrides = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
if (command === 'pnpm' && !overrides.missingApp) {
|
||||
if (
|
||||
command === 'pnpm' &&
|
||||
args.includes('build') &&
|
||||
!overrides.missingApp
|
||||
) {
|
||||
const target = args[args.indexOf('--target') + 1];
|
||||
mkdirSync(
|
||||
join(
|
||||
@@ -137,7 +142,16 @@ function fixture(t, overrides = {}) {
|
||||
stapled = true;
|
||||
}
|
||||
|
||||
if (command === 'ditto') {
|
||||
if (command === 'pnpm' && args.includes('signer')) {
|
||||
writeFileSync(`${args.at(-1)}.sig`, 'signature');
|
||||
}
|
||||
if (command === 'tar') {
|
||||
writeFileSync(args[1], 'updater with stapled ticket');
|
||||
}
|
||||
if (
|
||||
(command === 'hdiutil' && args[0] === 'create') ||
|
||||
command === 'ditto'
|
||||
) {
|
||||
writeFileSync(
|
||||
args.at(-1),
|
||||
stapled
|
||||
@@ -171,11 +185,14 @@ test('publishes only the stapled app archive with its exact SHA-256 checksum aft
|
||||
);
|
||||
assert.equal(
|
||||
readFileSync(`${result.archive}.sha256`, 'utf8'),
|
||||
`${result.checksum} KanbanGit-1.2.3-aarch64-apple-darwin.zip\n`,
|
||||
`${result.checksum} KanbanGit-1.2.3-aarch64-apple-darwin.dmg\n`,
|
||||
);
|
||||
assert.deepEqual(readdirSync(f.destination).sort(), [
|
||||
'KanbanGit-1.2.3-aarch64-apple-darwin.zip',
|
||||
'KanbanGit-1.2.3-aarch64-apple-darwin.zip.sha256',
|
||||
'KanbanGit-1.2.3-aarch64-apple-darwin.app.tar.gz',
|
||||
'KanbanGit-1.2.3-aarch64-apple-darwin.app.tar.gz.sha256',
|
||||
'KanbanGit-1.2.3-aarch64-apple-darwin.app.tar.gz.sig',
|
||||
'KanbanGit-1.2.3-aarch64-apple-darwin.dmg',
|
||||
'KanbanGit-1.2.3-aarch64-apple-darwin.dmg.sha256',
|
||||
]);
|
||||
assert.deepEqual(readdirSync(f.releaseDirectory), [
|
||||
'1.2.3-aarch64-apple-darwin',
|
||||
@@ -195,6 +212,11 @@ test('publishes only the stapled app archive with its exact SHA-256 checksum aft
|
||||
});
|
||||
|
||||
for (const [name, env, expected] of [
|
||||
[
|
||||
'missing updater signing key',
|
||||
{ TAURI_SIGNING_PRIVATE_KEY: '' },
|
||||
/TAURI_SIGNING_PRIVATE_KEY/,
|
||||
],
|
||||
[
|
||||
'missing identity',
|
||||
{ APPLE_SIGNING_IDENTITY: '' },
|
||||
@@ -343,7 +365,7 @@ test('supports an explicit Intel target with distinct output naming', (t) => {
|
||||
assert.equal(result.target, 'x86_64-apple-darwin');
|
||||
assert.match(
|
||||
result.archive,
|
||||
/1\.2\.3-x86_64-apple-darwin\/KanbanGit-1\.2\.3-x86_64-apple-darwin\.zip$/,
|
||||
/1\.2\.3-x86_64-apple-darwin\/KanbanGit-1\.2\.3-x86_64-apple-darwin\.dmg$/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -454,7 +476,9 @@ test('persists submission ID on timeout and resumes the same app without rebuild
|
||||
);
|
||||
assert.equal(
|
||||
f.calls.some(
|
||||
(call) => call.command === 'pnpm' || call.args[1] === 'submit',
|
||||
(call) =>
|
||||
(call.command === 'pnpm' && call.args.includes('build')) ||
|
||||
call.args[1] === 'submit',
|
||||
),
|
||||
false,
|
||||
);
|
||||
@@ -481,7 +505,9 @@ test('resume never rebuilds a missing previously submitted app', (t) => {
|
||||
);
|
||||
assert.equal(
|
||||
f.calls.some(
|
||||
(call) => call.command === 'pnpm' || call.args[1] === 'submit',
|
||||
(call) =>
|
||||
(call.command === 'pnpm' && call.args.includes('build')) ||
|
||||
call.args[1] === 'submit',
|
||||
),
|
||||
false,
|
||||
);
|
||||
@@ -552,3 +578,46 @@ for (const change of [
|
||||
assert.equal(existsSync(f.destination), false);
|
||||
});
|
||||
}
|
||||
|
||||
test('release versions agree across native manifests', () => {
|
||||
const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8');
|
||||
const config = JSON.parse(read('../src-tauri/tauri.conf.json'));
|
||||
const ios = JSON.parse(read('../src-tauri/tauri.ios.conf.json'));
|
||||
const packageInfo = JSON.parse(read('../package.json'));
|
||||
assert.equal(packageInfo.version, config.version);
|
||||
assert.equal(
|
||||
read('../src-tauri/Cargo.toml').match(/^version = "([^"]+)"$/m)?.[1],
|
||||
config.version,
|
||||
);
|
||||
assert.equal(
|
||||
read('../src-tauri/Cargo.lock').match(
|
||||
/name = "kanbangit-native"\nversion = "([^"]+)"/,
|
||||
)?.[1],
|
||||
config.version,
|
||||
);
|
||||
assert.equal(
|
||||
read('../src-tauri/gen/apple/project.yml').match(
|
||||
/MARKETING_VERSION: "([^"]+)"/,
|
||||
)?.[1],
|
||||
config.version,
|
||||
);
|
||||
assert.equal(
|
||||
read('../src-tauri/gen/apple/project.yml').match(
|
||||
/CURRENT_PROJECT_VERSION: "([^"]+)"/,
|
||||
)?.[1],
|
||||
ios.bundle.iOS.bundleVersion,
|
||||
);
|
||||
const project = read(
|
||||
'../src-tauri/gen/apple/kanbangit-native.xcodeproj/project.pbxproj',
|
||||
);
|
||||
for (const [, version] of project.matchAll(
|
||||
/MARKETING_VERSION = ([^;]+);/g,
|
||||
)) {
|
||||
assert.equal(version, config.version);
|
||||
}
|
||||
for (const [, version] of project.matchAll(
|
||||
/CURRENT_PROJECT_VERSION = ([^;]+);/g,
|
||||
)) {
|
||||
assert.equal(version, ios.bundle.iOS.bundleVersion);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -75,7 +75,7 @@ function fixture(t) {
|
||||
);
|
||||
return { ...result, output: readFileSync(output, 'utf8') };
|
||||
};
|
||||
return { run, git, checkout, commit };
|
||||
return { run, git, checkout, remote, commit };
|
||||
}
|
||||
|
||||
test('release selection succeeds using Git alone without an administrative API token', (t) => {
|
||||
@@ -111,9 +111,56 @@ test('manual release on main accepts an existing tag without moving it', (t) =>
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(f.git(f.checkout, 'rev-parse', 'macos-v1.2.3'), f.commit);
|
||||
assert.doesNotMatch(workflow, /inputs\.mode|RELEASE_MODE|\n {2}resume:/);
|
||||
assert.match(workflow, /--ci --target .* --bundles app,dmg/);
|
||||
assert.match(workflow, /node native\/macos-ci.mjs package/);
|
||||
assert.match(workflow, /node native\/publish-macos.mjs upload/);
|
||||
});
|
||||
|
||||
test('a moved macOS tag selects the new main commit for a full rebuild', (t) => {
|
||||
const f = fixture(t);
|
||||
f.git(
|
||||
f.remote,
|
||||
'-c',
|
||||
'commit.gpgsign=false',
|
||||
'commit',
|
||||
'--allow-empty',
|
||||
'-m',
|
||||
'Fix failed release',
|
||||
);
|
||||
const rebuilt = f.git(f.remote, 'rev-parse', 'HEAD');
|
||||
f.git(f.remote, 'tag', '--force', 'macos-v1.2.3');
|
||||
f.git(
|
||||
f.checkout,
|
||||
'fetch',
|
||||
'--force',
|
||||
'origin',
|
||||
'refs/tags/macos-v1.2.3:refs/tags/macos-v1.2.3',
|
||||
);
|
||||
const result = f.run();
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.output, `commit=${rebuilt}\ntag=macos-v1.2.3\n`);
|
||||
assert.notEqual(rebuilt, f.commit);
|
||||
assert.match(workflow, /tags:\n\s+- "macos-v\*"/);
|
||||
assert.match(workflow, /^ {2}group: macos-release$/m);
|
||||
assert.match(workflow, /^ {2}cancel-in-progress: false$/m);
|
||||
});
|
||||
|
||||
test('release DMG packaging disables Finder automation in the Tauri action environment', () => {
|
||||
const build = workflow
|
||||
.split(' - name: Build, sign and notarize with Tauri\n')[1]
|
||||
.split('\n - name:')[0];
|
||||
const environment = build
|
||||
.split(' env:\n')[1]
|
||||
.split(' with:\n')[0];
|
||||
assert.match(environment, /^ {10}CI: "true"$/m);
|
||||
assert.match(environment, /^ {10}TAURI_BUNDLER_DMG_IGNORE_CI: "false"$/m);
|
||||
assert.match(build, /--bundles app,dmg/);
|
||||
assert.match(build, /"createUpdaterArtifacts":true/);
|
||||
});
|
||||
|
||||
for (const [name, overrides] of [
|
||||
['wrong tag case', { GITHUB_REF: 'refs/tags/macOS-V1.2.3' }],
|
||||
['web release tag', { GITHUB_REF: 'refs/tags/v1.2.3' }],
|
||||
['missing tag', { GITHUB_REF: 'refs/tags/macos-v9.9.9' }],
|
||||
[
|
||||
|
||||
@@ -47,6 +47,18 @@ test('Android launcher sends only explicit local actions and preserves switch co
|
||||
try {
|
||||
await import('../ui/main.js');
|
||||
assert.equal(messages.shift().pathname, '/action/ready');
|
||||
assert.equal(element('#workspace-settings').hidden, true);
|
||||
window.updateLauncher(
|
||||
'server=https%3A%2F%2Fexample.com%2F&phase=connecting&ready=false',
|
||||
);
|
||||
assert.equal(element('#workspace-settings').hidden, true);
|
||||
assert.equal(element('#startup-loading').hidden, false);
|
||||
window.updateLauncher(
|
||||
'server=https%3A%2F%2Fexample.com%2F&phase=connectionError&ready=false',
|
||||
);
|
||||
assert.equal(element('#workspace-settings').hidden, false);
|
||||
assert.equal(element('#startup-loading').hidden, true);
|
||||
assert.equal(element('.open-workspace').textContent, 'Try again');
|
||||
window.updateLauncher(
|
||||
'server=https%3A%2F%2Fexample.com%2F&phase=idle&ready=false&platform=android',
|
||||
);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { createServer } from 'node:http';
|
||||
import process from 'node:process';
|
||||
|
||||
const { chromium } = await import(
|
||||
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
|
||||
);
|
||||
const script = await readFile(
|
||||
new URL('../ui/workspace-navigation.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const server = createServer((request, response) => {
|
||||
response.setHeader('Content-Type', 'text/html');
|
||||
response.end(`<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><style>
|
||||
body { margin: 0; } #board { overflow-x: auto; width: 300px; height: 100px; }
|
||||
#board > div { width: 1500px; height: 100px; } dialog { max-height: 200px; overflow-y: auto; }
|
||||
</style></head><body><p id="page">${request.url}</p><input id="draft" value="retained"><div id="board"><div>Board columns</div></div>
|
||||
<dialog><button id="close">Close</button><div style="height:1000px">Long dialog</div></dialog><div id="long"></div></body></html>`);
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
await page.addInitScript(script);
|
||||
const root = `http://127.0.0.1:${server.address().port}`;
|
||||
try {
|
||||
await page.goto(root);
|
||||
assert.equal(
|
||||
await page.evaluate(() => typeof window.__kanbanNavigation),
|
||||
'undefined',
|
||||
);
|
||||
await page.goto(`${root}/full-page`);
|
||||
await page.goBack();
|
||||
await page.waitForURL(`${root}/`);
|
||||
await page.goForward();
|
||||
await page.waitForURL(`${root}/full-page`);
|
||||
await page.locator('#draft').fill('unsaved draft');
|
||||
assert.equal(await page.locator('#draft').inputValue(), 'unsaved draft');
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
getComputedStyle(document.documentElement).overscrollBehaviorY,
|
||||
),
|
||||
'none',
|
||||
);
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
getComputedStyle(document.documentElement).overscrollBehaviorX,
|
||||
),
|
||||
'auto',
|
||||
);
|
||||
assert.equal(
|
||||
await page.evaluate(() => document.documentElement.scrollHeight),
|
||||
844,
|
||||
);
|
||||
await page.mouse.move(380, 400);
|
||||
await page.mouse.wheel(0, -1000);
|
||||
await page.waitForTimeout(200);
|
||||
assert.equal(await page.evaluate(() => scrollY), 0);
|
||||
await page
|
||||
.locator('#long')
|
||||
.evaluate((element) => (element.style.height = '3000px'));
|
||||
await page.mouse.wheel(0, 1000);
|
||||
await page.waitForFunction(() => scrollY > 0);
|
||||
await page.evaluate(() =>
|
||||
scrollTo(0, document.documentElement.scrollHeight),
|
||||
);
|
||||
await page.mouse.wheel(0, 5000);
|
||||
await page.waitForTimeout(200);
|
||||
assert.equal(
|
||||
await page.evaluate(() => scrollY + innerHeight),
|
||||
await page.evaluate(() => document.documentElement.scrollHeight),
|
||||
);
|
||||
await page.evaluate(() => scrollTo(0, 0));
|
||||
await page.locator('#board').hover();
|
||||
await page.mouse.wheel(500, 0);
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('#board').scrollLeft > 0,
|
||||
);
|
||||
assert.equal(new URL(page.url()).pathname, '/full-page');
|
||||
await page.locator('dialog').evaluate((dialog) => dialog.showModal());
|
||||
await page.locator('dialog').hover();
|
||||
await page.mouse.wheel(0, 500);
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('dialog').scrollTop > 0,
|
||||
);
|
||||
console.log(
|
||||
'PASS: browser-owned history, editable input, short/long scroll bounds, horizontal board and dialog scrolling',
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
#import <AppKit/AppKit.h>
|
||||
#import <WebKit/WebKit.h>
|
||||
#import "../src-tauri/src/navigation.m"
|
||||
|
||||
static void check(BOOL condition, NSString *message) {
|
||||
if (!condition) {
|
||||
fprintf(stderr, "FAIL: %s\n", message.UTF8String);
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
static void waitUntil(BOOL (^condition)(void), NSString *message) {
|
||||
NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:10];
|
||||
while (!condition() && deadline.timeIntervalSinceNow > 0) {
|
||||
[NSRunLoop.currentRunLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.01]];
|
||||
}
|
||||
check(condition(), message);
|
||||
}
|
||||
|
||||
@interface WorkspacePages : NSObject <WKURLSchemeHandler, WKNavigationDelegate>
|
||||
@property(nonatomic) NSUInteger finished;
|
||||
@end
|
||||
|
||||
@implementation WorkspacePages
|
||||
- (void)webView:(WKWebView *)webView startURLSchemeTask:(id<WKURLSchemeTask>)task {
|
||||
NSData *body = [[NSString stringWithFormat:@"<html><body>%@<input value='draft'></body></html>", task.request.URL.path]
|
||||
dataUsingEncoding:NSUTF8StringEncoding];
|
||||
NSURLResponse *response = [[NSURLResponse alloc] initWithURL:task.request.URL MIMEType:@"text/html"
|
||||
expectedContentLength:body.length textEncodingName:@"utf-8"];
|
||||
[task didReceiveResponse:response];
|
||||
[task didReceiveData:body];
|
||||
[task didFinish];
|
||||
}
|
||||
- (void)webView:(WKWebView *)webView stopURLSchemeTask:(id<WKURLSchemeTask>)task {}
|
||||
- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation {
|
||||
self.finished++;
|
||||
}
|
||||
@end
|
||||
|
||||
int main(void) {
|
||||
@autoreleasepool {
|
||||
[NSApplication sharedApplication];
|
||||
WorkspacePages *pages = [WorkspacePages new];
|
||||
WKWebViewConfiguration *configuration = [WKWebViewConfiguration new];
|
||||
configuration.websiteDataStore = WKWebsiteDataStore.nonPersistentDataStore;
|
||||
[configuration setURLSchemeHandler:pages forURLScheme:@"kanban-test"];
|
||||
WKWebView *webview = [[WKWebView alloc] initWithFrame:NSMakeRect(0, 0, 800, 600) configuration:configuration];
|
||||
webview.navigationDelegate = pages;
|
||||
check(!webview.allowsBackForwardNavigationGestures, @"WebKit defaults to disabled gestures");
|
||||
install_workspace_navigation((__bridge void *)webview);
|
||||
install_workspace_navigation((__bridge void *)webview);
|
||||
check(webview.allowsBackForwardNavigationGestures, @"The workspace must enable WebKit's interactive gestures");
|
||||
check(webview.configuration.userContentController.userScripts.count == 0, @"Enabling gestures must not inject a history adapter");
|
||||
check([webview goBack] == nil && [webview goForward] == nil, @"Empty history must not navigate");
|
||||
|
||||
[webview loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"kanban-test://workspace/first"]]];
|
||||
waitUntil(^BOOL { return pages.finished == 1; }, @"First page must load");
|
||||
check(!webview.canGoBack && !webview.canGoForward, @"The first page must have no navigation target");
|
||||
[webview loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"kanban-test://workspace/second"]]];
|
||||
waitUntil(^BOOL { return pages.finished == 2; }, @"Second page must load");
|
||||
check(webview.canGoBack && !webview.canGoForward, @"Native history must retain the first page");
|
||||
check([webview goBack] != nil, @"Native back navigation must start");
|
||||
waitUntil(^BOOL { return pages.finished == 3; }, @"Native back navigation must complete");
|
||||
check([webview.URL.path isEqualToString:@"/first"] && !webview.canGoBack && webview.canGoForward,
|
||||
@"Back must restore the first page and retain forward history");
|
||||
check([webview goBack] == nil, @"Back at the first page must leave it in place");
|
||||
check([webview goForward] != nil, @"Native forward navigation must start");
|
||||
waitUntil(^BOOL { return pages.finished == 4; }, @"Native forward navigation must complete");
|
||||
check([webview.URL.path isEqualToString:@"/second"] && webview.canGoBack && !webview.canGoForward,
|
||||
@"Forward must restore the second page");
|
||||
check(webview.allowsBackForwardNavigationGestures, @"Native gestures must remain enabled across page navigation");
|
||||
puts("PASS: WebKit interactive gesture configuration, full-page back/forward and empty history");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import process from 'node:process';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
test(
|
||||
'WebKit owns interactive navigation and preserves native back/forward history',
|
||||
{ skip: process.platform !== 'darwin' },
|
||||
() => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-navigation-'));
|
||||
const binary = join(directory, 'navigation-test');
|
||||
try {
|
||||
const compile = spawnSync(
|
||||
'xcrun',
|
||||
[
|
||||
'--sdk',
|
||||
'macosx',
|
||||
'clang',
|
||||
'-fobjc-arc',
|
||||
'-fblocks',
|
||||
'-Wall',
|
||||
'-Werror',
|
||||
'-framework',
|
||||
'AppKit',
|
||||
'-framework',
|
||||
'WebKit',
|
||||
fileURLToPath(
|
||||
new URL('./navigation-macos.m', import.meta.url),
|
||||
),
|
||||
'-o',
|
||||
binary,
|
||||
],
|
||||
{ encoding: 'utf8', timeout: 30000 },
|
||||
);
|
||||
assert.equal(
|
||||
compile.status,
|
||||
0,
|
||||
compile.stderr || compile.error?.message,
|
||||
);
|
||||
const result = spawnSync(binary, [], {
|
||||
encoding: 'utf8',
|
||||
timeout: 30000,
|
||||
});
|
||||
assert.equal(
|
||||
result.status,
|
||||
0,
|
||||
result.stderr || result.error?.message,
|
||||
);
|
||||
assert.match(result.stdout, /PASS: WebKit/);
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,19 +1,71 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import process from 'node:process';
|
||||
import test from 'node:test';
|
||||
import { publishMacos, storageConfiguration } from '../publish-macos.mjs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
conditionalEtag,
|
||||
publishMacos,
|
||||
storageConfiguration,
|
||||
} from '../publish-macos.mjs';
|
||||
import { signedFixture } from './updater-fixture.mjs';
|
||||
|
||||
function fixture(t) {
|
||||
test('publisher rejects the removed resume command before accessing storage', () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
fileURLToPath(new URL('../publish-macos.mjs', import.meta.url)),
|
||||
'resume',
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(result.stdout, '');
|
||||
assert.match(
|
||||
result.stderr,
|
||||
/Use config or upload; upload requires RELEASE_DIRECTORY/,
|
||||
);
|
||||
});
|
||||
|
||||
test('conditional writes use bare S3 ETags without weakening the condition', () => {
|
||||
const etag = 'b88312b96169f4b2098a4cc43b0abb6a';
|
||||
for (const value of [etag, `"${etag}"`, `${etag}-2`, `"${etag}-2"`]) {
|
||||
assert.equal(conditionalEtag(value), value.replaceAll('"', ''));
|
||||
}
|
||||
for (const invalid of [
|
||||
undefined,
|
||||
null,
|
||||
'',
|
||||
'*',
|
||||
'"*"',
|
||||
`W/"${etag}"`,
|
||||
`"${etag}`,
|
||||
`${etag}"`,
|
||||
`${etag},${etag}`,
|
||||
`${etag}\r\n`,
|
||||
{},
|
||||
42,
|
||||
]) {
|
||||
assert.throws(() => conditionalEtag(invalid), /valid S3 ETag/);
|
||||
}
|
||||
});
|
||||
|
||||
function fixture(
|
||||
t,
|
||||
conflictMessage = 'aws failed: (PreconditionFailed) object exists',
|
||||
) {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-upload-test-'));
|
||||
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',
|
||||
filename: 'KanbanGit-1.2.3-aarch64-apple-darwin.dmg',
|
||||
updaterFilename: 'KanbanGit-1.2.3-aarch64-apple-darwin.app.tar.gz',
|
||||
};
|
||||
const archive = Buffer.from('verified release');
|
||||
const checksum = createHash('sha256').update(archive).digest('hex');
|
||||
@@ -22,6 +74,17 @@ function fixture(t) {
|
||||
join(directory, `${metadata.filename}.sha256`),
|
||||
`${checksum} ${metadata.filename}\n`,
|
||||
);
|
||||
const updater = Buffer.from('signed update');
|
||||
const signing = signedFixture(updater);
|
||||
writeFileSync(join(directory, metadata.updaterFilename), updater);
|
||||
writeFileSync(
|
||||
join(directory, `${metadata.updaterFilename}.sig`),
|
||||
signing.signature,
|
||||
);
|
||||
writeFileSync(
|
||||
join(directory, `${metadata.updaterFilename}.sha256`),
|
||||
`${createHash('sha256').update(updater).digest('hex')} ${metadata.updaterFilename}\n`,
|
||||
);
|
||||
const env = {
|
||||
S3_BUCKET: 'release-bucket',
|
||||
S3_REGION: 'nbg1',
|
||||
@@ -36,24 +99,35 @@ function fixture(t) {
|
||||
const operation = args[args.indexOf('s3api') + 1];
|
||||
const value = (name) => args[args.indexOf(name) + 1];
|
||||
const key = value('--key');
|
||||
events.push({ operation, key });
|
||||
events.push({ operation, key, args });
|
||||
if (operation === 'get-bucket-versioning') {
|
||||
assert.equal(value('--output'), 'json');
|
||||
return '';
|
||||
}
|
||||
if (operation === 'put-object') {
|
||||
assert.equal(value('--if-none-match'), '*');
|
||||
if (!args.includes('--if-match')) {
|
||||
assert.equal(value('--if-none-match'), '*');
|
||||
}
|
||||
if (objects.has(key)) {
|
||||
throw new Error(
|
||||
'aws failed: (PreconditionFailed) object exists',
|
||||
if (!args.includes('--if-match')) {
|
||||
throw new Error(conflictMessage);
|
||||
}
|
||||
assert.equal(
|
||||
value('--if-match'),
|
||||
'b88312b96169f4b2098a4cc43b0abb6a',
|
||||
);
|
||||
}
|
||||
objects.set(key, readFileSync(value('--body')));
|
||||
return '{}';
|
||||
}
|
||||
if (operation === 'get-object') {
|
||||
if (!objects.has(key)) {
|
||||
throw new Error('aws failed: (NoSuchKey) object missing');
|
||||
}
|
||||
writeFileSync(args.at(-1), objects.get(key));
|
||||
return '{}';
|
||||
return JSON.stringify({
|
||||
ETag: '"b88312b96169f4b2098a4cc43b0abb6a"',
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected S3 operation: ${operation}`);
|
||||
};
|
||||
@@ -70,38 +144,167 @@ function fixture(t) {
|
||||
arrayBuffer: async () => objects.get(key),
|
||||
};
|
||||
};
|
||||
return { directory, metadata, env, objects, events, run, fetchPublic };
|
||||
return {
|
||||
directory,
|
||||
publicKey: signing.publicKey,
|
||||
feedBase:
|
||||
'https://release-bucket.nbg1.your-objectstorage.com/releases/macos',
|
||||
metadata,
|
||||
env,
|
||||
objects,
|
||||
events,
|
||||
run,
|
||||
fetchPublic,
|
||||
wait: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
test('uploads immutable files and writes completion record after public verification', async (t) => {
|
||||
test('uploads revalidated files and writes completion record after public verification', async (t) => {
|
||||
const f = fixture(t);
|
||||
const 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.events.filter((event) => event.operation === 'put-object').length,
|
||||
9,
|
||||
);
|
||||
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`,
|
||||
});
|
||||
assert.equal(f.objects.size, 9);
|
||||
for (const event of f.events.filter(
|
||||
(event) => event.operation === 'put-object',
|
||||
)) {
|
||||
assert.equal(
|
||||
event.args[event.args.indexOf('--cache-control') + 1],
|
||||
'no-cache, max-age=0, must-revalidate',
|
||||
);
|
||||
}
|
||||
assert(f.events.at(-1).key.endsWith('/latest.json'));
|
||||
assert.equal(
|
||||
f.events.at(-1).key,
|
||||
'releases/macos/aarch64-apple-darwin/latest.json',
|
||||
);
|
||||
const downloadKey = 'releases/macos/aarch64-apple-darwin/KanbanGit.dmg';
|
||||
assert.deepEqual(
|
||||
f.objects.get(downloadKey),
|
||||
readFileSync(join(f.directory, f.metadata.filename)),
|
||||
);
|
||||
const downloadPut = f.events.findIndex(
|
||||
(event) =>
|
||||
event.operation === 'put-object' && event.key === downloadKey,
|
||||
);
|
||||
const downloadVerified = f.events.findIndex(
|
||||
(event) =>
|
||||
event.operation === 'public-get' && event.key === downloadKey,
|
||||
);
|
||||
const feedPut = f.events.findIndex(
|
||||
(event) =>
|
||||
event.operation === 'put-object' &&
|
||||
event.key.endsWith('/latest.json'),
|
||||
);
|
||||
assert(downloadPut > 0 && downloadVerified > downloadPut);
|
||||
assert(feedPut > downloadVerified);
|
||||
assert.deepEqual(
|
||||
JSON.parse(
|
||||
f.objects.get(
|
||||
'releases/macos/aarch64-apple-darwin/1.2.3/release.json',
|
||||
),
|
||||
),
|
||||
{
|
||||
version: '1.2.3',
|
||||
target: f.metadata.target,
|
||||
url,
|
||||
sha256: createHash('sha256')
|
||||
.update('verified release')
|
||||
.digest('hex'),
|
||||
checksumUrl: `${url}.sha256`,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('replaces the stable download only for a newer verified release', async (t) => {
|
||||
const f = fixture(t);
|
||||
const downloadKey = `releases/macos/${f.metadata.target}/KanbanGit.dmg`;
|
||||
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
||||
f.objects.set(downloadKey, Buffer.from('previous download'));
|
||||
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.0.0' })));
|
||||
|
||||
await publishMacos(f);
|
||||
|
||||
assert.deepEqual(
|
||||
f.objects.get(downloadKey),
|
||||
readFileSync(join(f.directory, f.metadata.filename)),
|
||||
);
|
||||
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.2.3');
|
||||
const replacement = f.events.find(
|
||||
(event) =>
|
||||
event.operation === 'put-object' &&
|
||||
event.key === downloadKey &&
|
||||
event.args.includes('--if-match'),
|
||||
);
|
||||
assert(replacement);
|
||||
});
|
||||
|
||||
test('a newer published version protects the stable download even when its alias is missing', async (t) => {
|
||||
const f = fixture(t);
|
||||
const downloadKey = `releases/macos/${f.metadata.target}/KanbanGit.dmg`;
|
||||
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
||||
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '2.0.0' })));
|
||||
|
||||
await assert.rejects(publishMacos(f), /downgrade/);
|
||||
|
||||
assert.equal(f.objects.has(downloadKey), false);
|
||||
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '2.0.0');
|
||||
});
|
||||
|
||||
test('a published version cannot replace a different stable download', async (t) => {
|
||||
const f = fixture(t);
|
||||
const downloadKey = `releases/macos/${f.metadata.target}/KanbanGit.dmg`;
|
||||
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
||||
const original = Buffer.from('published download');
|
||||
f.objects.set(downloadKey, original);
|
||||
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.2.3' })));
|
||||
|
||||
await assert.rejects(publishMacos(f), /bump the app version/);
|
||||
|
||||
assert.deepEqual(f.objects.get(downloadKey), original);
|
||||
});
|
||||
|
||||
test('a failed public stable download blocks feed promotion', async (t) => {
|
||||
const f = fixture(t);
|
||||
const fetchPublic = f.fetchPublic;
|
||||
f.fetchPublic = (url, options) =>
|
||||
url.endsWith('/KanbanGit.dmg')
|
||||
? Promise.resolve({ ok: false, status: 403 })
|
||||
: fetchPublic(url, options);
|
||||
|
||||
await assert.rejects(publishMacos(f), /KanbanGit\.dmg: HTTP 403/);
|
||||
|
||||
assert.equal(
|
||||
f.objects.has(`releases/macos/${f.metadata.target}/latest.json`),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('concurrent stable download replacement preserves the winner', async (t) => {
|
||||
const f = fixture(t);
|
||||
const downloadKey = `releases/macos/${f.metadata.target}/KanbanGit.dmg`;
|
||||
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
||||
f.objects.set(downloadKey, Buffer.from('previous download'));
|
||||
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.0.0' })));
|
||||
const run = f.run;
|
||||
f.run = (command, args) => {
|
||||
if (args.includes('--if-match') && args.includes(downloadKey)) {
|
||||
f.objects.set(downloadKey, Buffer.from('concurrent winner'));
|
||||
throw new Error('(PreconditionFailed) concurrent replacement');
|
||||
}
|
||||
return run(command, args);
|
||||
};
|
||||
|
||||
await assert.rejects(publishMacos(f), /concurrent replacement/);
|
||||
|
||||
assert.equal(f.objects.get(downloadKey).toString(), 'concurrent winner');
|
||||
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.0.0');
|
||||
});
|
||||
|
||||
for (const response of ['', ' \n\t', '{}']) {
|
||||
@@ -113,9 +316,9 @@ for (const response of ['', ' \n\t', '{}']) {
|
||||
return args.includes('get-bucket-versioning') ? response : result;
|
||||
};
|
||||
await publishMacos(f);
|
||||
assert.equal(f.objects.size, 3);
|
||||
assert.equal(f.objects.size, 9);
|
||||
assert.equal(f.events.at(-1).operation, 'public-get');
|
||||
assert(f.events.at(-1).key.endsWith('/release.json'));
|
||||
assert(f.events.at(-1).key.endsWith('/latest.json'));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -139,18 +342,24 @@ test('retry accepts byte-identical objects without replacing them', async (t) =>
|
||||
assert.deepEqual([...f.objects.entries()], originals);
|
||||
assert.equal(
|
||||
f.events.filter((event) => event.operation === 'get-object').length,
|
||||
3,
|
||||
11,
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
for (const published of ['1.2.3', '1.2.4']) {
|
||||
test(`published release stays protected when the feed is at ${published}`, 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'));
|
||||
f.objects.set(
|
||||
`releases/macos/${f.metadata.target}/latest.json`,
|
||||
Buffer.from(JSON.stringify({ version: published })),
|
||||
);
|
||||
await assert.rejects(publishMacos(f), /bump the app version/);
|
||||
assert.equal(f.objects.get(key).toString(), 'previous version content');
|
||||
assert.equal(f.objects.size, 2);
|
||||
});
|
||||
}
|
||||
|
||||
test('checksum mismatch aborts before contacting S3', async (t) => {
|
||||
const f = fixture(t);
|
||||
@@ -194,6 +403,7 @@ for (const failure of ['private', 'wrong-content', 'network']) {
|
||||
}
|
||||
return {
|
||||
ok: failure !== 'private',
|
||||
status: failure === 'private' ? 403 : 200,
|
||||
arrayBuffer: async () => Buffer.from('wrong'),
|
||||
};
|
||||
};
|
||||
@@ -204,7 +414,7 @@ for (const failure of ['private', 'wrong-content', 'network']) {
|
||||
});
|
||||
}
|
||||
|
||||
test('a partial upload can resume with the same files', async (t) => {
|
||||
test('a partial upload can be retried with the same files', async (t) => {
|
||||
const f = fixture(t);
|
||||
const run = f.run;
|
||||
f.run = (command, args) => {
|
||||
@@ -217,10 +427,278 @@ test('a partial upload can resume with the same files', async (t) => {
|
||||
return run(command, args);
|
||||
};
|
||||
await assert.rejects(publishMacos(f), /interrupted/);
|
||||
assert.equal(f.objects.size, 1);
|
||||
assert.equal(f.objects.size, 3);
|
||||
f.run = run;
|
||||
await publishMacos(f);
|
||||
assert.equal(f.objects.size, 3);
|
||||
assert.equal(f.objects.size, 9);
|
||||
});
|
||||
|
||||
function rebuild(f) {
|
||||
const updater = Buffer.from('rebuilt signed update');
|
||||
const signing = signedFixture(updater);
|
||||
f.publicKey = signing.publicKey;
|
||||
f.metadata.notes = 'Fixed build of the same version';
|
||||
for (const [name, contents] of [
|
||||
[f.metadata.filename, Buffer.from('rebuilt disk image')],
|
||||
[f.metadata.updaterFilename, updater],
|
||||
]) {
|
||||
writeFileSync(join(f.directory, name), contents);
|
||||
writeFileSync(
|
||||
join(f.directory, `${name}.sha256`),
|
||||
`${createHash('sha256').update(contents).digest('hex')} ${name}\n`,
|
||||
);
|
||||
}
|
||||
writeFileSync(
|
||||
join(f.directory, `${f.metadata.updaterFilename}.sig`),
|
||||
signing.signature,
|
||||
);
|
||||
}
|
||||
|
||||
for (const previous of [null, '1.0.0']) {
|
||||
for (const interruptedFile of ['.sha256', '/latest.json']) {
|
||||
test(`rebuild replaces unfinished ${interruptedFile} release with previous feed ${previous}`, async (t) => {
|
||||
const f = fixture(t);
|
||||
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
||||
const previousBytes =
|
||||
previous === null
|
||||
? undefined
|
||||
: Buffer.from(JSON.stringify({ version: previous }));
|
||||
if (previousBytes) {
|
||||
f.objects.set(latestKey, previousBytes);
|
||||
}
|
||||
const run = f.run;
|
||||
f.run = (command, args) => {
|
||||
if (
|
||||
args.includes('put-object') &&
|
||||
args[args.indexOf('--key') + 1].endsWith(interruptedFile)
|
||||
) {
|
||||
throw new Error('upload interrupted');
|
||||
}
|
||||
return run(command, args);
|
||||
};
|
||||
await assert.rejects(publishMacos(f), /upload interrupted/);
|
||||
assert.deepEqual(f.objects.get(latestKey), previousBytes);
|
||||
rebuild(f);
|
||||
f.events.length = 0;
|
||||
let promoted = false;
|
||||
f.run = (command, args) => {
|
||||
if (!promoted) {
|
||||
assert.deepEqual(f.objects.get(latestKey), previousBytes);
|
||||
}
|
||||
const result = run(command, args);
|
||||
if (args.includes('put-object') && args.includes(latestKey)) {
|
||||
promoted = true;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
await publishMacos(f);
|
||||
assert.equal(f.objects.size, 9);
|
||||
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.2.3');
|
||||
assert.equal(
|
||||
JSON.parse(f.objects.get(latestKey)).notes,
|
||||
f.metadata.notes,
|
||||
);
|
||||
for (const name of [
|
||||
f.metadata.filename,
|
||||
`${f.metadata.filename}.sha256`,
|
||||
f.metadata.updaterFilename,
|
||||
`${f.metadata.updaterFilename}.sig`,
|
||||
`${f.metadata.updaterFilename}.sha256`,
|
||||
]) {
|
||||
assert.deepEqual(
|
||||
f.objects.get(
|
||||
`releases/macos/${f.metadata.target}/1.2.3/${name}`,
|
||||
),
|
||||
readFileSync(join(f.directory, name)),
|
||||
);
|
||||
}
|
||||
const published = [...f.objects.entries()];
|
||||
rebuild(f);
|
||||
await assert.rejects(publishMacos(f), /already been released/);
|
||||
assert.deepEqual([...f.objects.entries()], published);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('failure after promoting the feed still protects that version from rebuilding', async (t) => {
|
||||
const f = fixture(t);
|
||||
const fetchPublic = f.fetchPublic;
|
||||
f.fetchPublic = async (url, options) =>
|
||||
url.endsWith('/latest.json')
|
||||
? { ok: false, status: 403 }
|
||||
: fetchPublic(url, options);
|
||||
await assert.rejects(publishMacos(f), /HTTP 403/);
|
||||
const published = [...f.objects.entries()];
|
||||
rebuild(f);
|
||||
await assert.rejects(publishMacos(f), /already been released/);
|
||||
assert.deepEqual([...f.objects.entries()], published);
|
||||
});
|
||||
|
||||
test('a concurrent change to a partial artifact is not overwritten', async (t) => {
|
||||
const f = fixture(t);
|
||||
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
|
||||
f.objects.set(key, Buffer.from('unfinished old build'));
|
||||
const run = f.run;
|
||||
f.run = (command, args) => {
|
||||
if (args.includes('--if-match')) {
|
||||
assert(args.includes(key));
|
||||
f.objects.set(key, Buffer.from('concurrent winner'));
|
||||
throw new Error('(PreconditionFailed) concurrent replacement');
|
||||
}
|
||||
return run(command, args);
|
||||
};
|
||||
await assert.rejects(publishMacos(f), /concurrent replacement/);
|
||||
assert.equal(f.objects.get(key).toString(), 'concurrent winner');
|
||||
assert.equal(f.objects.size, 1);
|
||||
});
|
||||
|
||||
for (const failure of [
|
||||
'AccessDenied',
|
||||
'network failure',
|
||||
'invalid JSON',
|
||||
'invalid version',
|
||||
]) {
|
||||
test(`cannot replace a partial artifact when the feed check has ${failure}`, async (t) => {
|
||||
const f = fixture(t);
|
||||
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
|
||||
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
||||
f.objects.set(key, Buffer.from('unfinished old build'));
|
||||
f.objects.set(
|
||||
latestKey,
|
||||
Buffer.from(
|
||||
failure === 'invalid version' ? '{"version":"invalid"}' : '{',
|
||||
),
|
||||
);
|
||||
const originals = [...f.objects.entries()];
|
||||
const run = f.run;
|
||||
f.run = (command, args) => {
|
||||
if (
|
||||
args.includes('get-object') &&
|
||||
args.includes(latestKey) &&
|
||||
!failure.startsWith('invalid')
|
||||
) {
|
||||
throw new Error(failure);
|
||||
}
|
||||
return run(command, args);
|
||||
};
|
||||
await assert.rejects(publishMacos(f));
|
||||
assert.deepEqual([...f.objects.entries()], originals);
|
||||
});
|
||||
}
|
||||
|
||||
for (const listing of [
|
||||
{ KeyCount: 0, IsTruncated: false },
|
||||
{ KeyCount: 1, IsTruncated: false, Contents: [{ Key: 'latest.json' }] },
|
||||
{},
|
||||
{ KeyCount: 0, IsTruncated: true },
|
||||
{ KeyCount: 0, IsTruncated: false, Contents: '' },
|
||||
'AccessDenied',
|
||||
]) {
|
||||
test(`ambiguous feed read error needs a proven empty listing: ${JSON.stringify(listing)}`, async (t) => {
|
||||
const f = fixture(t);
|
||||
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
|
||||
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
||||
f.objects.set(key, Buffer.from('unfinished old build'));
|
||||
const run = f.run;
|
||||
f.run = (command, args) => {
|
||||
if (args.includes('get-object') && args.includes(latestKey)) {
|
||||
throw new Error(
|
||||
"argument of type 'NoneType' is not a container or iterable",
|
||||
);
|
||||
}
|
||||
if (args.includes('list-objects-v2')) {
|
||||
assert.equal(args[args.indexOf('--prefix') + 1], latestKey);
|
||||
if (listing === 'AccessDenied') {
|
||||
throw new Error('AccessDenied');
|
||||
}
|
||||
return JSON.stringify(listing);
|
||||
}
|
||||
return run(command, args);
|
||||
};
|
||||
if (
|
||||
listing.KeyCount === 0 &&
|
||||
listing.IsTruncated === false &&
|
||||
!('Contents' in listing)
|
||||
) {
|
||||
await publishMacos(f);
|
||||
assert.equal(f.objects.size, 9);
|
||||
} else {
|
||||
await assert.rejects(publishMacos(f), /NoneType|AccessDenied/);
|
||||
assert.equal(f.objects.get(key).toString(), 'unfinished old build');
|
||||
assert.equal(f.objects.size, 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const status of [403, 404, 408, 429, 500, 502, 503, 504, 'network']) {
|
||||
test(`temporary public download failure ${status} recovers before completion`, async (t) => {
|
||||
const f = fixture(t);
|
||||
const fetchPublic = f.fetchPublic;
|
||||
let attempts = 0;
|
||||
let cancelled = 0;
|
||||
const waits = [];
|
||||
f.wait = async (ms) => waits.push(ms);
|
||||
f.fetchPublic = async (...args) => {
|
||||
if (++attempts <= 2) {
|
||||
if (status === 'network') {
|
||||
throw new Error('temporary network failure');
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
body: { cancel: async () => cancelled++ },
|
||||
};
|
||||
}
|
||||
return fetchPublic(...args);
|
||||
};
|
||||
await publishMacos(f);
|
||||
assert.deepEqual(waits, [2000, 2000]);
|
||||
assert.equal(cancelled, status === 'network' ? 0 : 2);
|
||||
assert.equal(f.objects.size, 9);
|
||||
assert(f.events.at(-1).key.endsWith('/latest.json'));
|
||||
});
|
||||
}
|
||||
|
||||
test('persistent public access denial stops after five attempts with HTTP status', async (t) => {
|
||||
const f = fixture(t);
|
||||
let attempts = 0;
|
||||
const waits = [];
|
||||
f.wait = async (ms) => waits.push(ms);
|
||||
f.fetchPublic = async () => {
|
||||
attempts++;
|
||||
return { ok: false, status: 403 };
|
||||
};
|
||||
await assert.rejects(publishMacos(f), /HTTP 403/);
|
||||
assert.equal(attempts, 5);
|
||||
assert.equal(waits.length, 4);
|
||||
assert.equal(f.objects.size, 1);
|
||||
});
|
||||
|
||||
test('incorrect public bytes fail immediately without retry or completion record', async (t) => {
|
||||
const f = fixture(t);
|
||||
f.wait = async () => assert.fail('Do not retry mismatched content');
|
||||
f.fetchPublic = async () => ({
|
||||
ok: true,
|
||||
arrayBuffer: async () => Buffer.from('incorrect'),
|
||||
});
|
||||
await assert.rejects(publishMacos(f), /SHA-256 mismatch/);
|
||||
assert.equal(f.objects.size, 1);
|
||||
});
|
||||
|
||||
test('checksum access retries finish before publishing the completion record', async (t) => {
|
||||
const f = fixture(t);
|
||||
const fetchPublic = f.fetchPublic;
|
||||
let failures = 0;
|
||||
f.fetchPublic = async (url, options) => {
|
||||
if (url.endsWith(`${f.metadata.filename}.sha256`) && ++failures <= 2) {
|
||||
assert.equal(f.objects.size, 5);
|
||||
return { ok: false, status: 403 };
|
||||
}
|
||||
return fetchPublic(url, options);
|
||||
};
|
||||
await publishMacos(f);
|
||||
assert.equal(failures, 3);
|
||||
assert.equal(f.objects.size, 9);
|
||||
});
|
||||
|
||||
for (const overrides of [
|
||||
@@ -257,3 +735,154 @@ test('derives Hetzner endpoints and supports an explicit public proxy URL', () =
|
||||
assert.equal(config.publicBase, 'https://downloads.example.com');
|
||||
}
|
||||
});
|
||||
|
||||
test('refuses a destination that differs from the built-in update source', async (t) => {
|
||||
const f = fixture(t);
|
||||
f.feedBase = 'https://another.example/releases/macos';
|
||||
await assert.rejects(publishMacos(f), /does not match/);
|
||||
assert.equal(f.objects.size, 0);
|
||||
});
|
||||
|
||||
test('rejects a corrupt updater archive before publishing any object', async (t) => {
|
||||
const f = fixture(t);
|
||||
writeFileSync(join(f.directory, f.metadata.updaterFilename), 'tampered');
|
||||
await assert.rejects(publishMacos(f), /signature/);
|
||||
assert.equal(f.objects.size, 0);
|
||||
});
|
||||
|
||||
test('atomically promotes a newer feed only after all release files are public', async (t) => {
|
||||
const f = fixture(t);
|
||||
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
||||
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.0.0' })));
|
||||
await publishMacos(f);
|
||||
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.2.3');
|
||||
const releaseVerified = f.events.findIndex(
|
||||
(event) =>
|
||||
event.operation === 'public-get' &&
|
||||
event.key.endsWith('/update.json'),
|
||||
);
|
||||
const latestPut = f.events.findIndex(
|
||||
(event) => event.operation === 'put-object' && event.key === latestKey,
|
||||
);
|
||||
assert(latestPut > releaseVerified);
|
||||
});
|
||||
|
||||
for (const previous of [
|
||||
{ version: '2.0.0' },
|
||||
{ version: '1.2.3', notes: 'different' },
|
||||
]) {
|
||||
test(`does not replace conflicting update feed ${previous.version}`, async (t) => {
|
||||
const f = fixture(t);
|
||||
const key = `releases/macos/${f.metadata.target}/latest.json`;
|
||||
const original = Buffer.from(JSON.stringify(previous));
|
||||
f.objects.set(key, original);
|
||||
await assert.rejects(publishMacos(f), /downgrade|differs/);
|
||||
assert.deepEqual(f.objects.get(key), original);
|
||||
});
|
||||
}
|
||||
|
||||
test('concurrent feed replacement fails without overwriting the winner', async (t) => {
|
||||
const f = fixture(t);
|
||||
const key = `releases/macos/${f.metadata.target}/latest.json`;
|
||||
f.objects.set(key, Buffer.from(JSON.stringify({ version: '1.0.0' })));
|
||||
const run = f.run;
|
||||
f.run = (command, args) => {
|
||||
if (args.includes('--if-match')) {
|
||||
f.objects.set(
|
||||
key,
|
||||
Buffer.from(JSON.stringify({ version: '2.0.0' })),
|
||||
);
|
||||
throw new Error('(PreconditionFailed) concurrent publication');
|
||||
}
|
||||
return run(command, args);
|
||||
};
|
||||
await assert.rejects(publishMacos(f), /concurrent publication/);
|
||||
assert.equal(JSON.parse(f.objects.get(key)).version, '2.0.0');
|
||||
});
|
||||
|
||||
for (const message of [
|
||||
"aws failed (exit 255). aws: [ERROR]: argument of type 'NoneType' is not a container or iterable",
|
||||
"aws failed (exit 255). argument of type 'NoneType' is not iterable",
|
||||
]) {
|
||||
test(`empty S3 error message permits verified promotion and identical retry: ${message}`, async (t) => {
|
||||
const f = fixture(t, message);
|
||||
const key = `releases/macos/${f.metadata.target}/latest.json`;
|
||||
f.objects.set(key, Buffer.from(JSON.stringify({ version: '1.0.0' })));
|
||||
await publishMacos(f);
|
||||
assert.equal(JSON.parse(f.objects.get(key)).version, '1.2.3');
|
||||
const published = [...f.objects.entries()];
|
||||
await publishMacos(f);
|
||||
assert.deepEqual([...f.objects.entries()], published);
|
||||
});
|
||||
|
||||
test(`empty S3 error cannot replace a published release: ${message}`, async (t) => {
|
||||
const f = fixture(t, message);
|
||||
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
|
||||
f.objects.set(key, Buffer.from('different existing release'));
|
||||
f.objects.set(
|
||||
`releases/macos/${f.metadata.target}/latest.json`,
|
||||
Buffer.from(JSON.stringify({ version: '1.2.3' })),
|
||||
);
|
||||
await assert.rejects(publishMacos(f), /bump the app version/);
|
||||
assert.equal(f.objects.size, 2);
|
||||
assert.equal(
|
||||
f.objects.get(key).toString(),
|
||||
'different existing release',
|
||||
);
|
||||
});
|
||||
|
||||
for (const previous of [
|
||||
{ version: '2.0.0' },
|
||||
{ version: '1.2.3', notes: 'different' },
|
||||
]) {
|
||||
test(`empty S3 error cannot bypass feed conflict checks ${previous.version}: ${message}`, async (t) => {
|
||||
const f = fixture(t, message);
|
||||
const key = `releases/macos/${f.metadata.target}/latest.json`;
|
||||
const original = Buffer.from(JSON.stringify(previous));
|
||||
f.objects.set(key, original);
|
||||
await assert.rejects(publishMacos(f), /downgrade|differs/);
|
||||
assert.deepEqual(f.objects.get(key), original);
|
||||
});
|
||||
}
|
||||
|
||||
test(`empty S3 error aborts when existing contents cannot be read: ${message}`, async (t) => {
|
||||
const f = fixture(t, message);
|
||||
const run = f.run;
|
||||
f.run = (command, args) => {
|
||||
if (args.includes('put-object')) {
|
||||
throw new Error(message);
|
||||
}
|
||||
if (args.includes('get-object')) {
|
||||
throw new Error('AccessDenied');
|
||||
}
|
||||
return run(command, args);
|
||||
};
|
||||
await assert.rejects(publishMacos(f), /S3 get-object .*AccessDenied/);
|
||||
assert.equal(f.objects.size, 0);
|
||||
});
|
||||
|
||||
test(`empty S3 error during conditional replacement preserves a concurrent winner: ${message}`, async (t) => {
|
||||
const f = fixture(t, message);
|
||||
const key = `releases/macos/${f.metadata.target}/latest.json`;
|
||||
f.objects.set(key, Buffer.from(JSON.stringify({ version: '1.0.0' })));
|
||||
const run = f.run;
|
||||
let attempts = 0;
|
||||
f.run = (command, args) => {
|
||||
if (args.includes('--if-match')) {
|
||||
attempts++;
|
||||
f.objects.set(
|
||||
key,
|
||||
Buffer.from(JSON.stringify({ version: '2.0.0' })),
|
||||
);
|
||||
throw new Error(message);
|
||||
}
|
||||
return run(command, args);
|
||||
};
|
||||
await assert.rejects(
|
||||
publishMacos(f),
|
||||
/S3 put-object .*latest.json: .*NoneType/,
|
||||
);
|
||||
assert.equal(attempts, 1);
|
||||
assert.equal(JSON.parse(f.objects.get(key)).version, '2.0.0');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { createHash, generateKeyPairSync, sign } from 'node:crypto';
|
||||
|
||||
export function signedFixture(bytes) {
|
||||
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
||||
const keyId = Buffer.alloc(8, 7);
|
||||
const key = Buffer.concat([
|
||||
Buffer.from('Ed'),
|
||||
keyId,
|
||||
publicKey.export({ type: 'spki', format: 'der' }).subarray(-32),
|
||||
]);
|
||||
const signature = sign(
|
||||
null,
|
||||
createHash('blake2b512').update(bytes).digest(),
|
||||
privateKey,
|
||||
);
|
||||
const trusted = 'timestamp:123456';
|
||||
const globalSignature = sign(
|
||||
null,
|
||||
Buffer.concat([signature, Buffer.from(trusted)]),
|
||||
privateKey,
|
||||
);
|
||||
return {
|
||||
publicKey: Buffer.from(
|
||||
`untrusted comment: test\n${key.toString('base64')}\n`,
|
||||
).toString('base64'),
|
||||
signature: Buffer.from(
|
||||
`untrusted comment: test\n${Buffer.concat([Buffer.from('ED'), keyId, signature]).toString('base64')}\ntrusted comment: ${trusted}\n${globalSignature.toString('base64')}\n`,
|
||||
).toString('base64'),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { signedFixture } from './updater-fixture.mjs';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
compareVersions,
|
||||
updateManifest,
|
||||
verifyUpdateSignature,
|
||||
} from '../update-manifest.mjs';
|
||||
import { updatePresentation } from '../ui/updates-state.js';
|
||||
|
||||
test('compares stable versions numerically and rejects prerelease or malformed versions', () => {
|
||||
assert.equal(compareVersions('1.10.0', '1.9.9'), 1);
|
||||
assert.equal(compareVersions('1.0.0', '2.0.0'), -1);
|
||||
assert.equal(compareVersions('1.0.0', '1.0.0'), 0);
|
||||
for (const invalid of [
|
||||
'v1.0.0',
|
||||
'1.0.0-beta',
|
||||
'01.0.0',
|
||||
'1.0.0+2',
|
||||
'',
|
||||
'1.2',
|
||||
]) {
|
||||
assert.throws(() => compareVersions(invalid, '1.0.0'));
|
||||
}
|
||||
});
|
||||
test('verifies minisign archive and trusted comment with the permanent key', () => {
|
||||
const bytes = Buffer.from('signed app');
|
||||
const fixture = signedFixture(bytes);
|
||||
assert.doesNotThrow(() =>
|
||||
verifyUpdateSignature(bytes, fixture.signature, fixture.publicKey),
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
verifyUpdateSignature(
|
||||
Buffer.from('tampered app'),
|
||||
fixture.signature,
|
||||
fixture.publicKey,
|
||||
),
|
||||
/signature/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
verifyUpdateSignature(
|
||||
bytes,
|
||||
fixture.signature,
|
||||
signedFixture(bytes).publicKey,
|
||||
),
|
||||
/signature/,
|
||||
);
|
||||
for (const signature of [
|
||||
'',
|
||||
'invalid',
|
||||
Buffer.from(
|
||||
Buffer.from(fixture.signature, 'base64')
|
||||
.toString()
|
||||
.replace('timestamp:123456', 'timestamp:999999'),
|
||||
).toString('base64'),
|
||||
]) {
|
||||
assert.throws(
|
||||
() => verifyUpdateSignature(bytes, signature, fixture.publicKey),
|
||||
/signature/,
|
||||
);
|
||||
}
|
||||
});
|
||||
test('manifest selects the exact supported architecture and embeds release notes', () => {
|
||||
const metadata = {
|
||||
version: '1.2.3',
|
||||
target: 'aarch64-apple-darwin',
|
||||
updaterFilename: 'KanbanGit.app.tar.gz',
|
||||
};
|
||||
assert.deepEqual(
|
||||
updateManifest(
|
||||
metadata,
|
||||
'https://release.example/1.2.3',
|
||||
'signed',
|
||||
'Release notes',
|
||||
),
|
||||
{
|
||||
version: '1.2.3',
|
||||
notes: 'Release notes',
|
||||
platforms: {
|
||||
'darwin-aarch64': {
|
||||
url: 'https://release.example/1.2.3/KanbanGit.app.tar.gz',
|
||||
signature: 'signed',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.throws(() =>
|
||||
updateManifest(
|
||||
{ ...metadata, target: 'android' },
|
||||
'https://example.com',
|
||||
'signed',
|
||||
),
|
||||
);
|
||||
});
|
||||
test('update controls prevent concurrent operations and allow failed downloads to retry', () => {
|
||||
for (const phase of [
|
||||
'checking',
|
||||
'downloading',
|
||||
'verifying',
|
||||
'installing',
|
||||
'restarting',
|
||||
]) {
|
||||
const view = updatePresentation({ phase, version: '2.0.0' });
|
||||
assert.equal(view.busy, true);
|
||||
assert.equal(view.canInstall, false);
|
||||
}
|
||||
assert.equal(
|
||||
updatePresentation({ phase: 'available', version: '2.0.0' }).canInstall,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
updatePresentation({ phase: 'error', version: '2.0.0' }).canInstall,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
updatePresentation({ phase: 'current', version: null }).canInstall,
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import test from 'node:test';
|
||||
|
||||
const script = readFileSync(
|
||||
new URL('../ui/workspace-settings.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
function context(platform = 'apple', frame = false, protocol = 'https:') {
|
||||
const calls = [];
|
||||
const window = { dispatchEvent() {} };
|
||||
window.top = frame ? {} : window;
|
||||
if (platform === 'apple') {
|
||||
window.webkit = {
|
||||
messageHandlers: {
|
||||
kanbanSettings: {
|
||||
postMessage: async (body) => {
|
||||
const request = JSON.parse(body);
|
||||
calls.push(request);
|
||||
return JSON.stringify({
|
||||
id: request.id,
|
||||
result: { workspace: 'https://example.test/' },
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
vm.runInNewContext(script, {
|
||||
window,
|
||||
location: { protocol },
|
||||
Event,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
});
|
||||
return { window, calls };
|
||||
}
|
||||
test('Apple WebViews expose a versioned request/reply API without Tauri', async () => {
|
||||
const { window, calls } = context('apple');
|
||||
assert.equal(window.kanbanNative.version, 1);
|
||||
assert.equal(
|
||||
(await window.kanbanNative.request('getState')).workspace,
|
||||
'https://example.test/',
|
||||
);
|
||||
assert.deepEqual(calls[0], {
|
||||
version: 1,
|
||||
id: 1,
|
||||
action: 'getState',
|
||||
payload: {},
|
||||
});
|
||||
await assert.rejects(
|
||||
window.kanbanNative.request('shell'),
|
||||
/unsupportedAction/,
|
||||
);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(window.__TAURI_INTERNALS__, undefined);
|
||||
assert.equal(context('apple', true).window.kanbanNative, undefined);
|
||||
assert.equal(
|
||||
context('apple', false, 'http:').window.kanbanNative,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
test('normal browsers receive no native settings API', () => {
|
||||
assert.equal(context('browser').window.kanbanNative, undefined);
|
||||
});
|
||||
test('native errors reject the caller without losing future requests', async () => {
|
||||
const { window } = context();
|
||||
window.webkit.messageHandlers.kanbanSettings.postMessage = async () =>
|
||||
JSON.stringify({ error: 'invalidServer' });
|
||||
await assert.rejects(
|
||||
window.kanbanNative.request('setWorkspace', { server: 'bad' }),
|
||||
/invalidServer/,
|
||||
);
|
||||
});
|
||||
@@ -9,10 +9,12 @@
|
||||
<script type="module" src="./main.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<p id="startup-loading" role="status" aria-live="polite" data-i18n="connecting"></p>
|
||||
<main id="workspace-settings" hidden>
|
||||
<img class="brand" src="./mark.svg" alt="" width="48" height="48" />
|
||||
<p class="eyebrow" data-i18n="appName"></p>
|
||||
<h1 data-i18n="title"></h1>
|
||||
<button id="update-notice" type="button" data-i18n="updateNotice" hidden></button>
|
||||
<p class="description" data-i18n="description"></p>
|
||||
<form id="server-form" novalidate>
|
||||
<label for="server-url" data-i18n="serverLabel"></label>
|
||||
|
||||
@@ -1,4 +1,44 @@
|
||||
export const en = {
|
||||
updatesTitle: 'Software updates',
|
||||
installedVersion: 'Installed version',
|
||||
version: 'Version',
|
||||
build: 'build',
|
||||
checkUpdates: 'Check For Updates…',
|
||||
installUpdate: 'Install Update…',
|
||||
installAndRestart: 'Install and restart',
|
||||
installWarning:
|
||||
'Save your work before continuing. KanbanGit will download and verify the update, then install it and restart. Unsaved inputs will be lost. Your server settings and stored sessions are kept.',
|
||||
updatePreferences: 'Update preferences',
|
||||
automaticUpdates: 'Automatically check for updates',
|
||||
checkInterval: 'Check interval',
|
||||
hourly: 'Every hour',
|
||||
sixHourly: 'Every 6 hours',
|
||||
daily: 'Every day',
|
||||
updatesPrivacy:
|
||||
'Checks run at startup and at the selected interval. Downloads and restarts only happen after your confirmation.',
|
||||
noReleaseNotes: 'No release notes provided.',
|
||||
update_idle: 'Check for the latest stable version of KanbanGit.',
|
||||
update_checking: 'Checking for updates…',
|
||||
update_available: 'A new version of KanbanGit is available.',
|
||||
update_current: 'KanbanGit is up to date.',
|
||||
update_downloading: 'Downloading update…',
|
||||
update_verifying: 'Verifying update…',
|
||||
update_installing: 'Installing update…',
|
||||
update_restarting: 'Restarting KanbanGit…',
|
||||
update_error: 'The update could not be completed.',
|
||||
networkError:
|
||||
'Unable to contact the update service or finish the download. Check your connection and try again.',
|
||||
metadataError:
|
||||
'The update information is invalid or does not match this Mac. Try checking again later.',
|
||||
signatureError:
|
||||
'The update signature could not be verified. Nothing was installed. Try again or contact support.',
|
||||
verificationError:
|
||||
'The downloaded app does not match the announced version, architecture or publisher. Nothing was installed.',
|
||||
installationError:
|
||||
'The update could not be installed. Move KanbanGit to Applications, check available disk space and permissions, then try again.',
|
||||
preferencesError:
|
||||
'Update preferences could not be read or saved. Check your local storage permissions and select your preferences again.',
|
||||
updateNotice: 'An update is available. View update…',
|
||||
appName: 'KanbanGit',
|
||||
title: 'Choose your workspace.',
|
||||
description: 'Connect to your KanbanGit installation over HTTPS.',
|
||||
|
||||
@@ -31,6 +31,9 @@ window.updateLauncher = (query) => {
|
||||
const state = new URLSearchParams(query);
|
||||
const phase = state.get('phase');
|
||||
const server = state.get('server');
|
||||
const loading = phase === 'connecting' && state.get('ready') !== 'true';
|
||||
document.querySelector('#startup-loading').hidden = !loading;
|
||||
document.querySelector('#workspace-settings').hidden = loading;
|
||||
if (!initialized) {
|
||||
input.value = server;
|
||||
initialized = true;
|
||||
@@ -98,3 +101,10 @@ document.querySelector('#appearance').addEventListener('change', (event) => {
|
||||
if (window.nativeLauncher) {
|
||||
action('ready');
|
||||
}
|
||||
|
||||
window.updateAvailable = (available) => {
|
||||
document.querySelector('#update-notice').hidden = !available;
|
||||
};
|
||||
document
|
||||
.querySelector('#update-notice')
|
||||
.addEventListener('click', () => action('updates'));
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export function updatePresentation(state) {
|
||||
const busy = [
|
||||
'checking',
|
||||
'downloading',
|
||||
'verifying',
|
||||
'installing',
|
||||
'restarting',
|
||||
].includes(state.phase);
|
||||
return {
|
||||
busy,
|
||||
message: `update_${state.phase}`,
|
||||
canInstall: Boolean(state.version) && !busy,
|
||||
showProgress: ['downloading', 'verifying', 'installing'].includes(
|
||||
state.phase,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
body { display: block; padding: 28px; }
|
||||
main { margin: 0 auto; width: min(100%, 460px); }
|
||||
.brand { margin-bottom: 16px; }
|
||||
h2 { font-size: 18px; }
|
||||
#release-notes { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 180px; overflow: auto; }
|
||||
progress { width: 100%; accent-color: var(--primary); }
|
||||
fieldset { margin: 28px 0 0; padding: 16px; border: 1px solid var(--border); border-radius: 6px; }
|
||||
.automatic { display: flex; align-items: center; gap: 10px; }
|
||||
.automatic input { display: inline-block; width: 18px; min-height: 18px; margin: 0; }
|
||||
.appearance { margin-top: 16px; }
|
||||
#install-confirmation { margin-top: 16px; padding: 12px; border: 1px solid var(--border); border-radius: 6px; }
|
||||
#confirm-install { background: var(--primary); color: var(--surface); }
|
||||
@@ -0,0 +1,43 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>KanbanGit Updates</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
<link rel="stylesheet" href="./updates.css" />
|
||||
<script type="module" src="./updates.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<img class="brand" src="./mark.svg" alt="" width="48" height="48" />
|
||||
<p class="eyebrow" data-i18n="appName"></p>
|
||||
<h1 data-i18n="updatesTitle"></h1>
|
||||
<p id="installed" class="description"></p>
|
||||
<p id="update-status" role="status" aria-live="polite"></p>
|
||||
<p id="update-error" class="description" role="alert" hidden></p>
|
||||
<section id="release" hidden>
|
||||
<h2 id="available-version"></h2>
|
||||
<p id="release-notes" class="description"></p>
|
||||
</section>
|
||||
<progress id="download-progress" hidden></progress>
|
||||
<p id="download-size" class="description" hidden></p>
|
||||
<button id="check" type="button" data-i18n="checkUpdates"></button>
|
||||
<button id="install" type="button" data-i18n="installUpdate" hidden></button>
|
||||
<section id="install-confirmation" hidden>
|
||||
<p class="description" data-i18n="installWarning"></p>
|
||||
<button id="confirm-install" type="button" data-i18n="installAndRestart"></button>
|
||||
<button id="cancel-install" type="button" data-i18n="cancel"></button>
|
||||
</section>
|
||||
<fieldset id="preferences">
|
||||
<legend data-i18n="updatePreferences"></legend>
|
||||
<label class="automatic"><input id="automatic" type="checkbox" checked /><span data-i18n="automaticUpdates"></span></label>
|
||||
<label class="appearance" for="interval"><span data-i18n="checkInterval"></span>
|
||||
<select id="interval"><option value="1" data-i18n="hourly"></option><option value="6" data-i18n="sixHourly"></option><option value="24" data-i18n="daily"></option></select>
|
||||
</label>
|
||||
<p class="description" data-i18n="updatesPrivacy"></p>
|
||||
</fieldset>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,70 @@
|
||||
import { en } from './locales/en.js';
|
||||
import { updatePresentation } from './updates-state.js';
|
||||
|
||||
for (const element of document.querySelectorAll('[data-i18n]')) {
|
||||
element.textContent = en[element.dataset.i18n];
|
||||
}
|
||||
const element = (id) => document.getElementById(id);
|
||||
const action = (name, params = {}) => {
|
||||
const url = new URL(`/updates/${name}`, location.href);
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
location.href = url.href;
|
||||
};
|
||||
let state;
|
||||
window.updateStatus = (next) => {
|
||||
state = next;
|
||||
const view = updatePresentation(state);
|
||||
element('installed').textContent =
|
||||
`${en.installedVersion} ${state.currentVersion} (${en.build} ${state.build})`;
|
||||
element('update-status').textContent = en[view.message];
|
||||
element('update-error').hidden = !state.error;
|
||||
element('update-error').textContent = en[state.error] ?? '';
|
||||
element('check').disabled = view.busy;
|
||||
element('preferences').disabled = view.busy;
|
||||
element('release').hidden = !state.version;
|
||||
element('available-version').textContent =
|
||||
`${en.version} ${state.version ?? ''}`;
|
||||
element('release-notes').textContent = state.notes || en.noReleaseNotes;
|
||||
element('install').hidden = !view.canInstall;
|
||||
element('download-progress').hidden = !view.showProgress;
|
||||
element('download-progress').max = state.total || 1;
|
||||
if (state.total) {
|
||||
element('download-progress').value = state.downloaded;
|
||||
} else {
|
||||
element('download-progress').removeAttribute('value');
|
||||
}
|
||||
element('download-size').hidden = !view.showProgress;
|
||||
element('download-size').textContent =
|
||||
`${(state.downloaded / 1048576).toFixed(1)} MB${state.total ? ` / ${(state.total / 1048576).toFixed(1)} MB` : ''}`;
|
||||
element('automatic').checked = state.preferences.automatic;
|
||||
element('interval').value = String(state.preferences.intervalHours);
|
||||
element('interval').disabled = !state.preferences.automatic;
|
||||
if (view.busy || !view.canInstall) {
|
||||
element('install-confirmation').hidden = true;
|
||||
}
|
||||
};
|
||||
element('check').addEventListener('click', () => action('check'));
|
||||
element('install').addEventListener('click', () => {
|
||||
element('install-confirmation').hidden = false;
|
||||
element('confirm-install').focus();
|
||||
});
|
||||
element('cancel-install').addEventListener('click', () => {
|
||||
element('install-confirmation').hidden = true;
|
||||
element('install').focus();
|
||||
});
|
||||
element('confirm-install').addEventListener('click', () => {
|
||||
if (state && updatePresentation(state).canInstall) {
|
||||
element('install-confirmation').hidden = true;
|
||||
action('install', { version: state.version });
|
||||
}
|
||||
});
|
||||
for (const id of ['automatic', 'interval']) {
|
||||
element(id).addEventListener('change', () =>
|
||||
action('preferences', {
|
||||
automatic: String(element('automatic').checked),
|
||||
interval: element('interval').value,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
(() => {
|
||||
if (window.top !== window) {
|
||||
return;
|
||||
}
|
||||
|
||||
const constrainScroll = () => {
|
||||
if (document.documentElement) {
|
||||
document.documentElement.style.overscrollBehaviorY = 'none';
|
||||
}
|
||||
if (document.body) {
|
||||
document.body.style.overscrollBehaviorY = 'none';
|
||||
}
|
||||
};
|
||||
constrainScroll();
|
||||
document.addEventListener('DOMContentLoaded', constrainScroll, {
|
||||
once: true,
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,58 @@
|
||||
(() => {
|
||||
if (
|
||||
window !== window.top ||
|
||||
location.protocol !== 'https:' ||
|
||||
window.kanbanNative
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const handler = window.webkit?.messageHandlers?.kanbanSettings;
|
||||
if (!handler) {
|
||||
return;
|
||||
}
|
||||
let sequence = 0;
|
||||
const actions = new Set([
|
||||
'getState',
|
||||
'settingsReady',
|
||||
'openSettings',
|
||||
'setWorkspace',
|
||||
'checkForUpdates',
|
||||
'setUpdatePreferences',
|
||||
'installUpdate',
|
||||
]);
|
||||
Object.defineProperty(window, 'kanbanNative', {
|
||||
value: Object.freeze({
|
||||
version: 1,
|
||||
async request(action, payload = {}) {
|
||||
if (!actions.has(action)) {
|
||||
throw new Error('unsupportedAction');
|
||||
}
|
||||
const message = JSON.stringify({
|
||||
version: 1,
|
||||
id: ++sequence,
|
||||
action,
|
||||
payload,
|
||||
});
|
||||
let timer;
|
||||
try {
|
||||
const response = await Promise.race([
|
||||
handler.postMessage(message).then(JSON.parse),
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error('bridgeTimeout')),
|
||||
10000,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
return response.result;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
window.dispatchEvent(new Event('kanban-native-ready'));
|
||||
})();
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { createHash, createPublicKey, verify } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
export const updateSource = JSON.parse(
|
||||
readFileSync(new URL('./update-source.json', import.meta.url), 'utf8'),
|
||||
);
|
||||
export function compareVersions(left, right) {
|
||||
const pattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
||||
if (!pattern.test(left) || !pattern.test(right)) {
|
||||
throw new Error(
|
||||
'Update versions must be stable MAJOR.MINOR.PATCH versions.',
|
||||
);
|
||||
}
|
||||
const a = left.split('.').map(BigInt);
|
||||
const b = right.split('.').map(BigInt);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (a[i] !== b[i]) {
|
||||
return a[i] > b[i] ? 1 : -1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
export function verifyUpdateSignature(
|
||||
bytes,
|
||||
signature,
|
||||
publicKey = updateSource.publicKey,
|
||||
) {
|
||||
try {
|
||||
const key = Buffer.from(
|
||||
Buffer.from(publicKey, 'base64')
|
||||
.toString('utf8')
|
||||
.trim()
|
||||
.split('\n')[1],
|
||||
'base64',
|
||||
);
|
||||
const lines = Buffer.from(signature.trim(), 'base64')
|
||||
.toString('utf8')
|
||||
.trim()
|
||||
.split('\n');
|
||||
const signed = Buffer.from(lines[1], 'base64');
|
||||
if (
|
||||
key.length !== 42 ||
|
||||
signed.length !== 74 ||
|
||||
!key.subarray(2, 10).equals(signed.subarray(2, 10)) ||
|
||||
!lines[2].startsWith('trusted comment: ')
|
||||
) {
|
||||
throw new Error('Invalid signature structure');
|
||||
}
|
||||
const algorithm = signed.subarray(0, 2).toString();
|
||||
if (!['Ed', 'ED'].includes(algorithm)) {
|
||||
throw new Error('Unsupported signature');
|
||||
}
|
||||
const cryptoKey = createPublicKey({
|
||||
key: Buffer.concat([
|
||||
Buffer.from('302a300506032b6570032100', 'hex'),
|
||||
key.subarray(10),
|
||||
]),
|
||||
format: 'der',
|
||||
type: 'spki',
|
||||
});
|
||||
const contents =
|
||||
algorithm === 'ED'
|
||||
? createHash('blake2b512').update(bytes).digest()
|
||||
: bytes;
|
||||
if (
|
||||
!verify(null, contents, cryptoKey, signed.subarray(10)) ||
|
||||
!verify(
|
||||
null,
|
||||
Buffer.concat([
|
||||
signed.subarray(10),
|
||||
Buffer.from(lines[2].slice('trusted comment: '.length)),
|
||||
]),
|
||||
cryptoKey,
|
||||
Buffer.from(lines[3], 'base64'),
|
||||
)
|
||||
) {
|
||||
throw new Error('Signature mismatch');
|
||||
}
|
||||
} catch {
|
||||
throw new Error(
|
||||
'Updater archive signature does not match the permanent public key.',
|
||||
);
|
||||
}
|
||||
}
|
||||
export function updateManifest(metadata, publicPrefix, signature, notes) {
|
||||
compareVersions(metadata.version, '0.0.0');
|
||||
const platform = {
|
||||
'aarch64-apple-darwin': 'darwin-aarch64',
|
||||
'x86_64-apple-darwin': 'darwin-x86_64',
|
||||
}[metadata.target];
|
||||
if (!platform || !signature.trim()) {
|
||||
throw new Error('Invalid updater platform or missing signature.');
|
||||
}
|
||||
return {
|
||||
version: metadata.version,
|
||||
notes: notes?.trim() || `KanbanGit ${metadata.version}`,
|
||||
platforms: {
|
||||
[platform]: {
|
||||
url: `${publicPrefix}/${metadata.updaterFilename}`,
|
||||
signature: signature.trim(),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"baseUrl": "https://petsch-studios.nbg1.your-objectstorage.com/releases/macos",
|
||||
"publicKey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhBQzA0QjhENTI3OEE1QzAKUldUQXBYaFNqVXZBaXJVWmErWUZTNVp6QnllT25NWFMyemxqcFBnYnJHT3JBSWxrSkVYU1VVMFEK"
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
"dev": "vite",
|
||||
"lint": "eslint . --fix",
|
||||
"lint:check": "eslint .",
|
||||
"types:check": "vue-tsc --noEmit"
|
||||
"types:check": "php artisan wayfinder:generate --with-form --no-interaction && vue-tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
@@ -48,6 +48,11 @@
|
||||
"dependencies": {
|
||||
"@inertiajs/core": "2.3.18",
|
||||
"@inertiajs/vue3": "^2.3.7",
|
||||
"@tiptap/core": "^3.31.3",
|
||||
"@tiptap/markdown": "^3.31.3",
|
||||
"@tiptap/pm": "^3.31.3",
|
||||
"@tiptap/starter-kit": "^3.31.3",
|
||||
"@tiptap/vue-3": "^3.31.3",
|
||||
"@vueuse/core": "^12.8.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -203,6 +203,14 @@ settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
overrides:
|
||||
axios@>=1.13.0 <1.20.0: ^1.20.0
|
||||
axios@>=1.15.0 <1.20.0: ^1.20.0
|
||||
axios@>=1.15.1 <1.20.0: ^1.20.0
|
||||
axios@>=1.15.2 <1.20.0: ^1.20.0
|
||||
axios@>=1.16.1 <1.20.0: ^1.20.0
|
||||
axios@>=1.17.0 <1.20.0: ^1.20.0
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
@@ -213,6 +221,21 @@ importers:
|
||||
'@inertiajs/vue3':
|
||||
specifier: ^2.3.7
|
||||
version: 2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)(vue@3.5.30(typescript@5.9.3))
|
||||
'@tiptap/core':
|
||||
specifier: ^3.31.3
|
||||
version: 3.31.3(@tiptap/pm@3.31.3)
|
||||
'@tiptap/markdown':
|
||||
specifier: ^3.31.3
|
||||
version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||
'@tiptap/pm':
|
||||
specifier: ^3.31.3
|
||||
version: 3.31.3
|
||||
'@tiptap/starter-kit':
|
||||
specifier: ^3.31.3
|
||||
version: 3.31.3
|
||||
'@tiptap/vue-3':
|
||||
specifier: ^3.31.3
|
||||
version: 3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(vue@3.5.30(typescript@5.9.3))
|
||||
'@vueuse/core':
|
||||
specifier: ^12.8.2
|
||||
version: 12.8.2(typescript@5.9.3)
|
||||
@@ -725,6 +748,160 @@ packages:
|
||||
peerDependencies:
|
||||
vue: ^2.7.0 || ^3.0.0
|
||||
|
||||
'@tiptap/core@3.31.3':
|
||||
resolution: {integrity: sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg==}
|
||||
peerDependencies:
|
||||
'@tiptap/pm': 3.31.3
|
||||
|
||||
'@tiptap/extension-blockquote@3.31.3':
|
||||
resolution: {integrity: sha512-fyY2XMbyDDDfOTQ1Qdrnqa1qwC9DWE4n7AfE0EKQI0G8MfLV8RaDlLDcOZDJ9JbMPY7/Gx7EjyK4NKxSW9n2hQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.31.3
|
||||
'@tiptap/pm': 3.31.3
|
||||
|
||||
'@tiptap/extension-bold@3.31.3':
|
||||
resolution: {integrity: sha512-dIuYhKk8TitKU/FeDpoTeWZhU42YgDN5npgWNjAmMmRktPdoxnH3/wGSiwXlqZgJWjehN7kPWDePWpJeAImGpQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.31.3
|
||||
|
||||
'@tiptap/extension-bubble-menu@3.31.3':
|
||||
resolution: {integrity: sha512-EV6ZnwKc++2OM/OcD54n8s1B7C9LP7GKAtdEPwu0t3BYJf3sG8Ueoinf7SgGPO9oMPg96GneOkDNm1urMV167g==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.31.3
|
||||
'@tiptap/pm': 3.31.3
|
||||
|
||||
'@tiptap/extension-bullet-list@3.31.3':
|
||||
resolution: {integrity: sha512-qEyyoPapPef4LO8XKaN83bxtaNzkJ4kFn/IxLnEKd4BJ3Mvi4MH2yJYlyDqwFnMoev4pMA1zBHRAt/C0THRmZw==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': 3.31.3
|
||||
|
||||
'@tiptap/extension-code-block@3.31.3':
|
||||
resolution: {integrity: sha512-nvknt4FhyJQjYcvxptmeUlFsIAc8ibua3E5BN4Pim374/9RWepH4cdE9X0/qUTtguHElLx0iKtSIY3rW2qGTFA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.31.3
|
||||
'@tiptap/pm': 3.31.3
|
||||
|
||||
'@tiptap/extension-code@3.31.3':
|
||||
resolution: {integrity: sha512-SzxOqchrD2AcN3uT67PjKmRFEMOU3vNNiwNaamZJUbZrI6Hmy+bvdHJrc3jrIddyyCrAtsnAfRI0cmorW1jGfg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.31.3
|
||||
|
||||
'@tiptap/extension-document@3.31.3':
|
||||
resolution: {integrity: sha512-EexgmqnyDNyGlISxo7SMrp5MygpJYmqD+0cY5jB6L1U6L4CpKKRWUt8OO1sWzEHDE1+TTvwt+WIFoIWAziOtEA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.31.3
|
||||
|
||||
'@tiptap/extension-dropcursor@3.31.3':
|
||||
resolution: {integrity: sha512-NWomSfu5CSC7VacnMSDzKT8qm66SzMfZwVPEtwY5bPpRTJgTiT1rNK0neDrrzfMN27MfylGyKWWf7Q5Qf8w/fg==}
|
||||
peerDependencies:
|
||||
'@tiptap/extensions': 3.31.3
|
||||
|
||||
'@tiptap/extension-floating-menu@3.31.3':
|
||||
resolution: {integrity: sha512-rd4VJ9PGSP9Eop8ZTEwaLZcMzMXLuJKe3hUNf58rq+zANpwM+9fI+vB7g9MAc3eXwUNDxNDVACFIL2oeBqpQ3A==}
|
||||
peerDependencies:
|
||||
'@floating-ui/dom': ^1.0.0
|
||||
'@tiptap/core': 3.31.3
|
||||
'@tiptap/pm': 3.31.3
|
||||
|
||||
'@tiptap/extension-gapcursor@3.31.3':
|
||||
resolution: {integrity: sha512-EBXKb1FrVStsNYCcRGtd9jmzveCvR+eqgg1rVqoONrqFK6U7bga6LN+1dMKroP1kliDVgveYkP3vRYxqw+rFqg==}
|
||||
peerDependencies:
|
||||
'@tiptap/extensions': 3.31.3
|
||||
|
||||
'@tiptap/extension-hard-break@3.31.3':
|
||||
resolution: {integrity: sha512-QAdCvNO4+yW9ATwsrej11NTkDYFqPLIEQr3ARNrKOK1qaiS7A0fia2SEukb/hrkP3A6mbozhoQt2r2RGUf/DpQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.31.3
|
||||
|
||||
'@tiptap/extension-heading@3.31.3':
|
||||
resolution: {integrity: sha512-rk5VHMAeQcg06SLauN6EGdD2jc0O2qY8QkZYPd0LxNvLblw2BxBx+lxUQSYwLAT9Ie5914gKIK2YbRyO2Ts3ig==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.31.3
|
||||
|
||||
'@tiptap/extension-horizontal-rule@3.31.3':
|
||||
resolution: {integrity: sha512-YnHGy2KShRwvCseAmmxl9VP7R0qaj8QMp3DA6DJWZqp7r5gLGvDkAqhedxqqefqsE4Y43hjkBdjtB9Ce78LkIw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.31.3
|
||||
'@tiptap/pm': 3.31.3
|
||||
|
||||
'@tiptap/extension-italic@3.31.3':
|
||||
resolution: {integrity: sha512-ibGvdvAPyfxBMUVNRI43eb9h2/Jka1MRG5GtnGqbcCX/2+Y/y0EOfFrPQPkuYphiWQYyu9+FzPKMB/pjuaLuKQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.31.3
|
||||
|
||||
'@tiptap/extension-link@3.31.3':
|
||||
resolution: {integrity: sha512-986wOQzTL9Zr5lf84LCLpm+YOms8A0K39/8DVoqRfebqcOe0/eq4bnztmAlfabOd+kJY92g3AgZERFUx/w+dcw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.31.3
|
||||
'@tiptap/pm': 3.31.3
|
||||
|
||||
'@tiptap/extension-list-item@3.31.3':
|
||||
resolution: {integrity: sha512-4QlKOriJJMvg95QJTEsy9BUYPBQ6UvJyb8WURRwdUtQUkkqb8h32lg/eyQUv2FzW9IT1AdUyNZfVaxH64kRfQA==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': 3.31.3
|
||||
|
||||
'@tiptap/extension-list-keymap@3.31.3':
|
||||
resolution: {integrity: sha512-If8UOEdDZbPJU6iYTvLtH6DOp2KBy6BKxg9UELL1AevVetGHEF/7lW8hP50Gn1tHMVpPRqmhSzVrJRpEJJgb/Q==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': 3.31.3
|
||||
|
||||
'@tiptap/extension-list@3.31.3':
|
||||
resolution: {integrity: sha512-LoveGnC0FVdCV4jUNBaG1ZA+KWE07+adzV3kGy6uUYFcJEjbVUHTnPDrBOob3IwOSO3sCwIkvQb6EVYeXn/4yg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.31.3
|
||||
'@tiptap/pm': 3.31.3
|
||||
|
||||
'@tiptap/extension-ordered-list@3.31.3':
|
||||
resolution: {integrity: sha512-mp3g11NgA/PYu8rj7J7Ez3l4qBy6WfTSmHIG4PZvEGG5w2oUAIkgb9DU7nPPzjmeme27oazFYZw+AtZA0+u4tw==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': 3.31.3
|
||||
|
||||
'@tiptap/extension-paragraph@3.31.3':
|
||||
resolution: {integrity: sha512-+iPku7wJfy5hbNDNLX8dveFtYVsZMmh7vztjuq8hT3mipSC4IByDehDbU8fzUCjfXiEzmI7mQn7c8LGmHULuzA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.31.3
|
||||
|
||||
'@tiptap/extension-strike@3.31.3':
|
||||
resolution: {integrity: sha512-G29bhKttYwcKHT+BI6emWVFol3RO/gUXxQVcmr/iT8LXXy7j8J6HFUnsKM+Kg5YlP1rxMRgsa65dbrQVahZi0A==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.31.3
|
||||
|
||||
'@tiptap/extension-text@3.31.3':
|
||||
resolution: {integrity: sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.31.3
|
||||
|
||||
'@tiptap/extension-underline@3.31.3':
|
||||
resolution: {integrity: sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.31.3
|
||||
|
||||
'@tiptap/extensions@3.31.3':
|
||||
resolution: {integrity: sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.31.3
|
||||
'@tiptap/pm': 3.31.3
|
||||
|
||||
'@tiptap/markdown@3.31.3':
|
||||
resolution: {integrity: sha512-rBbYSxasseUoaBo15C8Yoaqafo7mJJzxwAwV9Z1OpLBvOroW9nQ0EbOdqaSYRw3/d9pP71B/3LNSzKyfyy+dpQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.31.3
|
||||
'@tiptap/pm': 3.31.3
|
||||
|
||||
'@tiptap/pm@3.31.3':
|
||||
resolution: {integrity: sha512-sZime0SWsz/k62W2WvHx5Ig7G2h7kVhrrmnqy+wEgIHfDwEfOlelRjaWCiBCFlF7dxGUntJusCh9FxlLhni0Ag==}
|
||||
|
||||
'@tiptap/starter-kit@3.31.3':
|
||||
resolution: {integrity: sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A==}
|
||||
|
||||
'@tiptap/vue-3@3.31.3':
|
||||
resolution: {integrity: sha512-PNt0+rm7BXzhe/U+xlNnXBeJ6t9x2mE3902VXeIeEBxu5CrYLCJ0a5yHrof1jU3KwJrLOe/BtlGWjbq4qnTTfQ==}
|
||||
peerDependencies:
|
||||
'@floating-ui/dom': ^1.0.0
|
||||
'@tiptap/core': 3.31.3
|
||||
'@tiptap/pm': 3.31.3
|
||||
vue: ^3.0.0
|
||||
|
||||
'@tybys/wasm-util@0.10.1':
|
||||
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
||||
|
||||
@@ -1075,8 +1252,8 @@ packages:
|
||||
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
axios@1.19.0:
|
||||
resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==}
|
||||
axios@1.20.0:
|
||||
resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==}
|
||||
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
@@ -1088,14 +1265,14 @@ packages:
|
||||
boolbase@1.0.0:
|
||||
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
||||
|
||||
brace-expansion@1.1.18:
|
||||
resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
|
||||
brace-expansion@1.1.21:
|
||||
resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==}
|
||||
|
||||
brace-expansion@2.1.4:
|
||||
resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==}
|
||||
brace-expansion@2.1.7:
|
||||
resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==}
|
||||
|
||||
brace-expansion@5.0.9:
|
||||
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
|
||||
brace-expansion@5.0.12:
|
||||
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
braces@3.0.3:
|
||||
@@ -1914,6 +2091,9 @@ packages:
|
||||
resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
|
||||
linkifyjs@4.3.3:
|
||||
resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==}
|
||||
|
||||
locate-path@6.0.0:
|
||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1936,6 +2116,11 @@ packages:
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
marked@17.0.6:
|
||||
resolution: {integrity: sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==}
|
||||
engines: {node: '>= 20'}
|
||||
hasBin: true
|
||||
|
||||
math-intrinsics@1.1.0:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2023,6 +2208,9 @@ packages:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
orderedmap@2.1.1:
|
||||
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
|
||||
|
||||
own-keys@1.0.1:
|
||||
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2150,6 +2338,45 @@ packages:
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
prosemirror-changeset@2.4.3:
|
||||
resolution: {integrity: sha512-J8jqUZ1xybXKY44v3K0c8m8ZTIqJPVT34t2UVyxv4VEWoNd5C9t5Y52GNuMriRJ3IpNxOSE2R60vN4qBrTMcyQ==}
|
||||
|
||||
prosemirror-commands@1.7.2:
|
||||
resolution: {integrity: sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==}
|
||||
|
||||
prosemirror-dropcursor@1.8.3:
|
||||
resolution: {integrity: sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==}
|
||||
|
||||
prosemirror-gapcursor@1.4.1:
|
||||
resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==}
|
||||
|
||||
prosemirror-history@1.5.0:
|
||||
resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==}
|
||||
|
||||
prosemirror-inputrules@1.5.1:
|
||||
resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==}
|
||||
|
||||
prosemirror-keymap@1.2.3:
|
||||
resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==}
|
||||
|
||||
prosemirror-model@1.25.12:
|
||||
resolution: {integrity: sha512-Ue2gTmXMa7EhpLNhC7J+h4+ykD8ha12K6rrZFFKKJHBForfIStw5gJ6Zrf1mqqAa9NmpmEB4wp9bKmA4eUVjWg==}
|
||||
|
||||
prosemirror-schema-list@1.5.1:
|
||||
resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==}
|
||||
|
||||
prosemirror-state@1.4.4:
|
||||
resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==}
|
||||
|
||||
prosemirror-tables@1.8.5:
|
||||
resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==}
|
||||
|
||||
prosemirror-transform@1.12.1:
|
||||
resolution: {integrity: sha512-t4F5615FycnCqsX7ShTUs8+jfnwcf46kuFRvSl/3qFx2QTZ4DjgowesLHQXcFP7G//TjesqZG3WtgL7vdyVJyA==}
|
||||
|
||||
prosemirror-view@1.42.5:
|
||||
resolution: {integrity: sha512-7qcRaCWM48NC4utNQnx5Obvvs48/jMHXXvOXHH6WlBojmiK15eyxoHYlv5aJARHJHiFccSuWZ7ULW2SPrFAS/g==}
|
||||
|
||||
proxy-from-env@2.1.0:
|
||||
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2203,6 +2430,9 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
rope-sequence@1.3.4:
|
||||
resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
|
||||
|
||||
run-parallel@1.2.0:
|
||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||
|
||||
@@ -2506,6 +2736,9 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
w3c-keyname@2.2.8:
|
||||
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
||||
|
||||
which-boxed-primitive@1.1.1:
|
||||
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2671,7 +2904,7 @@ snapshots:
|
||||
'@inertiajs/core@2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)':
|
||||
dependencies:
|
||||
'@types/lodash-es': 4.17.12
|
||||
axios: 1.19.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
||||
axios: 1.20.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
||||
laravel-precognition: 1.0.2(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
||||
lodash-es: 4.18.1
|
||||
qs: 6.16.0
|
||||
@@ -2888,6 +3121,178 @@ snapshots:
|
||||
'@tanstack/virtual-core': 3.13.23
|
||||
vue: 3.5.30(typescript@5.9.3)
|
||||
|
||||
'@tiptap/core@3.31.3(@tiptap/pm@3.31.3)':
|
||||
dependencies:
|
||||
'@tiptap/pm': 3.31.3
|
||||
|
||||
'@tiptap/extension-blockquote@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
'@tiptap/pm': 3.31.3
|
||||
|
||||
'@tiptap/extension-bold@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
|
||||
'@tiptap/extension-bubble-menu@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.6
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
'@tiptap/pm': 3.31.3
|
||||
optional: true
|
||||
|
||||
'@tiptap/extension-bullet-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||
|
||||
'@tiptap/extension-code-block@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
'@tiptap/pm': 3.31.3
|
||||
|
||||
'@tiptap/extension-code@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
|
||||
'@tiptap/extension-document@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
|
||||
'@tiptap/extension-dropcursor@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
|
||||
dependencies:
|
||||
'@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||
|
||||
'@tiptap/extension-floating-menu@3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.6
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
'@tiptap/pm': 3.31.3
|
||||
optional: true
|
||||
|
||||
'@tiptap/extension-gapcursor@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
|
||||
dependencies:
|
||||
'@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||
|
||||
'@tiptap/extension-hard-break@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
|
||||
'@tiptap/extension-heading@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
|
||||
'@tiptap/extension-horizontal-rule@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
'@tiptap/pm': 3.31.3
|
||||
|
||||
'@tiptap/extension-italic@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
|
||||
'@tiptap/extension-link@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
'@tiptap/pm': 3.31.3
|
||||
linkifyjs: 4.3.3
|
||||
|
||||
'@tiptap/extension-list-item@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||
|
||||
'@tiptap/extension-list-keymap@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||
|
||||
'@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
'@tiptap/pm': 3.31.3
|
||||
|
||||
'@tiptap/extension-ordered-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||
|
||||
'@tiptap/extension-paragraph@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
|
||||
'@tiptap/extension-strike@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
|
||||
'@tiptap/extension-text@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
|
||||
'@tiptap/extension-underline@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
|
||||
'@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
'@tiptap/pm': 3.31.3
|
||||
|
||||
'@tiptap/markdown@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
'@tiptap/pm': 3.31.3
|
||||
marked: 17.0.6
|
||||
|
||||
'@tiptap/pm@3.31.3':
|
||||
dependencies:
|
||||
prosemirror-changeset: 2.4.3
|
||||
prosemirror-commands: 1.7.2
|
||||
prosemirror-dropcursor: 1.8.3
|
||||
prosemirror-gapcursor: 1.4.1
|
||||
prosemirror-history: 1.5.0
|
||||
prosemirror-inputrules: 1.5.1
|
||||
prosemirror-keymap: 1.2.3
|
||||
prosemirror-model: 1.25.12
|
||||
prosemirror-schema-list: 1.5.1
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-tables: 1.8.5
|
||||
prosemirror-transform: 1.12.1
|
||||
prosemirror-view: 1.42.5
|
||||
|
||||
'@tiptap/starter-kit@3.31.3':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
'@tiptap/extension-blockquote': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||
'@tiptap/extension-bold': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
|
||||
'@tiptap/extension-bullet-list': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
|
||||
'@tiptap/extension-code': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
|
||||
'@tiptap/extension-code-block': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||
'@tiptap/extension-document': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
|
||||
'@tiptap/extension-dropcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
|
||||
'@tiptap/extension-gapcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
|
||||
'@tiptap/extension-hard-break': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
|
||||
'@tiptap/extension-heading': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
|
||||
'@tiptap/extension-horizontal-rule': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||
'@tiptap/extension-italic': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
|
||||
'@tiptap/extension-link': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||
'@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||
'@tiptap/extension-list-item': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
|
||||
'@tiptap/extension-list-keymap': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
|
||||
'@tiptap/extension-ordered-list': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
|
||||
'@tiptap/extension-paragraph': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
|
||||
'@tiptap/extension-strike': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
|
||||
'@tiptap/extension-text': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
|
||||
'@tiptap/extension-underline': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
|
||||
'@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||
'@tiptap/pm': 3.31.3
|
||||
|
||||
'@tiptap/vue-3@3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(vue@3.5.30(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.6
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
'@tiptap/pm': 3.31.3
|
||||
vue: 3.5.30(typescript@5.9.3)
|
||||
optionalDependencies:
|
||||
'@tiptap/extension-bubble-menu': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||
'@tiptap/extension-floating-menu': 3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||
|
||||
'@tybys/wasm-util@0.10.1':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -3287,7 +3692,7 @@ snapshots:
|
||||
dependencies:
|
||||
possible-typed-array-names: 1.1.0
|
||||
|
||||
axios@1.19.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1):
|
||||
axios@1.20.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1):
|
||||
dependencies:
|
||||
follow-redirects: 1.16.0(debug@4.4.3(supports-color@8.1.1))
|
||||
form-data: 4.0.6
|
||||
@@ -3303,16 +3708,16 @@ snapshots:
|
||||
|
||||
boolbase@1.0.0: {}
|
||||
|
||||
brace-expansion@1.1.18:
|
||||
brace-expansion@1.1.21:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
concat-map: 0.0.1
|
||||
|
||||
brace-expansion@2.1.4:
|
||||
brace-expansion@2.1.7:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
|
||||
brace-expansion@5.0.9:
|
||||
brace-expansion@5.0.12:
|
||||
dependencies:
|
||||
balanced-match: 4.0.4
|
||||
|
||||
@@ -4055,7 +4460,7 @@ snapshots:
|
||||
|
||||
laravel-precognition@1.0.2(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1):
|
||||
dependencies:
|
||||
axios: 1.19.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
||||
axios: 1.20.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
||||
lodash-es: 4.18.1
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
@@ -4171,6 +4576,8 @@ snapshots:
|
||||
lightningcss-win32-arm64-msvc: 1.33.0
|
||||
lightningcss-win32-x64-msvc: 1.33.0
|
||||
|
||||
linkifyjs@4.3.3: {}
|
||||
|
||||
locate-path@6.0.0:
|
||||
dependencies:
|
||||
p-locate: 5.0.0
|
||||
@@ -4189,6 +4596,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
marked@17.0.6: {}
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
merge2@1.4.1: {}
|
||||
@@ -4206,15 +4615,15 @@ snapshots:
|
||||
|
||||
minimatch@10.2.4:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.9
|
||||
brace-expansion: 5.0.12
|
||||
|
||||
minimatch@3.1.5:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.18
|
||||
brace-expansion: 1.1.21
|
||||
|
||||
minimatch@9.0.9:
|
||||
dependencies:
|
||||
brace-expansion: 2.1.4
|
||||
brace-expansion: 2.1.7
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
@@ -4276,6 +4685,8 @@ snapshots:
|
||||
type-check: 0.4.0
|
||||
word-wrap: 1.2.5
|
||||
|
||||
orderedmap@2.1.1: {}
|
||||
|
||||
own-keys@1.0.1:
|
||||
dependencies:
|
||||
get-intrinsic: 1.3.0
|
||||
@@ -4333,6 +4744,80 @@ snapshots:
|
||||
|
||||
prettier@3.8.2: {}
|
||||
|
||||
prosemirror-changeset@2.4.3:
|
||||
dependencies:
|
||||
prosemirror-transform: 1.12.1
|
||||
|
||||
prosemirror-commands@1.7.2:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.12
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.1
|
||||
|
||||
prosemirror-dropcursor@1.8.3:
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.1
|
||||
prosemirror-view: 1.42.5
|
||||
|
||||
prosemirror-gapcursor@1.4.1:
|
||||
dependencies:
|
||||
prosemirror-keymap: 1.2.3
|
||||
prosemirror-model: 1.25.12
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-view: 1.42.5
|
||||
|
||||
prosemirror-history@1.5.0:
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.1
|
||||
prosemirror-view: 1.42.5
|
||||
rope-sequence: 1.3.4
|
||||
|
||||
prosemirror-inputrules@1.5.1:
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.1
|
||||
|
||||
prosemirror-keymap@1.2.3:
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
w3c-keyname: 2.2.8
|
||||
|
||||
prosemirror-model@1.25.12:
|
||||
dependencies:
|
||||
orderedmap: 2.1.1
|
||||
|
||||
prosemirror-schema-list@1.5.1:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.12
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.1
|
||||
|
||||
prosemirror-state@1.4.4:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.12
|
||||
prosemirror-transform: 1.12.1
|
||||
prosemirror-view: 1.42.5
|
||||
|
||||
prosemirror-tables@1.8.5:
|
||||
dependencies:
|
||||
prosemirror-keymap: 1.2.3
|
||||
prosemirror-model: 1.25.12
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.1
|
||||
prosemirror-view: 1.42.5
|
||||
|
||||
prosemirror-transform@1.12.1:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.12
|
||||
|
||||
prosemirror-view@1.42.5:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.12
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.1
|
||||
|
||||
proxy-from-env@2.1.0: {}
|
||||
|
||||
punycode@2.3.1: {}
|
||||
@@ -4415,6 +4900,8 @@ snapshots:
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.2.5
|
||||
'@rolldown/binding-win32-x64-msvc': 1.2.5
|
||||
|
||||
rope-sequence@1.3.4: {}
|
||||
|
||||
run-parallel@1.2.0:
|
||||
dependencies:
|
||||
queue-microtask: 1.2.3
|
||||
@@ -4760,6 +5247,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
w3c-keyname@2.2.8: {}
|
||||
|
||||
which-boxed-primitive@1.1.1:
|
||||
dependencies:
|
||||
is-bigint: 1.1.0
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
minimumReleaseAge: 10080
|
||||
# Security patches for GHSA-q2hr-2g5m-vwhr, GHSA-qhr7-859c-m2p7 and GHSA-6j4f-fj2g-mc7p.
|
||||
minimumReleaseAgeExclude:
|
||||
- brace-expansion@1.1.21
|
||||
- brace-expansion@2.1.7
|
||||
- brace-expansion@5.0.12
|
||||
allowBuilds:
|
||||
unrs-resolver: true
|
||||
vue-demi: true
|
||||
overrides:
|
||||
axios@>=1.13.0 <1.20.0: ^1.20.0
|
||||
axios@>=1.15.0 <1.20.0: ^1.20.0
|
||||
axios@>=1.15.1 <1.20.0: ^1.20.0
|
||||
axios@>=1.15.2 <1.20.0: ^1.20.0
|
||||
axios@>=1.16.1 <1.20.0: ^1.20.0
|
||||
axios@>=1.17.0 <1.20.0: ^1.20.0
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
@import './theme.css';
|
||||
@import './ticket-description.css';
|
||||
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source '../../storage/framework/views/*.php';
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
.ticket-description {
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.75;
|
||||
}
|
||||
.ticket-description > * + * {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.ticket-description h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.ticket-description h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.ticket-description h3,
|
||||
.ticket-description h4,
|
||||
.ticket-description h5,
|
||||
.ticket-description h6 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ticket-description ul {
|
||||
list-style: disc;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
.ticket-description ol {
|
||||
list-style: decimal;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
.ticket-description li > ul,
|
||||
.ticket-description li > ol {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.ticket-description blockquote {
|
||||
border-left: 3px solid var(--color-border);
|
||||
padding-left: 1rem;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.ticket-description a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.ticket-description code {
|
||||
border-radius: 0.25rem;
|
||||
background: var(--color-muted);
|
||||
padding: 0.125rem 0.25rem;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
.ticket-description pre {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
border-radius: 0.375rem;
|
||||
background: var(--color-muted);
|
||||
padding: 0.75rem;
|
||||
white-space: pre;
|
||||
}
|
||||
.ticket-description pre code {
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
.ticket-description hr {
|
||||
border-color: var(--color-border);
|
||||
margin-block: 1rem;
|
||||
}
|
||||
.ticket-description:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
@@ -3,6 +3,10 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
|
||||
import type { DefineComponent } from 'vue';
|
||||
import { createApp, h } from 'vue';
|
||||
import '../css/app.css';
|
||||
import {
|
||||
initializeNativeApp,
|
||||
registerNativeRecovery,
|
||||
} from '@/composables/useNativeApp';
|
||||
import { createAppearance } from '@/composables/useAppearance';
|
||||
import { createLocalization } from '@/lib/localization';
|
||||
|
||||
@@ -21,6 +25,11 @@ createInertiaApp({
|
||||
.use(createLocalization(props.initialPage.props.localization))
|
||||
.use(createAppearance(props.initialPage.props.appearance))
|
||||
.mount(el);
|
||||
registerNativeRecovery();
|
||||
window.addEventListener('kanban-native-ready', () => {
|
||||
void initializeNativeApp();
|
||||
});
|
||||
void initializeNativeApp();
|
||||
},
|
||||
progress: {
|
||||
color: 'var(--primary)',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Link, usePage } from '@inertiajs/vue3';
|
||||
import { Monitor, Moon, Plus, Sun } from 'lucide-vue-next';
|
||||
import { Download, Monitor, Moon, Plus, Sun } from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import Breadcrumbs from '@/components/Breadcrumbs.vue';
|
||||
import UserMenuContent from '@/components/UserMenuContent.vue';
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from '@/composables/useAppearance';
|
||||
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { useMacDownload } from '@/composables/useMacDownload';
|
||||
import { create as createProject } from '@/routes/projects';
|
||||
import { index as board } from '@/routes/projects/board';
|
||||
import { create as createTicket } from '@/routes/projects/tickets';
|
||||
@@ -35,6 +36,7 @@ const page = usePage();
|
||||
const { project } = useAppNavigation();
|
||||
const { isCurrentUrl } = useCurrentUrl();
|
||||
const { t } = useLocalization();
|
||||
const { macDownloadUrl, showMacDownload } = useMacDownload();
|
||||
const { appearance, updateAppearance } = useAppearance();
|
||||
const themeIcon = computed(
|
||||
() => ({ light: Sun, dark: Moon, system: Monitor })[appearance.value],
|
||||
@@ -69,6 +71,21 @@ const createAction = computed(() => {
|
||||
<span v-else class="truncate font-semibold">KanbanGit</span>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1 sm:gap-2">
|
||||
<Button
|
||||
v-if="showMacDownload"
|
||||
as-child
|
||||
variant="outline"
|
||||
:aria-label="t('navigation.downloadAppAppleSilicon')"
|
||||
:title="t('navigation.downloadAppAppleSilicon')"
|
||||
class="px-3"
|
||||
>
|
||||
<a :href="macDownloadUrl"
|
||||
><Download aria-hidden="true" /><span
|
||||
class="hidden sm:inline"
|
||||
>{{ t('navigation.downloadApp') }}</span
|
||||
></a
|
||||
>
|
||||
</Button>
|
||||
<Button
|
||||
v-if="createAction"
|
||||
as-child
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineComponent, h } from 'vue';
|
||||
import {
|
||||
parseDescription,
|
||||
renderDescriptionNode,
|
||||
supportsVisualDescription,
|
||||
} from '@/lib/ticket-description';
|
||||
|
||||
const props = defineProps<{ description: string }>();
|
||||
const document = computed(() =>
|
||||
supportsVisualDescription(props.description)
|
||||
? parseDescription(props.description)
|
||||
: null,
|
||||
);
|
||||
const Content = defineComponent(
|
||||
() => () =>
|
||||
document.value
|
||||
? h('div', { class: 'ticket-description' }, [
|
||||
renderDescriptionNode(document.value),
|
||||
])
|
||||
: h(
|
||||
'p',
|
||||
{
|
||||
class: 'wrap-anywhere whitespace-pre-wrap text-sm leading-7',
|
||||
},
|
||||
props.description,
|
||||
),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Content />
|
||||
</template>
|
||||
@@ -0,0 +1,386 @@
|
||||
<script setup lang="ts">
|
||||
import { EditorContent, useEditor } from '@tiptap/vue-3';
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Strikethrough,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
List,
|
||||
ListOrdered,
|
||||
Quote,
|
||||
Code,
|
||||
SquareCode,
|
||||
Link,
|
||||
Unlink,
|
||||
Undo2,
|
||||
Redo2,
|
||||
} from 'lucide-vue-next';
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import {
|
||||
descriptionExtensions,
|
||||
parseDescription,
|
||||
safeDescriptionLink,
|
||||
supportsVisualDescription,
|
||||
} from '@/lib/ticket-description';
|
||||
import type {
|
||||
DescriptionToolbarAction,
|
||||
TicketDescriptionEditorProps,
|
||||
} from '@/types/ticket-description';
|
||||
|
||||
const props = defineProps<TicketDescriptionEditorProps>();
|
||||
const model = defineModel<string>({ required: true });
|
||||
const { t } = useLocalization();
|
||||
const sourceMode = ref(!supportsVisualDescription(model.value));
|
||||
const supported = computed(() => supportsVisualDescription(model.value));
|
||||
const visual = computed(() => !sourceMode.value && supported.value);
|
||||
const linkOpen = ref(false);
|
||||
const linkUrl = ref('');
|
||||
const linkError = ref('');
|
||||
const linkInput = ref<InstanceType<typeof Input> | null>(null);
|
||||
let currentMarkdown = model.value;
|
||||
const editor = useEditor({
|
||||
extensions: descriptionExtensions(),
|
||||
content: supported.value ? parseDescription(model.value) : '',
|
||||
editable: !props.disabled,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
id: props.id,
|
||||
role: 'textbox',
|
||||
'aria-multiline': 'true',
|
||||
'aria-labelledby': `${props.id}-label`,
|
||||
'aria-describedby': `${props.id}-hint ${props.id}-error`,
|
||||
'aria-invalid': String(Boolean(props.error)),
|
||||
'aria-disabled': String(Boolean(props.disabled)),
|
||||
class: 'ticket-description min-h-48 px-3 py-3 outline-none',
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor: instance }) => {
|
||||
currentMarkdown = instance.isEmpty ? '' : instance.getMarkdown();
|
||||
model.value = currentMarkdown;
|
||||
},
|
||||
});
|
||||
|
||||
watch(model, (value) => {
|
||||
if (value === currentMarkdown) {
|
||||
return;
|
||||
}
|
||||
currentMarkdown = value;
|
||||
if (supportsVisualDescription(value)) {
|
||||
editor.value?.commands.setContent(parseDescription(value), {
|
||||
emitUpdate: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
watch(
|
||||
() => props.disabled,
|
||||
(disabled) => {
|
||||
editor.value?.setEditable(!disabled, false);
|
||||
if (disabled) {
|
||||
linkOpen.value = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
watch([() => props.error, () => props.disabled], () => {
|
||||
editor.value?.setOptions({
|
||||
editorProps: {
|
||||
attributes: {
|
||||
...editor.value.options.editorProps.attributes,
|
||||
'aria-invalid': String(Boolean(props.error)),
|
||||
'aria-disabled': String(Boolean(props.disabled)),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const actions: DescriptionToolbarAction[] = [
|
||||
{
|
||||
label: 'descriptionEditor.bold',
|
||||
icon: Bold,
|
||||
active: 'bold',
|
||||
run: (c) => c.toggleBold(),
|
||||
},
|
||||
{
|
||||
label: 'descriptionEditor.italic',
|
||||
icon: Italic,
|
||||
active: 'italic',
|
||||
run: (c) => c.toggleItalic(),
|
||||
},
|
||||
{
|
||||
label: 'descriptionEditor.strike',
|
||||
icon: Strikethrough,
|
||||
active: 'strike',
|
||||
run: (c) => c.toggleStrike(),
|
||||
},
|
||||
{
|
||||
label: 'descriptionEditor.heading1',
|
||||
icon: Heading1,
|
||||
active: 'heading',
|
||||
attributes: { level: 1 },
|
||||
run: (c) => c.toggleHeading({ level: 1 }),
|
||||
},
|
||||
{
|
||||
label: 'descriptionEditor.heading2',
|
||||
icon: Heading2,
|
||||
active: 'heading',
|
||||
attributes: { level: 2 },
|
||||
run: (c) => c.toggleHeading({ level: 2 }),
|
||||
},
|
||||
{
|
||||
label: 'descriptionEditor.heading3',
|
||||
icon: Heading3,
|
||||
active: 'heading',
|
||||
attributes: { level: 3 },
|
||||
run: (c) => c.toggleHeading({ level: 3 }),
|
||||
},
|
||||
{
|
||||
label: 'descriptionEditor.bulletList',
|
||||
icon: List,
|
||||
active: 'bulletList',
|
||||
run: (c) => c.toggleBulletList(),
|
||||
},
|
||||
{
|
||||
label: 'descriptionEditor.orderedList',
|
||||
icon: ListOrdered,
|
||||
active: 'orderedList',
|
||||
run: (c) => c.toggleOrderedList(),
|
||||
},
|
||||
{
|
||||
label: 'descriptionEditor.quote',
|
||||
icon: Quote,
|
||||
active: 'blockquote',
|
||||
run: (c) => c.toggleBlockquote(),
|
||||
},
|
||||
{
|
||||
label: 'descriptionEditor.code',
|
||||
icon: Code,
|
||||
active: 'code',
|
||||
run: (c) => c.toggleCode(),
|
||||
},
|
||||
{
|
||||
label: 'descriptionEditor.codeBlock',
|
||||
icon: SquareCode,
|
||||
active: 'codeBlock',
|
||||
run: (c) => c.toggleCodeBlock(),
|
||||
},
|
||||
{ label: 'descriptionEditor.undo', icon: Undo2, run: (c) => c.undo() },
|
||||
{ label: 'descriptionEditor.redo', icon: Redo2, run: (c) => c.redo() },
|
||||
];
|
||||
function runAction(action: DescriptionToolbarAction): void {
|
||||
if (editor.value && !props.disabled) {
|
||||
action.run(editor.value.chain().focus()).run();
|
||||
}
|
||||
}
|
||||
function switchMode(): void {
|
||||
linkOpen.value = false;
|
||||
sourceMode.value = !sourceMode.value;
|
||||
}
|
||||
async function editLink(): Promise<void> {
|
||||
linkUrl.value = editor.value?.getAttributes('link').href ?? '';
|
||||
linkError.value = '';
|
||||
linkOpen.value = true;
|
||||
await nextTick();
|
||||
linkInput.value?.$el?.focus();
|
||||
}
|
||||
function saveLink(): void {
|
||||
if (!editor.value || props.disabled) {
|
||||
return;
|
||||
}
|
||||
const href = safeDescriptionLink(linkUrl.value.trim());
|
||||
if (!href) {
|
||||
linkError.value = t('descriptionEditor.invalidLink');
|
||||
return;
|
||||
}
|
||||
const chain = editor.value.chain().focus().extendMarkRange('link');
|
||||
if (editor.value.state.selection.empty && !editor.value.isActive('link')) {
|
||||
chain
|
||||
.insertContent({
|
||||
type: 'text',
|
||||
text: href,
|
||||
marks: [{ type: 'link', attrs: { href } }],
|
||||
})
|
||||
.run();
|
||||
} else {
|
||||
chain.setLink({ href }).run();
|
||||
}
|
||||
linkOpen.value = false;
|
||||
}
|
||||
function closeLink(): void {
|
||||
linkOpen.value = false;
|
||||
editor.value?.commands.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<Label
|
||||
:id="`${id}-label`"
|
||||
:for="id"
|
||||
@click="visual && editor?.commands.focus()"
|
||||
>{{ label }}</Label
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:disabled="disabled || (!supported && !visual)"
|
||||
:aria-pressed="sourceMode"
|
||||
@click="switchMode"
|
||||
>
|
||||
{{
|
||||
visual
|
||||
? t('descriptionEditor.source')
|
||||
: t('descriptionEditor.visual')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
class="border-input bg-card focus-within:border-ring focus-within:ring-ring/35 shadow-xs min-w-0 overflow-hidden rounded-md border focus-within:ring-2"
|
||||
:class="{ 'border-destructive': error, 'opacity-60': disabled }"
|
||||
>
|
||||
<div
|
||||
v-if="visual"
|
||||
role="group"
|
||||
:aria-label="t('descriptionEditor.formatting')"
|
||||
class="bg-muted/30 flex flex-wrap gap-1 border-b p-2"
|
||||
>
|
||||
<Button
|
||||
v-for="action in actions"
|
||||
:key="action.label"
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
:variant="
|
||||
action.active &&
|
||||
editor?.isActive(action.active, action.attributes)
|
||||
? 'secondary'
|
||||
: 'ghost'
|
||||
"
|
||||
:title="t(action.label)"
|
||||
:aria-label="t(action.label)"
|
||||
:aria-pressed="
|
||||
action.active
|
||||
? Boolean(
|
||||
editor?.isActive(
|
||||
action.active,
|
||||
action.attributes,
|
||||
),
|
||||
)
|
||||
: undefined
|
||||
"
|
||||
:disabled="
|
||||
disabled ||
|
||||
!editor ||
|
||||
!action.run(editor.can().chain()).run()
|
||||
"
|
||||
@mousedown.prevent
|
||||
@click="runAction(action)"
|
||||
>
|
||||
<component
|
||||
:is="action.icon"
|
||||
class="size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
:title="t('descriptionEditor.link')"
|
||||
:aria-label="t('descriptionEditor.link')"
|
||||
:aria-pressed="Boolean(editor?.isActive('link'))"
|
||||
:disabled="
|
||||
disabled ||
|
||||
!editor ||
|
||||
!editor.can().setLink({ href: 'https://example.com' })
|
||||
"
|
||||
@mousedown.prevent
|
||||
@click="editLink"
|
||||
><Link class="size-4" aria-hidden="true"
|
||||
/></Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
:title="t('descriptionEditor.unlink')"
|
||||
:aria-label="t('descriptionEditor.unlink')"
|
||||
:disabled="disabled || !editor?.isActive('link')"
|
||||
@mousedown.prevent
|
||||
@click="
|
||||
editor
|
||||
?.chain()
|
||||
.focus()
|
||||
.extendMarkRange('link')
|
||||
.unsetLink()
|
||||
.run()
|
||||
"
|
||||
><Unlink class="size-4" aria-hidden="true"
|
||||
/></Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="linkOpen && visual"
|
||||
class="grid gap-2 border-b p-3"
|
||||
@keydown.esc.prevent.stop="closeLink"
|
||||
>
|
||||
<Label :for="`${id}-link`">{{
|
||||
t('descriptionEditor.linkUrl')
|
||||
}}</Label>
|
||||
<Input
|
||||
:id="`${id}-link`"
|
||||
ref="linkInput"
|
||||
v-model="linkUrl"
|
||||
type="text"
|
||||
:disabled="disabled"
|
||||
:invalid="Boolean(linkError)"
|
||||
:aria-describedby="`${id}-link-error`"
|
||||
placeholder="https://"
|
||||
@keydown.enter.prevent.stop="saveLink"
|
||||
/>
|
||||
<InputError :id="`${id}-link-error`" :message="linkError" />
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
:disabled="disabled"
|
||||
@click="saveLink"
|
||||
>{{ t('descriptionEditor.applyLink') }}</Button
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="closeLink"
|
||||
>{{ t('common.cancel') }}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<EditorContent v-if="visual && editor" :editor="editor" />
|
||||
<textarea
|
||||
v-else
|
||||
:id="id"
|
||||
v-model="model"
|
||||
:name="id"
|
||||
:disabled="disabled"
|
||||
:placeholder="placeholder"
|
||||
:aria-labelledby="`${id}-label`"
|
||||
:aria-describedby="`${id}-hint ${id}-error`"
|
||||
:aria-invalid="Boolean(error)"
|
||||
class="min-h-48 w-full resize-y bg-transparent px-3 py-3 font-mono text-sm outline-none"
|
||||
/>
|
||||
</div>
|
||||
<p :id="`${id}-hint`" class="text-muted-foreground text-xs">
|
||||
{{
|
||||
supported
|
||||
? t('descriptionEditor.hint')
|
||||
: t('descriptionEditor.unsupported')
|
||||
}}
|
||||
</p>
|
||||
<InputError :id="`${id}-error`" :message="error" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import TicketDescriptionEditor from '@/components/tickets/TicketDescriptionEditor.vue';
|
||||
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { ticketStatusLabel } from '@/lib/tickets';
|
||||
@@ -72,10 +73,9 @@ const statusOptions = computed(
|
||||
:placeholder="t('ticketForm.titlePlaceholder')"
|
||||
required
|
||||
/>
|
||||
<TicketFormField
|
||||
<TicketDescriptionEditor
|
||||
id="description"
|
||||
v-model="description"
|
||||
kind="textarea"
|
||||
:label="t('ticketDetail.description')"
|
||||
:error="errors.description"
|
||||
:disabled="disabled"
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
watch,
|
||||
} from 'vue';
|
||||
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||
import TicketDescription from '@/components/tickets/TicketDescription.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
@@ -272,16 +273,12 @@ const submit = (): void => {
|
||||
</h2></CardHeader
|
||||
>
|
||||
<CardContent>
|
||||
<p
|
||||
class="wrap-anywhere whitespace-pre-wrap text-sm leading-7"
|
||||
:class="{
|
||||
'text-muted-foreground': !ticket.description,
|
||||
}"
|
||||
>
|
||||
{{
|
||||
ticket.description ||
|
||||
t('ticketDetail.noDescription')
|
||||
}}
|
||||
<TicketDescription
|
||||
v-if="ticket.description"
|
||||
:description="ticket.description"
|
||||
/>
|
||||
<p v-else class="text-muted-foreground text-sm leading-7">
|
||||
{{ t('ticketDetail.noDescription') }}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -12,8 +12,12 @@ import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { useTicketLinkActions } from '@/composables/useTicketLinkActions';
|
||||
import { show as showTicket } from '@/routes/projects/tickets';
|
||||
import type { TicketDetailRelationshipsProps } from '@/types/ticket-detail';
|
||||
import type {
|
||||
TicketDetailRelationshipsProps,
|
||||
TicketLinkFormData,
|
||||
} from '@/types/ticket-detail';
|
||||
|
||||
const props = defineProps<TicketDetailRelationshipsProps>();
|
||||
defineEmits<{ editParent: [] }>();
|
||||
@@ -33,17 +37,17 @@ const canAddLink = computed(
|
||||
props.ticketLinkTypes.length > 0 &&
|
||||
props.ticketLinkOptions.length > 0,
|
||||
);
|
||||
const form = useForm({
|
||||
const form = useForm<TicketLinkFormData>({
|
||||
ticket_link_type_id: props.ticketLinkTypes[0]?.id ?? '',
|
||||
link_direction: 'source',
|
||||
linked_ticket_id: '',
|
||||
});
|
||||
const protection = useFormProtection(form, {
|
||||
isDirty: () => props.canEditTicket && form.isDirty,
|
||||
});
|
||||
const selectedType = computed(() =>
|
||||
props.ticketLinkTypes.find(
|
||||
(type) => String(type.id) === String(form.ticket_link_type_id),
|
||||
),
|
||||
const { actions, selectedAction, selectedActionLabel } = useTicketLinkActions(
|
||||
() => props.ticketLinkTypes,
|
||||
form,
|
||||
);
|
||||
const selectedTicket = computed(() =>
|
||||
props.ticketLinkOptions.find(
|
||||
@@ -224,16 +228,14 @@ function submit(): void {
|
||||
>
|
||||
<TicketFormField
|
||||
id="ticket_link_type_id"
|
||||
v-model="form.ticket_link_type_id"
|
||||
v-model="selectedAction"
|
||||
kind="select"
|
||||
:label="t('ticketDetail.linkType')"
|
||||
:options="
|
||||
ticketLinkTypes.map((type) => ({
|
||||
value: type.id,
|
||||
label: type.label,
|
||||
}))
|
||||
:label="t('ticketDetail.linkAction')"
|
||||
:options="actions"
|
||||
:error="
|
||||
form.errors.ticket_link_type_id ||
|
||||
form.errors.link_direction
|
||||
"
|
||||
:error="form.errors.ticket_link_type_id"
|
||||
:disabled="form.processing"
|
||||
required
|
||||
/>
|
||||
@@ -260,22 +262,19 @@ function submit(): void {
|
||||
/>
|
||||
</fieldset>
|
||||
<p
|
||||
v-if="selectedType && selectedTicket"
|
||||
v-if="selectedActionLabel && selectedTicket"
|
||||
aria-live="polite"
|
||||
class="text-muted-foreground flex flex-wrap items-center gap-2 text-sm"
|
||||
>
|
||||
<span class="font-mono text-xs">{{
|
||||
ticket.ticket_key
|
||||
}}</span
|
||||
><span>{{ selectedType.label }}</span
|
||||
><span>{{ selectedActionLabel }}</span
|
||||
><ArrowRight class="size-4" aria-hidden="true" /><span
|
||||
class="font-mono text-xs"
|
||||
>{{ selectedTicket.ticket_key }}</span
|
||||
>
|
||||
</p>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
{{ t('ticketRelationships.directionHelp') }}
|
||||
</p>
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="form.processing || !selectedTicket"
|
||||
|
||||