Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5769d84526 | ||
|
|
99264c7bc8 | ||
|
|
1b448e363c | ||
|
|
65e9df6d6d | ||
|
|
7d7b723f69 | ||
|
|
7a656de7ce | ||
|
|
811dbf7fa4 | ||
|
|
5567eea06f | ||
|
|
ca1f243234 | ||
|
|
9ed8d0c6cc | ||
|
|
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 |
@@ -1,78 +1,18 @@
|
|||||||
name: "Lint and Test APP"
|
name: test_and_check_app
|
||||||
description: "Run Pint and the Pest test suite"
|
description: Build the test image and run all application checks
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: composite
|
using: composite
|
||||||
steps:
|
steps:
|
||||||
- name: Setup PHP
|
- name: Test and check app
|
||||||
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 #v2.37.2
|
shell: bash
|
||||||
with:
|
|
||||||
php-version: "8.5"
|
|
||||||
extensions: mbstring, pdo_sqlite
|
|
||||||
coverage: none
|
|
||||||
tools: composer:v2
|
|
||||||
|
|
||||||
- name: Verify Composer lockfile
|
|
||||||
run: |
|
run: |
|
||||||
if [ ! -f composer.lock ]; then
|
set -Eeuo pipefail
|
||||||
echo "::error::composer.lock not found"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Get Composer cache directory
|
docker buildx build \
|
||||||
id: composer_cache
|
--file Dockerfile \
|
||||||
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
|
--target test \
|
||||||
shell: bash
|
--tag "local/kanbangit-test:${GITHUB_SHA}" \
|
||||||
|
--progress plain \
|
||||||
- name: Cache Composer packages
|
--load \
|
||||||
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
|
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
# Working Agreements
|
# 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
|
## 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.
|
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;
|
namespace App\Actions\Projects;
|
||||||
|
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class DeleteProject
|
class DeleteProject
|
||||||
{
|
{
|
||||||
@@ -10,7 +11,13 @@ class DeleteProject
|
|||||||
{
|
{
|
||||||
$projectName = $project->name;
|
$projectName = $project->name;
|
||||||
|
|
||||||
|
DB::transaction(function () use ($project): void {
|
||||||
|
$baseTemplate = $project->baseTemplate()->first();
|
||||||
|
|
||||||
|
$project->ticketStatuses()->delete();
|
||||||
$project->delete();
|
$project->delete();
|
||||||
|
$baseTemplate?->delete();
|
||||||
|
});
|
||||||
|
|
||||||
return $projectName;
|
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
|
public function handle(Project $project, Ticket $ticket, User $user, array $validated): TicketLink
|
||||||
{
|
{
|
||||||
$linkedTicketId = Arr::get($validated, 'linked_ticket_id');
|
$linkedTicketId = Arr::get($validated, 'linked_ticket_id');
|
||||||
$ticketLinkTypeId = Arr::get($validated, 'ticket_link_type_id');
|
$ticketLinkTypeId = Arr::get($validated, 'ticket_link_type_id');
|
||||||
|
$isSourceTicket = Arr::get($validated, 'link_direction', 'source') === 'source';
|
||||||
|
|
||||||
$linkedTicket = Ticket::query()
|
$linkedTicket = Ticket::query()
|
||||||
->where('project_id', $project->id)
|
->where('project_id', $project->id)
|
||||||
->findOrFail($linkedTicketId);
|
->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()
|
Ticket::query()
|
||||||
->whereKey([$ticket->id, $linkedTicket->id])
|
->whereKey([$ticket->id, $linkedTicket->id])
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
@@ -50,31 +53,31 @@ class StoreProjectTicketLink
|
|||||||
|
|
||||||
if ($ticketLink === null) {
|
if ($ticketLink === null) {
|
||||||
$ticketLink = TicketLink::query()->create([
|
$ticketLink = TicketLink::query()->create([
|
||||||
'source_ticket_id' => $ticket->id,
|
'source_ticket_id' => $sourceTicket->id,
|
||||||
'target_ticket_id' => $linkedTicket->id,
|
'target_ticket_id' => $targetTicket->id,
|
||||||
'ticket_link_type_id' => $ticketLinkTypeId,
|
'ticket_link_type_id' => $ticketLinkTypeId,
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
$ticketLink->update([
|
$ticketLink->update([
|
||||||
'source_ticket_id' => $ticket->id,
|
'source_ticket_id' => $sourceTicket->id,
|
||||||
'target_ticket_id' => $linkedTicket->id,
|
'target_ticket_id' => $targetTicket->id,
|
||||||
'ticket_link_type_id' => $ticketLinkTypeId,
|
'ticket_link_type_id' => $ticketLinkTypeId,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$ticketLink->load('ticketLinkType');
|
$ticketLink->load('ticketLinkType');
|
||||||
|
|
||||||
$sourceLabel = $ticketLink->ticketLinkType->labelForSource(true);
|
$ticketLabel = $ticketLink->ticketLinkType->labelForSource($isSourceTicket);
|
||||||
$targetLabel = $ticketLink->ticketLinkType->labelForSource(false);
|
$linkedTicketLabel = $ticketLink->ticketLinkType->labelForSource(! $isSourceTicket);
|
||||||
|
|
||||||
$this->createTicketWorklogEntry->handle(
|
$this->createTicketWorklogEntry->handle(
|
||||||
$ticket,
|
$ticket,
|
||||||
$user,
|
$user,
|
||||||
TicketWorklogEntryType::LinkUpdated,
|
TicketWorklogEntryType::LinkUpdated,
|
||||||
"Linked {$linkedTicket->ticket_key} as {$sourceLabel}.",
|
"Linked {$linkedTicket->ticket_key} as {$ticketLabel}.",
|
||||||
[
|
[
|
||||||
'linked_ticket_key' => $linkedTicket->ticket_key,
|
'linked_ticket_key' => $linkedTicket->ticket_key,
|
||||||
'ticket_link_type' => $sourceLabel,
|
'ticket_link_type' => $ticketLabel,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -82,10 +85,10 @@ class StoreProjectTicketLink
|
|||||||
$linkedTicket,
|
$linkedTicket,
|
||||||
$user,
|
$user,
|
||||||
TicketWorklogEntryType::LinkUpdated,
|
TicketWorklogEntryType::LinkUpdated,
|
||||||
"Linked {$ticket->ticket_key} as {$targetLabel}.",
|
"Linked {$ticket->ticket_key} as {$linkedTicketLabel}.",
|
||||||
[
|
[
|
||||||
'linked_ticket_key' => $ticket->ticket_key,
|
'linked_ticket_key' => $ticket->ticket_key,
|
||||||
'ticket_link_type' => $targetLabel,
|
'ticket_link_type' => $linkedTicketLabel,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class AcceptanceCriteriaChecklistMapper
|
|||||||
/**
|
/**
|
||||||
* @param array<int, array{id?: int, checked: bool, indent: int, text: string}> $items
|
* @param array<int, array{id?: int, checked: bool, indent: int, text: string}> $items
|
||||||
*/
|
*/
|
||||||
public static function syncTicket(Ticket $ticket, array $items): void
|
public static function syncTicket(Ticket $ticket, array $items, bool $reuseUnidentifiedCriteria = true): void
|
||||||
{
|
{
|
||||||
$normalized = self::normalize($items);
|
$normalized = self::normalize($items);
|
||||||
$existingCriteria = $ticket->acceptanceCriteria()->get()->keyBy('id');
|
$existingCriteria = $ticket->acceptanceCriteria()->get()->keyBy('id');
|
||||||
@@ -73,7 +73,8 @@ class AcceptanceCriteriaChecklistMapper
|
|||||||
: $existingCriteria->get($criterionId);
|
: $existingCriteria->get($criterionId);
|
||||||
|
|
||||||
while (
|
while (
|
||||||
$criterion === null
|
$reuseUnidentifiedCriteria
|
||||||
|
&& $criterion === null
|
||||||
&& $fallbackIndex < $fallbackCriteria->count()
|
&& $fallbackIndex < $fallbackCriteria->count()
|
||||||
) {
|
) {
|
||||||
/** @var AcceptanceCriterion $candidate */
|
/** @var AcceptanceCriterion $candidate */
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Tickets\Support;
|
||||||
|
|
||||||
|
use League\CommonMark\Environment\Environment;
|
||||||
|
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
|
||||||
|
use League\CommonMark\Extension\CommonMark\Node\Block\HtmlBlock;
|
||||||
|
use League\CommonMark\Extension\CommonMark\Node\Inline\HtmlInline;
|
||||||
|
use League\CommonMark\Extension\CommonMark\Node\Inline\Image;
|
||||||
|
use League\CommonMark\Extension\CommonMark\Node\Inline\Link;
|
||||||
|
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
|
||||||
|
use League\CommonMark\Extension\Table\Table;
|
||||||
|
use League\CommonMark\Extension\TaskList\TaskListItemMarker;
|
||||||
|
use League\CommonMark\Parser\MarkdownParser;
|
||||||
|
use League\Uri\Uri;
|
||||||
|
|
||||||
|
class TicketDescriptionFormat
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public static function contract(): array
|
||||||
|
{
|
||||||
|
return once(fn (): array => json_decode(file_get_contents(resource_path('js/lib/ticket-description-format.json')), true, flags: JSON_THROW_ON_ERROR));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function instructions(): string
|
||||||
|
{
|
||||||
|
$contract = self::contract();
|
||||||
|
|
||||||
|
return implode(' ', array_map(fn (string $key): string => $contract[$key], ['format', 'supported', 'unsupported', 'links', 'criteria']))
|
||||||
|
." Valid description example (Markdown string):\n".$contract['example'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function criteriaInstructions(): string
|
||||||
|
{
|
||||||
|
return self::contract()['criteria'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function safeLink(string $value): bool
|
||||||
|
{
|
||||||
|
$contract = self::contract();
|
||||||
|
$decoded = rawurldecode($value);
|
||||||
|
if (! mb_check_encoding($decoded, 'UTF-8') || preg_match('~'.$contract['unsafe_link_characters'].'~u', $decoded)
|
||||||
|
|| ! preg_match('~'.$contract['link_prefix'].'~i', $value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (str_starts_with($value, '/') || str_starts_with($value, '#')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$url = Uri::tryNew($decoded);
|
||||||
|
|
||||||
|
return $url !== null && ($url->getScheme() === 'mailto' || (filled($url->getHost()) && ($url->getPort() ?? 0) <= 65535));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public static function unsupportedContent(string $value): array
|
||||||
|
{
|
||||||
|
$environment = new Environment;
|
||||||
|
$environment->addExtension(new CommonMarkCoreExtension);
|
||||||
|
$environment->addExtension(new GithubFlavoredMarkdownExtension);
|
||||||
|
$document = (new MarkdownParser($environment))->parse($value);
|
||||||
|
$unsupported = [];
|
||||||
|
foreach ($document->iterator() as $node) {
|
||||||
|
$type = match (true) {
|
||||||
|
$node instanceof HtmlBlock, $node instanceof HtmlInline => 'html',
|
||||||
|
$node instanceof Table => 'table',
|
||||||
|
$node instanceof Image => 'image',
|
||||||
|
$node instanceof TaskListItemMarker => 'task',
|
||||||
|
$node instanceof Link && ! self::safeLink($node->getUrl()) => 'unsafe_link',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
if ($type !== null) {
|
||||||
|
$unsupported[] = self::contract()['unsupported_tokens'][$type];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($unsupported));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,7 @@ class UpdateProjectTicket
|
|||||||
Ticket $ticket,
|
Ticket $ticket,
|
||||||
User $actor,
|
User $actor,
|
||||||
array $validated,
|
array $validated,
|
||||||
|
bool $replaceCriteriaWithIds = false,
|
||||||
): bool {
|
): bool {
|
||||||
$fieldValueAttributes = Arr::get($validated, 'field_values', []);
|
$fieldValueAttributes = Arr::get($validated, 'field_values', []);
|
||||||
$nextAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::normalize(
|
$nextAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::normalize(
|
||||||
@@ -58,7 +59,7 @@ class UpdateProjectTicket
|
|||||||
);
|
);
|
||||||
$ticketAttributes = $this->buildTicketAttributes($ticket, $validated);
|
$ticketAttributes = $this->buildTicketAttributes($ticket, $validated);
|
||||||
|
|
||||||
return DB::transaction(function () use ($actor, $fieldValueAttributes, $nextAcceptanceCriteria, $ticket, $ticketAttributes): bool {
|
return DB::transaction(function () use ($actor, $fieldValueAttributes, $nextAcceptanceCriteria, $replaceCriteriaWithIds, $ticket, $ticketAttributes): bool {
|
||||||
if (! $ticketAttributes['is_blocked']) {
|
if (! $ticketAttributes['is_blocked']) {
|
||||||
$ticketAttributes['blocked_comment'] = null;
|
$ticketAttributes['blocked_comment'] = null;
|
||||||
}
|
}
|
||||||
@@ -91,7 +92,9 @@ class UpdateProjectTicket
|
|||||||
/** @var Collection<int, TicketFieldValue> $existingFieldValues */
|
/** @var Collection<int, TicketFieldValue> $existingFieldValues */
|
||||||
$existingFieldValues = $ticket->fieldValues
|
$existingFieldValues = $ticket->fieldValues
|
||||||
->keyBy('ticket_field_definition_id');
|
->keyBy('ticket_field_definition_id');
|
||||||
$currentAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::fromModels($ticket->acceptanceCriteria);
|
$currentAcceptanceCriteria = $replaceCriteriaWithIds
|
||||||
|
? AcceptanceCriteriaChecklistMapper::fromModelsWithIds($ticket->acceptanceCriteria)
|
||||||
|
: AcceptanceCriteriaChecklistMapper::fromModels($ticket->acceptanceCriteria);
|
||||||
|
|
||||||
$changes = [];
|
$changes = [];
|
||||||
$summaryLabels = [];
|
$summaryLabels = [];
|
||||||
@@ -209,7 +212,7 @@ class UpdateProjectTicket
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($currentAcceptanceCriteria !== $nextAcceptanceCriteria) {
|
if ($currentAcceptanceCriteria !== $nextAcceptanceCriteria) {
|
||||||
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $nextAcceptanceCriteria);
|
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $nextAcceptanceCriteria, ! $replaceCriteriaWithIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
$fieldValueChangeSet = $this->syncProjectTicketFieldValues->handle(
|
$fieldValueChangeSet = $this->syncProjectTicketFieldValues->handle(
|
||||||
|
|||||||
@@ -36,11 +36,12 @@ class ProjectTicketLinkController extends ApiController
|
|||||||
|
|
||||||
$ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated());
|
$ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated());
|
||||||
$ticketLink->setAttribute('current_ticket_id', $ticket->id);
|
$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']))
|
return TicketLinkResource::make($ticketLink->load(['sourceTicket', 'targetTicket', 'ticketLinkType']))
|
||||||
->additional([
|
->additional([
|
||||||
'meta' => [
|
'meta' => [
|
||||||
'message' => "{$ticketLink->ticketLinkType->label} link was saved.",
|
'message' => "{$label} link was saved.",
|
||||||
],
|
],
|
||||||
])
|
])
|
||||||
->response()
|
->response()
|
||||||
|
|||||||
@@ -34,9 +34,10 @@ class ProjectTicketLinkController extends Controller
|
|||||||
);
|
);
|
||||||
|
|
||||||
$ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated());
|
$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])
|
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(
|
public function destroy(
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class RequirePasswordChange
|
|||||||
|
|
||||||
if ($request->routeIs([
|
if ($request->routeIs([
|
||||||
'logout',
|
'logout',
|
||||||
|
'native-app.edit',
|
||||||
'password.force-change.edit',
|
'password.force-change.edit',
|
||||||
'user-password.update',
|
'user-password.update',
|
||||||
])) {
|
])) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class DeleteProjectRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
public function authorize(): bool
|
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),
|
fn ($query) => $query->where('project_id', $project->id),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
'link_direction' => ['sometimes', 'required', Rule::in(['source', 'counterpart'])],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,24 +2,32 @@
|
|||||||
|
|
||||||
namespace App\Mcp\Servers;
|
namespace App\Mcp\Servers;
|
||||||
|
|
||||||
|
use App\Actions\Tickets\Support\TicketDescriptionFormat;
|
||||||
use App\Mcp\Tools\CreateTicket;
|
use App\Mcp\Tools\CreateTicket;
|
||||||
|
use App\Mcp\Tools\GetProjectConfiguration;
|
||||||
use App\Mcp\Tools\GetTicket;
|
use App\Mcp\Tools\GetTicket;
|
||||||
use App\Mcp\Tools\ListMyAssignedTickets;
|
use App\Mcp\Tools\ListMyAssignedTickets;
|
||||||
|
use App\Mcp\Tools\ListProjects;
|
||||||
use App\Mcp\Tools\ListTicketsByStatus;
|
use App\Mcp\Tools\ListTicketsByStatus;
|
||||||
use App\Mcp\Tools\ListTicketTypes;
|
use App\Mcp\Tools\ListTicketTypes;
|
||||||
use App\Mcp\Tools\MoveTicketStatus;
|
use App\Mcp\Tools\MoveTicketStatus;
|
||||||
|
use App\Mcp\Tools\SearchTickets;
|
||||||
|
use App\Mcp\Tools\UpdateTicket;
|
||||||
use App\Mcp\Tools\UpdateTicketAssignee;
|
use App\Mcp\Tools\UpdateTicketAssignee;
|
||||||
use App\Mcp\Tools\UpdateTicketDescription;
|
use App\Mcp\Tools\UpdateTicketDescription;
|
||||||
use Laravel\Mcp\Server;
|
use Laravel\Mcp\Server;
|
||||||
use Laravel\Mcp\Server\Attributes\Instructions;
|
|
||||||
use Laravel\Mcp\Server\Attributes\Name;
|
use Laravel\Mcp\Server\Attributes\Name;
|
||||||
use Laravel\Mcp\Server\Attributes\Version;
|
use Laravel\Mcp\Server\Attributes\Version;
|
||||||
|
|
||||||
#[Name('KanbanGit MCP')]
|
#[Name('KanbanGit MCP')]
|
||||||
#[Version('0.1.0')]
|
#[Version('0.2.1')]
|
||||||
#[Instructions('Use these tools to read, create, and update KanbanGit tickets with the authenticated application key owner permissions. Prefer stable IDs when available, especially project_id, ticket_id, ticket_type_id, and ticket_status_id. Use list_ticket_types to discover the available ticket types in a project. Use create_ticket with parent_ticket_id to create a child ticket.')]
|
|
||||||
class KanbanGitServer extends Server
|
class KanbanGitServer extends Server
|
||||||
{
|
{
|
||||||
|
protected function boot(): void
|
||||||
|
{
|
||||||
|
$this->instructions = 'Use these tools to read, create, and update KanbanGit tickets with the authenticated application key owner permissions. Prefer stable IDs when available, especially project_id, ticket_id, ticket_type_id, and ticket_status_id. Use list_projects and get_project_configuration to discover visible projects and current workflow metadata. Use get_ticket with ticket_id or ticket_key; use search_tickets for paginated discovery. Use list_ticket_types to discover types and create_ticket with parent_ticket_id to create children of parent-enabled tickets. Use update_ticket for partial updates and move_ticket_status separately for workflow transitions. '.TicketDescriptionFormat::instructions();
|
||||||
|
}
|
||||||
|
|
||||||
protected array $tools = [
|
protected array $tools = [
|
||||||
ListMyAssignedTickets::class,
|
ListMyAssignedTickets::class,
|
||||||
ListTicketsByStatus::class,
|
ListTicketsByStatus::class,
|
||||||
@@ -29,6 +37,10 @@ class KanbanGitServer extends Server
|
|||||||
MoveTicketStatus::class,
|
MoveTicketStatus::class,
|
||||||
CreateTicket::class,
|
CreateTicket::class,
|
||||||
ListTicketTypes::class,
|
ListTicketTypes::class,
|
||||||
|
ListProjects::class,
|
||||||
|
GetProjectConfiguration::class,
|
||||||
|
SearchTickets::class,
|
||||||
|
UpdateTicket::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
protected array $resources = [
|
protected array $resources = [
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mcp\Support;
|
||||||
|
|
||||||
|
use App\Actions\Tickets\Support\TicketDescriptionFormat;
|
||||||
|
|
||||||
|
trait DescribesTicketWriting
|
||||||
|
{
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return parent::description().' '.TicketDescriptionFormat::instructions();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ use App\Models\TicketStatus;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
use Illuminate\Auth\AuthenticationException;
|
use Illuminate\Auth\AuthenticationException;
|
||||||
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Laravel\Mcp\Request;
|
use Laravel\Mcp\Request;
|
||||||
@@ -78,6 +79,17 @@ class KanbanGitMcp
|
|||||||
return $ticket;
|
return $ticket;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function ticketForKey(User $user, string $ticketKey): Ticket
|
||||||
|
{
|
||||||
|
$ticket = Ticket::query()->with('project')->where('ticket_key', $ticketKey)->first();
|
||||||
|
if ($ticket === null || $ticket->ticket_key !== $ticketKey) {
|
||||||
|
throw ValidationException::withMessages(['ticket_key' => 'Ticket was not found.']);
|
||||||
|
}
|
||||||
|
$this->ensureCanRead($user, $ticket->project);
|
||||||
|
|
||||||
|
return $ticket;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*/
|
*/
|
||||||
@@ -201,6 +213,18 @@ class KanbanGitMcp
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return array{current_page: int, per_page: int, total: int, last_page: int, has_more_pages: bool} */
|
||||||
|
public function paginationPayload(LengthAwarePaginator $paginator): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'current_page' => $paginator->currentPage(),
|
||||||
|
'per_page' => $paginator->perPage(),
|
||||||
|
'total' => $paginator->total(),
|
||||||
|
'last_page' => $paginator->lastPage(),
|
||||||
|
'has_more_pages' => $paginator->hasMorePages(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mcp\Support;
|
||||||
|
|
||||||
|
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
|
||||||
|
use App\Http\Requests\UpdateProjectTicketRequest;
|
||||||
|
use App\Models\Ticket;
|
||||||
|
use App\Models\TicketFieldDefinition;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Rules\VisualTicketDescription;
|
||||||
|
use Illuminate\Routing\Route;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Laravel\Mcp\Request;
|
||||||
|
|
||||||
|
class TicketUpdateInput
|
||||||
|
{
|
||||||
|
public function __construct(private readonly KanbanGitMcp $kanbanGitMcp) {}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function validate(Request $request, Ticket $ticket, User $user): array
|
||||||
|
{
|
||||||
|
$form = new UpdateProjectTicketRequest;
|
||||||
|
$route = new Route('PUT', '/', fn () => null);
|
||||||
|
$route->bind(\Illuminate\Http\Request::create('/', 'PUT'));
|
||||||
|
$route->setParameter('project', $ticket->project);
|
||||||
|
$route->setParameter('ticket', $ticket);
|
||||||
|
$form->setRouteResolver(fn () => $route);
|
||||||
|
$form->setUserResolver(fn () => $user);
|
||||||
|
$rules = $form->rules();
|
||||||
|
foreach ($rules as $key => &$rule) {
|
||||||
|
if (! str_contains($key, '.')) {
|
||||||
|
$rule = ['sometimes', ...$rule];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($rule);
|
||||||
|
$rules['description'][] = new VisualTicketDescription;
|
||||||
|
$rules['acceptance_criteria'] = ['sometimes', 'array', 'list'];
|
||||||
|
$rules['acceptance_criteria.*'] = ['array:id,text,checked,indent'];
|
||||||
|
$rules['acceptance_criteria.*.id'] = ['sometimes', 'integer', 'min:1', 'distinct', Rule::exists('acceptance_criteria', 'id')->where('ticket_id', $ticket->id)];
|
||||||
|
$rules['acceptance_criteria.*.checked'] = ['sometimes', 'boolean'];
|
||||||
|
$rules['acceptance_criteria.*.indent'] = ['sometimes', 'integer', 'min:0', 'max:'.AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL];
|
||||||
|
$rules['field_values'] = ['sometimes', 'array', 'list'];
|
||||||
|
$rules['field_values.*'] = ['array:ticket_field_definition_id,value_number,value_user_id'];
|
||||||
|
$rules['field_values.*.ticket_field_definition_id'][] = 'distinct';
|
||||||
|
$rules['ticket_status_id'] = ['prohibited'];
|
||||||
|
$rules['acceptance_criteria.*.text'][] = function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
|
if (is_string($value) && trim($value) === '') {
|
||||||
|
$fail('Criterion text must not be blank.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$input = $request->validate($rules);
|
||||||
|
$previousIndent = -1;
|
||||||
|
foreach (Arr::get($input, 'acceptance_criteria', []) as $index => $item) {
|
||||||
|
$indent = Arr::get($item, 'indent', 0);
|
||||||
|
if ($indent > $previousIndent + 1) {
|
||||||
|
throw ValidationException::withMessages(["acceptance_criteria.{$index}.indent" => 'Indentation cannot skip a level; the first item must have indent 0.']);
|
||||||
|
}
|
||||||
|
$previousIndent = $indent;
|
||||||
|
}
|
||||||
|
$merged = array_replace($this->kanbanGitMcp->updatePayload($ticket), $input);
|
||||||
|
$merged['acceptance_criteria'] = Arr::exists($input, 'acceptance_criteria')
|
||||||
|
? AcceptanceCriteriaChecklistMapper::normalize($input['acceptance_criteria'])
|
||||||
|
: AcceptanceCriteriaChecklistMapper::fromModelsWithIds($ticket->acceptanceCriteria()->get());
|
||||||
|
|
||||||
|
$validator = Validator::make($merged, $form->rules());
|
||||||
|
$form->setValidator($validator);
|
||||||
|
$validator->validate();
|
||||||
|
foreach ($form->after() as $callback) {
|
||||||
|
$callback($validator);
|
||||||
|
}
|
||||||
|
|
||||||
|
$people = User::query()->visibleToProject($ticket->project)->pluck('id')->all();
|
||||||
|
foreach (['assignee_user_id', 'author_user_id'] as $field) {
|
||||||
|
if (Arr::exists($input, $field) && $input[$field] !== null && ! in_array((int) $input[$field], $people, true)) {
|
||||||
|
$validator->errors()->add($field, 'The person must be able to view this project.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$definitions = $ticket->project->ticketFieldDefinitions()->get()->keyBy('id');
|
||||||
|
foreach (Arr::get($input, 'field_values', []) as $index => $value) {
|
||||||
|
$definition = $definitions->get($value['ticket_field_definition_id']);
|
||||||
|
$incompatible = $definition->field_type === 'user' ? 'value_number' : 'value_user_id';
|
||||||
|
if (Arr::get($value, $incompatible) !== null) {
|
||||||
|
$validator->errors()->add("field_values.{$index}.{$incompatible}", 'The value must match the custom field type.');
|
||||||
|
}
|
||||||
|
if (Arr::get($value, 'value_user_id') !== null && ! in_array((int) $value['value_user_id'], $people, true)) {
|
||||||
|
$validator->errors()->add("field_values.{$index}.value_user_id", 'The person must be able to view this project.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($validator->errors()->isNotEmpty()) {
|
||||||
|
throw new ValidationException($validator);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Arr::exists($input, 'field_values')) {
|
||||||
|
$replacement = $definitions->map(fn (TicketFieldDefinition $definition): array => [
|
||||||
|
'ticket_field_definition_id' => $definition->id,
|
||||||
|
'value_number' => null,
|
||||||
|
'value_user_id' => null,
|
||||||
|
])->all();
|
||||||
|
foreach ($input['field_values'] as $value) {
|
||||||
|
$replacement[$value['ticket_field_definition_id']] = $value;
|
||||||
|
}
|
||||||
|
$merged['field_values'] = array_values($replacement);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $merged;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,11 +5,14 @@ namespace App\Mcp\Tools;
|
|||||||
use App\Actions\Permissions\PermissionService;
|
use App\Actions\Permissions\PermissionService;
|
||||||
use App\Actions\Tickets\CreateProjectTicket;
|
use App\Actions\Tickets\CreateProjectTicket;
|
||||||
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
|
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
|
||||||
|
use App\Actions\Tickets\Support\TicketDescriptionFormat;
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
|
use App\Mcp\Support\DescribesTicketWriting;
|
||||||
use App\Mcp\Support\KanbanGitMcp;
|
use App\Mcp\Support\KanbanGitMcp;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Rules\VisualTicketDescription;
|
||||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
use Illuminate\JsonSchema\Types\Type;
|
use Illuminate\JsonSchema\Types\Type;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
@@ -28,6 +31,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
|||||||
#[IsDestructive(false)]
|
#[IsDestructive(false)]
|
||||||
class CreateTicket extends Tool
|
class CreateTicket extends Tool
|
||||||
{
|
{
|
||||||
|
use DescribesTicketWriting;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||||
private readonly CreateProjectTicket $createProjectTicket,
|
private readonly CreateProjectTicket $createProjectTicket,
|
||||||
@@ -44,7 +49,7 @@ class CreateTicket extends Tool
|
|||||||
$project = $this->kanbanGitMcp->projectForWrite($user, (int) Arr::get($projectInput, 'project_id'));
|
$project = $this->kanbanGitMcp->projectForWrite($user, (int) Arr::get($projectInput, 'project_id'));
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'title' => ['required', 'string', 'max:255'],
|
'title' => ['required', 'string', 'max:255'],
|
||||||
'description' => ['nullable', 'string'],
|
'description' => ['nullable', 'string', new VisualTicketDescription],
|
||||||
'ticket_type_id' => ['required', 'integer', Rule::exists('ticket_types', 'id')->where('project_id', $project->id)],
|
'ticket_type_id' => ['required', 'integer', Rule::exists('ticket_types', 'id')->where('project_id', $project->id)],
|
||||||
'ticket_status_id' => ['required', 'integer', Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id)],
|
'ticket_status_id' => ['required', 'integer', Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id)],
|
||||||
'ticket_priority_id' => ['required', 'integer', Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id)],
|
'ticket_priority_id' => ['required', 'integer', Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id)],
|
||||||
@@ -140,7 +145,7 @@ class CreateTicket extends Tool
|
|||||||
return [
|
return [
|
||||||
'project_id' => $schema->integer()->description('Project ID.')->required(),
|
'project_id' => $schema->integer()->description('Project ID.')->required(),
|
||||||
'title' => $schema->string()->max(255)->description('Ticket title.')->required(),
|
'title' => $schema->string()->max(255)->description('Ticket title.')->required(),
|
||||||
'description' => $schema->string()->nullable()->description('Ticket description.'),
|
'description' => $schema->string()->nullable()->description('Ticket description. '.TicketDescriptionFormat::instructions()),
|
||||||
'ticket_type_id' => $schema->integer()->description('Ticket type ID belonging to the project. Explicitly selects the type, including types that allow child tickets.')->required(),
|
'ticket_type_id' => $schema->integer()->description('Ticket type ID belonging to the project. Explicitly selects the type, including types that allow child tickets.')->required(),
|
||||||
'ticket_status_id' => $schema->integer()->description('Initial ticket status ID belonging to the project.')->required(),
|
'ticket_status_id' => $schema->integer()->description('Initial ticket status ID belonging to the project.')->required(),
|
||||||
'ticket_priority_id' => $schema->integer()->description('Ticket priority ID belonging to the project.')->required(),
|
'ticket_priority_id' => $schema->integer()->description('Ticket priority ID belonging to the project.')->required(),
|
||||||
@@ -156,7 +161,7 @@ class CreateTicket extends Tool
|
|||||||
'checked' => $schema->boolean()->default(false),
|
'checked' => $schema->boolean()->default(false),
|
||||||
'indent' => $schema->integer()->min(0)->max(AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL)->default(0),
|
'indent' => $schema->integer()->min(0)->max(AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL)->default(0),
|
||||||
])->withoutAdditionalProperties())
|
])->withoutAdditionalProperties())
|
||||||
->description('Ordered acceptance checklist. Indent 0 is a root item; larger values nest under preceding items.'),
|
->description('Ordered acceptance checklist. Indent 0 is a root item; larger values nest under preceding items. '.TicketDescriptionFormat::criteriaInstructions()),
|
||||||
'field_values' => $schema->array()
|
'field_values' => $schema->array()
|
||||||
->items($schema->object([
|
->items($schema->object([
|
||||||
'ticket_field_definition_id' => $schema->integer()->description('Custom field definition ID belonging to the project. Each definition may appear once.')->required(),
|
'ticket_field_definition_id' => $schema->integer()->description('Custom field definition ID belonging to the project. Each definition may appear once.')->required(),
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mcp\Tools;
|
||||||
|
|
||||||
|
use App\Actions\Permissions\PermissionService;
|
||||||
|
use App\Enums\Permission;
|
||||||
|
use App\Mcp\Support\KanbanGitMcp;
|
||||||
|
use App\Models\TicketStatus;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
|
use Illuminate\JsonSchema\Types\Type;
|
||||||
|
use Laravel\Mcp\Request;
|
||||||
|
use Laravel\Mcp\Response;
|
||||||
|
use Laravel\Mcp\ResponseFactory;
|
||||||
|
use Laravel\Mcp\Server\Attributes\Description;
|
||||||
|
use Laravel\Mcp\Server\Attributes\Name;
|
||||||
|
use Laravel\Mcp\Server\Tool;
|
||||||
|
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||||
|
|
||||||
|
#[Name('get_project_configuration')]
|
||||||
|
#[Description('Get compact current configuration for a visible project: ordered columns, statuses with IDs/slugs/column assignment and allowed target status IDs, types, priorities, custom field definitions, and permitted people IDs/names. Reads live effective configuration, without template snapshots or full user/project objects. People are paginated (default 25, maximum 100); use people_page for subsequent pages. Author changes require project admin permission, reported in permissions.')]
|
||||||
|
#[IsReadOnly]
|
||||||
|
class GetProjectConfiguration extends Tool
|
||||||
|
{
|
||||||
|
public function __construct(private readonly KanbanGitMcp $kanbanGitMcp, private readonly PermissionService $permissionService) {}
|
||||||
|
|
||||||
|
public function handle(Request $request): Response|ResponseFactory
|
||||||
|
{
|
||||||
|
$input = $request->validate([
|
||||||
|
'project_id' => ['required', 'integer'],
|
||||||
|
'people_page' => ['sometimes', 'integer', 'min:1'],
|
||||||
|
'people_per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
|
||||||
|
]);
|
||||||
|
$user = $this->kanbanGitMcp->user($request);
|
||||||
|
$project = $this->kanbanGitMcp->projectForRead($user, $input['project_id']);
|
||||||
|
$people = User::query()->visibleToProject($project)->orderBy('id')->paginate($input['people_per_page'] ?? 25, ['id', 'name'], 'people_page', $input['people_page'] ?? 1);
|
||||||
|
|
||||||
|
$columns = $project->boardColumns()->orderBy('id')->get(['id', 'name', 'position']);
|
||||||
|
$columnPositions = $columns->pluck('position', 'id');
|
||||||
|
$statuses = $project->ticketStatuses()->with('allowedTargetStatuses')->get()->sortBy(
|
||||||
|
fn (TicketStatus $status): array => [$columnPositions->get($status->board_column_id, 0), $status->position, $status->id],
|
||||||
|
)->values();
|
||||||
|
|
||||||
|
return Response::structured([
|
||||||
|
'data' => [
|
||||||
|
'project' => ['id' => $project->id, 'key' => $project->key, 'name' => $project->name],
|
||||||
|
'permissions' => [
|
||||||
|
'can_write' => $this->permissionService->allows($user, Permission::ProjectWrite, $project),
|
||||||
|
'can_change_author' => $this->permissionService->allows($user, Permission::ProjectAdmin, $project),
|
||||||
|
],
|
||||||
|
'board_columns' => $columns->toArray(),
|
||||||
|
'ticket_statuses' => $statuses->map(fn (TicketStatus $status): array => [
|
||||||
|
'id' => $status->id, 'name' => $status->name, 'slug' => $status->slug,
|
||||||
|
'position' => $status->position, 'board_column_id' => $status->board_column_id,
|
||||||
|
'allowed_target_status_ids' => $status->allowedTargetStatuses->pluck('id')->all(),
|
||||||
|
])->all(),
|
||||||
|
'ticket_types' => $project->ticketTypes()->orderBy('id')->get(['id', 'name', 'position', 'is_parent'])->toArray(),
|
||||||
|
'ticket_priorities' => $project->ticketPriorities()->orderBy('id')->get(['id', 'name', 'position'])->toArray(),
|
||||||
|
'ticket_field_definitions' => $project->ticketFieldDefinitions()->orderBy('id')->get(['id', 'label', 'field_type', 'position'])->toArray(),
|
||||||
|
'people' => $people->getCollection()->map(fn (User $person): array => ['id' => $person->id, 'name' => $person->name])->all(),
|
||||||
|
],
|
||||||
|
'meta' => ['people' => $this->kanbanGitMcp->paginationPayload($people)],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, Type> */
|
||||||
|
public function schema(JsonSchema $schema): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'project_id' => $schema->integer()->description('Visible project ID from list_projects.')->required(),
|
||||||
|
'people_page' => $schema->integer()->min(1)->description('People page number, defaults to 1.'),
|
||||||
|
'people_per_page' => $schema->integer()->min(1)->max(100)->description('People page size, defaults to 25; maximum 100.'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ use Laravel\Mcp\Server\Tool;
|
|||||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||||
|
|
||||||
#[Name('get_ticket')]
|
#[Name('get_ticket')]
|
||||||
#[Description('Get a visible ticket with project, status, type, priority, assignee, author, field values, and worklog data.')]
|
#[Description('Get a visible ticket by exactly one of numeric ticket_id or exact full ticket_key (e.g. KAN-67), with project, status, type, priority, assignee, author, field values, and worklog data.')]
|
||||||
#[IsReadOnly]
|
#[IsReadOnly]
|
||||||
class GetTicket extends Tool
|
class GetTicket extends Tool
|
||||||
{
|
{
|
||||||
@@ -25,11 +25,14 @@ class GetTicket extends Tool
|
|||||||
public function handle(Request $request): Response|ResponseFactory
|
public function handle(Request $request): Response|ResponseFactory
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'ticket_id' => ['required', 'integer'],
|
'ticket_id' => ['required_without:ticket_key', 'prohibits:ticket_key', 'integer'],
|
||||||
|
'ticket_key' => ['required_without:ticket_id', 'prohibits:ticket_id', 'string', 'regex:/^[A-Z0-9]+-[1-9][0-9]*$/'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = $this->kanbanGitMcp->user($request);
|
$user = $this->kanbanGitMcp->user($request);
|
||||||
$ticket = $this->kanbanGitMcp->ticketForRead($user, (int) $validated['ticket_id']);
|
$ticket = isset($validated['ticket_id'])
|
||||||
|
? $this->kanbanGitMcp->ticketForRead($user, (int) $validated['ticket_id'])
|
||||||
|
: $this->kanbanGitMcp->ticketForKey($user, $validated['ticket_key']);
|
||||||
|
|
||||||
return Response::structured([
|
return Response::structured([
|
||||||
'data' => $this->kanbanGitMcp->ticketPayload($ticket),
|
'data' => $this->kanbanGitMcp->ticketPayload($ticket),
|
||||||
@@ -43,8 +46,8 @@ class GetTicket extends Tool
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'ticket_id' => $schema->integer()
|
'ticket_id' => $schema->integer()
|
||||||
->description('Ticket ID.')
|
->description('Ticket ID. Supply exactly one of ticket_id or ticket_key.'),
|
||||||
->required(),
|
'ticket_key' => $schema->string()->description('Exact full ticket key, e.g. KAN-67. Supply exactly one of ticket_id or ticket_key.'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mcp\Tools;
|
||||||
|
|
||||||
|
use App\Actions\Permissions\PermissionService;
|
||||||
|
use App\Mcp\Support\KanbanGitMcp;
|
||||||
|
use App\Models\Project;
|
||||||
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
|
use Illuminate\JsonSchema\Types\Type;
|
||||||
|
use Laravel\Mcp\Request;
|
||||||
|
use Laravel\Mcp\Response;
|
||||||
|
use Laravel\Mcp\ResponseFactory;
|
||||||
|
use Laravel\Mcp\Server\Attributes\Description;
|
||||||
|
use Laravel\Mcp\Server\Attributes\Name;
|
||||||
|
use Laravel\Mcp\Server\Tool;
|
||||||
|
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||||
|
|
||||||
|
#[Name('list_projects')]
|
||||||
|
#[Description('List visible projects independently of assigned tickets. Returns only ID, key and name, ordered by ID, with bounded pagination. page starts at 1; per_page defaults to 25 and may not exceed 100.')]
|
||||||
|
#[IsReadOnly]
|
||||||
|
class ListProjects extends Tool
|
||||||
|
{
|
||||||
|
public function __construct(private readonly KanbanGitMcp $kanbanGitMcp, private readonly PermissionService $permissionService) {}
|
||||||
|
|
||||||
|
public function handle(Request $request): Response|ResponseFactory
|
||||||
|
{
|
||||||
|
$input = $request->validate(['page' => ['sometimes', 'integer', 'min:1'], 'per_page' => ['sometimes', 'integer', 'min:1', 'max:100']]);
|
||||||
|
$user = $this->kanbanGitMcp->user($request);
|
||||||
|
$projects = Project::query()->whereIn('id', $this->permissionService->viewableProjectIds($user))
|
||||||
|
->orderBy('id')->paginate($input['per_page'] ?? 25, ['id', 'key', 'name'], 'page', $input['page'] ?? 1);
|
||||||
|
|
||||||
|
return Response::structured([
|
||||||
|
'data' => $projects->getCollection()->map(fn (Project $project): array => ['id' => $project->id, 'key' => $project->key, 'name' => $project->name])->all(),
|
||||||
|
'meta' => $this->kanbanGitMcp->paginationPayload($projects),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, Type> */
|
||||||
|
public function schema(JsonSchema $schema): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'page' => $schema->integer()->min(1)->description('Page number, defaults to 1.'),
|
||||||
|
'per_page' => $schema->integer()->min(1)->max(100)->description('Page size, defaults to 25; maximum 100.'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Mcp\Tools;
|
namespace App\Mcp\Tools;
|
||||||
|
|
||||||
use App\Actions\Tickets\MoveProjectTicket;
|
use App\Actions\Tickets\MoveProjectTicket;
|
||||||
|
use App\Mcp\Support\DescribesTicketWriting;
|
||||||
use App\Mcp\Support\KanbanGitMcp;
|
use App\Mcp\Support\KanbanGitMcp;
|
||||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
use Illuminate\JsonSchema\Types\Type;
|
use Illuminate\JsonSchema\Types\Type;
|
||||||
@@ -19,6 +20,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
|||||||
#[IsDestructive(false)]
|
#[IsDestructive(false)]
|
||||||
class MoveTicketStatus extends Tool
|
class MoveTicketStatus extends Tool
|
||||||
{
|
{
|
||||||
|
use DescribesTicketWriting;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||||
private readonly MoveProjectTicket $moveProjectTicket,
|
private readonly MoveProjectTicket $moveProjectTicket,
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mcp\Tools;
|
||||||
|
|
||||||
|
use App\Actions\Permissions\PermissionService;
|
||||||
|
use App\Mcp\Support\KanbanGitMcp;
|
||||||
|
use App\Models\Ticket;
|
||||||
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\JsonSchema\Types\Type;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Laravel\Mcp\Request;
|
||||||
|
use Laravel\Mcp\Response;
|
||||||
|
use Laravel\Mcp\ResponseFactory;
|
||||||
|
use Laravel\Mcp\Server\Attributes\Description;
|
||||||
|
use Laravel\Mcp\Server\Attributes\Name;
|
||||||
|
use Laravel\Mcp\Server\Tool;
|
||||||
|
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||||
|
|
||||||
|
#[Name('search_tickets')]
|
||||||
|
#[Description('Search visible tickets with combined filters: exact full ticket key, literal text in title/description, project, status, type, assignee and parent. Omitted filters do not restrict results; assignee_user_id/parent_ticket_id null selects unassigned/top-level tickets. Returns compact ID, key, title, project ID and status with deterministic ID order and bounded pagination (default 25, maximum 100).')]
|
||||||
|
#[IsReadOnly]
|
||||||
|
class SearchTickets extends Tool
|
||||||
|
{
|
||||||
|
public function __construct(private readonly KanbanGitMcp $kanbanGitMcp, private readonly PermissionService $permissionService) {}
|
||||||
|
|
||||||
|
public function handle(Request $request): Response|ResponseFactory
|
||||||
|
{
|
||||||
|
$input = $request->validate([
|
||||||
|
'ticket_key' => ['sometimes', 'string', 'regex:/^[A-Z0-9]+-[1-9][0-9]*$/'],
|
||||||
|
'query' => ['sometimes', 'string', 'max:255'],
|
||||||
|
'project_id' => ['sometimes', 'integer'],
|
||||||
|
'ticket_status_id' => ['sometimes', 'integer'],
|
||||||
|
'ticket_type_id' => ['sometimes', 'integer'],
|
||||||
|
'assignee_user_id' => ['sometimes', 'nullable', 'integer'],
|
||||||
|
'parent_ticket_id' => ['sometimes', 'nullable', 'integer'],
|
||||||
|
'page' => ['sometimes', 'integer', 'min:1'],
|
||||||
|
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
|
||||||
|
]);
|
||||||
|
$user = $this->kanbanGitMcp->user($request);
|
||||||
|
$this->kanbanGitMcp->projectForRead($user, $input['project_id'] ?? null);
|
||||||
|
$query = Ticket::query()->whereIn('project_id', $this->permissionService->viewableProjectIds($user));
|
||||||
|
foreach (['ticket_key', 'project_id', 'ticket_status_id', 'ticket_type_id', 'assignee_user_id', 'parent_ticket_id'] as $filter) {
|
||||||
|
if (Arr::exists($input, $filter)) {
|
||||||
|
$query->where($filter, $input[$filter]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Arr::exists($input, 'query')) {
|
||||||
|
$pattern = '%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $input['query']).'%';
|
||||||
|
$query->where(fn (Builder $q) => $q->whereRaw("title LIKE ? ESCAPE '!'", [$pattern])->orWhereRaw("description LIKE ? ESCAPE '!'", [$pattern]));
|
||||||
|
}
|
||||||
|
$tickets = $query->with('ticketStatus:id,name,slug,board_column_id')->orderBy('id')
|
||||||
|
->paginate($input['per_page'] ?? 25, ['id', 'ticket_key', 'title', 'project_id', 'ticket_status_id'], 'page', $input['page'] ?? 1);
|
||||||
|
|
||||||
|
return Response::structured([
|
||||||
|
'data' => $tickets->getCollection()->map(fn (Ticket $ticket): array => [
|
||||||
|
'id' => $ticket->id,
|
||||||
|
'ticket_key' => $ticket->ticket_key,
|
||||||
|
'title' => $ticket->title,
|
||||||
|
'project_id' => $ticket->project_id,
|
||||||
|
'ticket_status_id' => $ticket->ticket_status_id,
|
||||||
|
'status' => $ticket->ticketStatus === null ? null : ['id' => $ticket->ticketStatus->id, 'name' => $ticket->ticketStatus->name, 'slug' => $ticket->ticketStatus->slug],
|
||||||
|
])->all(),
|
||||||
|
'meta' => $this->kanbanGitMcp->paginationPayload($tickets),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, Type> */
|
||||||
|
public function schema(JsonSchema $schema): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'ticket_key' => $schema->string()->description('Exact full key, e.g. KAN-67.'),
|
||||||
|
'query' => $schema->string()->max(255)->description('Literal substring in title or description.'),
|
||||||
|
'project_id' => $schema->integer()->description('Visible project ID.'),
|
||||||
|
'ticket_status_id' => $schema->integer()->description('Status ID from get_project_configuration.'),
|
||||||
|
'ticket_type_id' => $schema->integer()->description('Type ID from get_project_configuration.'),
|
||||||
|
'assignee_user_id' => $schema->integer()->nullable()->description('Assignee ID; null selects unassigned tickets.'),
|
||||||
|
'parent_ticket_id' => $schema->integer()->nullable()->description('Parent ID; null selects top-level tickets.'),
|
||||||
|
'page' => $schema->integer()->min(1)->description('Page number, defaults to 1.'),
|
||||||
|
'per_page' => $schema->integer()->min(1)->max(100)->description('Page size, defaults to 25; maximum 100.'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mcp\Tools;
|
||||||
|
|
||||||
|
use App\Actions\Tickets\Support\TicketDescriptionFormat;
|
||||||
|
use App\Actions\Tickets\UpdateProjectTicket;
|
||||||
|
use App\Mcp\Support\DescribesTicketWriting;
|
||||||
|
use App\Mcp\Support\KanbanGitMcp;
|
||||||
|
use App\Mcp\Support\TicketUpdateInput;
|
||||||
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
|
use Illuminate\JsonSchema\Types\Type;
|
||||||
|
use Laravel\Mcp\Request;
|
||||||
|
use Laravel\Mcp\Response;
|
||||||
|
use Laravel\Mcp\ResponseFactory;
|
||||||
|
use Laravel\Mcp\Server\Attributes\Description;
|
||||||
|
use Laravel\Mcp\Server\Attributes\Name;
|
||||||
|
use Laravel\Mcp\Server\Tool;
|
||||||
|
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||||
|
|
||||||
|
#[Name('update_ticket')]
|
||||||
|
#[Description('Partially update a ticket by ID using existing project permissions and ticket rules. Omitted fields stay unchanged. Explicit null clears nullable scalar fields only. acceptance_criteria and field_values replace their entire lists; [] clears a list and null is rejected. Checklist items with an existing ID retain their identity; omit id for a new item. Use move_ticket_status separately for workflow transitions. Author changes require project admin permission; people must be visible to the project.')]
|
||||||
|
#[IsDestructive(false)]
|
||||||
|
class UpdateTicket extends Tool
|
||||||
|
{
|
||||||
|
use DescribesTicketWriting;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||||
|
private readonly TicketUpdateInput $ticketUpdateInput,
|
||||||
|
private readonly UpdateProjectTicket $updateProjectTicket,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(Request $request): Response|ResponseFactory
|
||||||
|
{
|
||||||
|
$selector = $request->validate(['ticket_id' => ['required', 'integer']]);
|
||||||
|
$user = $this->kanbanGitMcp->user($request);
|
||||||
|
$ticket = $this->kanbanGitMcp->ticketForWrite($user, (int) $selector['ticket_id']);
|
||||||
|
$validated = $this->ticketUpdateInput->validate($request, $ticket, $user);
|
||||||
|
$updated = $this->updateProjectTicket->handle($ticket, $user, $validated, true);
|
||||||
|
|
||||||
|
return Response::structured([
|
||||||
|
'data' => $this->kanbanGitMcp->ticketPayload($ticket->fresh()),
|
||||||
|
'meta' => ['updated' => $updated],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, Type> */
|
||||||
|
public function schema(JsonSchema $schema): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'ticket_id' => $schema->integer()->description('Existing ticket ID.')->required(),
|
||||||
|
'title' => $schema->string()->max(255)->description('Nonblank title; omit to preserve.'),
|
||||||
|
'description' => $schema->string()->nullable()->description('Description; null clears it. '.TicketDescriptionFormat::instructions()),
|
||||||
|
'ticket_type_id' => $schema->integer()->description('Type ID from get_project_configuration.'),
|
||||||
|
'ticket_priority_id' => $schema->integer()->description('Priority ID from get_project_configuration.'),
|
||||||
|
'parent_ticket_id' => $schema->integer()->nullable()->description('Parent-enabled ticket in this project; null removes the parent. Cycles are forbidden.'),
|
||||||
|
'assignee_user_id' => $schema->integer()->nullable()->description('Allowed person ID; null unassigns.'),
|
||||||
|
'author_user_id' => $schema->integer()->nullable()->description('Author ID; null clears. Changes require project admin permission.'),
|
||||||
|
'story_points' => $schema->integer()->min(0)->nullable()->description('Nonnegative estimate; null clears.'),
|
||||||
|
'is_blocked' => $schema->boolean()->description('Blocked state; true requires a nonblank blocker comment. Omit to preserve.'),
|
||||||
|
'blocked_comment' => $schema->string()->nullable()->description('Blocker reason; null clears only when the ticket is unblocked.'),
|
||||||
|
'acceptance_criteria' => $schema->array()->items($schema->object([
|
||||||
|
'id' => $schema->integer()->min(1)->description('Existing criterion ID belonging to this ticket. Omit for a new item. IDs must be unique.'),
|
||||||
|
'text' => $schema->string()->description('Nonblank criterion text.')->required(),
|
||||||
|
'checked' => $schema->boolean()->description('Checked state, defaults to false for supplied items.'),
|
||||||
|
'indent' => $schema->integer()->min(0)->max(8)->description('Indent level, defaults to 0; cannot skip a level.'),
|
||||||
|
])->withoutAdditionalProperties())->description('Ordered replacement checklist. Omitted items are removed. [] clears; omit field to preserve; null is invalid. '.TicketDescriptionFormat::criteriaInstructions()),
|
||||||
|
'field_values' => $schema->array()->items($schema->object([
|
||||||
|
'ticket_field_definition_id' => $schema->integer()->description('Project field ID, supplied once.')->required(),
|
||||||
|
'value_number' => $schema->number()->nullable()->description('Value for a numeric field; null clears.'),
|
||||||
|
'value_user_id' => $schema->integer()->nullable()->description('Allowed person ID for a user field; null clears.'),
|
||||||
|
])->withoutAdditionalProperties())->description('Replacement custom-field list. Omitted definitions are cleared. [] clears all; omit field to preserve; null is invalid.'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Mcp\Tools;
|
namespace App\Mcp\Tools;
|
||||||
|
|
||||||
use App\Actions\Tickets\UpdateProjectTicket;
|
use App\Actions\Tickets\UpdateProjectTicket;
|
||||||
|
use App\Mcp\Support\DescribesTicketWriting;
|
||||||
use App\Mcp\Support\KanbanGitMcp;
|
use App\Mcp\Support\KanbanGitMcp;
|
||||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
use Illuminate\JsonSchema\Types\Type;
|
use Illuminate\JsonSchema\Types\Type;
|
||||||
@@ -19,6 +20,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
|||||||
#[IsDestructive(false)]
|
#[IsDestructive(false)]
|
||||||
class UpdateTicketAssignee extends Tool
|
class UpdateTicketAssignee extends Tool
|
||||||
{
|
{
|
||||||
|
use DescribesTicketWriting;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||||
private readonly UpdateProjectTicket $updateProjectTicket,
|
private readonly UpdateProjectTicket $updateProjectTicket,
|
||||||
|
|||||||
@@ -2,10 +2,14 @@
|
|||||||
|
|
||||||
namespace App\Mcp\Tools;
|
namespace App\Mcp\Tools;
|
||||||
|
|
||||||
|
use App\Actions\Tickets\Support\TicketDescriptionFormat;
|
||||||
use App\Actions\Tickets\UpdateProjectTicket;
|
use App\Actions\Tickets\UpdateProjectTicket;
|
||||||
|
use App\Mcp\Support\DescribesTicketWriting;
|
||||||
use App\Mcp\Support\KanbanGitMcp;
|
use App\Mcp\Support\KanbanGitMcp;
|
||||||
|
use App\Rules\VisualTicketDescription;
|
||||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
use Illuminate\JsonSchema\Types\Type;
|
use Illuminate\JsonSchema\Types\Type;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
use Laravel\Mcp\Request;
|
use Laravel\Mcp\Request;
|
||||||
use Laravel\Mcp\Response;
|
use Laravel\Mcp\Response;
|
||||||
use Laravel\Mcp\ResponseFactory;
|
use Laravel\Mcp\ResponseFactory;
|
||||||
@@ -19,6 +23,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
|||||||
#[IsDestructive(false)]
|
#[IsDestructive(false)]
|
||||||
class UpdateTicketDescription extends Tool
|
class UpdateTicketDescription extends Tool
|
||||||
{
|
{
|
||||||
|
use DescribesTicketWriting;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||||
private readonly UpdateProjectTicket $updateProjectTicket,
|
private readonly UpdateProjectTicket $updateProjectTicket,
|
||||||
@@ -27,7 +33,7 @@ class UpdateTicketDescription extends Tool
|
|||||||
public function handle(Request $request): Response|ResponseFactory
|
public function handle(Request $request): Response|ResponseFactory
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'description' => ['nullable', 'string'],
|
'description' => ['nullable', 'string', new VisualTicketDescription],
|
||||||
'ticket_id' => ['required', 'integer'],
|
'ticket_id' => ['required', 'integer'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -35,7 +41,7 @@ class UpdateTicketDescription extends Tool
|
|||||||
$ticket = $this->kanbanGitMcp->ticketForWrite($user, (int) $validated['ticket_id']);
|
$ticket = $this->kanbanGitMcp->ticketForWrite($user, (int) $validated['ticket_id']);
|
||||||
$payload = [
|
$payload = [
|
||||||
...$this->kanbanGitMcp->updatePayload($ticket),
|
...$this->kanbanGitMcp->updatePayload($ticket),
|
||||||
'description' => $validated['description'] ?? null,
|
...(Arr::exists($validated, 'description') ? ['description' => Arr::get($validated, 'description')] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
$wasUpdated = $this->updateProjectTicket->handle($ticket, $user, $payload);
|
$wasUpdated = $this->updateProjectTicket->handle($ticket, $user, $payload);
|
||||||
@@ -58,7 +64,7 @@ class UpdateTicketDescription extends Tool
|
|||||||
return [
|
return [
|
||||||
'description' => $schema->string()
|
'description' => $schema->string()
|
||||||
->nullable()
|
->nullable()
|
||||||
->description('New ticket description. Pass null to clear it.'),
|
->description('New ticket description. Omit to preserve; pass null to clear it. '.TicketDescriptionFormat::instructions()),
|
||||||
'ticket_id' => $schema->integer()
|
'ticket_id' => $schema->integer()
|
||||||
->description('Ticket ID.')
|
->description('Ticket ID.')
|
||||||
->required(),
|
->required(),
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Rules;
|
||||||
|
|
||||||
|
use App\Actions\Tickets\Support\TicketDescriptionFormat;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
|
||||||
|
class VisualTicketDescription implements ValidationRule
|
||||||
|
{
|
||||||
|
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||||
|
{
|
||||||
|
if (! is_string($value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$unsupported = TicketDescriptionFormat::unsupportedContent($value);
|
||||||
|
if ($unsupported !== []) {
|
||||||
|
$fail('The description contains unsupported '.implode(', ', $unsupported).'. Remove these contents or show literal examples inside code. Use ordinary Markdown and safe links; put acceptance criteria only in acceptance_criteria. No ticket fields were changed.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1451,20 +1451,20 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/framework",
|
"name": "laravel/framework",
|
||||||
"version": "v13.26.1",
|
"version": "v13.34.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/laravel/framework.git",
|
"url": "https://github.com/laravel/framework.git",
|
||||||
"reference": "e4a1bc52ef551d52e60244bb004256d6861da7ab"
|
"reference": "c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/laravel/framework/zipball/e4a1bc52ef551d52e60244bb004256d6861da7ab",
|
"url": "https://api.github.com/repos/laravel/framework/zipball/c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4",
|
||||||
"reference": "e4a1bc52ef551d52e60244bb004256d6861da7ab",
|
"reference": "c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"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",
|
"composer-runtime-api": "^2.2",
|
||||||
"doctrine/inflector": "^2.0.5",
|
"doctrine/inflector": "^2.0.5",
|
||||||
"dragonmantank/cron-expression": "^3.4",
|
"dragonmantank/cron-expression": "^3.4",
|
||||||
@@ -1576,18 +1576,20 @@
|
|||||||
"league/flysystem-sftp-v3": "^3.25.1",
|
"league/flysystem-sftp-v3": "^3.25.1",
|
||||||
"mockery/mockery": "^1.6.10",
|
"mockery/mockery": "^1.6.10",
|
||||||
"opis/json-schema": "^2.4.1",
|
"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",
|
"pda/pheanstalk": "^7.0.0 || ^8.0.0",
|
||||||
"php-http/discovery": "^1.15",
|
"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",
|
"phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0.3",
|
||||||
"predis/predis": "^2.3 || ^3.0",
|
"predis/predis": "^2.3 || ^3.0",
|
||||||
"rector/rector": "^2.3",
|
"rector/rector": "2.6.3",
|
||||||
"resend/resend-php": "^1.0",
|
"resend/resend-php": "^1.0",
|
||||||
"symfony/cache": "^7.4.0 || ^8.0.0",
|
"symfony/cache": "^7.4.0 || ^8.0.0",
|
||||||
"symfony/http-client": "^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/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": {
|
"suggest": {
|
||||||
"ably/ably-php": "Required to use the Ably broadcast driver (^1.0).",
|
"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/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/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/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/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",
|
"type": "library",
|
||||||
"extra": {
|
"extra": {
|
||||||
@@ -1674,7 +1678,7 @@
|
|||||||
"issues": "https://github.com/laravel/framework/issues",
|
"issues": "https://github.com/laravel/framework/issues",
|
||||||
"source": "https://github.com/laravel/framework"
|
"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",
|
"name": "laravel/mcp",
|
||||||
@@ -2003,16 +2007,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "league/commonmark",
|
"name": "league/commonmark",
|
||||||
"version": "2.10.0",
|
"version": "2.10.3",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/thephpleague/commonmark.git",
|
"url": "https://github.com/thephpleague/commonmark.git",
|
||||||
"reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4"
|
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
|
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6efbd9c472b91db0a3350fcd601c8332c2382e1f",
|
||||||
"reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
|
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -2106,7 +2110,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-08-11T16:06:25+00:00"
|
"time": "2026-09-21T13:07:34+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "league/config",
|
"name": "league/config",
|
||||||
@@ -2192,16 +2196,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "league/flysystem",
|
"name": "league/flysystem",
|
||||||
"version": "3.35.2",
|
"version": "3.36.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/thephpleague/flysystem.git",
|
"url": "https://github.com/thephpleague/flysystem.git",
|
||||||
"reference": "b277b5dc3d56650b68904117124e79c851e12376"
|
"reference": "f7fb152932f30072d573510cbd4dd657d6475b25"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/b277b5dc3d56650b68904117124e79c851e12376",
|
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f7fb152932f30072d573510cbd4dd657d6475b25",
|
||||||
"reference": "b277b5dc3d56650b68904117124e79c851e12376",
|
"reference": "f7fb152932f30072d573510cbd4dd657d6475b25",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -2269,9 +2273,9 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/thephpleague/flysystem/issues",
|
"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",
|
"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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "kanbangit-native",
|
"name": "kanbangit-native",
|
||||||
"version": "0.1.5",
|
"version": "0.2.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -14,8 +14,9 @@
|
|||||||
"build:android": "tauri android build --debug --apk --target aarch64",
|
"build:android": "tauri android build --debug --apk --target aarch64",
|
||||||
"smoke:macos": "cargo test --manifest-path src-tauri/Cargo.toml --locked --features smoke-test,custom-protocol --test macos_smoke",
|
"smoke:macos": "cargo test --manifest-path src-tauri/Cargo.toml --locked --features smoke-test,custom-protocol --test macos_smoke",
|
||||||
"dev:ios": "tauri ios dev",
|
"dev:ios": "tauri ios dev",
|
||||||
"build:ios:simulator": "rm -rf src-tauri/gen/apple/build/arm64-sim/KanbanGit.app && tauri ios build --debug --target aarch64-sim --no-sign --ci",
|
"build:ios:simulator": "node build-ios.mjs --mode simulator",
|
||||||
"build:ios:device": "tauri ios build --debug --target aarch64 --export-method debugging --archive-only",
|
"build:ios:device": "node build-ios.mjs --mode development",
|
||||||
|
"release:ios": "node build-ios.mjs --mode testflight",
|
||||||
"open:ios": "tauri ios dev --open --no-dev-server",
|
"open:ios": "tauri ios dev --open --no-dev-server",
|
||||||
"prepare:ios": "xcodegen generate --spec src-tauri/gen/apple/project.yml",
|
"prepare:ios": "xcodegen generate --spec src-tauri/gen/apple/project.yml",
|
||||||
"test:ios": "xcodebuild -project src-tauri/gen/apple/kanbangit-native.xcodeproj -scheme kanbangit-native_iOS -configuration debug -destination \"platform=iOS Simulator,id=${IOS_SIMULATOR_ID:?Set IOS_SIMULATOR_ID to an installed simulator UUID}\" -parallel-testing-enabled NO CODE_SIGNING_ALLOWED=NO test",
|
"test:ios": "xcodebuild -project src-tauri/gen/apple/kanbangit-native.xcodeproj -scheme kanbangit-native_iOS -configuration debug -destination \"platform=iOS Simulator,id=${IOS_SIMULATOR_ID:?Set IOS_SIMULATOR_ID to an installed simulator UUID}\" -parallel-testing-enabled NO CODE_SIGNING_ALLOWED=NO test",
|
||||||
@@ -24,5 +25,5 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "2.11.4"
|
"@tauri-apps/cli": "2.11.4"
|
||||||
},
|
},
|
||||||
"releaseNotes": "Install Update in the KanbanGit menu is now enabled when an update is available. The Update badge has been removed from the Dock icon."
|
"releaseNotes": "Fix blocked navigation after changing project creation options or other forms on iOS by displaying native confirmations for unsaved changes. Pending confirmations close safely when changing workspaces."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ export async function publishMacos({
|
|||||||
await wait(2000);
|
await wait(2000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function assertUnpublishedRelease() {
|
function assertUnpublishedRelease(allowSameVersion = false) {
|
||||||
const file = join(staging, 'published-latest.json');
|
const file = join(staging, 'published-latest.json');
|
||||||
try {
|
try {
|
||||||
s3('get-object', ['--key', latestKey, file]);
|
s3('get-object', ['--key', latestKey, file]);
|
||||||
@@ -261,7 +261,11 @@ export async function publishMacos({
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
const published = JSON.parse(readFileSync(file, 'utf8'));
|
const published = JSON.parse(readFileSync(file, 'utf8'));
|
||||||
if (compareVersions(metadata.version, published.version) <= 0) {
|
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(
|
throw new Error(
|
||||||
'This version has already been released through the update feed; bump the app version. Published objects were not overwritten.',
|
'This version has already been released through the update feed; bump the app version. Published objects were not overwritten.',
|
||||||
);
|
);
|
||||||
@@ -365,6 +369,55 @@ export async function publishMacos({
|
|||||||
);
|
);
|
||||||
putReleaseFile('update.json', latest, 'application/json');
|
putReleaseFile('update.json', latest, 'application/json');
|
||||||
await verifyPublic('update.json', latest);
|
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');
|
const latestFile = join(staging, 'latest.json');
|
||||||
writeFileSync(latestFile, latest);
|
writeFileSync(latestFile, latest);
|
||||||
function publishLatest(condition) {
|
function publishLatest(condition) {
|
||||||
|
|||||||
@@ -1770,7 +1770,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kanbangit-native"
|
name = "kanbangit-native"
|
||||||
version = "0.1.5"
|
version = "0.2.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "kanbangit-native"
|
name = "kanbangit-native"
|
||||||
version = "0.1.5"
|
version = "0.2.8"
|
||||||
description = "KanbanGit native application"
|
description = "KanbanGit native application"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
publish = false
|
publish = false
|
||||||
|
|||||||
@@ -1,3 +1,74 @@
|
|||||||
fn main() {
|
fn main() {
|
||||||
|
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") {
|
||||||
|
build_navigation();
|
||||||
|
}
|
||||||
tauri_build::build();
|
tauri_build::build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_navigation() {
|
||||||
|
use std::{env, path::PathBuf, process::Command};
|
||||||
|
|
||||||
|
println!("cargo:rerun-if-changed=src/navigation.m");
|
||||||
|
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.InputDevice
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
import android.webkit.CookieManager
|
import android.webkit.CookieManager
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
@@ -59,6 +60,59 @@ class PlatformTest {
|
|||||||
instrumentation.waitForIdleSync()
|
instrumentation.waitForIdleSync()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun coldStartRestoresWorkspaceWithoutSelectionOrLauncherHistory() {
|
||||||
|
val mode = InstrumentationRegistry.getArguments().getString("startupConfig", "saved")
|
||||||
|
val preferences = instrumentation.targetContext.getSharedPreferences("workspace", Context.MODE_PRIVATE)
|
||||||
|
preferences.edit().clear().apply {
|
||||||
|
if (mode == "saved") putString("server", "https://127.0.0.1:9/app/")
|
||||||
|
if (mode == "invalid") putString("server", "http://invalid.example/")
|
||||||
|
}.commit()
|
||||||
|
val scenario = ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
val launcher = AtomicReference<WebView>()
|
||||||
|
fun findLauncher(view: View): WebView? {
|
||||||
|
if (view is WebView && view.url?.startsWith(NavigationPolicy.LAUNCHER_ORIGIN) == true) return view
|
||||||
|
if (view is ViewGroup) for (index in 0 until view.childCount) {
|
||||||
|
findLauncher(view.getChildAt(index))?.let { return it }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
waitFor {
|
||||||
|
scenario.onActivity { launcher.set(findLauncher(it.window.decorView)) }
|
||||||
|
launcher.get() != null && evaluate(launcher.get(), "typeof window.updateLauncher === 'function'") == "true"
|
||||||
|
}
|
||||||
|
if (mode == "saved") {
|
||||||
|
waitFor {
|
||||||
|
val state = evaluate(launcher.get(), "document.querySelector('#connection-status').dataset.state")
|
||||||
|
if (state == "\"connecting\"") {
|
||||||
|
assertEquals("true", evaluate(launcher.get(), "document.querySelector('#workspace-settings').hidden"))
|
||||||
|
}
|
||||||
|
state == "\"connectionError\""
|
||||||
|
}
|
||||||
|
assertEquals("https://127.0.0.1:9/app/", preferences.getString("server", null))
|
||||||
|
assertEquals("\"Try again\"", evaluate(launcher.get(), "document.querySelector('.open-workspace').textContent"))
|
||||||
|
scenario.onActivity {
|
||||||
|
assertEquals("https://127.0.0.1:9/app/dashboard", it.workspace?.originalUrl)
|
||||||
|
assertFalse(it.workspace!!.canGoBack())
|
||||||
|
it.openWorkspace("https://127.0.0.1:9/app/")
|
||||||
|
}
|
||||||
|
waitFor { evaluate(launcher.get(), "document.querySelector('#connection-status').dataset.state") == "\"connectionError\"" }
|
||||||
|
} else {
|
||||||
|
waitFor { evaluate(launcher.get(), "document.querySelector('#workspace-settings').hidden") == "false" }
|
||||||
|
scenario.onActivity { assertNull(it.workspace) }
|
||||||
|
}
|
||||||
|
scenario.onActivity { it.openWorkspace("https://kanbangit.de/") }
|
||||||
|
val remote = AtomicReference<WebView>()
|
||||||
|
waitFor {
|
||||||
|
var visible = false
|
||||||
|
scenario.onActivity { remote.set(it.workspace); visible = it.workspace?.visibility == View.VISIBLE }
|
||||||
|
visible && remote.get() != null
|
||||||
|
}
|
||||||
|
assertEquals("\"/login\"", evaluate(remote.get(), "location.pathname"))
|
||||||
|
assertEquals("1", evaluate(remote.get(), "history.length"))
|
||||||
|
assertEquals("https://kanbangit.de/", preferences.getString("server", null))
|
||||||
|
scenario.moveToState(Lifecycle.State.CREATED)
|
||||||
|
}
|
||||||
|
|
||||||
@Test fun workspacePreservesFormsAndIsolatesServers() {
|
@Test fun workspacePreservesFormsAndIsolatesServers() {
|
||||||
instrumentation.targetContext.getSharedPreferences("workspace", Context.MODE_PRIVATE).edit().clear().commit()
|
instrumentation.targetContext.getSharedPreferences("workspace", Context.MODE_PRIVATE).edit().clear().commit()
|
||||||
val scenario = ActivityScenario.launch(MainActivity::class.java)
|
val scenario = ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
|||||||
@@ -164,9 +164,9 @@ class MainActivity : TauriActivity() {
|
|||||||
if (initialized) return
|
if (initialized) return
|
||||||
initialized = true
|
initialized = true
|
||||||
val saved = preferences.getString("server", null)
|
val saved = preferences.getString("server", null)
|
||||||
server = saved?.let(NavigationPolicy::installation) ?: NavigationPolicy.DEFAULT_SERVER
|
val restored = saved?.let(NavigationPolicy::installation)
|
||||||
updateLauncher()
|
server = restored ?: NavigationPolicy.DEFAULT_SERVER
|
||||||
if (saved != null) openWorkspace(server)
|
if (restored != null) openWorkspace(restored) else updateLauncher()
|
||||||
}
|
}
|
||||||
"/action/open" -> openWorkspace(url.getQueryParameter("server").orEmpty())
|
"/action/open" -> openWorkspace(url.getQueryParameter("server").orEmpty())
|
||||||
"/action/resume" -> if (ready) showWorkspace()
|
"/action/resume" -> if (ready) showWorkspace()
|
||||||
@@ -313,8 +313,8 @@ class MainActivity : TauriActivity() {
|
|||||||
val firstLoad = !ready
|
val firstLoad = !ready
|
||||||
ready = true
|
ready = true
|
||||||
CookieManager.getInstance().flush()
|
CookieManager.getInstance().flush()
|
||||||
updateLauncher()
|
|
||||||
if (firstLoad) showWorkspace()
|
if (firstLoad) showWorkspace()
|
||||||
|
updateLauncher()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) {
|
override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) {
|
||||||
@@ -333,7 +333,7 @@ class MainActivity : TauriActivity() {
|
|||||||
remote.visibility = View.GONE
|
remote.visibility = View.GONE
|
||||||
content.addView(remote, 0, FrameLayout.LayoutParams(-1, -1))
|
content.addView(remote, 0, FrameLayout.LayoutParams(-1, -1))
|
||||||
startTimeout(remote)
|
startTimeout(remote)
|
||||||
remote.loadUrl(server)
|
remote.loadUrl(NavigationPolicy.dashboard(server))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startTimeout(view: WebView) {
|
private fun startTimeout(view: WebView) {
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ object NavigationPolicy {
|
|||||||
"https://${uri.host.lowercase(Locale.ROOT)}$port$path"
|
"https://${uri.host.lowercase(Locale.ROOT)}$port$path"
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
|
|
||||||
|
fun dashboard(server: String): String = URI(requireNotNull(installation(server))).resolve("dashboard").toString()
|
||||||
|
|
||||||
fun allows(server: String, destination: String): Boolean = runCatching {
|
fun allows(server: String, destination: String): Boolean = runCatching {
|
||||||
val base = URI(server)
|
val base = URI(server)
|
||||||
val target = URI(destination)
|
val target = URI(destination)
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ class NavigationPolicyTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun startupTargetsDashboardWithinTheInstallation() {
|
||||||
|
assertEquals("https://example.com/dashboard", NavigationPolicy.dashboard("https://example.com/"))
|
||||||
|
assertEquals("https://example.com:8443/kanban/dashboard", NavigationPolicy.dashboard("https://example.com:8443/kanban/"))
|
||||||
|
}
|
||||||
|
|
||||||
@Test fun allowsLoginTwoFactorAndHistoryOnlyOnTheSelectedOrigin() {
|
@Test fun allowsLoginTwoFactorAndHistoryOnlyOnTheSelectedOrigin() {
|
||||||
val server = "https://example.com:8443/app/"
|
val server = "https://example.com:8443/app/"
|
||||||
for (path in listOf("/login", "/two-factor-challenge", "/projects/1?ticket=53#details")) {
|
for (path in listOf("/login", "/two-factor-challenge", "/projects/1?ticket=53#details")) {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 422 B After Width: | Height: | Size: 638 B |
|
Before Width: | Height: | Size: 548 B After Width: | Height: | Size: 774 B |
|
Before Width: | Height: | Size: 548 B After Width: | Height: | Size: 774 B |
|
Before Width: | Height: | Size: 800 B After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 415 B After Width: | Height: | Size: 658 B |
|
Before Width: | Height: | Size: 845 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 845 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 548 B After Width: | Height: | Size: 774 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 955 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.4 KiB |
@@ -22,6 +22,8 @@ struct WorkspaceServer: Codable, Equatable {
|
|||||||
return components.url
|
return components.url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var dashboardURL: URL { url.appendingPathComponent("dashboard") }
|
||||||
|
|
||||||
func allows(_ destination: URL) -> Bool {
|
func allows(_ destination: URL) -> Bool {
|
||||||
destination.scheme?.lowercased() == "https"
|
destination.scheme?.lowercased() == "https"
|
||||||
&& destination.host?.lowercased() == url.host?.lowercased()
|
&& destination.host?.lowercased() == url.host?.lowercased()
|
||||||
@@ -63,3 +65,32 @@ struct WorkspaceConnection {
|
|||||||
return true
|
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 connection = WorkspaceConnection()
|
||||||
private var pendingNavigation: WKNavigation?
|
private var pendingNavigation: WKNavigation?
|
||||||
private var timeout: DispatchWorkItem?
|
private var timeout: DispatchWorkItem?
|
||||||
|
private let loading = UIActivityIndicatorView(style: .large)
|
||||||
private let launcher = UIScrollView()
|
private let launcher = UIScrollView()
|
||||||
private let stack = UIStackView()
|
private let stack = UIStackView()
|
||||||
private let address = UITextField()
|
private let address = UITextField()
|
||||||
@@ -39,9 +40,10 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
private let connectButton = UIButton(type: .system)
|
private let connectButton = UIButton(type: .system)
|
||||||
private let resumeButton = UIButton(type: .system)
|
private let resumeButton = UIButton(type: .system)
|
||||||
private let toolbar = UIToolbar()
|
private let toolbar = UIToolbar()
|
||||||
private var historyObservation: NSKeyValueObservation?
|
private var settingsAvailable = false
|
||||||
private var backButton: UIBarButtonItem!
|
private var toolbarHeight: NSLayoutConstraint!
|
||||||
private var appearance: UISegmentedControl!
|
private var appearance: UISegmentedControl!
|
||||||
|
private var javaScriptConfirmation: (alert: UIAlertController, completion: (Bool) -> Void)?
|
||||||
|
|
||||||
init(defaults: UserDefaults = .standard) {
|
init(defaults: UserDefaults = .standard) {
|
||||||
self.defaults = defaults
|
self.defaults = defaults
|
||||||
@@ -50,6 +52,8 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
|
|
||||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
deinit { javaScriptConfirmation?.completion(false) }
|
||||||
|
|
||||||
private func text(_ key: String) -> String {
|
private func text(_ key: String) -> String {
|
||||||
NSLocalizedString(key, comment: "KanbanGit iOS workspace")
|
NSLocalizedString(key, comment: "KanbanGit iOS workspace")
|
||||||
}
|
}
|
||||||
@@ -58,6 +62,14 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
view.backgroundColor = .systemBackground
|
view.backgroundColor = .systemBackground
|
||||||
view.tintColor = UIColor(red: 0, green: 0.43, blue: 0.18, alpha: 1)
|
view.tintColor = UIColor(red: 0, green: 0.43, blue: 0.18, alpha: 1)
|
||||||
|
loading.accessibilityIdentifier = "workspaceLoading"
|
||||||
|
loading.accessibilityLabel = text("connecting")
|
||||||
|
loading.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(loading)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
loading.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||||
|
loading.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||||
|
])
|
||||||
configureLauncher()
|
configureLauncher()
|
||||||
configureToolbar()
|
configureToolbar()
|
||||||
if let data = defaults.data(forKey: serverKey) {
|
if let data = defaults.data(forKey: serverKey) {
|
||||||
@@ -69,8 +81,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
address.text = server?.url.absoluteString ?? "https://kanbangit.de/"
|
address.text = server?.url.absoluteString ?? "https://kanbangit.de/"
|
||||||
showSettings()
|
if let server { open(server.url) } else { showSettings() }
|
||||||
if let server { open(server.url) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func label(_ key: String, style: UIFont.TextStyle = .body) -> UILabel {
|
private func label(_ key: String, style: UIFont.TextStyle = .body) -> UILabel {
|
||||||
@@ -83,9 +94,10 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func configureLauncher() {
|
private func configureLauncher() {
|
||||||
|
launcher.isHidden = true
|
||||||
launcher.accessibilityIdentifier = "serverSettings"
|
launcher.accessibilityIdentifier = "serverSettings"
|
||||||
launcher.keyboardDismissMode = .interactive
|
launcher.keyboardDismissMode = .interactive
|
||||||
launcher.alwaysBounceVertical = true
|
launcher.bounces = false
|
||||||
view.addSubview(launcher)
|
view.addSubview(launcher)
|
||||||
launcher.translatesAutoresizingMaskIntoConstraints = false
|
launcher.translatesAutoresizingMaskIntoConstraints = false
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
@@ -152,14 +164,15 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func configureToolbar() {
|
private func configureToolbar() {
|
||||||
|
toolbar.isHidden = true
|
||||||
toolbar.translatesAutoresizingMaskIntoConstraints = false
|
toolbar.translatesAutoresizingMaskIntoConstraints = false
|
||||||
view.addSubview(toolbar)
|
view.addSubview(toolbar)
|
||||||
backButton = UIBarButtonItem(title: text("back"), style: .plain, target: self, action: #selector(goBack))
|
|
||||||
backButton.accessibilityIdentifier = "workspaceBack"
|
|
||||||
let settings = UIBarButtonItem(title: text("serverSettings"), style: .plain, target: self, action: #selector(showSettings))
|
let settings = UIBarButtonItem(title: text("serverSettings"), style: .plain, target: self, action: #selector(showSettings))
|
||||||
settings.accessibilityIdentifier = "showServerSettings"
|
settings.accessibilityIdentifier = "showServerSettings"
|
||||||
toolbar.items = [backButton, UIBarButtonItem(systemItem: .flexibleSpace), settings]
|
toolbar.items = [UIBarButtonItem(systemItem: .flexibleSpace), settings]
|
||||||
|
toolbarHeight = toolbar.heightAnchor.constraint(equalToConstant: 44)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
|
toolbarHeight,
|
||||||
toolbar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
toolbar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||||
toolbar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
toolbar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
||||||
toolbar.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
toolbar.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
||||||
@@ -189,23 +202,32 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
|
|
||||||
func open(_ url: URL) {
|
func open(_ url: URL) {
|
||||||
if server?.url == url, connection.ready { showWorkspace(); return }
|
if server?.url == url, connection.ready { showWorkspace(); return }
|
||||||
|
completeJavaScriptConfirmation(false)
|
||||||
let selected = WorkspaceServer.selecting(url, current: server)
|
let selected = WorkspaceServer.selecting(url, current: server)
|
||||||
guard let data = try? JSONEncoder().encode(selected) else { status.text = text("storageError"); return }
|
guard let data = try? JSONEncoder().encode(selected) else { status.text = text("storageError"); return }
|
||||||
defaults.set(data, forKey: serverKey)
|
defaults.set(data, forKey: serverKey)
|
||||||
timeout?.cancel()
|
timeout?.cancel()
|
||||||
historyObservation = nil
|
|
||||||
workspace?.stopLoading()
|
workspace?.stopLoading()
|
||||||
workspace?.navigationDelegate = nil
|
workspace?.navigationDelegate = nil
|
||||||
|
workspace?.configuration.userContentController.removeScriptMessageHandler(forName: "kanbanSettings", contentWorld: .page)
|
||||||
workspace?.removeFromSuperview()
|
workspace?.removeFromSuperview()
|
||||||
connection = WorkspaceConnection()
|
connection = WorkspaceConnection()
|
||||||
server = selected
|
server = selected
|
||||||
let configuration = WKWebViewConfiguration()
|
let configuration = WKWebViewConfiguration()
|
||||||
configuration.websiteDataStore = WKWebsiteDataStore(forIdentifier: selected.profile)
|
configuration.websiteDataStore = WKWebsiteDataStore(forIdentifier: selected.profile)
|
||||||
// Remote content receives a plain WKWebView, with no native message handlers or Tauri bridge.
|
let scriptURL = Bundle(for: WorkspaceViewController.self).url(forResource: "workspace-navigation", withExtension: "js")!
|
||||||
|
let script = try! String(contentsOf: scriptURL, encoding: .utf8)
|
||||||
|
configuration.userContentController.addUserScript(WKUserScript(source: script, injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
||||||
|
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)
|
let webview = WKWebView(frame: .zero, configuration: configuration)
|
||||||
webview.navigationDelegate = self
|
webview.navigationDelegate = self
|
||||||
webview.uiDelegate = self
|
webview.uiDelegate = self
|
||||||
webview.allowsBackForwardNavigationGestures = true
|
webview.allowsBackForwardNavigationGestures = true
|
||||||
|
webview.scrollView.bounces = false
|
||||||
webview.accessibilityIdentifier = "workspace"
|
webview.accessibilityIdentifier = "workspace"
|
||||||
webview.translatesAutoresizingMaskIntoConstraints = false
|
webview.translatesAutoresizingMaskIntoConstraints = false
|
||||||
webview.isHidden = true
|
webview.isHidden = true
|
||||||
@@ -217,13 +239,13 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
webview.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
webview.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
||||||
])
|
])
|
||||||
workspace = webview
|
workspace = webview
|
||||||
historyObservation = webview.observe(\.canGoBack, options: [.initial, .new]) { [weak self] view, _ in
|
|
||||||
self?.backButton.isEnabled = view.canGoBack
|
|
||||||
}
|
|
||||||
view.endEditing(true)
|
view.endEditing(true)
|
||||||
address.text = url.absoluteString
|
address.text = url.absoluteString
|
||||||
|
launcher.isHidden = true
|
||||||
|
toolbar.isHidden = true
|
||||||
|
loading.startAnimating()
|
||||||
beginNavigation()
|
beginNavigation()
|
||||||
pendingNavigation = webview.load(URLRequest(url: url))
|
pendingNavigation = webview.load(URLRequest(url: selected.dashboardURL))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func beginNavigation() {
|
private func beginNavigation() {
|
||||||
@@ -247,6 +269,8 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func showSettings() {
|
@objc private func showSettings() {
|
||||||
|
completeJavaScriptConfirmation(false)
|
||||||
|
loading.stopAnimating()
|
||||||
view.endEditing(true)
|
view.endEditing(true)
|
||||||
launcher.isHidden = false
|
launcher.isHidden = false
|
||||||
toolbar.isHidden = true
|
toolbar.isHidden = true
|
||||||
@@ -256,14 +280,83 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
|
|
||||||
@objc private func showWorkspace() {
|
@objc private func showWorkspace() {
|
||||||
guard connection.ready else { return }
|
guard connection.ready else { return }
|
||||||
|
loading.stopAnimating()
|
||||||
view.endEditing(true)
|
view.endEditing(true)
|
||||||
launcher.isHidden = true
|
launcher.isHidden = true
|
||||||
toolbar.isHidden = false
|
toolbar.isHidden = settingsAvailable
|
||||||
workspace?.isHidden = false
|
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, runJavaScriptConfirmPanelWithMessage message: String,
|
||||||
|
initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
|
||||||
|
guard webView === workspace, viewIfLoaded?.window != nil,
|
||||||
|
presentedViewController == nil, javaScriptConfirmation == nil else {
|
||||||
|
completionHandler(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let alert = UIAlertController(title: frame.request.url?.host ?? text("appName"),
|
||||||
|
message: message, preferredStyle: .alert)
|
||||||
|
let cancel = UIAlertAction(title: text("cancel"), style: .cancel) { [weak self] _ in
|
||||||
|
self?.completeJavaScriptConfirmation(false)
|
||||||
|
}
|
||||||
|
alert.addAction(cancel)
|
||||||
|
alert.addAction(UIAlertAction(title: text("ok"), style: .default) { [weak self] _ in
|
||||||
|
self?.completeJavaScriptConfirmation(true)
|
||||||
|
})
|
||||||
|
alert.preferredAction = cancel
|
||||||
|
javaScriptConfirmation = (alert, completionHandler)
|
||||||
|
present(alert, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func completeJavaScriptConfirmation(_ accepted: Bool) {
|
||||||
|
guard let confirmation = javaScriptConfirmation else { return }
|
||||||
|
javaScriptConfirmation = nil
|
||||||
|
if confirmation.alert.presentingViewController == nil {
|
||||||
|
confirmation.completion(accepted)
|
||||||
|
} else {
|
||||||
|
confirmation.alert.dismiss(animated: false) {
|
||||||
|
confirmation.completion(accepted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||||
guard webView === workspace, let url = action.request.url else { decisionHandler(.cancel); return }
|
guard webView === workspace, let url = action.request.url else { decisionHandler(.cancel); return }
|
||||||
@@ -291,12 +384,14 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
|
|
||||||
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||||
guard webView === workspace else { return }
|
guard webView === workspace else { return }
|
||||||
|
setSettingsAvailable(false)
|
||||||
pendingNavigation = navigation
|
pendingNavigation = navigation
|
||||||
beginNavigation()
|
beginNavigation()
|
||||||
}
|
}
|
||||||
|
|
||||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
guard webView === workspace, navigation === pendingNavigation else { return }
|
guard webView === workspace, navigation === pendingNavigation, connection.connecting,
|
||||||
|
let url = webView.url, server?.allows(url) == true else { return }
|
||||||
let firstLoad = !connection.ready
|
let firstLoad = !connection.ready
|
||||||
timeout?.cancel()
|
timeout?.cancel()
|
||||||
connection.finish()
|
connection.finish()
|
||||||
@@ -304,7 +399,6 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
connectButton.setTitle(text("openWorkspace"), for: .normal)
|
connectButton.setTitle(text("openWorkspace"), for: .normal)
|
||||||
status.text = text("connected")
|
status.text = text("connected")
|
||||||
resumeButton.isHidden = false
|
resumeButton.isHidden = false
|
||||||
backButton.isEnabled = webView.canGoBack
|
|
||||||
if firstLoad { showWorkspace() }
|
if firstLoad { showWorkspace() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,3 +424,13 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
showSettings()
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"switchWarning" = "Switching servers closes the current workspace and discards unsaved inputs. You will sign in separately on the new server.";
|
"switchWarning" = "Switching servers closes the current workspace and discards unsaved inputs. You will sign in separately on the new server.";
|
||||||
"confirmSwitch" = "Switch server";
|
"confirmSwitch" = "Switch server";
|
||||||
"cancel" = "Cancel";
|
"cancel" = "Cancel";
|
||||||
|
"ok" = "OK";
|
||||||
"connecting" = "Connecting to your server…";
|
"connecting" = "Connecting to your server…";
|
||||||
"connected" = "Your workspace is open. Use Server settings to change servers.";
|
"connected" = "Your workspace is open. Use Server settings to change servers.";
|
||||||
"connectionError" = "The server did not finish loading. Check your connection, server address and HTTPS certificate, then try again. An already open workspace and its inputs are kept.";
|
"connectionError" = "The server did not finish loading. Check your connection, server address and HTTPS certificate, then try again. An already open workspace and its inputs are kept.";
|
||||||
|
|||||||
@@ -17,6 +17,71 @@ final class WorkspaceTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testDashboardAddressPreservesTheInstallationPath() throws {
|
||||||
|
let url = try XCTUnwrap(WorkspaceServer.normalize("https://example.com:8443/kanban/"))
|
||||||
|
XCTAssertEqual(WorkspaceServer(url: url, profile: UUID()).dashboardURL.absoluteString,
|
||||||
|
"https://example.com:8443/kanban/dashboard")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testColdStartHidesSelectionUntilFailureAndPreservesSavedWorkspace() throws {
|
||||||
|
let name = "KAN69-startup-\(UUID())"
|
||||||
|
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
||||||
|
defer { defaults.removePersistentDomain(forName: name) }
|
||||||
|
let saved = WorkspaceServer(url: URL(string: "https://example.invalid/kanban/")!, profile: UUID())
|
||||||
|
let data = try JSONEncoder().encode(saved)
|
||||||
|
defaults.set(data, forKey: "ios.activeServer")
|
||||||
|
let controller = WorkspaceViewController(defaults: defaults)
|
||||||
|
controller.loadViewIfNeeded()
|
||||||
|
let launcher = try XCTUnwrap(controller.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" })
|
||||||
|
let loading = try XCTUnwrap(controller.view.subviews.first { $0.accessibilityIdentifier == "workspaceLoading" } as? UIActivityIndicatorView)
|
||||||
|
XCTAssertTrue(launcher.isHidden)
|
||||||
|
XCTAssertTrue(loading.isAnimating)
|
||||||
|
XCTAssertEqual(controller.server, saved)
|
||||||
|
let webview = try XCTUnwrap(controller.workspace)
|
||||||
|
XCTAssertFalse(webview.canGoBack)
|
||||||
|
XCTAssertTrue(webview.backForwardList.backList.isEmpty)
|
||||||
|
webview.stopLoading()
|
||||||
|
let navigation = try XCTUnwrap(webview.loadSimulatedRequest(URLRequest(url: saved.dashboardURL), responseHTML: "<html></html>"))
|
||||||
|
controller.webView(webview, didStartProvisionalNavigation: navigation)
|
||||||
|
XCTAssertTrue(launcher.isHidden)
|
||||||
|
controller.webView(webview, didFailProvisionalNavigation: navigation,
|
||||||
|
withError: NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet))
|
||||||
|
XCTAssertFalse(launcher.isHidden)
|
||||||
|
XCTAssertFalse(loading.isAnimating)
|
||||||
|
XCTAssertEqual(try JSONDecoder().decode(WorkspaceServer.self, from: XCTUnwrap(defaults.data(forKey: "ios.activeServer"))), saved)
|
||||||
|
controller.webView(webview, didFinish: navigation)
|
||||||
|
XCTAssertFalse(launcher.isHidden, "A late finish must not hide recovery after a failure")
|
||||||
|
controller.open(saved.url)
|
||||||
|
XCTAssertTrue(launcher.isHidden)
|
||||||
|
XCTAssertTrue(loading.isAnimating)
|
||||||
|
XCTAssertEqual(controller.server?.profile, saved.profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testMissingOrInvalidConfigurationShowsSetupAndServerChangesAreRestored() throws {
|
||||||
|
let name = "KAN69-setup-\(UUID())"
|
||||||
|
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
||||||
|
defer { defaults.removePersistentDomain(forName: name) }
|
||||||
|
for data in [nil, Data("invalid".utf8), try JSONEncoder().encode(WorkspaceServer(url: URL(string: "http://example.com/")!, profile: UUID()))] {
|
||||||
|
defaults.set(data, forKey: "ios.activeServer")
|
||||||
|
let controller = WorkspaceViewController(defaults: defaults)
|
||||||
|
controller.loadViewIfNeeded()
|
||||||
|
XCTAssertNil(controller.workspace)
|
||||||
|
let launcher = try XCTUnwrap(controller.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" })
|
||||||
|
XCTAssertFalse(launcher.isHidden)
|
||||||
|
}
|
||||||
|
let controller = WorkspaceViewController(defaults: defaults)
|
||||||
|
controller.loadViewIfNeeded()
|
||||||
|
controller.open(URL(string: "https://first.invalid/")!)
|
||||||
|
controller.open(URL(string: "https://second.invalid/app/")!)
|
||||||
|
let restored = WorkspaceViewController(defaults: defaults)
|
||||||
|
restored.loadViewIfNeeded()
|
||||||
|
XCTAssertEqual(restored.server, controller.server)
|
||||||
|
XCTAssertEqual(restored.server?.dashboardURL.absoluteString, "https://second.invalid/app/dashboard")
|
||||||
|
XCTAssertTrue(restored.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" }?.isHidden == true)
|
||||||
|
}
|
||||||
|
|
||||||
func testExternalLinksStayOutsideTheSelectedServer() throws {
|
func testExternalLinksStayOutsideTheSelectedServer() throws {
|
||||||
let server = WorkspaceServer(url: URL(string: "https://example.com/")!, profile: UUID())
|
let server = WorkspaceServer(url: URL(string: "https://example.com/")!, profile: UUID())
|
||||||
for value in ["https://git.example.com/org/repo/pulls/1#diff", "http://git.example.com/repo", "https://example.com:8443/other"] {
|
for value in ["https://git.example.com/org/repo/pulls/1#diff", "http://git.example.com/repo", "https://example.com:8443/other"] {
|
||||||
@@ -30,39 +95,218 @@ final class WorkspaceTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func testBackButtonTracksSameDocumentHistory() async throws {
|
func testWebKitOwnsInteractiveGesturesAndKeepsScrollBounds() async throws {
|
||||||
let name = "KAN55-history-\(UUID())"
|
let name = "KAN71-history-\(UUID())"
|
||||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
||||||
defer { defaults.removePersistentDomain(forName: name) }
|
defer { defaults.removePersistentDomain(forName: name) }
|
||||||
let controller = WorkspaceViewController(defaults: defaults)
|
let controller = WorkspaceViewController(defaults: defaults)
|
||||||
controller.loadViewIfNeeded()
|
controller.loadViewIfNeeded()
|
||||||
let url = try XCTUnwrap(WorkspaceServer.normalize("https://example.invalid/"))
|
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||||
|
let url = URL(string: "https://example.invalid/dashboard")!
|
||||||
|
controller.open(URL(string: "https://example.invalid/")!)
|
||||||
|
let webview = try XCTUnwrap(controller.workspace)
|
||||||
|
webview.stopLoading()
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testDirtyFormNavigationPresentsNativeConfirmation() async throws {
|
||||||
|
let animationsEnabled = UIView.areAnimationsEnabled
|
||||||
|
UIView.setAnimationsEnabled(false)
|
||||||
|
defer { UIView.setAnimationsEnabled(animationsEnabled) }
|
||||||
|
let name = "ios-confirmation-\(UUID())"
|
||||||
|
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
||||||
|
defer { defaults.removePersistentDomain(forName: name) }
|
||||||
|
let controller = WorkspaceViewController(defaults: defaults)
|
||||||
|
let window = UIWindow(frame: UIScreen.main.bounds)
|
||||||
|
window.rootViewController = controller
|
||||||
|
window.makeKeyAndVisible()
|
||||||
|
defer { window.isHidden = true; window.rootViewController = nil }
|
||||||
|
let url = URL(string: "https://example.invalid/")!
|
||||||
controller.open(url)
|
controller.open(url)
|
||||||
let webview = try XCTUnwrap(controller.workspace)
|
let webview = try XCTUnwrap(controller.workspace)
|
||||||
webview.stopLoading()
|
webview.stopLoading()
|
||||||
webview.loadHTMLString("<html><body><input id='draft' value='unsaved'></body></html>", baseURL: url)
|
webview.loadSimulatedRequest(URLRequest(url: url.appendingPathComponent("projects/create")), responseHTML: """
|
||||||
var loaded = false
|
<html><body><input id="duplicate" type="radio"><input id="draft" value="Unsaved project">
|
||||||
for _ in 0..<50 {
|
<a id="back" href="#back">Back</a><script>
|
||||||
if let value = try? await webview.evaluateJavaScript("document.getElementById('draft')?.value"),
|
window.visits = 0;
|
||||||
value as? String == "unsaved" { loaded = true; break }
|
document.querySelector('#back').onclick = event => {
|
||||||
try await Task.sleep(for: .milliseconds(100))
|
event.preventDefault();
|
||||||
|
if (!document.querySelector('#duplicate').checked || confirm('Discard unsaved changes?')) window.visits++;
|
||||||
|
};
|
||||||
|
</script></body></html>
|
||||||
|
""")
|
||||||
|
try await waitFor(webview, "document.readyState === 'complete' && typeof window.visits === 'number'")
|
||||||
|
_ = try await webview.evaluateJavaScript("document.querySelector('#back').click(); true")
|
||||||
|
let pristineVisits = try await webview.evaluateJavaScript("window.visits")
|
||||||
|
XCTAssertEqual(pristineVisits as? Int, 1)
|
||||||
|
XCTAssertNil(controller.presentedViewController)
|
||||||
|
_ = try await webview.evaluateJavaScript("document.querySelector('#duplicate').click(); true")
|
||||||
|
var expectedVisits = 1
|
||||||
|
for (index, accepted) in [false, true, false, true].enumerated() {
|
||||||
|
let click = Task { try await webview.evaluateJavaScript("document.querySelector('#back').click(); true") }
|
||||||
|
let alert = try await waitForConfirmation(controller)
|
||||||
|
XCTAssertEqual(alert.title, "example.invalid")
|
||||||
|
XCTAssertEqual(alert.message, "Discard unsaved changes?")
|
||||||
|
XCTAssertEqual(alert.actions.map(\.style), [.cancel, .default])
|
||||||
|
XCTAssertTrue(alert.preferredAction === alert.actions.first, "The default action must preserve the draft")
|
||||||
|
if index == 3 {
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
alert.dismiss(animated: false) { continuation.resume() }
|
||||||
}
|
}
|
||||||
XCTAssertTrue(loaded)
|
}
|
||||||
_ = try await webview.evaluateJavaScript("history.pushState({}, '', '#ticket')")
|
controller.completeJavaScriptConfirmation(accepted)
|
||||||
|
controller.completeJavaScriptConfirmation(!accepted)
|
||||||
|
_ = try await click.value
|
||||||
|
if accepted { expectedVisits += 1 }
|
||||||
|
let visits = try await webview.evaluateJavaScript("window.visits")
|
||||||
|
XCTAssertEqual(visits as? Int, expectedVisits, "Only accepted visits may run, exactly once each")
|
||||||
|
let draft = try await webview.evaluateJavaScript("document.querySelector('#draft').value")
|
||||||
|
XCTAssertEqual(draft as? String, "Unsaved project")
|
||||||
|
XCTAssertNil(controller.presentedViewController)
|
||||||
|
}
|
||||||
|
|
||||||
|
let otherAlert = UIAlertController(title: "Already open", message: nil, preferredStyle: .alert)
|
||||||
|
controller.present(otherAlert, animated: false)
|
||||||
|
let blocked = try await webview.evaluateJavaScript("confirm('Another dialog')")
|
||||||
|
XCTAssertEqual(blocked as? Bool, false, "A blocked presentation must release JavaScript")
|
||||||
|
XCTAssertTrue(controller.presentedViewController === otherAlert)
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
otherAlert.dismiss(animated: false) { continuation.resume() }
|
||||||
|
}
|
||||||
|
|
||||||
|
let pendingClick = Task { try await webview.evaluateJavaScript("document.querySelector('#back').click(); true") }
|
||||||
|
_ = try await waitForConfirmation(controller)
|
||||||
|
controller.open(URL(string: "https://other.invalid/")!)
|
||||||
|
_ = try await pendingClick.value
|
||||||
|
XCTAssertNil(controller.presentedViewController, "Replacing a workspace must dismiss its confirmation")
|
||||||
|
let oldVisits = try await webview.evaluateJavaScript("window.visits")
|
||||||
|
XCTAssertEqual(oldVisits as? Int, expectedVisits)
|
||||||
|
let stale = try await webview.evaluateJavaScript("confirm('Previous workspace')")
|
||||||
|
XCTAssertEqual(stale as? Bool, false, "A previous workspace must not present new confirmations")
|
||||||
|
XCTAssertNil(controller.presentedViewController)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func waitForConfirmation(_ controller: WorkspaceViewController) async throws -> UIAlertController {
|
||||||
|
for _ in 0..<100 {
|
||||||
|
if let alert = controller.presentedViewController as? UIAlertController, !alert.isBeingPresented { return alert }
|
||||||
|
try await Task.sleep(for: .milliseconds(50))
|
||||||
|
}
|
||||||
|
return try XCTUnwrap(controller.presentedViewController as? UIAlertController,
|
||||||
|
"Dirty forms must offer a visible confirmation instead of silently cancelling 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)
|
let toolbar = try XCTUnwrap(controller.view.subviews.compactMap { $0 as? UIToolbar }.first)
|
||||||
for _ in 0..<20 {
|
XCTAssertFalse(toolbar.isHidden, "Older pages must retain native recovery")
|
||||||
if toolbar.items?.first?.isEnabled == true { break }
|
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))
|
try await Task.sleep(for: .milliseconds(50))
|
||||||
}
|
}
|
||||||
XCTAssertTrue(webview.canGoBack)
|
XCTFail("WebView condition timed out: \(expression)")
|
||||||
XCTAssertTrue(toolbar.items?.first?.isEnabled == true)
|
|
||||||
webview.goBack()
|
|
||||||
for _ in 0..<20 {
|
|
||||||
if let hash = try? await webview.evaluateJavaScript("location.hash"), hash as? String == "" { break }
|
|
||||||
try await Task.sleep(for: .milliseconds(50))
|
|
||||||
}
|
|
||||||
let draft = try await webview.evaluateJavaScript("document.getElementById('draft').value")
|
|
||||||
XCTAssertEqual(draft as? String, "unsaved")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testServerChangesNeverReusePreviousProfile() throws {
|
func testServerChangesNeverReusePreviousProfile() throws {
|
||||||
@@ -122,7 +366,7 @@ final class WorkspaceTests: XCTestCase {
|
|||||||
controller.open(url)
|
controller.open(url)
|
||||||
let server = try XCTUnwrap(controller.server)
|
let server = try XCTUnwrap(controller.server)
|
||||||
XCTAssertEqual(controller.workspace?.configuration.websiteDataStore.identifier, server.profile)
|
XCTAssertEqual(controller.workspace?.configuration.websiteDataStore.identifier, server.profile)
|
||||||
XCTAssertTrue(controller.workspace?.configuration.userContentController.userScripts.isEmpty == true)
|
XCTAssertEqual(controller.workspace?.configuration.userContentController.userScripts.count, 2)
|
||||||
let restored = WorkspaceViewController(defaults: defaults)
|
let restored = WorkspaceViewController(defaults: defaults)
|
||||||
restored.loadViewIfNeeded()
|
restored.loadViewIfNeeded()
|
||||||
XCTAssertEqual(restored.server, server)
|
XCTAssertEqual(restored.server, server)
|
||||||
|
|||||||
@@ -13,11 +13,50 @@ final class WorkspaceUITests: XCTestCase {
|
|||||||
XCTAssertTrue(element.isHittable)
|
XCTAssertTrue(element.isHittable)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testSavedWorkspaceColdStartNeverShowsSelectionBeforeRecovery() {
|
||||||
|
continueAfterFailure = false
|
||||||
|
XCUIDevice.shared.orientation = .portrait
|
||||||
|
defer { XCUIDevice.shared.orientation = .portrait }
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launchEnvironment["OS_ACTIVITY_DT_MODE"] = "NO"
|
||||||
|
app.launchEnvironment["OS_ACTIVITY_MODE"] = "disable"
|
||||||
|
app.launch()
|
||||||
|
let settings = app.buttons["showServerSettings"]
|
||||||
|
if settings.waitForExistence(timeout: 3) { settings.tap() }
|
||||||
|
let address = app.textFields["serverAddress"]
|
||||||
|
XCTAssertTrue(address.waitForExistence(timeout: 20))
|
||||||
|
address.tap()
|
||||||
|
if address.buttons.firstMatch.exists { address.buttons.firstMatch.tap() }
|
||||||
|
address.typeText("https://127.0.0.1:9/")
|
||||||
|
app.buttons["openWorkspace"].tap()
|
||||||
|
let confirmation = app.alerts.buttons["Switch server"]
|
||||||
|
if confirmation.waitForExistence(timeout: 1) { confirmation.tap() }
|
||||||
|
XCTAssertTrue(app.staticTexts["connectionStatus"].waitForExistence(timeout: 20))
|
||||||
|
app.terminate()
|
||||||
|
app.launch()
|
||||||
|
// An unreachable saved server may fail immediately; only recovery may reveal setup.
|
||||||
|
for _ in 0..<30 {
|
||||||
|
if address.exists {
|
||||||
|
XCTAssertTrue(app.staticTexts["connectionStatus"].label.contains("server did not finish loading"))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
XCTAssertFalse(app.staticTexts["Choose your workspace."].exists)
|
||||||
|
Thread.sleep(forTimeInterval: 0.1)
|
||||||
|
}
|
||||||
|
XCTAssertTrue(address.waitForExistence(timeout: 20))
|
||||||
|
XCTAssertEqual(address.value as? String, "https://127.0.0.1:9/")
|
||||||
|
XCTAssertTrue(app.buttons["openWorkspace"].label.contains("Try again"))
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func testLauncherKeyboardValidationRotationAndAppearance() {
|
func testLauncherKeyboardValidationRotationAndAppearance() {
|
||||||
continueAfterFailure = false
|
continueAfterFailure = false
|
||||||
XCUIDevice.shared.orientation = .portrait
|
XCUIDevice.shared.orientation = .portrait
|
||||||
|
defer { XCUIDevice.shared.orientation = .portrait }
|
||||||
let app = XCUIApplication()
|
let app = XCUIApplication()
|
||||||
|
app.launchEnvironment["OS_ACTIVITY_DT_MODE"] = "NO"
|
||||||
|
app.launchEnvironment["OS_ACTIVITY_MODE"] = "disable"
|
||||||
app.launch()
|
app.launch()
|
||||||
let settings = app.buttons["showServerSettings"]
|
let settings = app.buttons["showServerSettings"]
|
||||||
if settings.waitForExistence(timeout: 3) { settings.tap() }
|
if settings.waitForExistence(timeout: 3) { settings.tap() }
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
009F8FEC2765B2F19DC12CA8 /* WorkspaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */; };
|
009F8FEC2765B2F19DC12CA8 /* WorkspaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */; };
|
||||||
|
0EC5CB2D7F774DBDCA69BCD8 /* workspace-navigation.js in Resources */ = {isa = PBXBuildFile; fileRef = 7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */; };
|
||||||
0FAB3CC7BB466BFF871C0C30 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 31630170C358EA8657E34854 /* Assets.xcassets */; };
|
0FAB3CC7BB466BFF871C0C30 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 31630170C358EA8657E34854 /* Assets.xcassets */; };
|
||||||
205D0CBA33F7D3C59AAD5300 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 63685B1189795CAEA85F7AB7 /* QuartzCore.framework */; };
|
205D0CBA33F7D3C59AAD5300 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 63685B1189795CAEA85F7AB7 /* QuartzCore.framework */; };
|
||||||
333E7761EA87AE29CE04F1AC /* main.mm in Sources */ = {isa = PBXBuildFile; fileRef = F46963153D20DB21D97843E9 /* main.mm */; };
|
333E7761EA87AE29CE04F1AC /* main.mm in Sources */ = {isa = PBXBuildFile; fileRef = F46963153D20DB21D97843E9 /* main.mm */; };
|
||||||
@@ -18,6 +19,8 @@
|
|||||||
7245AD23F39C4620BAA3AA92 /* WorkspaceUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED199829CC1B1598F58EDC9B /* WorkspaceUITests.swift */; };
|
7245AD23F39C4620BAA3AA92 /* WorkspaceUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED199829CC1B1598F58EDC9B /* WorkspaceUITests.swift */; };
|
||||||
73954B7AADE8C37663BEF288 /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CA65E67046326EA008175045 /* Metal.framework */; };
|
73954B7AADE8C37663BEF288 /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CA65E67046326EA008175045 /* Metal.framework */; };
|
||||||
98D5B23C9BE2F62C23DF397A /* WorkspacePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 554AB059B4228085924F4959 /* WorkspacePolicy.swift */; };
|
98D5B23C9BE2F62C23DF397A /* WorkspacePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 554AB059B4228085924F4959 /* WorkspacePolicy.swift */; };
|
||||||
|
99291BB0C1DE7714FB32F554 /* workspace-navigation.js in Resources */ = {isa = PBXBuildFile; fileRef = 7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */; };
|
||||||
|
99D76D98A2416CAA713186C2 /* workspace-settings.js in Resources */ = {isa = PBXBuildFile; fileRef = BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */; };
|
||||||
A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C8C02BC0E757897278EB954A /* Localizable.strings */; };
|
A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C8C02BC0E757897278EB954A /* Localizable.strings */; };
|
||||||
B939982700E800053A9C7663 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE3B71153677F722CAC6DCF7 /* Security.framework */; };
|
B939982700E800053A9C7663 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE3B71153677F722CAC6DCF7 /* Security.framework */; };
|
||||||
C362C102D4950A81D89BB8C8 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */; };
|
C362C102D4950A81D89BB8C8 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */; };
|
||||||
@@ -25,10 +28,18 @@
|
|||||||
C53E7EC983C008DF98244C74 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 2AB6F9398EA3CB62A68287D2 /* InfoPlist.strings */; };
|
C53E7EC983C008DF98244C74 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 2AB6F9398EA3CB62A68287D2 /* InfoPlist.strings */; };
|
||||||
CD64D99EBD87E6FFC0C9001C /* MetalKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D581BB90601D7A89F8F188E2 /* MetalKit.framework */; };
|
CD64D99EBD87E6FFC0C9001C /* MetalKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D581BB90601D7A89F8F188E2 /* MetalKit.framework */; };
|
||||||
D8772A5E508092D6F831058A /* libapp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 91991DFDB0C064B06DF854ED /* libapp.a */; };
|
D8772A5E508092D6F831058A /* libapp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 91991DFDB0C064B06DF854ED /* libapp.a */; };
|
||||||
|
EAD1D5A29503456CEED3B324 /* workspace-settings.js in Resources */ = {isa = PBXBuildFile; fileRef = BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */; };
|
||||||
EB37EAF992C1A156795CAEC0 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */; };
|
EB37EAF992C1A156795CAEC0 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
1C790E40E71919331C1C4F34 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = AA6AD2A91A9E86D319D86D59 /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = 721CF221CEDDC28FF2C5EBD5;
|
||||||
|
remoteInfo = "kanbangit-native_iOS";
|
||||||
|
};
|
||||||
8665A25D07C5F1A295164797 /* PBXContainerItemProxy */ = {
|
8665A25D07C5F1A295164797 /* PBXContainerItemProxy */ = {
|
||||||
isa = PBXContainerItemProxy;
|
isa = PBXContainerItemProxy;
|
||||||
containerPortal = AA6AD2A91A9E86D319D86D59 /* Project object */;
|
containerPortal = AA6AD2A91A9E86D319D86D59 /* Project object */;
|
||||||
@@ -43,22 +54,29 @@
|
|||||||
05A753935B6CB0316DA56D96 /* desktop.rs */ = {isa = PBXFileReference; path = desktop.rs; sourceTree = "<group>"; };
|
05A753935B6CB0316DA56D96 /* desktop.rs */ = {isa = PBXFileReference; path = desktop.rs; sourceTree = "<group>"; };
|
||||||
08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTests.swift; sourceTree = "<group>"; };
|
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; };
|
0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; };
|
||||||
23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = "kanbangit-native_iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
1CDE5C303A96423E2E6A194D /* settings.rs */ = {isa = PBXFileReference; path = settings.rs; sourceTree = "<group>"; };
|
||||||
2B96972F58CC158AB3DFF94A /* lib.rs */ = {isa = PBXFileReference; path = lib.rs; sourceTree = "<group>"; };
|
2B96972F58CC158AB3DFF94A /* lib.rs */ = {isa = PBXFileReference; path = lib.rs; sourceTree = "<group>"; };
|
||||||
31630170C358EA8657E34854 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
31630170C358EA8657E34854 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
47C531812A31207F97A89C5F /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
47C531812A31207F97A89C5F /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
||||||
4C76059D31C50B198779A5F7 /* en.lproj */ = {isa = PBXFileReference; path = en.lproj; sourceTree = "<group>"; };
|
4C76059D31C50B198779A5F7 /* en.lproj */ = {isa = PBXFileReference; path = en.lproj; sourceTree = "<group>"; };
|
||||||
554AB059B4228085924F4959 /* WorkspacePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacePolicy.swift; sourceTree = "<group>"; };
|
554AB059B4228085924F4959 /* WorkspacePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacePolicy.swift; sourceTree = "<group>"; };
|
||||||
|
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; };
|
63685B1189795CAEA85F7AB7 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; };
|
||||||
|
6610D376CF901AC1733D394E /* updates.rs */ = {isa = PBXFileReference; path = updates.rs; sourceTree = "<group>"; };
|
||||||
7440C7F0426E6B3977DD1D94 /* main.rs */ = {isa = PBXFileReference; path = main.rs; sourceTree = "<group>"; };
|
7440C7F0426E6B3977DD1D94 /* main.rs */ = {isa = PBXFileReference; path = main.rs; sourceTree = "<group>"; };
|
||||||
|
7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "workspace-navigation.js"; sourceTree = "<group>"; };
|
||||||
8243D0C3821DAC731FECF105 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; };
|
8243D0C3821DAC731FECF105 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; };
|
||||||
|
8C0F963909020429B3D257D5 /* navigation.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = navigation.m; sourceTree = "<group>"; };
|
||||||
91991DFDB0C064B06DF854ED /* libapp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libapp.a; sourceTree = "<group>"; };
|
91991DFDB0C064B06DF854ED /* libapp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libapp.a; sourceTree = "<group>"; };
|
||||||
92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; };
|
92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; };
|
||||||
946BA5CC3FD90AD90D101A36 /* kanbangit-native_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "kanbangit-native_iOS.entitlements"; sourceTree = "<group>"; };
|
946BA5CC3FD90AD90D101A36 /* kanbangit-native_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "kanbangit-native_iOS.entitlements"; sourceTree = "<group>"; };
|
||||||
9C56BE6890F0452F69D64795 /* android.rs */ = {isa = PBXFileReference; path = android.rs; sourceTree = "<group>"; };
|
9C56BE6890F0452F69D64795 /* android.rs */ = {isa = PBXFileReference; path = android.rs; sourceTree = "<group>"; };
|
||||||
9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
9E29B2FA90E1D53F604961F6 /* KanbanGit.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = KanbanGit.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
AEBAD9ABE758897C50BC1F2E /* locale.rs */ = {isa = PBXFileReference; path = locale.rs; sourceTree = "<group>"; };
|
AEBAD9ABE758897C50BC1F2E /* locale.rs */ = {isa = PBXFileReference; path = locale.rs; sourceTree = "<group>"; };
|
||||||
|
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; };
|
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>"; };
|
CAF08AD60D2F29A879CDAEBB /* ios.rs */ = {isa = PBXFileReference; path = ios.rs; sourceTree = "<group>"; };
|
||||||
D13B8D854BEB7757AB95231B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
D13B8D854BEB7757AB95231B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||||
@@ -108,6 +126,7 @@
|
|||||||
8606F3EE5A736385061692EF /* Sources */,
|
8606F3EE5A736385061692EF /* Sources */,
|
||||||
335B1262A9019C70C7F887A5 /* src */,
|
335B1262A9019C70C7F887A5 /* src */,
|
||||||
140A7DB7E2461E64B1FBFA70 /* Tests */,
|
140A7DB7E2461E64B1FBFA70 /* Tests */,
|
||||||
|
709BE6DF191B953724A55555 /* ui */,
|
||||||
3C9F31BB065F210267ECA390 /* UITests */,
|
3C9F31BB065F210267ECA390 /* UITests */,
|
||||||
3028C3786B437115B6486F1C /* Frameworks */,
|
3028C3786B437115B6486F1C /* Frameworks */,
|
||||||
59D2DE9FD94E6D2D64CB0117 /* Products */,
|
59D2DE9FD94E6D2D64CB0117 /* Products */,
|
||||||
@@ -146,7 +165,12 @@
|
|||||||
2B96972F58CC158AB3DFF94A /* lib.rs */,
|
2B96972F58CC158AB3DFF94A /* lib.rs */,
|
||||||
AEBAD9ABE758897C50BC1F2E /* locale.rs */,
|
AEBAD9ABE758897C50BC1F2E /* locale.rs */,
|
||||||
7440C7F0426E6B3977DD1D94 /* main.rs */,
|
7440C7F0426E6B3977DD1D94 /* main.rs */,
|
||||||
|
5F71F06AB639140AB9714425 /* metadata.rs */,
|
||||||
|
8C0F963909020429B3D257D5 /* navigation.m */,
|
||||||
E8BA104C81071623E1FF367D /* server.rs */,
|
E8BA104C81071623E1FF367D /* server.rs */,
|
||||||
|
5F196E34D1B9E14FBD7191F4 /* settings.m */,
|
||||||
|
1CDE5C303A96423E2E6A194D /* settings.rs */,
|
||||||
|
6610D376CF901AC1733D394E /* updates.rs */,
|
||||||
);
|
);
|
||||||
name = src;
|
name = src;
|
||||||
path = ../../src;
|
path = ../../src;
|
||||||
@@ -172,13 +196,23 @@
|
|||||||
59D2DE9FD94E6D2D64CB0117 /* Products */ = {
|
59D2DE9FD94E6D2D64CB0117 /* Products */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */,
|
9E29B2FA90E1D53F604961F6 /* KanbanGit.app */,
|
||||||
A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */,
|
A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */,
|
||||||
9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */,
|
9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
709BE6DF191B953724A55555 /* ui */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */,
|
||||||
|
BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */,
|
||||||
|
);
|
||||||
|
name = ui;
|
||||||
|
path = ../../../ui;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
8606F3EE5A736385061692EF /* Sources */ = {
|
8606F3EE5A736385061692EF /* Sources */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -221,7 +255,7 @@
|
|||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
);
|
);
|
||||||
productName = "kanbangit-native_iOS";
|
productName = "kanbangit-native_iOS";
|
||||||
productReference = 23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */;
|
productReference = 9E29B2FA90E1D53F604961F6 /* KanbanGit.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
};
|
};
|
||||||
988CFF304CB81C8F3A4D6612 /* WorkspaceUITests */ = {
|
988CFF304CB81C8F3A4D6612 /* WorkspaceUITests */ = {
|
||||||
@@ -247,10 +281,12 @@
|
|||||||
buildConfigurationList = 26F675D59EF454AE0D263F35 /* Build configuration list for PBXNativeTarget "WorkspaceTests" */;
|
buildConfigurationList = 26F675D59EF454AE0D263F35 /* Build configuration list for PBXNativeTarget "WorkspaceTests" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
2A0467365B852D44D1A9FD5B /* Sources */,
|
2A0467365B852D44D1A9FD5B /* Sources */,
|
||||||
|
DC3295DE0893151786FEA37B /* Resources */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
|
DEBBEA8683567208E49E40A5 /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
name = WorkspaceTests;
|
name = WorkspaceTests;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
@@ -269,12 +305,18 @@
|
|||||||
LastUpgradeCheck = 1430;
|
LastUpgradeCheck = 1430;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
721CF221CEDDC28FF2C5EBD5 = {
|
721CF221CEDDC28FF2C5EBD5 = {
|
||||||
DevelopmentTeam = "";
|
DevelopmentTeam = ZZX2KZGTHU;
|
||||||
ProvisioningStyle = Automatic;
|
ProvisioningStyle = Manual;
|
||||||
};
|
};
|
||||||
988CFF304CB81C8F3A4D6612 = {
|
988CFF304CB81C8F3A4D6612 = {
|
||||||
|
DevelopmentTeam = ZZX2KZGTHU;
|
||||||
|
ProvisioningStyle = Manual;
|
||||||
TestTargetID = 721CF221CEDDC28FF2C5EBD5;
|
TestTargetID = 721CF221CEDDC28FF2C5EBD5;
|
||||||
};
|
};
|
||||||
|
CBC4561F8018D37DD49A0598 = {
|
||||||
|
DevelopmentTeam = ZZX2KZGTHU;
|
||||||
|
ProvisioningStyle = Manual;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = A9CA551DD3F3EBCCF8C41CC1 /* Build configuration list for PBXProject "kanbangit-native" */;
|
buildConfigurationList = A9CA551DD3F3EBCCF8C41CC1 /* Build configuration list for PBXProject "kanbangit-native" */;
|
||||||
@@ -307,6 +349,17 @@
|
|||||||
C53E7EC983C008DF98244C74 /* InfoPlist.strings in Resources */,
|
C53E7EC983C008DF98244C74 /* InfoPlist.strings in Resources */,
|
||||||
34C189B72A1B9EBE33D700D1 /* LaunchScreen.storyboard in Resources */,
|
34C189B72A1B9EBE33D700D1 /* LaunchScreen.storyboard in Resources */,
|
||||||
A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */,
|
A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */,
|
||||||
|
99291BB0C1DE7714FB32F554 /* workspace-navigation.js in Resources */,
|
||||||
|
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;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -332,7 +385,7 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "export PATH=\"$(dirname \"$(rustup which cargo)\"):$PATH\"\nexport RUSTC=\"$(rustup which rustc)\"\ncd \"$SRCROOT/../../..\"\n./node_modules/.bin/tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths \"${FRAMEWORK_SEARCH_PATHS:?}\" --header-search-paths \"${HEADER_SEARCH_PATHS:?}\" --gcc-preprocessor-definitions \"${GCC_PREPROCESSOR_DEFINITIONS:-}\" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}\n";
|
shellScript = "set -eu\nif [ -n \"${KANBANGIT_IOS_LIBRARY:-}\" ]; then\n mkdir -p \"$SRCROOT/Externals/arm64/$CONFIGURATION\"\n cp \"$KANBANGIT_IOS_LIBRARY\" \"$SRCROOT/Externals/arm64/$CONFIGURATION/libapp.a\"\n exit 0\nfi\nexport PATH=\"$(dirname \"$(rustup which cargo)\"):$PATH\"\nexport RUSTC=\"$(rustup which rustc)\"\ncd \"$SRCROOT/../../..\"\n./node_modules/.bin/tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths \"${FRAMEWORK_SEARCH_PATHS:?}\" --header-search-paths \"${HEADER_SEARCH_PATHS:?}\" --gcc-preprocessor-definitions \"${GCC_PREPROCESSOR_DEFINITIONS:-}\" --configuration ${CONFIGURATION:?} ${FORCE_COLOR:-} ${ARCHS:?}\n";
|
||||||
};
|
};
|
||||||
/* End PBXShellScriptBuildPhase section */
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
@@ -373,6 +426,11 @@
|
|||||||
target = 721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */;
|
target = 721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */;
|
||||||
targetProxy = 8665A25D07C5F1A295164797 /* PBXContainerItemProxy */;
|
targetProxy = 8665A25D07C5F1A295164797 /* PBXContainerItemProxy */;
|
||||||
};
|
};
|
||||||
|
DEBBEA8683567208E49E40A5 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = 721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */;
|
||||||
|
targetProxy = 1C790E40E71919331C1C4F34 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
/* End PBXTargetDependency section */
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin PBXVariantGroup section */
|
/* Begin PBXVariantGroup section */
|
||||||
@@ -404,9 +462,10 @@
|
|||||||
);
|
);
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Manual;
|
||||||
DEVELOPMENT_TEAM = "";
|
CURRENT_PROJECT_VERSION = 7;
|
||||||
|
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
|
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
|
||||||
@@ -421,9 +480,10 @@
|
|||||||
);
|
);
|
||||||
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||||
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.desktop;
|
MARKETING_VERSION = 0.2.8;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
|
||||||
PRODUCT_MODULE_NAME = KanbanGitNative;
|
PRODUCT_MODULE_NAME = KanbanGitNative;
|
||||||
PRODUCT_NAME = "KanbanGit";
|
PRODUCT_NAME = KanbanGit;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
@@ -498,13 +558,15 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Manual;
|
||||||
|
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@loader_path/Frameworks",
|
"@loader_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.uitests;
|
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios.uitests;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
@@ -577,9 +639,10 @@
|
|||||||
);
|
);
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Manual;
|
||||||
DEVELOPMENT_TEAM = "";
|
CURRENT_PROJECT_VERSION = 7;
|
||||||
|
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
|
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
|
||||||
@@ -594,9 +657,10 @@
|
|||||||
);
|
);
|
||||||
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||||
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.desktop;
|
MARKETING_VERSION = 0.2.8;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
|
||||||
PRODUCT_MODULE_NAME = KanbanGitNative;
|
PRODUCT_MODULE_NAME = KanbanGitNative;
|
||||||
PRODUCT_NAME = "KanbanGit";
|
PRODUCT_NAME = KanbanGit;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
@@ -608,13 +672,15 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Manual;
|
||||||
|
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@loader_path/Frameworks",
|
"@loader_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.uitests;
|
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios.uitests;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
@@ -626,16 +692,19 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Manual;
|
||||||
|
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@loader_path/Frameworks",
|
"@loader_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.tests;
|
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios.tests;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/KanbanGit.app/KanbanGit";
|
||||||
};
|
};
|
||||||
name = debug;
|
name = debug;
|
||||||
};
|
};
|
||||||
@@ -643,16 +712,19 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Manual;
|
||||||
|
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@loader_path/Frameworks",
|
"@loader_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.tests;
|
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios.tests;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/KanbanGit.app/KanbanGit";
|
||||||
};
|
};
|
||||||
name = release;
|
name = release;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,9 +15,9 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>0.1.0</string>
|
<string>$(MARKETING_VERSION)</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>0.1.0</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
name: kanbangit-native
|
name: kanbangit-native
|
||||||
options:
|
options:
|
||||||
bundleIdPrefix: de.kanbangit.desktop
|
bundleIdPrefix: de.kanbangit.ios
|
||||||
deploymentTarget:
|
deploymentTarget:
|
||||||
iOS: 17.0
|
iOS: 17.0
|
||||||
fileGroups: [../../src]
|
fileGroups: [../../src]
|
||||||
@@ -14,10 +14,17 @@ settingGroups:
|
|||||||
SWIFT_VERSION: "5.0"
|
SWIFT_VERSION: "5.0"
|
||||||
PRODUCT_MODULE_NAME: KanbanGitNative
|
PRODUCT_MODULE_NAME: KanbanGitNative
|
||||||
TARGETED_DEVICE_FAMILY: "1,2"
|
TARGETED_DEVICE_FAMILY: "1,2"
|
||||||
CODE_SIGN_STYLE: Automatic
|
CODE_SIGN_STYLE: Manual
|
||||||
DEVELOPMENT_TEAM: ""
|
DEVELOPMENT_TEAM: ZZX2KZGTHU
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING: false
|
ENABLE_USER_SCRIPT_SANDBOXING: false
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.desktop
|
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios
|
||||||
|
MARKETING_VERSION: "0.2.8"
|
||||||
|
CURRENT_PROJECT_VERSION: "7"
|
||||||
|
configs:
|
||||||
|
debug:
|
||||||
|
CODE_SIGN_IDENTITY: Apple Development
|
||||||
|
release:
|
||||||
|
CODE_SIGN_IDENTITY: Apple Distribution
|
||||||
targetTemplates:
|
targetTemplates:
|
||||||
app:
|
app:
|
||||||
type: application
|
type: application
|
||||||
@@ -33,8 +40,11 @@ targets:
|
|||||||
kanbangit-native_iOS:
|
kanbangit-native_iOS:
|
||||||
type: application
|
type: application
|
||||||
platform: iOS
|
platform: iOS
|
||||||
|
productName: KanbanGit
|
||||||
sources:
|
sources:
|
||||||
- path: Sources
|
- path: Sources
|
||||||
|
- path: ../../../ui/workspace-navigation.js
|
||||||
|
- path: ../../../ui/workspace-settings.js
|
||||||
- path: Assets.xcassets
|
- path: Assets.xcassets
|
||||||
- path: kanbangit-native_iOS
|
- path: kanbangit-native_iOS
|
||||||
- path: LaunchScreen.storyboard
|
- path: LaunchScreen.storyboard
|
||||||
@@ -55,8 +65,8 @@ targets:
|
|||||||
- UIInterfaceOrientationPortraitUpsideDown
|
- UIInterfaceOrientationPortraitUpsideDown
|
||||||
- UIInterfaceOrientationLandscapeLeft
|
- UIInterfaceOrientationLandscapeLeft
|
||||||
- UIInterfaceOrientationLandscapeRight
|
- UIInterfaceOrientationLandscapeRight
|
||||||
CFBundleShortVersionString: 0.1.0
|
CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||||
CFBundleVersion: "0.1.0"
|
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||||
entitlements:
|
entitlements:
|
||||||
path: kanbangit-native_iOS/kanbangit-native_iOS.entitlements
|
path: kanbangit-native_iOS/kanbangit-native_iOS.entitlements
|
||||||
scheme:
|
scheme:
|
||||||
@@ -88,10 +98,16 @@ targets:
|
|||||||
- sdk: WebKit.framework
|
- sdk: WebKit.framework
|
||||||
preBuildScripts:
|
preBuildScripts:
|
||||||
- script: |
|
- script: |
|
||||||
|
set -eu
|
||||||
|
if [ -n "${KANBANGIT_IOS_LIBRARY:-}" ]; then
|
||||||
|
mkdir -p "$SRCROOT/Externals/arm64/$CONFIGURATION"
|
||||||
|
cp "$KANBANGIT_IOS_LIBRARY" "$SRCROOT/Externals/arm64/$CONFIGURATION/libapp.a"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
export PATH="$(dirname "$(rustup which cargo)"):$PATH"
|
export PATH="$(dirname "$(rustup which cargo)"):$PATH"
|
||||||
export RUSTC="$(rustup which rustc)"
|
export RUSTC="$(rustup which rustc)"
|
||||||
cd "$SRCROOT/../../.."
|
cd "$SRCROOT/../../.."
|
||||||
./node_modules/.bin/tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths "${FRAMEWORK_SEARCH_PATHS:?}" --header-search-paths "${HEADER_SEARCH_PATHS:?}" --gcc-preprocessor-definitions "${GCC_PREPROCESSOR_DEFINITIONS:-}" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}
|
./node_modules/.bin/tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths "${FRAMEWORK_SEARCH_PATHS:?}" --header-search-paths "${HEADER_SEARCH_PATHS:?}" --gcc-preprocessor-definitions "${GCC_PREPROCESSOR_DEFINITIONS:-}" --configuration ${CONFIGURATION:?} ${FORCE_COLOR:-} ${ARCHS:?}
|
||||||
name: Build Rust Code
|
name: Build Rust Code
|
||||||
basedOnDependencyAnalysis: false
|
basedOnDependencyAnalysis: false
|
||||||
outputFiles:
|
outputFiles:
|
||||||
@@ -100,7 +116,11 @@ targets:
|
|||||||
WorkspaceTests:
|
WorkspaceTests:
|
||||||
type: bundle.unit-test
|
type: bundle.unit-test
|
||||||
platform: iOS
|
platform: iOS
|
||||||
|
dependencies:
|
||||||
|
- target: kanbangit-native_iOS
|
||||||
sources:
|
sources:
|
||||||
|
- path: ../../../ui/workspace-navigation.js
|
||||||
|
- path: ../../../ui/workspace-settings.js
|
||||||
- path: Tests
|
- path: Tests
|
||||||
- path: Sources/kanbangit-native/WorkspacePolicy.swift
|
- path: Sources/kanbangit-native/WorkspacePolicy.swift
|
||||||
- path: Sources/kanbangit-native/WorkspaceViewController.swift
|
- path: Sources/kanbangit-native/WorkspaceViewController.swift
|
||||||
@@ -108,7 +128,10 @@ targets:
|
|||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
GENERATE_INFOPLIST_FILE: YES
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.tests
|
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios.tests
|
||||||
|
TEST_HOST: $(BUILT_PRODUCTS_DIR)/KanbanGit.app/KanbanGit
|
||||||
|
DEVELOPMENT_TEAM: ZZX2KZGTHU
|
||||||
|
CODE_SIGN_STYLE: Manual
|
||||||
SWIFT_VERSION: "5.0"
|
SWIFT_VERSION: "5.0"
|
||||||
WorkspaceUITests:
|
WorkspaceUITests:
|
||||||
type: bundle.ui-testing
|
type: bundle.ui-testing
|
||||||
@@ -119,6 +142,8 @@ targets:
|
|||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
GENERATE_INFOPLIST_FILE: YES
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.uitests
|
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios.uitests
|
||||||
|
DEVELOPMENT_TEAM: ZZX2KZGTHU
|
||||||
|
CODE_SIGN_STYLE: Manual
|
||||||
SWIFT_VERSION: "5.0"
|
SWIFT_VERSION: "5.0"
|
||||||
TEST_TARGET_NAME: kanbangit-native_iOS
|
TEST_TARGET_NAME: kanbangit-native_iOS
|
||||||
|
|||||||
@@ -17,6 +17,15 @@ struct Connection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
fn begin_navigation(&mut self) -> u64 {
|
||||||
self.revision += 1;
|
self.revision += 1;
|
||||||
self.phase = "connecting";
|
self.phase = "connecting";
|
||||||
@@ -169,13 +178,15 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
|
|||||||
let popup_server = server_url.clone();
|
let popup_server = server_url.clone();
|
||||||
let popup_handle = app.clone();
|
let popup_handle = app.clone();
|
||||||
let popup_label = label.clone();
|
let popup_label = label.clone();
|
||||||
let window = WebviewWindowBuilder::new(app, &label, WebviewUrl::External(server.url))
|
let window =
|
||||||
|
WebviewWindowBuilder::new(app, &label, WebviewUrl::External(server.dashboard_url()))
|
||||||
.title(en::APP_NAME)
|
.title(en::APP_NAME)
|
||||||
.inner_size(1280.0, 850.0)
|
.inner_size(1280.0, 850.0)
|
||||||
.min_inner_size(375.0, 600.0)
|
.min_inner_size(375.0, 600.0)
|
||||||
.center()
|
.center()
|
||||||
.visible(false)
|
.visible(false)
|
||||||
.data_store_identifier(server.profile)
|
.data_store_identifier(server.profile)
|
||||||
|
.initialization_script(include_str!("../../ui/workspace-navigation.js"))
|
||||||
.on_navigation(move |destination| {
|
.on_navigation(move |destination| {
|
||||||
if !allows_server_navigation(&server_url, destination) {
|
if !allows_server_navigation(&server_url, destination) {
|
||||||
open_external(destination);
|
open_external(destination);
|
||||||
@@ -202,7 +213,12 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
|
|||||||
let state = app.state::<Mutex<Connection>>();
|
let state = app.state::<Mutex<Connection>>();
|
||||||
let should_show = {
|
let should_show = {
|
||||||
let mut state = state.lock().unwrap();
|
let mut state = state.lock().unwrap();
|
||||||
if state.label.as_deref() != Some(window.label()) {
|
if state.label.as_deref() != Some(window.label())
|
||||||
|
|| state.phase != "connecting"
|
||||||
|
|| !state.server.as_ref().is_some_and(|server| {
|
||||||
|
allows_server_navigation(&server.url, payload.url())
|
||||||
|
})
|
||||||
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let first_load = !state.ready;
|
let first_load = !state.ready;
|
||||||
@@ -210,7 +226,6 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
|
|||||||
state.phase = "connected";
|
state.phase = "connected";
|
||||||
first_load
|
first_load
|
||||||
};
|
};
|
||||||
update_launcher(app);
|
|
||||||
if should_show {
|
if should_show {
|
||||||
let _ = window.show();
|
let _ = window.show();
|
||||||
let _ = window.set_focus();
|
let _ = window.set_focus();
|
||||||
@@ -218,33 +233,88 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
|
|||||||
let _ = launcher.hide();
|
let _ = launcher.hide();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
update_launcher(app);
|
||||||
})
|
})
|
||||||
.build()
|
.build()
|
||||||
.map_err(|_| "connectionError")?;
|
.map_err(|_| "connectionError")?;
|
||||||
|
#[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());
|
||||||
|
})
|
||||||
|
.map_err(|_| "connectionError")?;
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
crate::settings::install(app, &window).map_err(|_| "connectionError")?;
|
||||||
let handle = app.clone();
|
let handle = app.clone();
|
||||||
window.on_window_event(move |event| {
|
window.on_window_event(move |event| {
|
||||||
if matches!(event, WindowEvent::Destroyed) {
|
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||||
let state = handle.state::<Mutex<Connection>>();
|
let current = handle
|
||||||
let current = {
|
.state::<Mutex<Connection>>()
|
||||||
let mut state = state.lock().unwrap();
|
.lock()
|
||||||
if state.label.as_deref() == Some(&label) {
|
.unwrap()
|
||||||
state.label = None;
|
.label
|
||||||
state.ready = false;
|
.as_deref()
|
||||||
state.revision += 1;
|
== Some(&label);
|
||||||
state.phase = "idle";
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if current {
|
if current {
|
||||||
show_launcher(&handle);
|
api.prevent_close();
|
||||||
|
handle.exit(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
Ok(())
|
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) {
|
fn handle_launcher_action(app: &AppHandle, destination: &Url) {
|
||||||
let result = match destination.path() {
|
let result = match destination.path() {
|
||||||
"/action/updates" => {
|
"/action/updates" => {
|
||||||
@@ -298,9 +368,11 @@ fn about_metadata(version: String) -> AboutMetadata<'static> {
|
|||||||
pub fn builder() -> tauri::Builder<tauri::Wry> {
|
pub fn builder() -> tauri::Builder<tauri::Wry> {
|
||||||
tauri::Builder::default().setup(|app| {
|
tauri::Builder::default().setup(|app| {
|
||||||
let (server, phase) = match Server::load(&configuration_path(app.handle())?) {
|
let (server, phase) = match Server::load(&configuration_path(app.handle())?) {
|
||||||
Ok(server) => (server, "idle"),
|
Ok(Some(server)) => (Some(server), "connecting"),
|
||||||
|
Ok(None) => (None, "idle"),
|
||||||
Err(_) => (None, "storageError"),
|
Err(_) => (None, "storageError"),
|
||||||
};
|
};
|
||||||
|
let startup_server = server.clone();
|
||||||
app.manage(Mutex::new(Connection {
|
app.manage(Mutex::new(Connection {
|
||||||
server,
|
server,
|
||||||
phase,
|
phase,
|
||||||
@@ -385,6 +457,7 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
|
|||||||
.title(en::APP_NAME)
|
.title(en::APP_NAME)
|
||||||
.inner_size(560.0, 720.0)
|
.inner_size(560.0, 720.0)
|
||||||
.min_inner_size(375.0, 640.0)
|
.min_inner_size(375.0, 640.0)
|
||||||
|
.visible(false)
|
||||||
.center()
|
.center()
|
||||||
.on_navigation(move |destination| {
|
.on_navigation(move |destination| {
|
||||||
if !allows_launcher_navigation(destination) {
|
if !allows_launcher_navigation(destination) {
|
||||||
@@ -407,35 +480,41 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
|
|||||||
update_launcher(&app);
|
update_launcher(&app);
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
crate::updates::refresh(&app);
|
crate::updates::refresh(&app);
|
||||||
std::thread::spawn(move || {
|
let needs_setup = app
|
||||||
let handle = app.clone();
|
|
||||||
let _ = app.run_on_main_thread(move || {
|
|
||||||
let server = handle
|
|
||||||
.state::<Mutex<Connection>>()
|
.state::<Mutex<Connection>>()
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.server
|
.server
|
||||||
.clone();
|
.is_none();
|
||||||
if let Some(server) = server {
|
if needs_setup {
|
||||||
if let Err(error) = open_workspace(&handle, server.url.as_str())
|
show_launcher(&app);
|
||||||
{
|
|
||||||
handle.state::<Mutex<Connection>>().lock().unwrap().phase =
|
|
||||||
error;
|
|
||||||
show_launcher(&handle);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.build()?;
|
.build()?;
|
||||||
let window = launcher.clone();
|
let window = launcher.clone();
|
||||||
launcher.on_window_event(move |event| {
|
launcher.on_window_event(move |event| {
|
||||||
if let WindowEvent::CloseRequested { api, .. } = event {
|
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||||
api.prevent_close();
|
api.prevent_close();
|
||||||
|
let has_workspace = window
|
||||||
|
.app_handle()
|
||||||
|
.state::<Mutex<Connection>>()
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.ready;
|
||||||
|
if has_workspace {
|
||||||
let _ = window.hide();
|
let _ = window.hide();
|
||||||
|
} else {
|
||||||
|
window.app_handle().exit(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if let Some(server) = startup_server {
|
||||||
|
if let Err(error) = open_workspace(app.handle(), server.url.as_str()) {
|
||||||
|
app.state::<Mutex<Connection>>().lock().unwrap().phase = error;
|
||||||
|
show_launcher(app.handle());
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -444,6 +523,30 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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")]
|
#[cfg(target_os = "macos")]
|
||||||
#[test]
|
#[test]
|
||||||
fn about_shows_app_version_followed_by_the_actual_build_number() {
|
fn about_shows_app_version_followed_by_the_actual_build_number() {
|
||||||
|
|||||||
@@ -27,3 +27,6 @@ pub use updates::refresh_menu as refresh_update_menu_for_smoke_test;
|
|||||||
pub fn run() {
|
pub fn run() {
|
||||||
builder().run(context()).expect("Could not start KanbanGit");
|
builder().run(context()).expect("Could not start KanbanGit");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
mod settings;
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
#import <AppKit/AppKit.h>
|
||||||
|
#import <WebKit/WebKit.h>
|
||||||
|
#import <objc/runtime.h>
|
||||||
|
|
||||||
|
@interface KanbanWorkspaceUIDelegate : NSObject <WKUIDelegate>
|
||||||
|
@property(nonatomic, strong) id<WKUIDelegate> originalDelegate;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation KanbanWorkspaceUIDelegate
|
||||||
|
- (BOOL)respondsToSelector:(SEL)selector {
|
||||||
|
return [super respondsToSelector:selector] || [self.originalDelegate respondsToSelector:selector];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (id)forwardingTargetForSelector:(SEL)selector {
|
||||||
|
if ([self.originalDelegate respondsToSelector:selector]) {
|
||||||
|
return self.originalDelegate;
|
||||||
|
}
|
||||||
|
return [super forwardingTargetForSelector:selector];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)webView:(WKWebView *)webView runJavaScriptConfirmPanelWithMessage:(NSString *)message
|
||||||
|
initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(BOOL))completionHandler {
|
||||||
|
NSWindow *window = webView.window;
|
||||||
|
if (!window || window.attachedSheet) {
|
||||||
|
completionHandler(NO);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSAlert *alert = [NSAlert new];
|
||||||
|
alert.alertStyle = NSAlertStyleWarning;
|
||||||
|
alert.messageText = frame.request.URL.host ?: @"KanbanGit";
|
||||||
|
alert.informativeText = message;
|
||||||
|
[alert addButtonWithTitle:@"OK"];
|
||||||
|
[alert addButtonWithTitle:@"Cancel"];
|
||||||
|
[alert beginSheetModalForWindow:window completionHandler:^(NSModalResponse response) {
|
||||||
|
completionHandler(response == NSAlertFirstButtonReturn);
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
@end
|
||||||
|
|
||||||
|
static char workspaceUIDelegateKey;
|
||||||
|
|
||||||
|
void install_workspace_navigation(void *pointer) {
|
||||||
|
WKWebView *webview = (__bridge WKWebView *)pointer;
|
||||||
|
webview.allowsBackForwardNavigationGestures = YES;
|
||||||
|
if ([webview.UIDelegate isKindOfClass:KanbanWorkspaceUIDelegate.class]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
KanbanWorkspaceUIDelegate *delegate = [KanbanWorkspaceUIDelegate new];
|
||||||
|
delegate.originalDelegate = webview.UIDelegate;
|
||||||
|
// WKWebView keeps a weak UI delegate, so retain the forwarding adapter with its view.
|
||||||
|
objc_setAssociatedObject(webview, &workspaceUIDelegateKey, delegate, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
||||||
|
webview.UIDelegate = delegate;
|
||||||
|
}
|
||||||
@@ -37,6 +37,12 @@ pub fn validate_url(input: &str) -> Result<Url, &'static str> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Server {
|
impl Server {
|
||||||
|
pub fn dashboard_url(&self) -> Url {
|
||||||
|
self.url
|
||||||
|
.join("dashboard")
|
||||||
|
.expect("Validated installation URL")
|
||||||
|
}
|
||||||
|
|
||||||
pub fn new(url: Url) -> io::Result<Self> {
|
pub fn new(url: Url) -> io::Result<Self> {
|
||||||
let mut profile = [0; 16];
|
let mut profile = [0; 16];
|
||||||
fs::File::open("/dev/urandom")?.read_exact(&mut profile)?;
|
fs::File::open("/dev/urandom")?.read_exact(&mut profile)?;
|
||||||
@@ -111,6 +117,21 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn startup_targets_dashboard_within_the_installation() {
|
||||||
|
for (base, expected) in [
|
||||||
|
("https://example.com/", "https://example.com/dashboard"),
|
||||||
|
(
|
||||||
|
"https://example.com:8443/kanban/",
|
||||||
|
"https://example.com:8443/kanban/dashboard",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let server = Server::new(validate_url(base).unwrap()).unwrap();
|
||||||
|
assert_eq!(server.dashboard_url().as_str(), expected);
|
||||||
|
assert_eq!(server.url.as_str(), base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn configuration_round_trip_preserves_only_the_address_and_profile() {
|
fn configuration_round_trip_preserves_only_the_address_and_profile() {
|
||||||
let server = Server::new(validate_url(DEFAULT_SERVER).unwrap()).unwrap();
|
let server = Server::new(validate_url(DEFAULT_SERVER).unwrap()).unwrap();
|
||||||
|
|||||||
@@ -0,0 +1,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 {}))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -188,6 +188,12 @@ pub fn check(app: &AppHandle, manual: bool) {
|
|||||||
if manual {
|
if manual {
|
||||||
show(app);
|
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
|
let begin = app
|
||||||
.state::<Mutex<State>>()
|
.state::<Mutex<State>>()
|
||||||
.lock()
|
.lock()
|
||||||
@@ -297,7 +303,7 @@ fn verify_bundle(archive: &Path, directory: &Path, version: &str) -> Result<(),
|
|||||||
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")?])?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
fn install(app: &AppHandle, confirmed_version: Option<&str>) {
|
pub fn install(app: &AppHandle, confirmed_version: Option<&str>) {
|
||||||
let update = {
|
let update = {
|
||||||
let state = app.state::<Mutex<State>>();
|
let state = app.state::<Mutex<State>>();
|
||||||
let mut state = state.lock().unwrap();
|
let mut state = state.lock().unwrap();
|
||||||
@@ -400,6 +406,35 @@ fn action(app: &AppHandle, url: &Url) {
|
|||||||
fail(app, "preferencesError");
|
fail(app, "preferencesError");
|
||||||
return;
|
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 saved = (|| -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let directory = app.path().app_config_dir()?;
|
let directory = app.path().app_config_dir()?;
|
||||||
fs::create_dir_all(&directory)?;
|
fs::create_dir_all(&directory)?;
|
||||||
@@ -408,15 +443,11 @@ fn action(app: &AppHandle, url: &Url) {
|
|||||||
fs::rename(pending, directory.join("update-preferences.json"))?;
|
fs::rename(pending, directory.join("update-preferences.json"))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})();
|
})();
|
||||||
if saved.is_err() {
|
saved.map_err(|_| "preferencesError")?;
|
||||||
fail(app, "preferencesError");
|
state.view.preferences = preferences;
|
||||||
return;
|
drop(state);
|
||||||
}
|
|
||||||
app.state::<Mutex<State>>().lock().unwrap().view.preferences = preferences;
|
|
||||||
refresh(app);
|
refresh(app);
|
||||||
}
|
Ok(())
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
pub fn setup(app: &AppHandle) -> tauri::Result<()> {
|
pub fn setup(app: &AppHandle) -> tauri::Result<()> {
|
||||||
app.plugin(
|
app.plugin(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "KanbanGit",
|
"productName": "KanbanGit",
|
||||||
"version": "0.1.5",
|
"version": "0.2.8",
|
||||||
"identifier": "de.kanbangit.desktop",
|
"identifier": "de.kanbangit.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../ui"
|
"frontendDist": "../ui"
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
],
|
],
|
||||||
"macOS": {
|
"macOS": {
|
||||||
"minimumSystemVersion": "14.0",
|
"minimumSystemVersion": "14.0",
|
||||||
"bundleVersion": "5",
|
"bundleVersion": "12",
|
||||||
"dmg": {
|
"dmg": {
|
||||||
"windowSize": {
|
"windowSize": {
|
||||||
"width": 660,
|
"width": 660,
|
||||||
@@ -40,6 +40,9 @@
|
|||||||
"y": 170
|
"y": 170
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"versionCode": 2004
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"identifier": "de.kanbangit.ios",
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"iOS": { "minimumSystemVersion": "17.0" }
|
"iOS": {
|
||||||
|
"minimumSystemVersion": "17.0",
|
||||||
|
"developmentTeam": "ZZX2KZGTHU",
|
||||||
|
"bundleVersion": "7"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,18 +6,65 @@ fn main() {
|
|||||||
};
|
};
|
||||||
use tauri::{webview::PageLoadEvent, Manager};
|
use tauri::{webview::PageLoadEvent, Manager};
|
||||||
|
|
||||||
|
let startup = std::env::var("KANBANGIT_SMOKE_STARTUP").unwrap_or_default();
|
||||||
|
let online = startup == "online";
|
||||||
|
let needs_setup = matches!(startup.as_str(), "missing" | "invalid");
|
||||||
let passed = Arc::new(AtomicBool::new(false));
|
let passed = Arc::new(AtomicBool::new(false));
|
||||||
let stage = Arc::new(AtomicUsize::new(0));
|
let stage = Arc::new(AtomicUsize::new(0));
|
||||||
let page_stage = stage.clone();
|
let page_stage = stage.clone();
|
||||||
let result_passed = passed.clone();
|
let result_passed = passed.clone();
|
||||||
|
let setup_passed = passed.clone();
|
||||||
let plugin = tauri::plugin::Builder::<tauri::Wry>::new("macos-smoke")
|
let plugin = tauri::plugin::Builder::<tauri::Wry>::new("macos-smoke")
|
||||||
.js_init_script("window.addEventListener('error', event => { location.href = 'tauri://localhost/__native_smoke_result?passed=false&error=' + encodeURIComponent(event.message || event.target.src); }, true);")
|
.setup(move |app, _| {
|
||||||
|
let directory = app.path().app_config_dir()?;
|
||||||
|
std::fs::create_dir_all(&directory)?;
|
||||||
|
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| {
|
.on_page_load(move |webview, payload| {
|
||||||
|
|
||||||
|
eprintln!("Smoke load: {} {:?} {}", webview.label(), payload.event(), payload.url());
|
||||||
if payload.event() != PageLoadEvent::Finished {
|
if payload.event() != PageLoadEvent::Finished {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if webview.label() == "launcher" {
|
if webview.label() == "launcher" {
|
||||||
|
if needs_setup {
|
||||||
|
let app = webview.app_handle().clone();
|
||||||
|
let passed = setup_passed.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||||
|
let handle = app.clone();
|
||||||
|
app.run_on_main_thread(move || {
|
||||||
|
let launcher = handle.get_webview_window("launcher").unwrap();
|
||||||
|
assert!(launcher.is_visible().unwrap(), "First launch and invalid configuration must show setup");
|
||||||
|
assert!(!handle.webview_windows().keys().any(|label| label.starts_with("workspace-")));
|
||||||
|
passed.store(true, Ordering::SeqCst);
|
||||||
|
launcher.close().unwrap();
|
||||||
|
}).unwrap();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assert!(!webview.app_handle().get_webview_window("launcher").unwrap().is_visible().unwrap(), "Saved startup must not show the native launcher window");
|
||||||
|
if online { return; }
|
||||||
webview.eval(r#"
|
webview.eval(r#"
|
||||||
(async () => {
|
(async () => {
|
||||||
const wait = async predicate => {
|
const wait = async predicate => {
|
||||||
@@ -28,7 +75,9 @@ fn main() {
|
|||||||
throw new Error('Timed out waiting for local state');
|
throw new Error('Timed out waiting for local state');
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
await wait(() => document.querySelector('.open-workspace')?.textContent === 'Open workspace');
|
await wait(() => document.querySelector('#connection-status')?.dataset.state === 'connectionError');
|
||||||
|
if (window.__selectionFlashed) throw new Error('Workspace selection flashed during saved startup');
|
||||||
|
if (document.querySelector('#server-url').value !== 'https://127.0.0.1:9/') throw new Error('Saved workspace was discarded');
|
||||||
const input = document.querySelector('#server-url');
|
const input = document.querySelector('#server-url');
|
||||||
const form = document.querySelector('#server-form');
|
const form = document.querySelector('#server-form');
|
||||||
const status = document.querySelector('#connection-status');
|
const status = document.querySelector('#connection-status');
|
||||||
@@ -43,6 +92,7 @@ fn main() {
|
|||||||
}
|
}
|
||||||
input.value = 'https://127.0.0.1:9/';
|
input.value = 'https://127.0.0.1:9/';
|
||||||
form.requestSubmit();
|
form.requestSubmit();
|
||||||
|
await wait(() => status.dataset.state === 'connecting');
|
||||||
await wait(() => status.dataset.state === 'connectionError');
|
await wait(() => status.dataset.state === 'connectionError');
|
||||||
if (input.value !== 'https://127.0.0.1:9/' || document.querySelector('.open-workspace').disabled) throw new Error('Offline recovery failed');
|
if (input.value !== 'https://127.0.0.1:9/' || document.querySelector('.open-workspace').disabled) throw new Error('Offline recovery failed');
|
||||||
input.value = 'https://kanbangit.de/';
|
input.value = 'https://kanbangit.de/';
|
||||||
@@ -53,7 +103,7 @@ fn main() {
|
|||||||
form.requestSubmit();
|
form.requestSubmit();
|
||||||
document.querySelector('#confirm-switch').click();
|
document.querySelector('#confirm-switch').click();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
location.href = 'tauri://localhost/__native_smoke_result?passed=false&reason=' + encodeURIComponent(error.message);
|
location.href = 'tauri://localhost/?smoke=result&passed=false&reason=' + encodeURIComponent(error.message);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
"#).unwrap();
|
"#).unwrap();
|
||||||
@@ -69,16 +119,34 @@ fn main() {
|
|||||||
const rendered = document.querySelector('#app')?.innerText.trim().length > 100;
|
const rendered = document.querySelector('#app')?.innerText.trim().length > 100;
|
||||||
let denied = true;
|
let denied = true;
|
||||||
for (const cmd of ['plugin:path|resolve_directory', 'plugin:window|get_all_windows']) {
|
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'); }
|
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');
|
const input = document.createElement('input');
|
||||||
input.id = 'kan52-unsaved'; input.value = 'unsaved input'; document.body.append(input);
|
input.id = 'kan52-unsaved'; input.value = 'unsaved input'; document.body.append(input);
|
||||||
document.cookie = 'kan52_fixture=isolated; Path=/; Secure; SameSite=Lax';
|
document.cookie = 'kan52_fixture=isolated; Path=/; Secure; SameSite=Lax';
|
||||||
localStorage.setItem('kan52_fixture', 'isolated');
|
localStorage.setItem('kan52_fixture', 'isolated');
|
||||||
const ok = rendered && denied && typeof window.__TAURI__ === 'undefined';
|
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;
|
location.href = '/__native_smoke_result?stage=loaded&passed=' + ok;
|
||||||
})();
|
})().catch(error => { location.href = '/__native_smoke_result?passed=false&reason=' + encodeURIComponent(String(error)); });
|
||||||
"#).unwrap();
|
"#).unwrap();
|
||||||
} else {
|
} else {
|
||||||
webview.eval(r#"
|
webview.eval(r#"
|
||||||
@@ -90,7 +158,8 @@ fn main() {
|
|||||||
})
|
})
|
||||||
.on_navigation(move |webview, url| {
|
.on_navigation(move |webview, url| {
|
||||||
|
|
||||||
if url.path() != "/__native_smoke_result" {
|
eprintln!("Smoke navigation: {} {}", webview.label(), url);
|
||||||
|
if url.path() != "/__native_smoke_result" && !url.query_pairs().any(|(key, value)| key == "smoke" && value == "result") {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
let success = url.query_pairs().any(|(key, value)| key == "passed" && value == "true");
|
let success = url.query_pairs().any(|(key, value)| key == "passed" && value == "true");
|
||||||
@@ -109,17 +178,25 @@ fn main() {
|
|||||||
location.href = '/__native_smoke_result?passed=false&stage=preservation';
|
location.href = '/__native_smoke_result?passed=false&stage=preservation';
|
||||||
}
|
}
|
||||||
"#).unwrap();
|
"#).unwrap();
|
||||||
let launcher = app.get_webview_window("launcher").unwrap();
|
window.eval(r#"
|
||||||
launcher.show().unwrap();
|
window.kanbanNative.request('setWorkspace', {server:'https://kanbangit.de/login/'}).catch(error => {
|
||||||
launcher.eval(r#"
|
location.href = '/__native_smoke_result?passed=false&stage=settings-switch&reason=' + encodeURIComponent(error.message);
|
||||||
document.querySelector('#server-url').value = 'https://kanbangit.de/login/';
|
});
|
||||||
document.querySelector('#server-form').requestSubmit();
|
|
||||||
document.querySelector('#confirm-switch').click();
|
|
||||||
"#).unwrap();
|
"#).unwrap();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
result_passed.store(success, Ordering::SeqCst);
|
result_passed.store(success, Ordering::SeqCst);
|
||||||
webview.app_handle().exit(if success { 0 } else { 1 });
|
if success {
|
||||||
|
let app = webview.app_handle().clone();
|
||||||
|
let label = webview.label().to_string();
|
||||||
|
let handle = app.clone();
|
||||||
|
app.run_on_main_thread(move || {
|
||||||
|
assert!(!handle.get_webview_window("launcher").unwrap().is_visible().unwrap());
|
||||||
|
handle.get_webview_window(&label).unwrap().close().unwrap();
|
||||||
|
}).unwrap();
|
||||||
|
} else {
|
||||||
|
webview.app_handle().exit(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
false
|
false
|
||||||
})
|
})
|
||||||
@@ -130,6 +207,25 @@ fn main() {
|
|||||||
.plugin(plugin)
|
.plugin(plugin)
|
||||||
.build(context)
|
.build(context)
|
||||||
.expect("The macOS application must start");
|
.expect("The macOS application must start");
|
||||||
|
let visibility_handle = app.handle().clone();
|
||||||
|
let visibility_stage = stage.clone();
|
||||||
|
let visibility_check = std::thread::spawn(move || {
|
||||||
|
if needs_setup {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for _ in 0..30 {
|
||||||
|
if visibility_stage.load(Ordering::SeqCst) > 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let Some(launcher) = visibility_handle.get_webview_window("launcher") {
|
||||||
|
assert!(
|
||||||
|
!launcher.is_visible().unwrap(),
|
||||||
|
"Launcher flashed while the workspace was loading"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
});
|
||||||
let config_dir = app.path().app_config_dir().unwrap();
|
let config_dir = app.path().app_config_dir().unwrap();
|
||||||
let handle = app.handle().clone();
|
let handle = app.handle().clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
@@ -137,7 +233,41 @@ fn main() {
|
|||||||
eprintln!("macOS smoke test timed out");
|
eprintln!("macOS smoke test timed out");
|
||||||
handle.exit(1);
|
handle.exit(1);
|
||||||
});
|
});
|
||||||
app.run_return(|_, _| {});
|
let closed = Arc::new(AtomicBool::new(false));
|
||||||
|
let closed_result = closed.clone();
|
||||||
|
let exit_code = app.run_return(move |app, event| {
|
||||||
|
if let tauri::RunEvent::ExitRequested { code: Some(0), .. } = event {
|
||||||
|
closed_result.store(true, Ordering::SeqCst);
|
||||||
|
assert!(
|
||||||
|
needs_setup
|
||||||
|
|| !app
|
||||||
|
.get_webview_window("launcher")
|
||||||
|
.unwrap()
|
||||||
|
.is_visible()
|
||||||
|
.unwrap(),
|
||||||
|
"Closing the workspace must not reopen settings"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
visibility_check.join().unwrap();
|
||||||
|
assert_eq!(exit_code, 0);
|
||||||
|
assert!(
|
||||||
|
closed.load(Ordering::SeqCst),
|
||||||
|
"The close button must quit the application"
|
||||||
|
);
|
||||||
|
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);
|
let _ = std::fs::remove_dir_all(config_dir);
|
||||||
assert!(
|
assert!(
|
||||||
passed.load(Ordering::SeqCst),
|
passed.load(Ordering::SeqCst),
|
||||||
|
|||||||
@@ -0,0 +1,534 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { Buffer } from 'node:buffer';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import {
|
||||||
|
appleTeamId,
|
||||||
|
buildIos,
|
||||||
|
bundleIdentifier,
|
||||||
|
selectIdentity,
|
||||||
|
validateProfile,
|
||||||
|
} from '../build-ios.mjs';
|
||||||
|
|
||||||
|
const certificate = Buffer.from('synthetic test certificate');
|
||||||
|
const fingerprint = createHash('sha1')
|
||||||
|
.update(certificate)
|
||||||
|
.digest('hex')
|
||||||
|
.toUpperCase();
|
||||||
|
|
||||||
|
function profileFor(mode = 'testflight') {
|
||||||
|
return {
|
||||||
|
UUID: '00000000-1111-2222-3333-444444444444',
|
||||||
|
TeamIdentifier: [appleTeamId],
|
||||||
|
Platform: ['iOS'],
|
||||||
|
ExpirationDate: '2099-01-01T00:00:00Z',
|
||||||
|
DeveloperCertificates: [certificate.toString('base64')],
|
||||||
|
...(mode === 'development'
|
||||||
|
? { ProvisionedDevices: ['test-device'] }
|
||||||
|
: {}),
|
||||||
|
Entitlements: {
|
||||||
|
'application-identifier': `${appleTeamId}.${bundleIdentifier}`,
|
||||||
|
'com.apple.developer.team-identifier': appleTeamId,
|
||||||
|
'get-task-allow': mode === 'development',
|
||||||
|
'beta-reports-active': mode === 'testflight',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixture(t, mode = 'testflight', overrides = {}) {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'kanbangit-ios-test-'));
|
||||||
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||||
|
const directory = join(root, 'repo/native');
|
||||||
|
const profilePath = join(root, 'profile.mobileprovision');
|
||||||
|
const config = {
|
||||||
|
productName: 'KanbanGit',
|
||||||
|
identifier: 'de.kanbangit.desktop',
|
||||||
|
version: '1.2.3',
|
||||||
|
};
|
||||||
|
const ios = {
|
||||||
|
identifier: bundleIdentifier,
|
||||||
|
bundle: {
|
||||||
|
iOS: {
|
||||||
|
developmentTeam: appleTeamId,
|
||||||
|
bundleVersion: '7',
|
||||||
|
minimumSystemVersion: '17.0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mkdirSync(join(directory, 'src-tauri'), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(directory, 'src-tauri/tauri.conf.json'),
|
||||||
|
JSON.stringify(config),
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(directory, 'src-tauri/tauri.ios.conf.json'),
|
||||||
|
JSON.stringify(ios),
|
||||||
|
);
|
||||||
|
writeFileSync(profilePath, 'external provisioning profile');
|
||||||
|
const profile = profileFor(mode);
|
||||||
|
const calls = [];
|
||||||
|
const destination = join(directory, `src-tauri/target/ios-${mode}/1.2.3-7`);
|
||||||
|
const home = join(root, 'home');
|
||||||
|
const installedProfile = join(
|
||||||
|
home,
|
||||||
|
`Library/Developer/Xcode/UserData/Provisioning Profiles/${profile.UUID}.mobileprovision`,
|
||||||
|
);
|
||||||
|
const info = {
|
||||||
|
CFBundleIdentifier: bundleIdentifier,
|
||||||
|
CFBundleShortVersionString: '1.2.3',
|
||||||
|
CFBundleVersion: '7',
|
||||||
|
};
|
||||||
|
function app(path) {
|
||||||
|
mkdirSync(path, { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(path, 'Info.plist'),
|
||||||
|
JSON.stringify({ ...info, ...overrides.info }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const options = {
|
||||||
|
directory,
|
||||||
|
home,
|
||||||
|
mode,
|
||||||
|
platform: 'darwin',
|
||||||
|
log() {},
|
||||||
|
env: { PATH: '/test/bin', IOS_PROVISIONING_PROFILE: profilePath },
|
||||||
|
run(command, args, settings) {
|
||||||
|
calls.push({ command, args, settings });
|
||||||
|
if (overrides.failStep === settings.step) {
|
||||||
|
throw new Error('Simulated build failure');
|
||||||
|
}
|
||||||
|
if (command === 'python3') {
|
||||||
|
return settings.input.toString();
|
||||||
|
}
|
||||||
|
if (command === 'security') {
|
||||||
|
if (args[0] === 'cms') {
|
||||||
|
const embedded = args
|
||||||
|
.at(-1)
|
||||||
|
.endsWith('embedded.mobileprovision');
|
||||||
|
return JSON.stringify(
|
||||||
|
embedded && overrides.embeddedProfile
|
||||||
|
? overrides.embeddedProfile
|
||||||
|
: profile,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return `1) ${fingerprint} "Apple ${mode === 'development' ? 'Development' : 'Distribution'}: Example (${appleTeamId})"`;
|
||||||
|
}
|
||||||
|
if (command === 'rustup') {
|
||||||
|
if (args[0] === 'target') {
|
||||||
|
return (
|
||||||
|
overrides.targets ??
|
||||||
|
'aarch64-apple-ios\naarch64-apple-ios-sim'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return `/permanent/toolchain/bin/${args[1]}`;
|
||||||
|
}
|
||||||
|
if (command === '/permanent/toolchain/bin/cargo') {
|
||||||
|
const library = join(
|
||||||
|
directory,
|
||||||
|
'src-tauri/target',
|
||||||
|
args[args.indexOf('--target') + 1],
|
||||||
|
args.includes('--release') ? 'release' : 'debug',
|
||||||
|
'libkanbangit_native.a',
|
||||||
|
);
|
||||||
|
mkdirSync(dirname(library), { recursive: true });
|
||||||
|
writeFileSync(library, 'compiled library');
|
||||||
|
}
|
||||||
|
if (command === 'xcodebuild' && args.includes('archive')) {
|
||||||
|
assert.ok(mode === 'simulator' || existsSync(installedProfile));
|
||||||
|
app(
|
||||||
|
join(
|
||||||
|
args[args.indexOf('-archivePath') + 1],
|
||||||
|
'Products/Applications/KanbanGit.app',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (command === 'xcodebuild' && args.includes('-exportArchive')) {
|
||||||
|
const output = args[args.indexOf('-exportPath') + 1];
|
||||||
|
mkdirSync(output, { recursive: true });
|
||||||
|
if (!overrides.missingIpa) {
|
||||||
|
writeFileSync(
|
||||||
|
join(output, 'KanbanGit.ipa'),
|
||||||
|
'signed ipa bytes',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (command === 'ditto') {
|
||||||
|
app(join(args.at(-1), 'Payload/KanbanGit.app'));
|
||||||
|
}
|
||||||
|
if (command === 'xcrun') {
|
||||||
|
return '/permanent/Xcode/SDK';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
options,
|
||||||
|
calls,
|
||||||
|
profile,
|
||||||
|
profilePath,
|
||||||
|
destination,
|
||||||
|
installedProfile,
|
||||||
|
ios,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('TestFlight produces a verified local IPA with manual signing and no Apple mutation or upload flags', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
f.options.env.APPLE_API_KEY = 'must-not-reach-build';
|
||||||
|
f.options.env.IOS_CERTIFICATE = 'must-not-reach-build';
|
||||||
|
assert.equal(buildIos(f.options), f.destination);
|
||||||
|
const options = JSON.parse(
|
||||||
|
readFileSync(join(f.destination, 'ExportOptions.plist'), 'utf8'),
|
||||||
|
);
|
||||||
|
assert.equal(options.method, 'app-store-connect');
|
||||||
|
assert.equal(options.destination, 'export');
|
||||||
|
assert.equal(options.signingStyle, 'manual');
|
||||||
|
assert.equal(options.manageAppVersionAndBuildNumber, false);
|
||||||
|
assert.equal(options.signingCertificate, fingerprint);
|
||||||
|
assert.deepEqual(options.provisioningProfiles, {
|
||||||
|
[bundleIdentifier]: f.profile.UUID,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
readFileSync(
|
||||||
|
join(f.destination, 'export/KanbanGit.ipa.sha256'),
|
||||||
|
'utf8',
|
||||||
|
),
|
||||||
|
`${createHash('sha256').update('signed ipa bytes').digest('hex')} KanbanGit.ipa\n`,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
f.calls.filter(
|
||||||
|
(call) =>
|
||||||
|
call.command === 'codesign' && call.args.includes('--verify'),
|
||||||
|
).length,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
assert.equal(existsSync(f.installedProfile), false);
|
||||||
|
const verification = f.calls.find((call) => call.command === 'codesign');
|
||||||
|
const requirement = verification.args[verification.args.indexOf('-R') + 1];
|
||||||
|
assert.ok(requirement.includes('! entitlement["get-task-allow"] exists'));
|
||||||
|
assert.ok(
|
||||||
|
requirement.includes('entitlement["beta-reports-active"] exists'),
|
||||||
|
);
|
||||||
|
assert.ok(requirement.includes(`certificate leaf = H"${fingerprint}"`));
|
||||||
|
for (const call of f.calls) {
|
||||||
|
assert.equal(call.settings.env.APPLE_API_KEY, undefined);
|
||||||
|
assert.equal(call.settings.env.IOS_CERTIFICATE, undefined);
|
||||||
|
assert.ok(
|
||||||
|
!call.args.some((arg) =>
|
||||||
|
/allowProvisioning|authenticationKey|upload|downloadPlatform/.test(
|
||||||
|
arg,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const archive = f.calls.find((call) => call.args.includes('archive'));
|
||||||
|
assert.ok(archive.args.includes('CODE_SIGN_STYLE=Manual'));
|
||||||
|
assert.ok(
|
||||||
|
archive.args.includes(
|
||||||
|
`PROVISIONING_PROFILE_SPECIFIER=${f.profile.UUID}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
archive.settings.env.KANBANGIT_IOS_LIBRARY.endsWith(
|
||||||
|
'/aarch64-apple-ios/release/libkanbangit_native.a',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the simulator needs no certificate or profile and disables signing explicitly', (t) => {
|
||||||
|
const f = fixture(t, 'simulator');
|
||||||
|
delete f.options.env.IOS_PROVISIONING_PROFILE;
|
||||||
|
buildIos(f.options);
|
||||||
|
assert.ok(
|
||||||
|
f.calls.some((call) => call.args.includes('CODE_SIGNING_ALLOWED=NO')),
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
f.calls.some((call) => call.args.includes('aarch64-apple-ios-sim')),
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!f.calls.some(
|
||||||
|
(call) =>
|
||||||
|
['security', 'codesign'].includes(call.command) ||
|
||||||
|
call.args.includes('-exportArchive'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('local device development uses a registered-device development profile', (t) => {
|
||||||
|
const f = fixture(t, 'development');
|
||||||
|
buildIos(f.options);
|
||||||
|
const options = JSON.parse(
|
||||||
|
readFileSync(join(f.destination, 'ExportOptions.plist'), 'utf8'),
|
||||||
|
);
|
||||||
|
assert.equal(options.method, 'debugging');
|
||||||
|
assert.equal(options.destination, 'export');
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [name, change, message] of [
|
||||||
|
[
|
||||||
|
'different team',
|
||||||
|
(p) => {
|
||||||
|
p.TeamIdentifier = ['OLDTEAM'];
|
||||||
|
},
|
||||||
|
/explicitly match/,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'wildcard identifier',
|
||||||
|
(p) => {
|
||||||
|
p.Entitlements['application-identifier'] = `${appleTeamId}.*`;
|
||||||
|
},
|
||||||
|
/explicitly match/,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'macOS identifier',
|
||||||
|
(p) => {
|
||||||
|
p.Entitlements['application-identifier'] =
|
||||||
|
`${appleTeamId}.de.kanbangit.desktop`;
|
||||||
|
},
|
||||||
|
/explicitly match/,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'expired profile',
|
||||||
|
(p) => {
|
||||||
|
p.ExpirationDate = '2000-01-01';
|
||||||
|
},
|
||||||
|
/expired/,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'invalid expiry',
|
||||||
|
(p) => {
|
||||||
|
p.ExpirationDate = 'invalid';
|
||||||
|
},
|
||||||
|
/expired/,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'invalid UUID',
|
||||||
|
(p) => {
|
||||||
|
p.UUID = '../unexpected';
|
||||||
|
},
|
||||||
|
/invalid/,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'missing certificate',
|
||||||
|
(p) => {
|
||||||
|
p.DeveloperCertificates = [];
|
||||||
|
},
|
||||||
|
/invalid/,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'wrong platform',
|
||||||
|
(p) => {
|
||||||
|
p.Platform = ['OSX'];
|
||||||
|
},
|
||||||
|
/iOS profile/,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Ad Hoc profile',
|
||||||
|
(p) => {
|
||||||
|
p.ProvisionedDevices = ['device'];
|
||||||
|
},
|
||||||
|
/App Store Connect/,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'enterprise profile',
|
||||||
|
(p) => {
|
||||||
|
p.ProvisionsAllDevices = true;
|
||||||
|
},
|
||||||
|
/App Store Connect/,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'development entitlement',
|
||||||
|
(p) => {
|
||||||
|
p.Entitlements['get-task-allow'] = true;
|
||||||
|
},
|
||||||
|
/App Store Connect/,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'missing beta entitlement',
|
||||||
|
(p) => {
|
||||||
|
delete p.Entitlements['beta-reports-active'];
|
||||||
|
},
|
||||||
|
/App Store Connect/,
|
||||||
|
],
|
||||||
|
]) {
|
||||||
|
test(`rejects ${name} before signing`, () => {
|
||||||
|
const profile = profileFor();
|
||||||
|
change(profile);
|
||||||
|
assert.throws(() => validateProfile(profile, 'testflight'), message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a distribution profile cannot be used for device development', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => validateProfile(profileFor(), 'development'),
|
||||||
|
/App Development/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('identity selection requires the profile certificate and a usable private key of the correct kind', () => {
|
||||||
|
const profile = profileFor();
|
||||||
|
for (const identities of [
|
||||||
|
'0 valid identities found',
|
||||||
|
`1) ${'A'.repeat(40)} "Apple Distribution: Other"`,
|
||||||
|
`1) ${fingerprint} "Developer ID Application: Example"`,
|
||||||
|
`1) ${fingerprint} "Apple Development: Example"`,
|
||||||
|
]) {
|
||||||
|
assert.throws(
|
||||||
|
() => selectIdentity(profile, identities, 'testflight'),
|
||||||
|
/private key/,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const step of [
|
||||||
|
'Generating the Xcode project',
|
||||||
|
'Building the iOS Rust library',
|
||||||
|
'Archiving locally without Apple provisioning updates',
|
||||||
|
'Verifying the Apple signature and team',
|
||||||
|
'Exporting the signed IPA locally (no upload)',
|
||||||
|
]) {
|
||||||
|
test(`failure at ${step} does not publish a successful build and cleans its profile`, (t) => {
|
||||||
|
const f = fixture(t, 'testflight', { failStep: step });
|
||||||
|
assert.throws(() => buildIos(f.options), /Simulated build failure/);
|
||||||
|
assert.equal(existsSync(f.destination), false);
|
||||||
|
assert.equal(existsSync(f.installedProfile), false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('an existing identical local profile is preserved after the build', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
mkdirSync(dirname(f.installedProfile), { recursive: true });
|
||||||
|
writeFileSync(f.installedProfile, readFileSync(f.profilePath));
|
||||||
|
buildIos(f.options);
|
||||||
|
assert.equal(existsSync(f.installedProfile), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a conflicting local profile is never overwritten or deleted', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
mkdirSync(dirname(f.installedProfile), { recursive: true });
|
||||||
|
writeFileSync(f.installedProfile, 'user profile');
|
||||||
|
assert.throws(() => buildIos(f.options), /same UUID/);
|
||||||
|
assert.equal(readFileSync(f.installedProfile, 'utf8'), 'user profile');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing toolchain targets fail before compilation without installing anything', (t) => {
|
||||||
|
const f = fixture(t, 'testflight', { targets: 'aarch64-apple-darwin' });
|
||||||
|
assert.throws(
|
||||||
|
() => buildIos(f.options),
|
||||||
|
/rustup target add aarch64-apple-ios/,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!f.calls.some(
|
||||||
|
(call) => call.args.includes('build') || call.args.includes('add'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing profile and ambient team overrides fail clearly', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
delete f.options.env.IOS_PROVISIONING_PROFILE;
|
||||||
|
assert.throws(() => buildIos(f.options), /IOS_PROVISIONING_PROFILE/);
|
||||||
|
f.options.env.APPLE_DEVELOPMENT_TEAM = 'OLDTEAM';
|
||||||
|
assert.throws(() => buildIos(f.options), /must be ZZX2KZGTHU/);
|
||||||
|
delete f.options.env.APPLE_DEVELOPMENT_TEAM;
|
||||||
|
f.options.env.TAURI_CONFIG = '{}';
|
||||||
|
assert.throws(() => buildIos(f.options), /Remove TAURI_CONFIG/);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [name, overrides, message] of [
|
||||||
|
[
|
||||||
|
'wrong app version',
|
||||||
|
{ info: { CFBundleVersion: '8' } },
|
||||||
|
/unexpected identifier, version/,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'changed embedded profile',
|
||||||
|
{
|
||||||
|
embeddedProfile: {
|
||||||
|
...profileFor(),
|
||||||
|
UUID: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
/unexpected provisioning profile/,
|
||||||
|
],
|
||||||
|
['missing IPA', { missingIpa: true }, /did not produce/],
|
||||||
|
]) {
|
||||||
|
test(`rejects ${name} in the build output`, (t) => {
|
||||||
|
const f = fixture(t, 'testflight', overrides);
|
||||||
|
assert.throws(() => buildIos(f.options), message);
|
||||||
|
assert.equal(existsSync(f.destination), false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('completed artifacts are not silently overwritten', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
buildIos(f.options);
|
||||||
|
assert.throws(() => buildIos(f.options), /already exists/);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const mode of ['simulator', 'development']) {
|
||||||
|
test(`${mode} can rebuild the same version and preserves the previous build on failure`, (t) => {
|
||||||
|
const overrides = {};
|
||||||
|
const f = fixture(t, mode, overrides);
|
||||||
|
buildIos(f.options);
|
||||||
|
const previousBuild = join(f.destination, 'previous-build');
|
||||||
|
writeFileSync(previousBuild, 'verified artifact');
|
||||||
|
overrides.failStep = 'Building the iOS Rust library';
|
||||||
|
assert.throws(() => buildIos(f.options), /Simulated build failure/);
|
||||||
|
assert.equal(readFileSync(previousBuild, 'utf8'), 'verified artifact');
|
||||||
|
delete overrides.failStep;
|
||||||
|
buildIos(f.options);
|
||||||
|
assert.equal(existsSync(previousBuild), false);
|
||||||
|
assert.ok(existsSync(join(f.destination, 'KanbanGit.xcarchive')));
|
||||||
|
assert.equal(existsSync(f.installedProfile), false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('every iOS icon has its declared dimensions and contains no alpha channel or transparency chunk', () => {
|
||||||
|
const assets = fileURLToPath(
|
||||||
|
new URL(
|
||||||
|
'../src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/',
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const catalog = JSON.parse(
|
||||||
|
readFileSync(join(assets, 'Contents.json'), 'utf8'),
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
catalog.images.some(
|
||||||
|
(icon) =>
|
||||||
|
icon.idiom === 'ios-marketing' && icon.size === '1024x1024',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const icon of catalog.images) {
|
||||||
|
const png = readFileSync(join(assets, icon.filename));
|
||||||
|
assert.equal(png.subarray(0, 8).toString('hex'), '89504e470d0a1a0a');
|
||||||
|
const [width, height] = icon.size.split('x').map(Number);
|
||||||
|
const scale = Number.parseInt(icon.scale, 10);
|
||||||
|
assert.equal(png.readUInt32BE(16), width * scale, icon.filename);
|
||||||
|
assert.equal(png.readUInt32BE(20), height * scale, icon.filename);
|
||||||
|
assert.equal(png[25], 2, `${icon.filename} must use RGB without alpha`);
|
||||||
|
for (
|
||||||
|
let offset = 8;
|
||||||
|
offset < png.length;
|
||||||
|
offset += png.readUInt32BE(offset) + 12
|
||||||
|
) {
|
||||||
|
assert.notEqual(
|
||||||
|
png.subarray(offset + 4, offset + 8).toString(),
|
||||||
|
'tRNS',
|
||||||
|
icon.filename,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -62,6 +62,12 @@ const latestAction = async (name) => {
|
|||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
await page.goto(`http://127.0.0.1:${server.address().port}/`);
|
await page.goto(`http://127.0.0.1:${server.address().port}/`);
|
||||||
|
assert.equal(await page.locator('#workspace-settings').isVisible(), false);
|
||||||
|
await state('connecting');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
assert.equal(await page.locator('#workspace-settings').isVisible(), false);
|
||||||
|
assert.equal(await page.locator('#startup-loading').isVisible(), true);
|
||||||
|
await page.reload();
|
||||||
await state('idle');
|
await state('idle');
|
||||||
for (const width of [320, 390, 1440]) {
|
for (const width of [320, 390, 1440]) {
|
||||||
await page.setViewportSize({ width, height: 900 });
|
await page.setViewportSize({ width, height: 900 });
|
||||||
@@ -98,12 +104,7 @@ try {
|
|||||||
'https://first.example.com/',
|
'https://first.example.com/',
|
||||||
);
|
);
|
||||||
await state('connecting', false, 'https://first.example.com/');
|
await state('connecting', false, 'https://first.example.com/');
|
||||||
assert.equal(
|
assert.equal(await page.locator('.open-workspace').isDisabled(), true);
|
||||||
await page
|
|
||||||
.getByRole('button', { name: 'Open workspace', exact: true })
|
|
||||||
.isDisabled(),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
await state('connectionError', false, 'https://first.example.com/');
|
await state('connectionError', false, 'https://first.example.com/');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await page.getByLabel('Server address').inputValue(),
|
await page.getByLabel('Server address').inputValue(),
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import { normalizeServer, needsConfirmation } from '../ui/server.js';
|
|||||||
|
|
||||||
const html = readFileSync(new URL('../ui/index.html', import.meta.url), 'utf8');
|
const html = readFileSync(new URL('../ui/index.html', import.meta.url), 'utf8');
|
||||||
|
|
||||||
|
test('workspace selection is hidden before native configuration is available', () => {
|
||||||
|
assert.match(html, /<main id="workspace-settings" hidden>/);
|
||||||
|
assert.match(html, /id="startup-loading" role="status"/);
|
||||||
|
});
|
||||||
|
|
||||||
test('every launcher text resolves to a nonempty English translation', () => {
|
test('every launcher text resolves to a nonempty English translation', () => {
|
||||||
for (const [, key] of html.matchAll(/data-i18n="([^"]+)"/g)) {
|
for (const [, key] of html.matchAll(/data-i18n="([^"]+)"/g)) {
|
||||||
assert.equal(typeof en[key], 'string', key);
|
assert.equal(typeof en[key], 'string', key);
|
||||||
|
|||||||
@@ -578,3 +578,46 @@ for (const change of [
|
|||||||
assert.equal(existsSync(f.destination), false);
|
assert.equal(existsSync(f.destination), false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('release versions agree across native manifests', () => {
|
||||||
|
const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8');
|
||||||
|
const config = JSON.parse(read('../src-tauri/tauri.conf.json'));
|
||||||
|
const ios = JSON.parse(read('../src-tauri/tauri.ios.conf.json'));
|
||||||
|
const packageInfo = JSON.parse(read('../package.json'));
|
||||||
|
assert.equal(packageInfo.version, config.version);
|
||||||
|
assert.equal(
|
||||||
|
read('../src-tauri/Cargo.toml').match(/^version = "([^"]+)"$/m)?.[1],
|
||||||
|
config.version,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
read('../src-tauri/Cargo.lock').match(
|
||||||
|
/name = "kanbangit-native"\nversion = "([^"]+)"/,
|
||||||
|
)?.[1],
|
||||||
|
config.version,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
read('../src-tauri/gen/apple/project.yml').match(
|
||||||
|
/MARKETING_VERSION: "([^"]+)"/,
|
||||||
|
)?.[1],
|
||||||
|
config.version,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
read('../src-tauri/gen/apple/project.yml').match(
|
||||||
|
/CURRENT_PROJECT_VERSION: "([^"]+)"/,
|
||||||
|
)?.[1],
|
||||||
|
ios.bundle.iOS.bundleVersion,
|
||||||
|
);
|
||||||
|
const project = read(
|
||||||
|
'../src-tauri/gen/apple/kanbangit-native.xcodeproj/project.pbxproj',
|
||||||
|
);
|
||||||
|
for (const [, version] of project.matchAll(
|
||||||
|
/MARKETING_VERSION = ([^;]+);/g,
|
||||||
|
)) {
|
||||||
|
assert.equal(version, config.version);
|
||||||
|
}
|
||||||
|
for (const [, version] of project.matchAll(
|
||||||
|
/CURRENT_PROJECT_VERSION = ([^;]+);/g,
|
||||||
|
)) {
|
||||||
|
assert.equal(version, ios.bundle.iOS.bundleVersion);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -47,6 +47,18 @@ test('Android launcher sends only explicit local actions and preserves switch co
|
|||||||
try {
|
try {
|
||||||
await import('../ui/main.js');
|
await import('../ui/main.js');
|
||||||
assert.equal(messages.shift().pathname, '/action/ready');
|
assert.equal(messages.shift().pathname, '/action/ready');
|
||||||
|
assert.equal(element('#workspace-settings').hidden, true);
|
||||||
|
window.updateLauncher(
|
||||||
|
'server=https%3A%2F%2Fexample.com%2F&phase=connecting&ready=false',
|
||||||
|
);
|
||||||
|
assert.equal(element('#workspace-settings').hidden, true);
|
||||||
|
assert.equal(element('#startup-loading').hidden, false);
|
||||||
|
window.updateLauncher(
|
||||||
|
'server=https%3A%2F%2Fexample.com%2F&phase=connectionError&ready=false',
|
||||||
|
);
|
||||||
|
assert.equal(element('#workspace-settings').hidden, false);
|
||||||
|
assert.equal(element('#startup-loading').hidden, true);
|
||||||
|
assert.equal(element('.open-workspace').textContent, 'Try again');
|
||||||
window.updateLauncher(
|
window.updateLauncher(
|
||||||
'server=https%3A%2F%2Fexample.com%2F&phase=idle&ready=false&platform=android',
|
'server=https%3A%2F%2Fexample.com%2F&phase=idle&ready=false&platform=android',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,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,165 @@
|
|||||||
|
#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);
|
||||||
|
}
|
||||||
|
|
||||||
|
static id evaluate(WKWebView *webview, NSString *script) {
|
||||||
|
__block BOOL finished = NO;
|
||||||
|
__block id result;
|
||||||
|
[webview evaluateJavaScript:script completionHandler:^(id value, NSError *error) {
|
||||||
|
check(error == nil, error.localizedDescription);
|
||||||
|
result = value;
|
||||||
|
finished = YES;
|
||||||
|
}];
|
||||||
|
waitUntil(^BOOL { return finished; }, @"JavaScript must finish after the confirmation is dismissed");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static NSButton *findButton(NSView *view, NSString *title) {
|
||||||
|
if ([view isKindOfClass:NSButton.class] && [((NSButton *)view).title isEqualToString:title]) {
|
||||||
|
return (NSButton *)view;
|
||||||
|
}
|
||||||
|
for (NSView *child in view.subviews) {
|
||||||
|
NSButton *button = findButton(child, title);
|
||||||
|
if (button) { return button; }
|
||||||
|
}
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
static BOOL containsText(NSView *view, NSString *text) {
|
||||||
|
if ([view isKindOfClass:NSTextField.class] && [((NSTextField *)view).stringValue isEqualToString:text]) {
|
||||||
|
return YES;
|
||||||
|
}
|
||||||
|
for (NSView *child in view.subviews) {
|
||||||
|
if (containsText(child, text)) { return YES; }
|
||||||
|
}
|
||||||
|
return NO;
|
||||||
|
}
|
||||||
|
|
||||||
|
@interface WorkspaceUI : NSObject <WKUIDelegate>
|
||||||
|
@property(nonatomic) BOOL closed;
|
||||||
|
@property(nonatomic) BOOL uploaded;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation WorkspaceUI
|
||||||
|
- (void)webViewDidClose:(WKWebView *)webView { self.closed = YES; }
|
||||||
|
- (void)webView:(WKWebView *)webView runOpenPanelWithParameters:(WKOpenPanelParameters *)parameters
|
||||||
|
initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(NSArray<NSURL *> *))completionHandler {
|
||||||
|
self.uploaded = YES;
|
||||||
|
completionHandler(nil);
|
||||||
|
}
|
||||||
|
@end
|
||||||
|
|
||||||
|
@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;
|
||||||
|
WorkspaceUI *originalUI = [WorkspaceUI new];
|
||||||
|
webview.UIDelegate = originalUI;
|
||||||
|
check(!webview.allowsBackForwardNavigationGestures, @"WebKit defaults to disabled gestures");
|
||||||
|
install_workspace_navigation((__bridge void *)webview);
|
||||||
|
id<WKUIDelegate> installedUI = webview.UIDelegate;
|
||||||
|
install_workspace_navigation((__bridge void *)webview);
|
||||||
|
check(webview.UIDelegate == installedUI, @"Installing workspace behavior twice must retain the same delegate");
|
||||||
|
check([installedUI respondsToSelector:@selector(webViewDidClose:)], @"Existing popup callbacks must remain available");
|
||||||
|
[installedUI webViewDidClose:webview];
|
||||||
|
check(originalUI.closed, @"Existing popup callbacks must reach the original delegate");
|
||||||
|
check([installedUI respondsToSelector:@selector(webView:runOpenPanelWithParameters:initiatedByFrame:completionHandler:)],
|
||||||
|
@"Existing file upload callbacks must remain available");
|
||||||
|
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");
|
||||||
|
|
||||||
|
NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 800, 600)
|
||||||
|
styleMask:NSWindowStyleMaskTitled backing:NSBackingStoreBuffered defer:NO];
|
||||||
|
window.releasedWhenClosed = NO;
|
||||||
|
window.title = @"KanbanGit navigation test";
|
||||||
|
window.contentView = webview;
|
||||||
|
[window orderFront:nil];
|
||||||
|
evaluate(webview, @"document.body.innerHTML = `<input id='duplicate' type='radio'><input id='draft' value='Unsaved project'><input id='upload' type='file'><a id='back' href='#back'>Back</a>`; window.visits = 0; document.querySelector('#back').onclick = event => { event.preventDefault(); if (!document.querySelector('#duplicate').checked || confirm('Discard unsaved changes?')) window.visits++; }; true;");
|
||||||
|
evaluate(webview, @"document.querySelector('#upload').click()");
|
||||||
|
waitUntil(^BOOL { return originalUI.uploaded; }, @"File selection must still reach the original UI delegate");
|
||||||
|
evaluate(webview, @"document.querySelector('#back').click()");
|
||||||
|
check([evaluate(webview, @"window.visits") intValue] == 1, @"Pristine forms must navigate without a confirmation");
|
||||||
|
evaluate(webview, @"document.querySelector('#duplicate').click()");
|
||||||
|
for (NSString *choice in @[@"Cancel", @"OK", @"Cancel", @"OK"]) {
|
||||||
|
__block BOOL finished = NO;
|
||||||
|
[webview evaluateJavaScript:@"document.querySelector('#back').click()" completionHandler:^(id result, NSError *error) {
|
||||||
|
check(error == nil, error.localizedDescription);
|
||||||
|
finished = YES;
|
||||||
|
}];
|
||||||
|
waitUntil(^BOOL { return window.attachedSheet != nil; }, @"Dirty form navigation must show a native confirmation");
|
||||||
|
check(!finished, @"The original click must wait for the user's decision");
|
||||||
|
check(containsText(window.attachedSheet.contentView, @"workspace"), @"The confirmation must identify the requesting website");
|
||||||
|
check(containsText(window.attachedSheet.contentView, @"Discard unsaved changes?"), @"The confirmation must show the application's message");
|
||||||
|
NSButton *button = findButton(window.attachedSheet.contentView, choice);
|
||||||
|
check(button != nil, @"The native confirmation must offer OK and Cancel");
|
||||||
|
[button performClick:nil];
|
||||||
|
waitUntil(^BOOL { return finished && window.attachedSheet == nil; }, @"Dismissal must release the original navigation");
|
||||||
|
check([evaluate(webview, @"document.querySelector('#draft').value") isEqualToString:@"Unsaved project"],
|
||||||
|
@"Cancelling a confirmation must preserve the draft");
|
||||||
|
}
|
||||||
|
check([evaluate(webview, @"window.visits") intValue] == 3, @"Only accepted visits must run, exactly once each");
|
||||||
|
window.contentView = nil;
|
||||||
|
check(![evaluate(webview, @"confirm('Detached view')") boolValue], @"A detached view must cancel and release JavaScript immediately");
|
||||||
|
[window orderOut:nil];
|
||||||
|
[window close];
|
||||||
|
puts("PASS: WebKit interactive gesture configuration, full-page back/forward, native confirmations and delegate forwarding");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 preserves history and confirms dirty form navigation without losing delegate callbacks',
|
||||||
|
{ 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 || result.signal,
|
||||||
|
);
|
||||||
|
assert.match(result.stdout, /PASS: WebKit/);
|
||||||
|
} finally {
|
||||||
|
rmSync(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -168,9 +168,9 @@ test('uploads revalidated files and writes completion record after public verifi
|
|||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
f.events.filter((event) => event.operation === 'put-object').length,
|
f.events.filter((event) => event.operation === 'put-object').length,
|
||||||
8,
|
9,
|
||||||
);
|
);
|
||||||
assert.equal(f.objects.size, 8);
|
assert.equal(f.objects.size, 9);
|
||||||
for (const event of f.events.filter(
|
for (const event of f.events.filter(
|
||||||
(event) => event.operation === 'put-object',
|
(event) => event.operation === 'put-object',
|
||||||
)) {
|
)) {
|
||||||
@@ -184,6 +184,26 @@ test('uploads revalidated files and writes completion record after public verifi
|
|||||||
f.events.at(-1).key,
|
f.events.at(-1).key,
|
||||||
'releases/macos/aarch64-apple-darwin/latest.json',
|
'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(
|
assert.deepEqual(
|
||||||
JSON.parse(
|
JSON.parse(
|
||||||
f.objects.get(
|
f.objects.get(
|
||||||
@@ -202,6 +222,91 @@ test('uploads revalidated files and writes completion record after public verifi
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('replaces the stable download only for a newer verified release', async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const downloadKey = `releases/macos/${f.metadata.target}/KanbanGit.dmg`;
|
||||||
|
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
||||||
|
f.objects.set(downloadKey, Buffer.from('previous download'));
|
||||||
|
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.0.0' })));
|
||||||
|
|
||||||
|
await publishMacos(f);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
f.objects.get(downloadKey),
|
||||||
|
readFileSync(join(f.directory, f.metadata.filename)),
|
||||||
|
);
|
||||||
|
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.2.3');
|
||||||
|
const replacement = f.events.find(
|
||||||
|
(event) =>
|
||||||
|
event.operation === 'put-object' &&
|
||||||
|
event.key === downloadKey &&
|
||||||
|
event.args.includes('--if-match'),
|
||||||
|
);
|
||||||
|
assert(replacement);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a newer published version protects the stable download even when its alias is missing', async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const downloadKey = `releases/macos/${f.metadata.target}/KanbanGit.dmg`;
|
||||||
|
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
||||||
|
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '2.0.0' })));
|
||||||
|
|
||||||
|
await assert.rejects(publishMacos(f), /downgrade/);
|
||||||
|
|
||||||
|
assert.equal(f.objects.has(downloadKey), false);
|
||||||
|
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '2.0.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a published version cannot replace a different stable download', async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const downloadKey = `releases/macos/${f.metadata.target}/KanbanGit.dmg`;
|
||||||
|
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
||||||
|
const original = Buffer.from('published download');
|
||||||
|
f.objects.set(downloadKey, original);
|
||||||
|
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.2.3' })));
|
||||||
|
|
||||||
|
await assert.rejects(publishMacos(f), /bump the app version/);
|
||||||
|
|
||||||
|
assert.deepEqual(f.objects.get(downloadKey), original);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed public stable download blocks feed promotion', async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const fetchPublic = f.fetchPublic;
|
||||||
|
f.fetchPublic = (url, options) =>
|
||||||
|
url.endsWith('/KanbanGit.dmg')
|
||||||
|
? Promise.resolve({ ok: false, status: 403 })
|
||||||
|
: fetchPublic(url, options);
|
||||||
|
|
||||||
|
await assert.rejects(publishMacos(f), /KanbanGit\.dmg: HTTP 403/);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
f.objects.has(`releases/macos/${f.metadata.target}/latest.json`),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('concurrent stable download replacement preserves the winner', async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const downloadKey = `releases/macos/${f.metadata.target}/KanbanGit.dmg`;
|
||||||
|
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
||||||
|
f.objects.set(downloadKey, Buffer.from('previous download'));
|
||||||
|
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.0.0' })));
|
||||||
|
const run = f.run;
|
||||||
|
f.run = (command, args) => {
|
||||||
|
if (args.includes('--if-match') && args.includes(downloadKey)) {
|
||||||
|
f.objects.set(downloadKey, Buffer.from('concurrent winner'));
|
||||||
|
throw new Error('(PreconditionFailed) concurrent replacement');
|
||||||
|
}
|
||||||
|
return run(command, args);
|
||||||
|
};
|
||||||
|
|
||||||
|
await assert.rejects(publishMacos(f), /concurrent replacement/);
|
||||||
|
|
||||||
|
assert.equal(f.objects.get(downloadKey).toString(), 'concurrent winner');
|
||||||
|
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.0.0');
|
||||||
|
});
|
||||||
|
|
||||||
for (const response of ['', ' \n\t', '{}']) {
|
for (const response of ['', ' \n\t', '{}']) {
|
||||||
test(`unversioned bucket response ${JSON.stringify(response)} permits a verified upload`, async (t) => {
|
test(`unversioned bucket response ${JSON.stringify(response)} permits a verified upload`, async (t) => {
|
||||||
const f = fixture(t);
|
const f = fixture(t);
|
||||||
@@ -211,7 +316,7 @@ for (const response of ['', ' \n\t', '{}']) {
|
|||||||
return args.includes('get-bucket-versioning') ? response : result;
|
return args.includes('get-bucket-versioning') ? response : result;
|
||||||
};
|
};
|
||||||
await publishMacos(f);
|
await publishMacos(f);
|
||||||
assert.equal(f.objects.size, 8);
|
assert.equal(f.objects.size, 9);
|
||||||
assert.equal(f.events.at(-1).operation, 'public-get');
|
assert.equal(f.events.at(-1).operation, 'public-get');
|
||||||
assert(f.events.at(-1).key.endsWith('/latest.json'));
|
assert(f.events.at(-1).key.endsWith('/latest.json'));
|
||||||
});
|
});
|
||||||
@@ -237,7 +342,7 @@ test('retry accepts byte-identical objects without replacing them', async (t) =>
|
|||||||
assert.deepEqual([...f.objects.entries()], originals);
|
assert.deepEqual([...f.objects.entries()], originals);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
f.events.filter((event) => event.operation === 'get-object').length,
|
f.events.filter((event) => event.operation === 'get-object').length,
|
||||||
8,
|
11,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -325,7 +430,7 @@ test('a partial upload can be retried with the same files', async (t) => {
|
|||||||
assert.equal(f.objects.size, 3);
|
assert.equal(f.objects.size, 3);
|
||||||
f.run = run;
|
f.run = run;
|
||||||
await publishMacos(f);
|
await publishMacos(f);
|
||||||
assert.equal(f.objects.size, 8);
|
assert.equal(f.objects.size, 9);
|
||||||
});
|
});
|
||||||
|
|
||||||
function rebuild(f) {
|
function rebuild(f) {
|
||||||
@@ -387,7 +492,7 @@ for (const previous of [null, '1.0.0']) {
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
await publishMacos(f);
|
await publishMacos(f);
|
||||||
assert.equal(f.objects.size, 8);
|
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)).version, '1.2.3');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
JSON.parse(f.objects.get(latestKey)).notes,
|
JSON.parse(f.objects.get(latestKey)).notes,
|
||||||
@@ -516,7 +621,7 @@ for (const listing of [
|
|||||||
!('Contents' in listing)
|
!('Contents' in listing)
|
||||||
) {
|
) {
|
||||||
await publishMacos(f);
|
await publishMacos(f);
|
||||||
assert.equal(f.objects.size, 8);
|
assert.equal(f.objects.size, 9);
|
||||||
} else {
|
} else {
|
||||||
await assert.rejects(publishMacos(f), /NoneType|AccessDenied/);
|
await assert.rejects(publishMacos(f), /NoneType|AccessDenied/);
|
||||||
assert.equal(f.objects.get(key).toString(), 'unfinished old build');
|
assert.equal(f.objects.get(key).toString(), 'unfinished old build');
|
||||||
@@ -549,7 +654,7 @@ for (const status of [403, 404, 408, 429, 500, 502, 503, 504, 'network']) {
|
|||||||
await publishMacos(f);
|
await publishMacos(f);
|
||||||
assert.deepEqual(waits, [2000, 2000]);
|
assert.deepEqual(waits, [2000, 2000]);
|
||||||
assert.equal(cancelled, status === 'network' ? 0 : 2);
|
assert.equal(cancelled, status === 'network' ? 0 : 2);
|
||||||
assert.equal(f.objects.size, 8);
|
assert.equal(f.objects.size, 9);
|
||||||
assert(f.events.at(-1).key.endsWith('/latest.json'));
|
assert(f.events.at(-1).key.endsWith('/latest.json'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -593,7 +698,7 @@ test('checksum access retries finish before publishing the completion record', a
|
|||||||
};
|
};
|
||||||
await publishMacos(f);
|
await publishMacos(f);
|
||||||
assert.equal(failures, 3);
|
assert.equal(failures, 3);
|
||||||
assert.equal(f.objects.size, 8);
|
assert.equal(f.objects.size, 9);
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const overrides of [
|
for (const overrides of [
|
||||||
|
|||||||
@@ -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,7 +9,8 @@
|
|||||||
<script type="module" src="./main.js"></script>
|
<script type="module" src="./main.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<p id="startup-loading" role="status" aria-live="polite" data-i18n="connecting"></p>
|
||||||
|
<main id="workspace-settings" hidden>
|
||||||
<img class="brand" src="./mark.svg" alt="" width="48" height="48" />
|
<img class="brand" src="./mark.svg" alt="" width="48" height="48" />
|
||||||
<p class="eyebrow" data-i18n="appName"></p>
|
<p class="eyebrow" data-i18n="appName"></p>
|
||||||
<h1 data-i18n="title"></h1>
|
<h1 data-i18n="title"></h1>
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ window.updateLauncher = (query) => {
|
|||||||
const state = new URLSearchParams(query);
|
const state = new URLSearchParams(query);
|
||||||
const phase = state.get('phase');
|
const phase = state.get('phase');
|
||||||
const server = state.get('server');
|
const server = state.get('server');
|
||||||
|
const loading = phase === 'connecting' && state.get('ready') !== 'true';
|
||||||
|
document.querySelector('#startup-loading').hidden = !loading;
|
||||||
|
document.querySelector('#workspace-settings').hidden = loading;
|
||||||
if (!initialized) {
|
if (!initialized) {
|
||||||
input.value = server;
|
input.value = server;
|
||||||
initialized = true;
|
initialized = true;
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
})();
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"lint": "eslint . --fix",
|
"lint": "eslint . --fix",
|
||||||
"lint:check": "eslint .",
|
"lint:check": "eslint .",
|
||||||
"types:check": "vue-tsc --noEmit"
|
"types:check": "php artisan wayfinder:generate --with-form --no-interaction && vue-tsc --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
@@ -48,6 +48,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@inertiajs/core": "2.3.18",
|
"@inertiajs/core": "2.3.18",
|
||||||
"@inertiajs/vue3": "^2.3.7",
|
"@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",
|
"@vueuse/core": "^12.8.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -57,7 +62,7 @@
|
|||||||
"tailwind-merge": "^3.2.0",
|
"tailwind-merge": "^3.2.0",
|
||||||
"tailwindcss": "^4.1.1",
|
"tailwindcss": "^4.1.1",
|
||||||
"tw-animate-css": "^1.2.5",
|
"tw-animate-css": "^1.2.5",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.43",
|
||||||
"vue-input-otp": "^0.3.2"
|
"vue-input-otp": "^0.3.2"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
|
|||||||
@@ -1,4 +1,22 @@
|
|||||||
minimumReleaseAge: 10080
|
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:
|
allowBuilds:
|
||||||
unrs-resolver: true
|
unrs-resolver: true
|
||||||
vue-demi: true
|
vue-demi: true
|
||||||
|
overrides:
|
||||||
|
'@vue/server-renderer@<3.5.42': ^3.5.42
|
||||||
|
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
|
||||||
|
shell-quote@>=1.8.4 <1.11.0: ^1.11.0
|
||||||
|
source-map-js@>=1.0.0 <1.2.2: ^1.2.2
|
||||||
|
audit:
|
||||||
|
ignore:
|
||||||
|
- GHSA-vfj7-8cjw-p6xm
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
@import 'tw-animate-css';
|
@import 'tw-animate-css';
|
||||||
@import './theme.css';
|
@import './theme.css';
|
||||||
|
@import './ticket-description.css';
|
||||||
|
|
||||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||||
@source '../../storage/framework/views/*.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 type { DefineComponent } from 'vue';
|
||||||
import { createApp, h } from 'vue';
|
import { createApp, h } from 'vue';
|
||||||
import '../css/app.css';
|
import '../css/app.css';
|
||||||
|
import {
|
||||||
|
initializeNativeApp,
|
||||||
|
registerNativeRecovery,
|
||||||
|
} from '@/composables/useNativeApp';
|
||||||
import { createAppearance } from '@/composables/useAppearance';
|
import { createAppearance } from '@/composables/useAppearance';
|
||||||
import { createLocalization } from '@/lib/localization';
|
import { createLocalization } from '@/lib/localization';
|
||||||
|
|
||||||
@@ -21,6 +25,11 @@ createInertiaApp({
|
|||||||
.use(createLocalization(props.initialPage.props.localization))
|
.use(createLocalization(props.initialPage.props.localization))
|
||||||
.use(createAppearance(props.initialPage.props.appearance))
|
.use(createAppearance(props.initialPage.props.appearance))
|
||||||
.mount(el);
|
.mount(el);
|
||||||
|
registerNativeRecovery();
|
||||||
|
window.addEventListener('kanban-native-ready', () => {
|
||||||
|
void initializeNativeApp();
|
||||||
|
});
|
||||||
|
void initializeNativeApp();
|
||||||
},
|
},
|
||||||
progress: {
|
progress: {
|
||||||
color: 'var(--primary)',
|
color: 'var(--primary)',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Link, usePage } from '@inertiajs/vue3';
|
import { Link, usePage } from '@inertiajs/vue3';
|
||||||
import { Monitor, Moon, Plus, Sun } from 'lucide-vue-next';
|
import { Download, Monitor, Moon, Plus, Sun } from 'lucide-vue-next';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import Breadcrumbs from '@/components/Breadcrumbs.vue';
|
import Breadcrumbs from '@/components/Breadcrumbs.vue';
|
||||||
import UserMenuContent from '@/components/UserMenuContent.vue';
|
import UserMenuContent from '@/components/UserMenuContent.vue';
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} from '@/composables/useAppearance';
|
} from '@/composables/useAppearance';
|
||||||
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { useMacDownload } from '@/composables/useMacDownload';
|
||||||
import { create as createProject } from '@/routes/projects';
|
import { create as createProject } from '@/routes/projects';
|
||||||
import { index as board } from '@/routes/projects/board';
|
import { index as board } from '@/routes/projects/board';
|
||||||
import { create as createTicket } from '@/routes/projects/tickets';
|
import { create as createTicket } from '@/routes/projects/tickets';
|
||||||
@@ -35,6 +36,7 @@ const page = usePage();
|
|||||||
const { project } = useAppNavigation();
|
const { project } = useAppNavigation();
|
||||||
const { isCurrentUrl } = useCurrentUrl();
|
const { isCurrentUrl } = useCurrentUrl();
|
||||||
const { t } = useLocalization();
|
const { t } = useLocalization();
|
||||||
|
const { macDownloadUrl, showMacDownload } = useMacDownload();
|
||||||
const { appearance, updateAppearance } = useAppearance();
|
const { appearance, updateAppearance } = useAppearance();
|
||||||
const themeIcon = computed(
|
const themeIcon = computed(
|
||||||
() => ({ light: Sun, dark: Moon, system: Monitor })[appearance.value],
|
() => ({ light: Sun, dark: Moon, system: Monitor })[appearance.value],
|
||||||
@@ -69,6 +71,21 @@ const createAction = computed(() => {
|
|||||||
<span v-else class="truncate font-semibold">KanbanGit</span>
|
<span v-else class="truncate font-semibold">KanbanGit</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex shrink-0 items-center gap-1 sm:gap-2">
|
<div class="flex shrink-0 items-center gap-1 sm:gap-2">
|
||||||
|
<Button
|
||||||
|
v-if="showMacDownload"
|
||||||
|
as-child
|
||||||
|
variant="outline"
|
||||||
|
:aria-label="t('navigation.downloadAppAppleSilicon')"
|
||||||
|
:title="t('navigation.downloadAppAppleSilicon')"
|
||||||
|
class="px-3"
|
||||||
|
>
|
||||||
|
<a :href="macDownloadUrl"
|
||||||
|
><Download aria-hidden="true" /><span
|
||||||
|
class="hidden sm:inline"
|
||||||
|
>{{ t('navigation.downloadApp') }}</span
|
||||||
|
></a
|
||||||
|
>
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
v-if="createAction"
|
v-if="createAction"
|
||||||
as-child
|
as-child
|
||||||
|
|||||||
@@ -0,0 +1,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">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
import TicketDescriptionEditor from '@/components/tickets/TicketDescriptionEditor.vue';
|
||||||
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import { ticketStatusLabel } from '@/lib/tickets';
|
import { ticketStatusLabel } from '@/lib/tickets';
|
||||||
@@ -72,10 +73,9 @@ const statusOptions = computed(
|
|||||||
:placeholder="t('ticketForm.titlePlaceholder')"
|
:placeholder="t('ticketForm.titlePlaceholder')"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<TicketFormField
|
<TicketDescriptionEditor
|
||||||
id="description"
|
id="description"
|
||||||
v-model="description"
|
v-model="description"
|
||||||
kind="textarea"
|
|
||||||
:label="t('ticketDetail.description')"
|
:label="t('ticketDetail.description')"
|
||||||
:error="errors.description"
|
:error="errors.description"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
watch,
|
watch,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||||
|
import TicketDescription from '@/components/tickets/TicketDescription.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
|
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
|
||||||
import FormFeedback from '@/components/FormFeedback.vue';
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
@@ -272,16 +273,12 @@ const submit = (): void => {
|
|||||||
</h2></CardHeader
|
</h2></CardHeader
|
||||||
>
|
>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p
|
<TicketDescription
|
||||||
class="wrap-anywhere whitespace-pre-wrap text-sm leading-7"
|
v-if="ticket.description"
|
||||||
:class="{
|
:description="ticket.description"
|
||||||
'text-muted-foreground': !ticket.description,
|
/>
|
||||||
}"
|
<p v-else class="text-muted-foreground text-sm leading-7">
|
||||||
>
|
{{ t('ticketDetail.noDescription') }}
|
||||||
{{
|
|
||||||
ticket.description ||
|
|
||||||
t('ticketDetail.noDescription')
|
|
||||||
}}
|
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -12,8 +12,12 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
import { useFormProtection } from '@/composables/useFormProtection';
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { useTicketLinkActions } from '@/composables/useTicketLinkActions';
|
||||||
import { show as showTicket } from '@/routes/projects/tickets';
|
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>();
|
const props = defineProps<TicketDetailRelationshipsProps>();
|
||||||
defineEmits<{ editParent: [] }>();
|
defineEmits<{ editParent: [] }>();
|
||||||
@@ -33,17 +37,17 @@ const canAddLink = computed(
|
|||||||
props.ticketLinkTypes.length > 0 &&
|
props.ticketLinkTypes.length > 0 &&
|
||||||
props.ticketLinkOptions.length > 0,
|
props.ticketLinkOptions.length > 0,
|
||||||
);
|
);
|
||||||
const form = useForm({
|
const form = useForm<TicketLinkFormData>({
|
||||||
ticket_link_type_id: props.ticketLinkTypes[0]?.id ?? '',
|
ticket_link_type_id: props.ticketLinkTypes[0]?.id ?? '',
|
||||||
|
link_direction: 'source',
|
||||||
linked_ticket_id: '',
|
linked_ticket_id: '',
|
||||||
});
|
});
|
||||||
const protection = useFormProtection(form, {
|
const protection = useFormProtection(form, {
|
||||||
isDirty: () => props.canEditTicket && form.isDirty,
|
isDirty: () => props.canEditTicket && form.isDirty,
|
||||||
});
|
});
|
||||||
const selectedType = computed(() =>
|
const { actions, selectedAction, selectedActionLabel } = useTicketLinkActions(
|
||||||
props.ticketLinkTypes.find(
|
() => props.ticketLinkTypes,
|
||||||
(type) => String(type.id) === String(form.ticket_link_type_id),
|
form,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
const selectedTicket = computed(() =>
|
const selectedTicket = computed(() =>
|
||||||
props.ticketLinkOptions.find(
|
props.ticketLinkOptions.find(
|
||||||
@@ -224,16 +228,14 @@ function submit(): void {
|
|||||||
>
|
>
|
||||||
<TicketFormField
|
<TicketFormField
|
||||||
id="ticket_link_type_id"
|
id="ticket_link_type_id"
|
||||||
v-model="form.ticket_link_type_id"
|
v-model="selectedAction"
|
||||||
kind="select"
|
kind="select"
|
||||||
:label="t('ticketDetail.linkType')"
|
:label="t('ticketDetail.linkAction')"
|
||||||
:options="
|
:options="actions"
|
||||||
ticketLinkTypes.map((type) => ({
|
:error="
|
||||||
value: type.id,
|
form.errors.ticket_link_type_id ||
|
||||||
label: type.label,
|
form.errors.link_direction
|
||||||
}))
|
|
||||||
"
|
"
|
||||||
:error="form.errors.ticket_link_type_id"
|
|
||||||
:disabled="form.processing"
|
:disabled="form.processing"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -260,22 +262,19 @@ function submit(): void {
|
|||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<p
|
<p
|
||||||
v-if="selectedType && selectedTicket"
|
v-if="selectedActionLabel && selectedTicket"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
class="text-muted-foreground flex flex-wrap items-center gap-2 text-sm"
|
class="text-muted-foreground flex flex-wrap items-center gap-2 text-sm"
|
||||||
>
|
>
|
||||||
<span class="font-mono text-xs">{{
|
<span class="font-mono text-xs">{{
|
||||||
ticket.ticket_key
|
ticket.ticket_key
|
||||||
}}</span
|
}}</span
|
||||||
><span>{{ selectedType.label }}</span
|
><span>{{ selectedActionLabel }}</span
|
||||||
><ArrowRight class="size-4" aria-hidden="true" /><span
|
><ArrowRight class="size-4" aria-hidden="true" /><span
|
||||||
class="font-mono text-xs"
|
class="font-mono text-xs"
|
||||||
>{{ selectedTicket.ticket_key }}</span
|
>{{ selectedTicket.ticket_key }}</span
|
||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
<p class="text-muted-foreground text-xs">
|
|
||||||
{{ t('ticketRelationships.directionHelp') }}
|
|
||||||
</p>
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="form.processing || !selectedTicket"
|
:disabled="form.processing || !selectedTicket"
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Link, useForm } from '@inertiajs/vue3';
|
import { Link, useForm } from '@inertiajs/vue3';
|
||||||
import { ArrowDownLeft, ArrowUpRight, Unlink } from 'lucide-vue-next';
|
import { Unlink } from 'lucide-vue-next';
|
||||||
import { computed } from 'vue';
|
|
||||||
import { destroy } from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
|
import { destroy } from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
|
||||||
import FormFeedback from '@/components/FormFeedback.vue';
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useFormProtection } from '@/composables/useFormProtection';
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
@@ -20,7 +18,6 @@ const props = defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
const emit = defineEmits<{ removed: [] }>();
|
const emit = defineEmits<{ removed: [] }>();
|
||||||
const { t } = useLocalization();
|
const { t } = useLocalization();
|
||||||
const outgoing = computed(() => props.link.source_ticket_id === props.ticketId);
|
|
||||||
const form = useForm({});
|
const form = useForm({});
|
||||||
const protection = useFormProtection(form);
|
const protection = useFormProtection(form);
|
||||||
function remove(): void {
|
function remove(): void {
|
||||||
@@ -47,27 +44,6 @@ function remove(): void {
|
|||||||
:data-ticket-link="link.id"
|
:data-ticket-link="link.id"
|
||||||
>
|
>
|
||||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
class="text-muted-foreground max-w-full whitespace-normal"
|
|
||||||
>
|
|
||||||
<ArrowUpRight
|
|
||||||
v-if="outgoing"
|
|
||||||
class="size-3.5 shrink-0"
|
|
||||||
aria-hidden="true"
|
|
||||||
/><ArrowDownLeft
|
|
||||||
v-else
|
|
||||||
class="size-3.5 shrink-0"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{{
|
|
||||||
t(
|
|
||||||
outgoing
|
|
||||||
? 'ticketRelationships.outgoing'
|
|
||||||
: 'ticketRelationships.incoming',
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</Badge>
|
|
||||||
<span class="text-muted-foreground font-mono text-xs">{{
|
<span class="text-muted-foreground font-mono text-xs">{{
|
||||||
ticketKey
|
ticketKey
|
||||||
}}</span>
|
}}</span>
|
||||||
|
|||||||