Compare commits

...
31 Commits
Author SHA1 Message Date
Twyco 5769d84526 Merge pull request 'Fix/ios unsaved form navigation' (#180) from fix/ios-unsaved-form-navigation into main
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m47s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m24s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 26s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 3m9s
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Successful in 21s
CI/CD / Publish immutable image (push) Successful in 22s
Reviewed-on: #180
2026-10-15 07:55:42 +00:00
Twyco 99264c7bc8 fix: pnpm packages
CI/CD / preconditions (pull_request) Successful in 4s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m31s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 2m55s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 2m16s
2026-10-15 09:50:04 +02:00
Twyco 1b448e363c fix: restore iOS navigation with unsaved forms 2026-10-15 09:46:19 +02:00
Twyco 65e9df6d6d fix: pnpm packages
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m34s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m49s
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Failing after 2m15s
CI/CD / Publish immutable image (push) Skipped
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Canceled after 0s
macOS release to Hetzner / Validate release ref and configuration (push) Canceled after 31s
2026-10-15 09:22:09 +02:00
Twyco 7d7b723f69 Merge pull request 'fix: restore macOS navigation with unsaved forms' (#179) from fix/macos-unsaved-form-navigation into main
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m18s
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m32s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 28s
CI/CD / test_and_check_app (push) Failing after 19s
CI/CD / preconditions (push) Successful in 3s
CI/CD / Publish immutable image (push) Skipped
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 3m14s
Reviewed-on: #179
2026-10-15 07:06:48 +00:00
Twyco 7a656de7ce fix: restore macOS navigation with unsaved forms 2026-10-15 07:06:48 +00:00
Twyco 811dbf7fa4 Merge pull request 'KAN-73: Explain and validate MCP ticket description format' (#178) from feature/KAN-73-mcp-description-contract into main
CI/CD / preconditions (push) Successful in 1s
CI/CD / test_and_check_app (push) Failing after 1m26s
CI/CD / Publish immutable image (push) Skipped
Reviewed-on: #178
2026-10-15 07:06:05 +00:00
Twyco 5567eea06f KAN-73: document and validate MCP ticket description format 2026-10-15 07:06:05 +00:00
Twyco ca1f243234 Merge pull request 'KAN-72: Complete MCP ticket editing and discovery' (#177) from feature/KAN-72-complete-mcp-ticket-editing into main
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Failing after 1m46s
CI/CD / Publish immutable image (push) Skipped
Reviewed-on: #177
2026-10-15 07:04:52 +00:00
Twyco 9ed8d0c6cc feat(KAN-72): complete MCP ticket editing and discovery
CI/CD / preconditions (pull_request) Successful in 2s
CI/CD / test_and_check_app (pull_request) Successful in 6m12s
CI/CD / Publish immutable image (pull_request) Skipped
2026-10-02 13:34:00 +02:00
Twyco ba12e65e81 Merge pull request 'Fix KAN-64 project deletion on MariaDB' (#176) from fix/KAN-64-project-delete-response into main
CI/CD / preconditions (push) Successful in 1s
CI/CD / test_and_check_app (push) Successful in 10s
CI/CD / Publish immutable image (push) Successful in 10s
Reviewed-on: #176
2026-10-02 10:17:19 +00:00
Twyco 85b9409e3c Fix project deletion with linked ticket statuses on MariaDB 2026-10-02 10:17:19 +00:00
Twyco f862a6a226 Merge pull request 'KAN-12: Simplify links to action and ticket' (#175) from feature/KAN-12-simplify-link-actions into main
CI/CD / Publish immutable image (push) Successful in 14s
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Successful in 8s
Reviewed-on: #175
2026-10-01 20:36:03 +00:00
Twyco c8f494fe7c feat: simplify ticket links to action and ticket (KAN-12)
CI/CD / preconditions (pull_request) Successful in 2s
CI/CD / test_and_check_app (pull_request) Successful in 6m2s
CI/CD / Publish immutable image (pull_request) Skipped
2026-10-01 22:32:02 +02:00
Twyco f7c3873c19 Merge pull request 'KAN-68: Add WYSIWYG ticket description editor' (#172) from feature/KAN-68-wysiwyg-description-editor into main
macOS checks / Native Rust tests and unsigned build (push) Canceled after 0s
CI/CD / preconditions (push) Successful in 1s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m10s
CI/CD / test_and_check_app (push) Successful in 8s
CI/CD / Publish immutable image (push) Successful in 40s
Reviewed-on: #172
2026-10-01 19:22:22 +00:00
Twyco d8e1659f4b feat(KAN-68): add visual Markdown ticket description editor
CI/CD / preconditions (pull_request) Successful in 1s
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 0s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m11s
CI/CD / test_and_check_app (pull_request) Successful in 5m58s
CI/CD / Publish immutable image (pull_request) Skipped
2026-10-01 19:21:22 +00:00
Twyco 704fe5d241 fix
CI/CD / preconditions (push) Successful in 1s
CI/CD / test_and_check_app (push) Successful in 10s
CI/CD / Publish immutable image (push) Successful in 11s
2026-10-01 21:15:43 +02:00
Twyco 73f6ab6918 fix
CI/CD / preconditions (push) Successful in 2s
macOS checks / Native Rust tests and unsigned build (push) Canceled after 0s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m1s
CI/CD / test_and_check_app (push) Failing after 55s
CI/CD / Publish immutable image (push) Skipped
2026-10-01 21:03:01 +02:00
Twyco 6d4a823fe5 fix
CI/CD / preconditions (push) Successful in 2s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m9s
CI/CD / test_and_check_app (push) Failing after 57s
CI/CD / Publish immutable image (push) Skipped
macOS checks / Native Rust tests and unsigned build (push) Canceled after 0s
2026-10-01 20:45:34 +02:00
Twyco 6054690607 Merge pull request 'KAN-64: Allow project admins to delete projects' (#171) from fix/KAN-64-cant-delete-project into main
CI/CD / preconditions (push) Successful in 1s
CI/CD / test_and_check_app (push) Failing after 54s
CI/CD / Publish immutable image (push) Skipped
Reviewed-on: #171
2026-10-01 15:16:03 +00:00
Twyco 723285f820 fix: allow project admins to delete projects (KAN-64) 2026-10-01 15:16:03 +00:00
Twyco 393d074bb7 Merge pull request 'KAN-12: Support both ticket link directions' (#174) from feature/KAN-12-bidirectional-ticket-links into main
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Failing after 11s
CI/CD / Publish immutable image (push) Skipped
Reviewed-on: #174
2026-10-01 15:15:32 +00:00
Twyco 3ec59ef367 feat: support both ticket link directions (KAN-12)
CI/CD / preconditions (pull_request) Successful in 1s
CI/CD / test_and_check_app (pull_request) Failing after 59s
CI/CD / Publish immutable image (pull_request) Skipped
2026-10-01 08:13:54 +02:00
Twyco 10f88f6029 Merge pull request 'Fix dependency CVEs and provide shared PR base' (#173) from fix/dependency-cves into main
CI/CD / preconditions (push) Successful in 2s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m8s
CI/CD / test_and_check_app (push) Successful in 10s
CI/CD / Publish immutable image (push) Successful in 35s
macOS checks / Native Rust tests and unsigned build (push) Canceled after 0s
Reviewed-on: #173
2026-09-30 10:07:53 +00:00
Twyco 3b110f08ae fix
CI/CD / preconditions (pull_request) Successful in 1s
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 0s
Native JavaScript checks / Native launcher and release tests (pull_request) Canceled after 51s
CI/CD / test_and_check_app (pull_request) Successful in 5m49s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-30 11:57:19 +02:00
Twyco 1a022c257f fix
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 59s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 4m37s
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 0s
2026-09-30 11:53:42 +02:00
Twyco ce36060ba1 fix: patch vulnerable framework and build dependencies
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m7s
CI/CD / test_and_check_app (pull_request) Successful in 6m26s
CI/CD / Publish immutable image (pull_request) Skipped
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 0s
2026-09-30 11:19:21 +02:00
Twyco f4434a3203 Merge pull request 'KAN-70: Integrate native workspace and update settings' (#170) from feature/KAN-70-native-app-workspace-settings into main
Native JavaScript checks / Native launcher and release tests (push) Successful in 59s
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m30s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 21s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 3m23s
CI/CD / test_and_check_app (push) Successful in 6m57s
CI/CD / preconditions (push) Successful in 1s
CI/CD / Publish immutable image (push) Successful in 13s
Reviewed-on: #170
2026-09-29 15:24:44 +00:00
Twyco 912cfc28c2 feat(KAN-70): integrate native workspace and update settings
CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m2s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m38s
CI/CD / test_and_check_app (pull_request) Successful in 6m47s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-29 17:11:10 +02:00
Twyco 752ba2cd38 Merge pull request 'KAN-71: Use interactive WebKit gestures on iOS and macOS' (#169) from fix/KAN-71-native-navigation-scroll into main
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m31s
CI/CD / preconditions (push) Successful in 1s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m2s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 22s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 3m24s
CI/CD / test_and_check_app (push) Successful in 6m43s
CI/CD / Publish immutable image (push) Successful in 1m20s
Reviewed-on: #169
2026-09-29 06:04:38 +00:00
Twyco 1cf7524a7b fix(KAN-71): use interactive WebKit navigation gestures
CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m13s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m44s
CI/CD / test_and_check_app (pull_request) Successful in 7m4s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-29 08:02:10 +02:00
94 changed files with 5790 additions and 848 deletions
+12 -72
View File
@@ -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
+10
View File
@@ -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.
+8 -1
View File
@@ -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;
$project->delete(); DB::transaction(function () use ($project): void {
$baseTemplate = $project->baseTemplate()->first();
$project->ticketStatuses()->delete();
$project->delete();
$baseTemplate?->delete();
});
return $projectName; return $projectName;
} }
+15 -12
View File
@@ -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));
}
}
+6 -3
View File
@@ -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',
])) { ])) {
+1 -1
View File
@@ -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'])],
]; ];
} }
+15 -3
View File
@@ -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();
}
}
+24
View File
@@ -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>
*/ */
+110
View File
@@ -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;
}
}
+8 -3
View File
@@ -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(),
+75
View File
@@ -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.'),
];
}
}
+8 -5
View File
@@ -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.'),
]; ];
} }
} }
+46
View File
@@ -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
View File
@@ -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,
+83
View File
@@ -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.'),
];
}
}
+75
View File
@@ -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
View File
@@ -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,
+9 -3
View File
@@ -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(),
+21
View File
@@ -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.');
}
}
}
Generated
+26 -22
View File
@@ -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",
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "kanbangit-native", "name": "kanbangit-native",
"version": "0.2.4", "version": "0.2.8",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -25,5 +25,5 @@
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "2.11.4" "@tauri-apps/cli": "2.11.4"
}, },
"releaseNotes": "Correct iOS and macOS navigation gestures, preserve single-step page history, and constrain vertical overscroll." "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."
} }
+1 -1
View File
@@ -1770,7 +1770,7 @@ dependencies = [
[[package]] [[package]]
name = "kanbangit-native" name = "kanbangit-native"
version = "0.2.4" version = "0.2.8"
dependencies = [ dependencies = [
"semver", "semver",
"serde", "serde",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "kanbangit-native" name = "kanbangit-native"
version = "0.2.4" version = "0.2.8"
description = "KanbanGit native application" description = "KanbanGit native application"
edition = "2021" edition = "2021"
publish = false publish = false
+23
View File
@@ -9,8 +9,10 @@ fn build_navigation() {
use std::{env, path::PathBuf, process::Command}; use std::{env, path::PathBuf, process::Command};
println!("cargo:rerun-if-changed=src/navigation.m"); 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 output = PathBuf::from(env::var_os("OUT_DIR").unwrap());
let object = output.join("navigation.o"); let object = output.join("navigation.o");
let settings_object = output.join("settings.o");
let archive = output.join("libkanban_navigation.a"); let archive = output.join("libkanban_navigation.a");
let arch = match env::var("CARGO_CFG_TARGET_ARCH").unwrap().as_str() { let arch = match env::var("CARGO_CFG_TARGET_ARCH").unwrap().as_str() {
"aarch64" => "arm64", "aarch64" => "arm64",
@@ -37,10 +39,31 @@ fn build_navigation() {
.status() .status()
.expect("Xcode clang is required") .expect("Xcode clang is required")
.success()); .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") assert!(Command::new("xcrun")
.args(["ar", "crs"]) .args(["ar", "crs"])
.arg(&archive) .arg(&archive)
.arg(&object) .arg(&object)
.arg(&settings_object)
.status() .status()
.expect("Xcode ar is required") .expect("Xcode ar is required")
.success()); .success());
@@ -65,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)
}
}
@@ -24,7 +24,7 @@ func installIOSWorkspace() {
} }
} }
final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKUIDelegate, UITextFieldDelegate, UIGestureRecognizerDelegate { final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKUIDelegate, UITextFieldDelegate {
private let defaults: UserDefaults private let defaults: UserDefaults
private let serverKey = "ios.activeServer" private let serverKey = "ios.activeServer"
private(set) var server: WorkspaceServer? private(set) var server: WorkspaceServer?
@@ -40,10 +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 gestureStart = CGPoint.zero private var settingsAvailable = false
private var gestureAllowed = false private var toolbarHeight: NSLayoutConstraint!
private var gestureRevision = 0
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
@@ -52,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")
} }
@@ -168,7 +170,9 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
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 = [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),
@@ -198,12 +202,14 @@ 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()
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
@@ -212,21 +218,16 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
let scriptURL = Bundle(for: WorkspaceViewController.self).url(forResource: "workspace-navigation", withExtension: "js")! let scriptURL = Bundle(for: WorkspaceViewController.self).url(forResource: "workspace-navigation", withExtension: "js")!
let script = try! String(contentsOf: scriptURL, encoding: .utf8) let script = try! String(contentsOf: scriptURL, encoding: .utf8)
configuration.userContentController.addUserScript(WKUserScript(source: script, injectionTime: .atDocumentStart, forMainFrameOnly: true)) configuration.userContentController.addUserScript(WKUserScript(source: script, injectionTime: .atDocumentStart, forMainFrameOnly: true))
// Remote content receives no native message handlers or Tauri bridge. 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
// Native WebKit swipes can skip async pushState entries. One edge recognizer webview.allowsBackForwardNavigationGestures = true
// owns each gesture and traverses the same history that Inertia restores.
webview.allowsBackForwardNavigationGestures = false
webview.scrollView.bounces = false webview.scrollView.bounces = false
for edge: UIRectEdge in [.left, .right] {
let gesture = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(navigateGesture(_:)))
gesture.edges = edge
gesture.delegate = self
webview.addGestureRecognizer(gesture)
webview.scrollView.panGestureRecognizer.require(toFail: gesture)
}
webview.accessibilityIdentifier = "workspace" webview.accessibilityIdentifier = "workspace"
webview.translatesAutoresizingMaskIntoConstraints = false webview.translatesAutoresizingMaskIntoConstraints = false
webview.isHidden = true webview.isHidden = true
@@ -268,6 +269,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
} }
@objc private func showSettings() { @objc private func showSettings() {
completeJavaScriptConfirmation(false)
loading.stopAnimating() loading.stopAnimating()
view.endEditing(true) view.endEditing(true)
launcher.isHidden = false launcher.isHidden = false
@@ -281,47 +283,79 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
loading.stopAnimating() 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
} }
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { private func setSettingsAvailable(_ available: Bool) {
guard gestureRecognizer is UIScreenEdgePanGestureRecognizer, let webview = workspace else { return false } settingsAvailable = available
gestureStart = touch.location(in: webview) toolbarHeight.constant = available ? 0 : 44
gestureAllowed = false toolbar.isHidden = available || !launcher.isHidden || !connection.ready
gestureRevision += 1 }
let revision = gestureRevision
webview.evaluateJavaScript("window.__kanbanNavigation?.canSwipe(\(gestureStart.x), \(gestureStart.y)) === true") { [weak self, weak gestureRecognizer] result, _ in func handleSettings(_ message: WKScriptMessage, reply: @escaping (Any?, String?) -> Void) {
guard let self, self.gestureRevision == revision, self.workspace === webview else { return } guard message.webView === workspace, message.frameInfo.isMainFrame,
self.gestureAllowed = result as? Bool == true let source = message.frameInfo.request.url, server?.allows(source) == true,
// Release the scroll view immediately when a board, field or overlay owns the touch. let current = workspace?.url, server?.allows(current) == true,
if !self.gestureAllowed, gestureRecognizer?.state == .possible { gestureRecognizer?.state = .failed } 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))
} }
return true
} }
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String,
guard let edge = gestureRecognizer as? UIScreenEdgePanGestureRecognizer, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
let webview = workspace, !webview.isHidden, gestureAllowed else { return false } guard webView === workspace, viewIfLoaded?.window != nil,
let velocity = edge.velocity(in: webview) presentedViewController == nil, javaScriptConfirmation == nil else {
return abs(velocity.x) > abs(velocity.y) completionHandler(false)
&& (edge.edges == .left ? webview.canGoBack && velocity.x > 0 : webview.canGoForward && velocity.x < 0) 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)
} }
@objc private func navigateGesture(_ gesture: UIScreenEdgePanGestureRecognizer) { func completeJavaScriptConfirmation(_ accepted: Bool) {
guard gesture.state == .ended, let webview = workspace else { return } guard let confirmation = javaScriptConfirmation else { return }
let direction = gesture.edges == .left ? -1 : 1 javaScriptConfirmation = nil
let distance = gesture.translation(in: webview).x * CGFloat(-direction) if confirmation.alert.presentingViewController == nil {
let velocity = gesture.velocity(in: webview).x * CGFloat(-direction) confirmation.completion(accepted)
guard distance > webview.bounds.width * 0.25 || (distance > 30 && velocity > 500) else { return } } else {
navigateHistory(direction, from: gestureStart) confirmation.alert.dismiss(animated: false) {
} confirmation.completion(accepted)
}
func navigateHistory(_ direction: Int, from point: CGPoint) { }
guard let webview = workspace,
direction == -1 ? webview.canGoBack : webview.canGoForward else { return }
let script = "if (window.__kanbanNavigation?.canSwipe(\(point.x), \(point.y))) window.__kanbanNavigation.step(\(direction))"
webview.evaluateJavaScript(script, completionHandler: nil)
} }
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
@@ -350,6 +384,7 @@ 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()
} }
@@ -389,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.";
@@ -95,7 +95,7 @@ final class WorkspaceTests: XCTestCase {
} }
@MainActor @MainActor
func testGesturesTraverseEverySPAEntryOnceAndPreserveForwardHistory() async throws { func testWebKitOwnsInteractiveGesturesAndKeepsScrollBounds() async throws {
let name = "KAN71-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) }
@@ -113,44 +113,16 @@ final class WorkspaceTests: XCTestCase {
""") """)
try await waitFor(webview, "document.querySelector('#draft')?.value === 'unsaved'") try await waitFor(webview, "document.querySelector('#draft')?.value === 'unsaved'")
XCTAssertFalse(webview.canGoBack) XCTAssertFalse(webview.canGoBack)
controller.navigateHistory(-1, from: CGPoint(x: 1, y: 1)) XCTAssertNil(webview.goBack())
XCTAssertNil(webview.goForward())
try await waitFor(webview, "location.pathname === '/dashboard'") try await waitFor(webview, "location.pathname === '/dashboard'")
for index in 1...3 { XCTAssertTrue(webview.allowsBackForwardNavigationGestures)
_ = try await webview.evaluateJavaScript("history.pushState({page: {url: '/page\(index)'}}, '', '/page\(index)')") XCTAssertFalse(webview.scrollView.bounces)
} XCTAssertFalse(controller.responds(to: NSSelectorFromString("navigateGesture:")))
_ = try await webview.evaluateJavaScript("window.steps = 0; addEventListener('popstate', () => window.steps++)") let customNavigation = try await webview.evaluateJavaScript("typeof window.__kanbanNavigation")
let edge = TestEdgeGesture() XCTAssertEqual(customNavigation as? String, "undefined")
edge.edges = .left
edge.testVelocity = CGPoint(x: 800, y: 0)
XCTAssertTrue(controller.gestureRecognizer(edge, shouldReceive: TestTouch()))
var allowed = false
for _ in 0..<50 {
if controller.gestureRecognizerShouldBegin(edge) { allowed = true; break }
try await Task.sleep(for: .milliseconds(20))
}
XCTAssertTrue(allowed)
edge.testState = .cancelled
edge.testTranslation = CGPoint(x: 200, y: 0)
controller.perform(NSSelectorFromString("navigateGesture:"), with: edge)
try await Task.sleep(for: .milliseconds(100))
try await waitFor(webview, "location.pathname === '/page3' && window.steps === 0")
edge.testState = .ended
edge.testTranslation = CGPoint(x: 10, y: 0)
controller.perform(NSSelectorFromString("navigateGesture:"), with: edge)
try await Task.sleep(for: .milliseconds(100))
try await waitFor(webview, "location.pathname === '/page3' && window.steps === 0")
edge.testTranslation = CGPoint(x: 200, y: 0)
controller.perform(NSSelectorFromString("navigateGesture:"), with: edge)
try await waitFor(webview, "location.pathname === '/page2' && window.steps === 1")
controller.navigateHistory(-1, from: CGPoint(x: 1, y: 1))
try await waitFor(webview, "location.pathname === '/page1' && window.steps === 2")
controller.navigateHistory(1, from: CGPoint(x: 1, y: 1))
try await waitFor(webview, "location.pathname === '/page2' && window.steps === 3")
let draft = try await webview.evaluateJavaScript("document.querySelector('#draft').value") let draft = try await webview.evaluateJavaScript("document.querySelector('#draft').value")
XCTAssertEqual(draft as? String, "unsaved") XCTAssertEqual(draft as? String, "unsaved")
XCTAssertFalse(webview.scrollView.bounces)
XCTAssertFalse(webview.allowsBackForwardNavigationGestures)
XCTAssertEqual(webview.gestureRecognizers?.compactMap { $0 as? UIScreenEdgePanGestureRecognizer }.count, 2)
let toolbar = try XCTUnwrap(controller.view.subviews.compactMap { $0 as? UIToolbar }.first) let toolbar = try XCTUnwrap(controller.view.subviews.compactMap { $0 as? UIToolbar }.first)
XCTAssertFalse(toolbar.items!.contains { $0.accessibilityIdentifier == "workspaceBack" }) XCTAssertFalse(toolbar.items!.contains { $0.accessibilityIdentifier == "workspaceBack" })
XCTAssertTrue(toolbar.items!.contains { $0.accessibilityIdentifier == "showServerSettings" }) XCTAssertTrue(toolbar.items!.contains { $0.accessibilityIdentifier == "showServerSettings" })
@@ -158,10 +130,174 @@ final class WorkspaceTests: XCTestCase {
XCTAssertEqual(scroll as? String, "none") XCTAssertEqual(scroll as? String, "none")
let horizontal = try await webview.evaluateJavaScript("getComputedStyle(document.documentElement).overscrollBehaviorX") let horizontal = try await webview.evaluateJavaScript("getComputedStyle(document.documentElement).overscrollBehaviorX")
XCTAssertEqual(horizontal as? String, "auto") XCTAssertEqual(horizontal as? String, "auto")
_ = try await webview.evaluateJavaScript("document.body.insertAdjacentHTML('beforeend', '<div role=dialog style=\"position:fixed;inset:0\">Dialog</div>')") controller.open(URL(string: "https://other.invalid/")!)
controller.navigateHistory(-1, from: CGPoint(x: 1, y: 1)) XCTAssertTrue(try XCTUnwrap(controller.workspace).allowsBackForwardNavigationGestures,
try await Task.sleep(for: .milliseconds(200)) "Changing workspaces must retain native gesture navigation")
try await waitFor(webview, "location.pathname === '/page2' && window.steps === 3") }
@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)
let webview = try XCTUnwrap(controller.workspace)
webview.stopLoading()
webview.loadSimulatedRequest(URLRequest(url: url.appendingPathComponent("projects/create")), responseHTML: """
<html><body><input id="duplicate" type="radio"><input id="draft" value="Unsaved project">
<a id="back" href="#back">Back</a><script>
window.visits = 0;
document.querySelector('#back').onclick = event => {
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() }
}
}
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)
XCTAssertFalse(toolbar.isHidden, "Older pages must retain native recovery")
let rejected = try await callScript(webview, """
try {
await window.frames[0].webkit.messageHandlers.kanbanSettings.postMessage(JSON.stringify({version:1,id:2,action:'settingsReady',payload:{}}));
return false;
} catch { return true; }
""")
XCTAssertEqual(rejected as? Bool, true)
XCTAssertFalse(toolbar.isHidden)
_ = try await callScript(webview, "return await window.kanbanNative.request('settingsReady')")
controller.view.layoutIfNeeded()
XCTAssertTrue(toolbar.isHidden)
XCTAssertEqual(toolbar.frame.height, 0)
XCTAssertEqual(webview.frame.minY, controller.view.safeAreaLayoutGuide.layoutFrame.minY, accuracy: 1)
let invalid = try await callScript(webview, "try { await window.kanbanNative.request('setWorkspace', {server:'http://bad.test'}); return false; } catch { return true; }")
XCTAssertEqual(invalid as? Bool, true)
XCTAssertEqual(controller.server?.url, url)
_ = try await callScript(webview, "return await window.kanbanNative.request('openSettings')")
XCTAssertFalse(controller.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" }!.isHidden)
_ = try await callScript(webview, "return await window.kanbanNative.request('setWorkspace', {server:'https://other.invalid/app/'})")
for _ in 0..<50 {
if controller.workspace !== webview { break }
try await Task.sleep(for: .milliseconds(20))
}
XCTAssertFalse(controller.workspace === webview)
XCTAssertNotEqual(controller.server?.profile, originalProfile)
let restored = WorkspaceViewController(defaults: defaults)
restored.loadViewIfNeeded()
XCTAssertEqual(restored.server?.url.absoluteString, "https://other.invalid/app/")
let stale = try await callScript(webview, "try { await window.kanbanNative.request('setWorkspace', {server:'https://stale.invalid/'}); return false; } catch { return true; }")
XCTAssertEqual(stale as? Bool, true)
XCTAssertEqual(controller.server?.url.absoluteString, "https://other.invalid/app/")
}
@MainActor
private func callScript(_ webview: WKWebView, _ script: String) async throws -> Any {
try await withCheckedThrowingContinuation { continuation in
webview.callAsyncJavaScript(script, arguments: [:], in: nil, in: .page) { result in
continuation.resume(with: result)
}
}
} }
@MainActor @MainActor
@@ -230,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)
XCTAssertEqual(controller.workspace?.configuration.userContentController.userScripts.count, 1) 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)
@@ -272,19 +408,3 @@ final class WorkspaceTests: XCTestCase {
} }
} }
private final class TestTouch: UITouch {
override func location(in view: UIView?) -> CGPoint { CGPoint(x: 1, y: 1) }
}
private final class TestEdgeGesture: UIScreenEdgePanGestureRecognizer {
var testState: UIGestureRecognizer.State = .possible
var testVelocity = CGPoint.zero
var testTranslation = CGPoint.zero
override var state: UIGestureRecognizer.State {
get { testState }
set { testState = newValue }
}
override func velocity(in view: UIView?) -> CGPoint { testVelocity }
override func translation(in view: UIView?) -> CGPoint { testTranslation }
}
@@ -20,6 +20,7 @@
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 */; }; 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 */; };
@@ -27,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 */;
@@ -45,12 +54,13 @@
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>"; }; 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>"; }; 6610D376CF901AC1733D394E /* updates.rs */ = {isa = PBXFileReference; path = updates.rs; sourceTree = "<group>"; };
@@ -63,8 +73,10 @@
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>"; };
@@ -156,6 +168,8 @@
5F71F06AB639140AB9714425 /* metadata.rs */, 5F71F06AB639140AB9714425 /* metadata.rs */,
8C0F963909020429B3D257D5 /* navigation.m */, 8C0F963909020429B3D257D5 /* navigation.m */,
E8BA104C81071623E1FF367D /* server.rs */, E8BA104C81071623E1FF367D /* server.rs */,
5F196E34D1B9E14FBD7191F4 /* settings.m */,
1CDE5C303A96423E2E6A194D /* settings.rs */,
6610D376CF901AC1733D394E /* updates.rs */, 6610D376CF901AC1733D394E /* updates.rs */,
); );
name = src; name = src;
@@ -182,7 +196,7 @@
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 */,
); );
@@ -193,6 +207,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */, 7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */,
BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */,
); );
name = ui; name = ui;
path = ../../../ui; path = ../../../ui;
@@ -240,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 */ = {
@@ -271,6 +286,7 @@
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
DEBBEA8683567208E49E40A5 /* PBXTargetDependency */,
); );
name = WorkspaceTests; name = WorkspaceTests;
packageProductDependencies = ( packageProductDependencies = (
@@ -334,6 +350,7 @@
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 */, 99291BB0C1DE7714FB32F554 /* workspace-navigation.js in Resources */,
99D76D98A2416CAA713186C2 /* workspace-settings.js in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -342,6 +359,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
0EC5CB2D7F774DBDCA69BCD8 /* workspace-navigation.js in Resources */, 0EC5CB2D7F774DBDCA69BCD8 /* workspace-navigation.js in Resources */,
EAD1D5A29503456CEED3B324 /* workspace-settings.js in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -408,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 */
@@ -441,7 +464,7 @@
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements"; CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual; CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 4; CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = ZZX2KZGTHU; DEVELOPMENT_TEAM = ZZX2KZGTHU;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -457,7 +480,7 @@
); );
"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)";
MARKETING_VERSION = 0.2.4; MARKETING_VERSION = 0.2.8;
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios; PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
PRODUCT_MODULE_NAME = KanbanGitNative; PRODUCT_MODULE_NAME = KanbanGitNative;
PRODUCT_NAME = KanbanGit; PRODUCT_NAME = KanbanGit;
@@ -618,7 +641,7 @@
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements"; CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Manual; CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 4; CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = ZZX2KZGTHU; DEVELOPMENT_TEAM = ZZX2KZGTHU;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -634,7 +657,7 @@
); );
"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)";
MARKETING_VERSION = 0.2.4; MARKETING_VERSION = 0.2.8;
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios; PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
PRODUCT_MODULE_NAME = KanbanGitNative; PRODUCT_MODULE_NAME = KanbanGitNative;
PRODUCT_NAME = KanbanGit; PRODUCT_NAME = KanbanGit;
@@ -681,6 +704,7 @@
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;
}; };
@@ -700,6 +724,7 @@
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;
}; };
+8 -2
View File
@@ -18,8 +18,8 @@ settingGroups:
DEVELOPMENT_TEAM: ZZX2KZGTHU DEVELOPMENT_TEAM: ZZX2KZGTHU
ENABLE_USER_SCRIPT_SANDBOXING: false ENABLE_USER_SCRIPT_SANDBOXING: false
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios
MARKETING_VERSION: "0.2.4" MARKETING_VERSION: "0.2.8"
CURRENT_PROJECT_VERSION: "4" CURRENT_PROJECT_VERSION: "7"
configs: configs:
debug: debug:
CODE_SIGN_IDENTITY: Apple Development CODE_SIGN_IDENTITY: Apple Development
@@ -40,9 +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-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
@@ -114,8 +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-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
@@ -124,6 +129,7 @@ targets:
base: base:
GENERATE_INFOPLIST_FILE: YES GENERATE_INFOPLIST_FILE: YES
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios.tests PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios.tests
TEST_HOST: $(BUILT_PRODUCTS_DIR)/KanbanGit.app/KanbanGit
DEVELOPMENT_TEAM: ZZX2KZGTHU DEVELOPMENT_TEAM: ZZX2KZGTHU
CODE_SIGN_STYLE: Manual CODE_SIGN_STYLE: Manual
SWIFT_VERSION: "5.0" SWIFT_VERSION: "5.0"
+83
View File
@@ -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";
@@ -237,6 +246,8 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
install_workspace_navigation(webview.inner().cast()); install_workspace_navigation(webview.inner().cast());
}) })
.map_err(|_| "connectionError")?; .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 let WindowEvent::CloseRequested { api, .. } = event { if let WindowEvent::CloseRequested { api, .. } = event {
@@ -256,6 +267,54 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
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" => {
@@ -464,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() {
+3
View File
@@ -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;
+37 -84
View File
@@ -2,101 +2,54 @@
#import <WebKit/WebKit.h> #import <WebKit/WebKit.h>
#import <objc/runtime.h> #import <objc/runtime.h>
// Keep AppKit's system swipe preferences and cancellation thresholds, but use @interface KanbanWorkspaceUIDelegate : NSObject <WKUIDelegate>
// script history traversal so asynchronously created Inertia entries aren't skipped. @property(nonatomic, strong) id<WKUIDelegate> originalDelegate;
@interface KanbanNavigation : NSObject
@property(nonatomic, weak) WKWebView *webview;
@property(nonatomic, strong) id monitor;
@property(nonatomic) BOOL pending;
@property(nonatomic) BOOL tracking;
@property(nonatomic) BOOL blocked;
@property(nonatomic) NSUInteger sequence;
@end @end
@implementation KanbanNavigation @implementation KanbanWorkspaceUIDelegate
- (BOOL)swipeTrackingEnabled { - (BOOL)respondsToSelector:(SEL)selector {
return NSEvent.isSwipeTrackingFromScrollEventsEnabled; return [super respondsToSelector:selector] || [self.originalDelegate respondsToSelector:selector];
} }
- (void)dealloc { - (id)forwardingTargetForSelector:(SEL)selector {
if (_monitor) [NSEvent removeMonitor:_monitor]; if ([self.originalDelegate respondsToSelector:selector]) {
return self.originalDelegate;
}
return [super forwardingTargetForSelector:selector];
} }
- (NSEvent *)handleEvent:(NSEvent *)event { - (void)webView:(WKWebView *)webView runJavaScriptConfirmPanelWithMessage:(NSString *)message
WKWebView *webview = self.webview; initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(BOOL))completionHandler {
if (!webview || event.window != webview.window || !webview.window.isKeyWindow || NSWindow *window = webView.window;
webview.hidden || ![self swipeTrackingEnabled]) return event; if (!window || window.attachedSheet) {
if (event.phase == NSEventPhaseBegan) { completionHandler(NO);
self.sequence++; return;
self.blocked = NO;
self.pending = NO;
self.tracking = NO;
} }
if (self.tracking) return nil;
if (event.phase == NSEventPhaseEnded || event.phase == NSEventPhaseCancelled) { NSAlert *alert = [NSAlert new];
self.sequence++; alert.alertStyle = NSAlertStyleWarning;
self.pending = NO; alert.messageText = frame.request.URL.host ?: @"KanbanGit";
return event; alert.informativeText = message;
} [alert addButtonWithTitle:@"OK"];
if (self.blocked || self.pending || event.momentumPhase != NSEventPhaseNone || [alert addButtonWithTitle:@"Cancel"];
!(event.phase & (NSEventPhaseBegan | NSEventPhaseChanged)) || !event.hasPreciseScrollingDeltas) return event; [alert beginSheetModalForWindow:window completionHandler:^(NSModalResponse response) {
if (fabs(event.scrollingDeltaX) <= fabs(event.scrollingDeltaY)) { completionHandler(response == NSAlertFirstButtonReturn);
if (fabs(event.scrollingDeltaY) > 0) self.blocked = YES;
return event;
}
NSPoint point = [webview convertPoint:event.locationInWindow fromView:nil];
if (!NSPointInRect(point, webview.bounds)) return event;
if (!webview.isFlipped) point.y = webview.bounds.size.height - point.y;
NSString *script = [NSString stringWithFormat:@"window.__kanbanNavigation?.canSwipe(%f, %f) === true", point.x, point.y];
self.pending = YES;
NSUInteger sequence = self.sequence;
NSURL *page = webview.URL;
__weak KanbanNavigation *weakSelf = self;
[webview evaluateJavaScript:script completionHandler:^(id allowed, NSError *error) {
KanbanNavigation *owner = weakSelf;
if (!owner || owner.sequence != sequence) return;
owner.pending = NO;
owner.blocked = YES;
if (error || ![allowed isEqual:@YES] || ![webview.URL isEqual:page] ||
(!webview.canGoBack && !webview.canGoForward)) return;
owner.tracking = YES;
[event trackSwipeEventWithOptions:NSEventSwipeTrackingLockDirection | NSEventSwipeTrackingClampGestureAmount
dampenAmountThresholdMin:webview.canGoForward ? -1 : 0
max:webview.canGoBack ? 1 : 0
usingHandler:^(CGFloat amount, NSEventPhase phase, BOOL complete, BOOL *stop) {
KanbanNavigation *current = weakSelf;
if (current.sequence != sequence) {
*stop = YES;
return;
}
if (!current || !webview.window.isKeyWindow || ![webview.URL isEqual:page]) {
current.tracking = NO;
*stop = YES;
return;
}
if (!complete) return;
current.tracking = NO;
if (phase == NSEventPhaseCancelled) return;
NSInteger direction = amount >= 1 ? -1 : amount <= -1 ? 1 : 0;
if ((direction == -1 && webview.canGoBack) || (direction == 1 && webview.canGoForward)) {
NSString *navigate = [NSString stringWithFormat:@"if (%@) window.__kanbanNavigation.step(%ld)", script, (long)direction];
[webview evaluateJavaScript:navigate completionHandler:nil];
}
}];
}]; }];
return event;
} }
@end @end
static char workspaceUIDelegateKey;
void install_workspace_navigation(void *pointer) { void install_workspace_navigation(void *pointer) {
WKWebView *webview = (__bridge WKWebView *)pointer; WKWebView *webview = (__bridge WKWebView *)pointer;
webview.allowsBackForwardNavigationGestures = NO; webview.allowsBackForwardNavigationGestures = YES;
KanbanNavigation *navigation = [KanbanNavigation new]; if ([webview.UIDelegate isKindOfClass:KanbanWorkspaceUIDelegate.class]) {
navigation.webview = webview; return;
__weak KanbanNavigation *weakNavigation = navigation; }
navigation.monitor = [NSEvent addLocalMonitorForEventsMatchingMask:NSEventMaskScrollWheel handler:^NSEvent *(NSEvent *event) {
KanbanNavigation *owner = weakNavigation; KanbanWorkspaceUIDelegate *delegate = [KanbanWorkspaceUIDelegate new];
return owner ? [owner handleEvent:event] : event; delegate.originalDelegate = webview.UIDelegate;
}]; // WKWebView keeps a weak UI delegate, so retain the forwarding adapter with its view.
objc_setAssociatedObject(webview, @selector(handleEvent:), navigation, OBJC_ASSOCIATION_RETAIN_NONATOMIC); objc_setAssociatedObject(webview, &workspaceUIDelegateKey, delegate, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
webview.UIDelegate = delegate;
} }
+47
View File
@@ -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];
}
+147
View File
@@ -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 {}))
));
}
}
+45 -14
View File
@@ -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,24 +406,49 @@ fn action(app: &AppHandle, url: &Url) {
fail(app, "preferencesError"); fail(app, "preferencesError");
return; return;
} }
let saved = (|| -> Result<(), Box<dyn std::error::Error>> { if let Err(error) =
let directory = app.path().app_config_dir()?; save_preferences(app, preferences.automatic, preferences.interval_hours)
fs::create_dir_all(&directory)?; {
let pending = directory.join("update-preferences.pending"); fail(app, error);
fs::write(&pending, serde_json::to_vec(&preferences)?)?;
fs::rename(pending, directory.join("update-preferences.json"))?;
Ok(())
})();
if saved.is_err() {
fail(app, "preferencesError");
return;
} }
app.state::<Mutex<State>>().lock().unwrap().view.preferences = preferences;
refresh(app);
} }
_ => {} _ => {}
} }
} }
pub fn snapshot(app: &AppHandle) -> serde_json::Value {
serde_json::to_value(&app.state::<Mutex<State>>().lock().unwrap().view).unwrap()
}
pub fn save_preferences(
app: &AppHandle,
automatic: bool,
interval_hours: u64,
) -> Result<(), &'static str> {
let preferences = Preferences {
automatic,
interval_hours,
};
if !preferences.valid() {
return Err("preferencesError");
}
let state = app.state::<Mutex<State>>();
let mut state = state.lock().unwrap();
if state.busy() {
return Err("updateBusy");
}
let saved = (|| -> Result<(), Box<dyn std::error::Error>> {
let directory = app.path().app_config_dir()?;
fs::create_dir_all(&directory)?;
let pending = directory.join("update-preferences.pending");
fs::write(&pending, serde_json::to_vec(&preferences)?)?;
fs::rename(pending, directory.join("update-preferences.json"))?;
Ok(())
})();
saved.map_err(|_| "preferencesError")?;
state.view.preferences = preferences;
drop(state);
refresh(app);
Ok(())
}
pub fn setup(app: &AppHandle) -> tauri::Result<()> { pub fn setup(app: &AppHandle) -> tauri::Result<()> {
app.plugin( app.plugin(
tauri_plugin_updater::Builder::new() tauri_plugin_updater::Builder::new()
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "KanbanGit", "productName": "KanbanGit",
"version": "0.2.4", "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": "9", "bundleVersion": "12",
"dmg": { "dmg": {
"windowSize": { "windowSize": {
"width": 660, "width": 660,
+1 -1
View File
@@ -5,7 +5,7 @@
"iOS": { "iOS": {
"minimumSystemVersion": "17.0", "minimumSystemVersion": "17.0",
"developmentTeam": "ZZX2KZGTHU", "developmentTeam": "ZZX2KZGTHU",
"bundleVersion": "4" "bundleVersion": "7"
} }
} }
} }
+39 -29
View File
@@ -18,6 +18,7 @@ fn main() {
.setup(move |app, _| { .setup(move |app, _| {
let directory = app.path().app_config_dir()?; let directory = app.path().app_config_dir()?;
std::fs::create_dir_all(&directory)?; std::fs::create_dir_all(&directory)?;
std::fs::write(directory.join("update-preferences.json"), r#"{"automatic":false,"intervalHours":6}"#)?;
if needs_setup { if needs_setup {
if startup == "invalid" { if startup == "invalid" {
std::fs::write(directory.join("active-server"), "invalid configuration")?; std::fs::write(directory.join("active-server"), "invalid configuration")?;
@@ -118,36 +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 initialHistory = history.length; if (typeof window.__kanbanNavigation !== 'undefined') throw new Error('Custom gesture bridge must be removed');
const initialPath = location.pathname; if (getComputedStyle(document.documentElement).overscrollBehaviorY !== 'none' || getComputedStyle(document.documentElement).overscrollBehaviorX !== 'auto') throw new Error('Scroll boundary policy missing');
const navigation = window.__kanbanNavigation; const ok = rendered && denied && typeof window.__TAURI__ === 'undefined' && location.pathname === '/login' && history.length === 1;
if (!navigation || getComputedStyle(document.documentElement).overscrollBehaviorY !== 'none' || getComputedStyle(document.documentElement).overscrollBehaviorX !== 'auto') throw new Error('Scroll boundary policy missing');
const traverse = direction => new Promise(resolve => {
addEventListener('popstate', resolve, { once: true });
navigation.step(direction);
});
for (const hash of ['#board', '#ticket', '#settings']) history.pushState({ page: { url: hash } }, '', hash);
await traverse(-1);
if (location.hash !== '#ticket') throw new Error('Back skipped an SPA entry');
await traverse(-1);
if (location.hash !== '#board') throw new Error('Back skipped a second SPA entry');
await traverse(1);
if (location.hash !== '#ticket' || history.length !== initialHistory + 3) throw new Error('Forward history was lost');
const dialog = document.createElement('dialog');
dialog.textContent = 'Navigation overlay'; document.body.append(dialog); dialog.showModal();
if (navigation.canSwipe(1, 1)) throw new Error('Overlay must own navigation');
dialog.close(); dialog.remove();
if (input.value !== 'unsaved input') throw new Error('History discarded form state');
const ok = rendered && denied && typeof window.__TAURI__ === 'undefined' && initialPath === '/login' && initialHistory === 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#"
@@ -179,12 +178,10 @@ 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 {
@@ -258,6 +255,19 @@ fn main() {
closed.load(Ordering::SeqCst), closed.load(Ordering::SeqCst),
"The close button must quit the application" "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),
+8 -65
View File
@@ -25,74 +25,17 @@ await page.addInitScript(script);
const root = `http://127.0.0.1:${server.address().port}`; const root = `http://127.0.0.1:${server.address().port}`;
try { try {
await page.goto(root); await page.goto(root);
const length = await page.evaluate(() => history.length);
for (const path of ['/board', '/ticket', '/settings']) {
await page.evaluate(
(path) =>
history.pushState(
{
page: { url: path },
rememberedState: { draft: 'retained' },
},
'',
path,
),
path,
);
}
await page.evaluate(() => {
window.steps = 0;
addEventListener('popstate', () => window.steps++);
});
await page.evaluate(() => window.__kanbanNavigation.step(-1));
await page.waitForFunction(() => location.pathname === '/ticket');
assert.equal(await page.evaluate(() => window.steps), 1);
await page.evaluate(() => window.__kanbanNavigation.step(-1));
await page.waitForFunction(() => location.pathname === '/board');
await page.evaluate(() => window.__kanbanNavigation.step(1));
await page.waitForFunction(() => location.pathname === '/ticket');
assert.equal(await page.evaluate(() => window.steps), 3);
assert.equal(await page.evaluate(() => history.length), length + 3);
assert.equal(await page.locator('#draft').inputValue(), 'retained');
assert.equal( assert.equal(
await page.evaluate(() => history.state.rememberedState.draft), await page.evaluate(() => typeof window.__kanbanNavigation),
'retained', 'undefined',
); );
await page.goto(`${root}/full-page`); await page.goto(`${root}/full-page`);
await page.evaluate(() => window.__kanbanNavigation.step(-1)); await page.goBack();
await page.waitForURL(`${root}/ticket`); await page.waitForURL(`${root}/`);
await page.evaluate(() => window.__kanbanNavigation.step(1)); await page.goForward();
await page.waitForURL(`${root}/full-page`); await page.waitForURL(`${root}/full-page`);
await page.locator('dialog').evaluate((dialog) => dialog.showModal()); await page.locator('#draft').fill('unsaved draft');
assert.equal( assert.equal(await page.locator('#draft').inputValue(), 'unsaved draft');
await page.evaluate(() => window.__kanbanNavigation.canSwipe(1, 1)),
false,
);
await page.locator('dialog').evaluate((dialog) => dialog.close());
for (const selector of ['#board', '#draft']) {
const point = await page.locator(selector).boundingBox();
assert.equal(
await page.evaluate(
({ x, y }) => window.__kanbanNavigation.canSwipe(x + 5, y + 5),
point,
),
false,
);
}
assert.equal(
await page.evaluate(() => window.__kanbanNavigation.canSwipe(1, 1)),
true,
);
await page.dispatchEvent('#board', 'dragstart');
assert.equal(
await page.evaluate(() => window.__kanbanNavigation.canSwipe(1, 1)),
false,
);
await page.dispatchEvent('#board', 'dragend');
assert.equal(
await page.evaluate(() => window.__kanbanNavigation.canSwipe(1, 1)),
true,
);
assert.equal( assert.equal(
await page.evaluate( await page.evaluate(
() => () =>
@@ -143,7 +86,7 @@ try {
() => document.querySelector('dialog').scrollTop > 0, () => document.querySelector('dialog').scrollTop > 0,
); );
console.log( console.log(
'PASS: SPA/full-page history, forward history, overlays, short/long scroll bounds, horizontal board, drag and input guards', 'PASS: browser-owned history, editable input, short/long scroll bounds, horizontal board and dialog scrolling',
); );
} finally { } finally {
await browser.close(); await browser.close();
+147 -124
View File
@@ -1,142 +1,165 @@
#import <AppKit/AppKit.h>
#import <WebKit/WebKit.h>
#import "../src-tauri/src/navigation.m" #import "../src-tauri/src/navigation.m"
// Exercise the AppKit event adapter deterministically; macos_smoke separately static void check(BOOL condition, NSString *message) {
// runs the shared history script against a real WKWebView. if (!condition) {
@interface TestWindow : NSWindow fprintf(stderr, "FAIL: %s\n", message.UTF8String);
@end exit(1);
@implementation TestWindow }
- (BOOL)isKeyWindow { return YES; } }
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 @end
@interface TestWebView : WKWebView @implementation WorkspaceUI
@property(nonatomic, strong) NSWindow *testWindow; - (void)webViewDidClose:(WKWebView *)webView { self.closed = YES; }
@property(nonatomic, strong) NSURL *testURL; - (void)webView:(WKWebView *)webView runOpenPanelWithParameters:(WKOpenPanelParameters *)parameters
@property(nonatomic) BOOL back; initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(NSArray<NSURL *> *))completionHandler {
@property(nonatomic) BOOL forward; self.uploaded = YES;
@property(nonatomic) BOOL allowsSwipe; completionHandler(nil);
@property(nonatomic) BOOL deferPermission;
@property(nonatomic, copy) void (^permission)(id, NSError *);
@property(nonatomic, strong) NSMutableArray<NSString *> *steps;
@end
@implementation TestWebView
- (NSWindow *)window { return self.testWindow; }
- (NSURL *)URL { return self.testURL; }
- (BOOL)canGoBack { return self.back; }
- (BOOL)canGoForward { return self.forward; }
- (NSPoint)convertPoint:(NSPoint)point fromView:(NSView *)view { return point; }
- (void)evaluateJavaScript:(NSString *)script completionHandler:(void (^)(id, NSError *))handler {
if ([script containsString:@".step("]) [self.steps addObject:script];
else if (self.deferPermission) self.permission = handler;
else handler(@(self.allowsSwipe), nil);
} }
@end @end
@interface TestEvent : NSEvent @interface WorkspacePages : NSObject <WKURLSchemeHandler, WKNavigationDelegate>
@property(nonatomic, strong) NSWindow *testWindow; @property(nonatomic) NSUInteger finished;
@property(nonatomic) NSEventPhase testPhase;
@property(nonatomic) NSEventPhase testMomentum;
@property(nonatomic) CGFloat dx;
@property(nonatomic) CGFloat dy;
@property(nonatomic) BOOL precise;
@property(nonatomic) NSInteger trackCount;
@property(nonatomic, copy) void (^tracking)(CGFloat, NSEventPhase, BOOL, BOOL *);
@end
@implementation TestEvent
- (NSWindow *)window { return self.testWindow; }
- (NSEventPhase)phase { return self.testPhase; }
- (NSEventPhase)momentumPhase { return self.testMomentum; }
- (CGFloat)scrollingDeltaX { return self.dx; }
- (CGFloat)scrollingDeltaY { return self.dy; }
- (BOOL)hasPreciseScrollingDeltas { return self.precise; }
- (NSPoint)locationInWindow { return NSMakePoint(20, 20); }
- (void)trackSwipeEventWithOptions:(NSEventSwipeTrackingOptions)options dampenAmountThresholdMin:(CGFloat)minimum max:(CGFloat)maximum usingHandler:(void (^)(CGFloat, NSEventPhase, BOOL, BOOL *))handler {
NSCAssert(options & NSEventSwipeTrackingLockDirection, @"Direction must be locked");
self.trackCount++;
self.tracking = handler;
}
@end @end
@interface TestNavigation : KanbanNavigation @implementation WorkspacePages
@property(nonatomic) BOOL enabled; - (void)webView:(WKWebView *)webView startURLSchemeTask:(id<WKURLSchemeTask>)task {
@end NSData *body = [[NSString stringWithFormat:@"<html><body>%@<input value='draft'></body></html>", task.request.URL.path]
@implementation TestNavigation dataUsingEncoding:NSUTF8StringEncoding];
- (BOOL)swipeTrackingEnabled { return self.enabled; } 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 @end
int main(void) { int main(void) {
@autoreleasepool { @autoreleasepool {
[NSApplication sharedApplication]; [NSApplication sharedApplication];
TestWindow *window = [[TestWindow alloc] initWithContentRect:NSMakeRect(0, 0, 600, 400) styleMask:NSWindowStyleMaskBorderless backing:NSBackingStoreBuffered defer:NO]; WorkspacePages *pages = [WorkspacePages new];
TestWebView *webview = [[TestWebView alloc] initWithFrame:NSMakeRect(0, 0, 600, 400)]; WKWebViewConfiguration *configuration = [WKWebViewConfiguration new];
webview.testWindow = window; configuration.websiteDataStore = WKWebsiteDataStore.nonPersistentDataStore;
webview.testURL = [NSURL URLWithString:@"https://example.com/board"]; [configuration setURLSchemeHandler:pages forURLScheme:@"kanban-test"];
webview.back = YES; WKWebView *webview = [[WKWebView alloc] initWithFrame:NSMakeRect(0, 0, 800, 600) configuration:configuration];
webview.forward = YES; webview.navigationDelegate = pages;
webview.allowsSwipe = YES; WorkspaceUI *originalUI = [WorkspaceUI new];
webview.steps = [NSMutableArray new]; webview.UIDelegate = originalUI;
TestNavigation *navigation = [TestNavigation new]; check(!webview.allowsBackForwardNavigationGestures, @"WebKit defaults to disabled gestures");
navigation.webview = webview; install_workspace_navigation((__bridge void *)webview);
navigation.enabled = YES; id<WKUIDelegate> installedUI = webview.UIDelegate;
TestEvent *(^event)(void) = ^{ install_workspace_navigation((__bridge void *)webview);
TestEvent *value = [TestEvent new]; check(webview.UIDelegate == installedUI, @"Installing workspace behavior twice must retain the same delegate");
value.testWindow = window; check([installedUI respondsToSelector:@selector(webViewDidClose:)], @"Existing popup callbacks must remain available");
value.testPhase = NSEventPhaseBegan; [installedUI webViewDidClose:webview];
value.precise = YES; check(originalUI.closed, @"Existing popup callbacks must reach the original delegate");
value.dx = 15; check([installedUI respondsToSelector:@selector(webView:runOpenPanelWithParameters:initiatedByFrame:completionHandler:)],
return value; @"Existing file upload callbacks must remain available");
}; check(webview.allowsBackForwardNavigationGestures, @"The workspace must enable WebKit's interactive gestures");
BOOL stop = NO; check(webview.configuration.userContentController.userScripts.count == 0, @"Enabling gestures must not inject a history adapter");
TestEvent *back = event(); check([webview goBack] == nil && [webview goForward] == nil, @"Empty history must not navigate");
[navigation handleEvent:back];
NSCAssert(back.trackCount == 1, @"A horizontal gesture must use AppKit tracking");
back.tracking(0.5, NSEventPhaseChanged, NO, &stop);
NSCAssert(webview.steps.count == 0, @"A partial swipe must not navigate");
back.tracking(1, NSEventPhaseNone, YES, &stop);
NSCAssert(webview.steps.count == 1 && [webview.steps.lastObject containsString:@"step(-1)"], @"Back must request exactly one step");
TestEvent *forward = event();
forward.dx = -15;
[navigation handleEvent:forward];
forward.tracking(-1, NSEventPhaseNone, YES, &stop);
NSCAssert(webview.steps.count == 2 && [webview.steps.lastObject containsString:@"step(1)"], @"Forward must request exactly one step");
TestEvent *cancelled = event();
[navigation handleEvent:cancelled];
cancelled.tracking(0, NSEventPhaseCancelled, YES, &stop);
NSCAssert(webview.steps.count == 2, @"Cancelled swipes must not navigate");
TestEvent *interrupted = event();
[navigation handleEvent:interrupted];
webview.allowsSwipe = NO;
[navigation handleEvent:event()];
interrupted.tracking(1, NSEventPhaseNone, YES, &stop);
NSCAssert(webview.steps.count == 2, @"A new gesture must cancel the previous gesture's animation callback");
for (NSInteger test = 0; test < 7; test++) { [webview loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"kanban-test://workspace/first"]]];
TestEvent *ignored = event(); waitUntil(^BOOL { return pages.finished == 1; }, @"First page must load");
navigation.enabled = test != 0; check(!webview.canGoBack && !webview.canGoForward, @"The first page must have no navigation target");
webview.allowsSwipe = test != 1; [webview loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"kanban-test://workspace/second"]]];
webview.back = webview.forward = test != 2; waitUntil(^BOOL { return pages.finished == 2; }, @"Second page must load");
if (test == 3) ignored.precise = NO; check(webview.canGoBack && !webview.canGoForward, @"Native history must retain the first page");
if (test == 4) ignored.dy = 30; check([webview goBack] != nil, @"Native back navigation must start");
if (test == 5) ignored.testMomentum = NSEventPhaseChanged; waitUntil(^BOOL { return pages.finished == 3; }, @"Native back navigation must complete");
if (test == 6) ignored.testWindow = nil; check([webview.URL.path isEqualToString:@"/first"] && !webview.canGoBack && webview.canGoForward,
NSCAssert([navigation handleEvent:ignored] == ignored && ignored.trackCount == 0, @"Non-navigation events must remain available for scrolling"); @"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");
} }
navigation.enabled = YES; check([evaluate(webview, @"window.visits") intValue] == 3, @"Only accepted visits must run, exactly once each");
webview.allowsSwipe = YES; window.contentView = nil;
webview.back = YES; check(![evaluate(webview, @"confirm('Detached view')") boolValue], @"A detached view must cancel and release JavaScript immediately");
webview.deferPermission = YES; [window orderOut:nil];
TestEvent *stale = event(); [window close];
[navigation handleEvent:stale]; puts("PASS: WebKit interactive gesture configuration, full-page back/forward, native confirmations and delegate forwarding");
TestEvent *ended = event();
ended.testPhase = NSEventPhaseEnded;
[navigation handleEvent:ended];
webview.permission(@YES, nil);
NSCAssert(stale.trackCount == 0, @"Late hit tests must not resurrect ended gestures");
TestEvent *changedPage = event();
[navigation handleEvent:changedPage];
webview.testURL = [NSURL URLWithString:@"https://example.com/settings"];
webview.permission(@YES, nil);
NSCAssert(changedPage.trackCount == 0, @"Page changes must invalidate a pending gesture");
puts("PASS: AppKit back/forward, cancelled/partial swipes, system preference, nested scroll ownership, empty history, mouse/vertical/momentum events, stale callbacks");
} }
return 0;
} }
+3 -3
View File
@@ -8,7 +8,7 @@ import test from 'node:test';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
test( test(
'AppKit gestures preserve single steps, cancellation, system preferences and scroll ownership', 'WebKit preserves history and confirms dirty form navigation without losing delegate callbacks',
{ skip: process.platform !== 'darwin' }, { skip: process.platform !== 'darwin' },
() => { () => {
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-navigation-')); const directory = mkdtempSync(join(tmpdir(), 'kanbangit-navigation-'));
@@ -48,9 +48,9 @@ test(
assert.equal( assert.equal(
result.status, result.status,
0, 0,
result.stderr || result.error?.message, result.stderr || result.error?.message || result.signal,
); );
assert.match(result.stdout, /PASS: AppKit/); assert.match(result.stdout, /PASS: WebKit/);
} finally { } finally {
rmSync(directory, { recursive: true, force: true }); rmSync(directory, { recursive: true, force: true });
} }
+75
View File
@@ -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/,
);
});
+1 -65
View File
@@ -1,21 +1,8 @@
(() => { (() => {
if (window.top !== window || window.__kanbanNavigation) { if (window.top !== window) {
return; return;
} }
let dragging = false;
const overlay = () =>
[
...document.querySelectorAll(
'dialog[open], [role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"], [data-reka-popper-content-wrapper]',
),
].some(
(element) =>
element.getAttribute('data-state') !== 'closed' &&
element.getClientRects().length > 0 &&
getComputedStyle(element).visibility !== 'hidden',
);
const constrainScroll = () => { const constrainScroll = () => {
if (document.documentElement) { if (document.documentElement) {
document.documentElement.style.overscrollBehaviorY = 'none'; document.documentElement.style.overscrollBehaviorY = 'none';
@@ -28,55 +15,4 @@
document.addEventListener('DOMContentLoaded', constrainScroll, { document.addEventListener('DOMContentLoaded', constrainScroll, {
once: true, once: true,
}); });
document.addEventListener('dragstart', () => {
dragging = true;
});
document.addEventListener('dragend', () => {
dragging = false;
});
document.addEventListener('drop', () => {
dragging = false;
});
window.__kanbanNavigation = Object.freeze({
canSwipe(x, y) {
if (dragging || overlay()) {
return false;
}
for (
let element = document.elementFromPoint(x, y);
element;
element = element.parentElement
) {
if (
element.matches(
'input, textarea, select, [contenteditable]:not([contenteditable="false"]), [draggable="true"]',
)
) {
return false;
}
const style = getComputedStyle(element);
if (
/(auto|scroll)/.test(style.overflowX) &&
element.scrollWidth > element.clientWidth
) {
return false;
}
if (
style.overscrollBehaviorX === 'contain' ||
style.overscrollBehaviorX === 'none'
) {
return false;
}
}
return true;
},
step(direction) {
// The host checks canGoBack/canGoForward. JS traversal preserves SPA entries
// that WebKit's user-initiated history traversal is allowed to skip.
if (direction === -1 || direction === 1) {
window.history.go(direction);
}
},
});
})(); })();
+58
View File
@@ -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'));
})();
+7 -2
View File
@@ -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": {
+645 -138
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -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
View File
@@ -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';
+71
View File
@@ -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;
}
+9
View File
@@ -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)',
@@ -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>
+136
View File
@@ -0,0 +1,136 @@
import { computed, readonly, ref } from 'vue';
import { isNativeAppState, updateIsBusy } from '@/lib/native-app';
import type { NativeAction, NativeAppState } from '@/types/native-app';
const state = ref<NativeAppState | null>(null);
const pending = ref(false);
const error = ref<string | null>(null);
let initializing = false;
let refreshing: Promise<void> | null = null;
async function request(
action: NativeAction,
payload: Record<string, unknown> = {},
) {
if (typeof window === 'undefined' || window.kanbanNative?.version !== 1) {
throw new Error('bridgeUnavailable');
}
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
window.kanbanNative.request(action, payload),
new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error('bridgeTimeout')),
11000,
);
}),
]);
} finally {
clearTimeout(timer);
}
}
async function refresh() {
if (refreshing) {
return refreshing;
}
refreshing = (async () => {
const result = await request('getState');
if (!isNativeAppState(result)) {
throw new Error('incompatibleVersion');
}
state.value = result;
})();
try {
await refreshing;
} finally {
refreshing = null;
}
}
export async function initializeNativeApp() {
if (
initializing ||
state.value ||
typeof window === 'undefined' ||
window.kanbanNative?.version !== 1
) {
return;
}
initializing = true;
try {
await refresh();
await request('settingsReady');
} catch {
state.value = null;
} finally {
initializing = false;
}
}
export function useNativeApp() {
const busy = computed(
() => pending.value || updateIsBusy(state.value?.updates),
);
async function act(
action: NativeAction,
payload: Record<string, unknown> = {},
) {
if (
pending.value ||
(updateIsBusy(state.value?.updates) && action !== 'openSettings')
) {
return false;
}
pending.value = true;
error.value = null;
try {
await request(action, payload);
if (action !== 'setWorkspace') {
await refresh();
}
return true;
} catch (cause) {
error.value =
cause instanceof Error ? cause.message : 'bridgeUnavailable';
return false;
} finally {
pending.value = false;
}
}
async function poll() {
try {
await refresh();
} catch (cause) {
error.value =
cause instanceof Error ? cause.message : 'bridgeUnavailable';
}
}
return { state: readonly(state), busy, error: readonly(error), act, poll };
}
export async function recoverNativeWorkspace() {
if (!state.value) {
return;
}
try {
await request('openSettings');
} catch {
/* Native recovery remains available from the app menu or toolbar. */
}
}
export function registerNativeRecovery() {
window.addEventListener('offline', () => {
void recoverNativeWorkspace();
});
document.addEventListener('inertia:exception', () => {
void recoverNativeWorkspace();
});
document.addEventListener('inertia:invalid', (event) => {
if (event.detail.response.status >= 400) {
void recoverNativeWorkspace();
}
});
}
@@ -0,0 +1,57 @@
import { computed, toValue } from 'vue';
import type { MaybeRefOrGetter } from 'vue';
import type { TicketLinkTypeModel } from '@/types/models/ticket-link-type-model';
import type {
TicketLinkActionOption,
TicketLinkFormData,
} from '@/types/ticket-detail';
export function useTicketLinkActions(
ticketLinkTypes: MaybeRefOrGetter<TicketLinkTypeModel[]>,
form: TicketLinkFormData,
) {
const actions = computed<TicketLinkActionOption[]>(() =>
toValue(ticketLinkTypes).flatMap((type) => {
const choices: TicketLinkActionOption[] = [
{
value: `${type.id}:source`,
label: type.label,
ticketLinkTypeId: type.id,
direction: 'source',
},
];
if (
type.counterpart_label &&
type.counterpart_label !== type.label
) {
choices.push({
value: `${type.id}:counterpart`,
label: type.counterpart_label,
ticketLinkTypeId: type.id,
direction: 'counterpart',
});
}
return choices;
}),
);
const selectedAction = computed({
get: () => `${form.ticket_link_type_id}:${form.link_direction}`,
set: (value: string | number | null) => {
const action = actions.value.find(
(option) => option.value === value,
);
if (action) {
form.ticket_link_type_id = action.ticketLinkTypeId;
form.link_direction = action.direction;
}
},
});
const selectedActionLabel = computed(
() =>
actions.value.find(
(action) => action.value === selectedAction.value,
)?.label,
);
return { actions, selectedAction, selectedActionLabel };
}
@@ -6,11 +6,14 @@ import AppLogo from '@/components/AppLogo.vue';
import AppearanceTabs from '@/components/AppearanceTabs.vue'; import AppearanceTabs from '@/components/AppearanceTabs.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization'; import { useLocalization } from '@/composables/useLocalization';
import { useNativeApp } from '@/composables/useNativeApp';
import { edit as editNativeApp } from '@/routes/native-app';
import { useMacDownload } from '@/composables/useMacDownload'; import { useMacDownload } from '@/composables/useMacDownload';
import { home } from '@/routes/index'; import { home } from '@/routes/index';
defineProps<{ title: string; description: string; icon?: Component }>(); defineProps<{ title: string; description: string; icon?: Component }>();
const { t } = useLocalization(); const { t } = useLocalization();
const { state: nativeApp } = useNativeApp();
const { macDownloadUrl, showMacDownload } = useMacDownload(); const { macDownloadUrl, showMacDownload } = useMacDownload();
</script> </script>
@@ -25,6 +28,9 @@ const { macDownloadUrl, showMacDownload } = useMacDownload();
class="focus-visible:ring-ring/35 rounded-md outline-none focus-visible:ring-2" class="focus-visible:ring-ring/35 rounded-md outline-none focus-visible:ring-2"
><AppLogo ><AppLogo
/></Link> /></Link>
<Button v-if="nativeApp" as-child variant="outline" size="sm">
<Link :href="editNativeApp()">{{ t('nativeApp.title') }}</Link>
</Button>
<Button <Button
v-if="showMacDownload" v-if="showMacDownload"
as-child as-child
+13
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link } from '@inertiajs/vue3'; import { Link } from '@inertiajs/vue3';
import { import {
AppWindow,
GitBranch, GitBranch,
KeyRound, KeyRound,
Palette, Palette,
@@ -10,6 +11,8 @@ import {
} from 'lucide-vue-next'; } from 'lucide-vue-next';
import { computed } from 'vue'; import { computed } from 'vue';
import { useCurrentUrl } from '@/composables/useCurrentUrl'; import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { useNativeApp } from '@/composables/useNativeApp';
import { edit as editNativeApp } from '@/routes/native-app';
import { useLocalization } from '@/composables/useLocalization'; import { useLocalization } from '@/composables/useLocalization';
import { toUrl } from '@/lib/utils'; import { toUrl } from '@/lib/utils';
import { edit as editAppearance } from '@/routes/appearance/index'; import { edit as editAppearance } from '@/routes/appearance/index';
@@ -20,6 +23,7 @@ import { edit as editSecurity } from '@/routes/security/index';
import type { NavItem } from '@/types'; import type { NavItem } from '@/types';
const { t } = useLocalization(); const { t } = useLocalization();
const { state: nativeApp } = useNativeApp();
const sidebarNavItems = computed<NavItem[]>(() => [ const sidebarNavItems = computed<NavItem[]>(() => [
{ {
title: t('navigation.profile'), title: t('navigation.profile'),
@@ -41,6 +45,15 @@ const sidebarNavItems = computed<NavItem[]>(() => [
href: editApplications(), href: editApplications(),
icon: KeyRound, icon: KeyRound,
}, },
...(nativeApp.value
? [
{
title: t('nativeApp.title'),
href: editNativeApp(),
icon: AppWindow,
},
]
: []),
{ {
title: t('navigation.appearance'), title: t('navigation.appearance'),
href: editAppearance(), href: editAppearance(),
+69
View File
@@ -0,0 +1,69 @@
import type { NativeAppState, NativeUpdates } from '@/types/native-app';
export function isNativeAppState(value: unknown): value is NativeAppState {
if (!value || typeof value !== 'object') {
return false;
}
const state = value as NativeAppState;
if (
state.apiVersion !== 1 ||
!['macos', 'ios'].includes(state.platform) ||
typeof state.version !== 'string' ||
typeof state.workspace !== 'string' ||
!Array.isArray(state.capabilities) ||
!state.capabilities.includes('workspace')
) {
return false;
}
try {
if (new URL(state.workspace).protocol !== 'https:') {
return false;
}
} catch {
return false;
}
if (!state.capabilities.includes('updates')) {
return state.updates === undefined;
}
if (state.platform !== 'macos' || !state.updates) {
return false;
}
const updates = state.updates;
return (
[
'idle',
'checking',
'available',
'current',
'downloading',
'verifying',
'installing',
'restarting',
'error',
].includes(updates.phase) &&
typeof updates.currentVersion === 'string' &&
typeof updates.build === 'string' &&
(updates.version === null || typeof updates.version === 'string') &&
(updates.notes === null || typeof updates.notes === 'string') &&
(updates.error === null || typeof updates.error === 'string') &&
Number.isFinite(updates.downloaded) &&
updates.downloaded >= 0 &&
(updates.total === null ||
(Number.isFinite(updates.total) && updates.total >= 0)) &&
typeof updates.preferences?.automatic === 'boolean' &&
[1, 6, 24].includes(updates.preferences.intervalHours)
);
}
export function updateIsBusy(updates?: NativeUpdates): boolean {
return (
!!updates &&
[
'checking',
'downloading',
'verifying',
'installing',
'restarting',
].includes(updates.phase)
);
}
@@ -0,0 +1,17 @@
{
"format": "Markdown stored verbatim and supported by the Tiptap WYSIWYG editor and ticket detail view.",
"supported": "Supported: plain text, paragraphs, line breaks, headings, bold, italic, strikethrough, ordinary ordered/unordered lists (including nested lists), blockquotes, inline/fenced/indented code, horizontal rules, and safe links.",
"unsupported": "Not visually supported: task checkboxes, tables, HTML, images, or unsafe links. Newly supplied incompatible descriptions reject the entire mutation with a description field error; existing descriptions are preserved when description is omitted.",
"criteria": "Put acceptance criteria exclusively in the structured acceptance_criteria field. Do not repeat them in description as task checkboxes or a copied criteria list. No criteria are automatically extracted or moved.",
"links": "Safe link destinations: http://, https://, mailto:, a root-relative path starting with a single /, or a #fragment. Protocol-relative URLs, other schemes, whitespace, control characters, and backslashes (including percent-encoded forms) are not allowed.",
"example": "## Goal\n\nExplain the **context** and approach.\n\n- Implement the change\n- Verify [the guide](https://example.com/guide)\n\n> Keep the scope clear.\n\nUse `code` when needed.",
"unsupported_tokens": {
"html": "HTML",
"table": "tables",
"image": "images",
"task": "task checkboxes",
"unsafe_link": "unsafe links"
},
"link_prefix": "^(?:https?://|mailto:|/(?!/)|#)",
"unsafe_link_characters": "[\\s\\\\\\x00-\\x1f\\x7f]"
}
+145
View File
@@ -0,0 +1,145 @@
import type { JSONContent } from '@tiptap/core';
import { Markdown, MarkdownManager } from '@tiptap/markdown';
import StarterKit from '@tiptap/starter-kit';
import { h } from 'vue';
import descriptionFormat from './ticket-description-format.json';
import type { VNodeChild } from 'vue';
export function safeDescriptionLink(value: unknown): string | null {
if (
typeof value !== 'string' ||
new RegExp(descriptionFormat.unsafe_link_characters, 'u').test(value)
) {
return null;
}
try {
if (
new RegExp(descriptionFormat.unsafe_link_characters, 'u').test(
decodeURIComponent(value),
)
) {
return null;
}
} catch {
return null;
}
if (/^(https?:\/\/|mailto:)/i.test(value)) {
try {
new URL(value);
return new RegExp(descriptionFormat.link_prefix, 'i').test(value)
? value
: null;
} catch {
return null;
}
}
return new RegExp(descriptionFormat.link_prefix, 'i').test(value)
? value
: null;
}
export function descriptionExtensions() {
return [
StarterKit.configure({
underline: false,
trailingNode: false,
link: {
openOnClick: false,
autolink: false,
isAllowedUri: (url) => safeDescriptionLink(url) !== null,
HTMLAttributes: {
rel: 'noopener noreferrer',
target: '_blank',
},
},
}),
Markdown.configure({ markedOptions: { gfm: true, breaks: true } }),
];
}
const markdown = new MarkdownManager({
extensions: descriptionExtensions(),
markedOptions: { gfm: true, breaks: true },
});
/** Unsupported Markdown stays editable verbatim instead of being silently discarded. */
export function supportsVisualDescription(value: string): boolean {
let supported = true;
markdown.instance.walkTokens(
markdown.instance.lexer(value, { gfm: true, breaks: true }),
(token) => {
if (
Object.hasOwn(
descriptionFormat.unsupported_tokens,
token.type,
) ||
(token.type === 'list_item' && token.task) ||
(token.type === 'link' && !safeDescriptionLink(token.href))
) {
supported = false;
}
},
);
return supported;
}
export function parseDescription(value: string): JSONContent {
return markdown.parse(value);
}
/** Build Vue nodes from an explicit schema; never insert user-provided HTML. */
export function renderDescriptionNode(node: JSONContent): VNodeChild {
if (node.type === 'text') {
let content: VNodeChild = node.text ?? '';
for (const mark of node.marks ?? []) {
const tag = {
bold: 'strong',
italic: 'em',
strike: 's',
code: 'code',
}[mark.type];
if (tag) {
content = h(tag, {}, [content]);
} else if (mark.type === 'link') {
const href = safeDescriptionLink(mark.attrs?.href);
if (href) {
content = h(
'a',
{ href, target: '_blank', rel: 'noopener noreferrer' },
[content],
);
}
}
}
return content;
}
const children = (node.content ?? []).map(renderDescriptionNode);
switch (node.type) {
case 'doc':
return children;
case 'paragraph':
return h('p', {}, children);
case 'heading':
return h(
`h${Math.min(6, Math.max(1, Number(node.attrs?.level) || 1))}`,
{},
children,
);
case 'bulletList':
return h('ul', {}, children);
case 'orderedList':
return h('ol', { start: Number(node.attrs?.start) || 1 }, children);
case 'listItem':
return h('li', {}, children);
case 'blockquote':
return h('blockquote', {}, children);
case 'codeBlock':
return h('pre', {}, [h('code', {}, children)]);
case 'hardBreak':
return h('br');
case 'horizontalRule':
return h('hr');
default:
return children;
}
}
+87 -5
View File
@@ -1,4 +1,90 @@
export const en = { export const en = {
'descriptionEditor.bold': 'Bold',
'descriptionEditor.italic': 'Italic',
'descriptionEditor.strike': 'Strikethrough',
'descriptionEditor.heading1': 'Heading 1',
'descriptionEditor.heading2': 'Heading 2',
'descriptionEditor.heading3': 'Heading 3',
'descriptionEditor.bulletList': 'Bullet list',
'descriptionEditor.orderedList': 'Numbered list',
'descriptionEditor.quote': 'Quote',
'descriptionEditor.code': 'Inline code',
'descriptionEditor.codeBlock': 'Code block',
'descriptionEditor.undo': 'Undo',
'descriptionEditor.redo': 'Redo',
'descriptionEditor.link': 'Add or edit link',
'descriptionEditor.unlink': 'Remove link',
'descriptionEditor.linkUrl': 'Link address',
'descriptionEditor.applyLink': 'Apply link',
'descriptionEditor.invalidLink':
'Enter a valid https://, http://, mailto:, /path or #anchor address without spaces.',
'descriptionEditor.formatting': 'Description formatting',
'descriptionEditor.source': 'Markdown',
'descriptionEditor.visual': 'Visual editor',
'descriptionEditor.hint':
'Format your description using the toolbar or edit its Markdown source.',
'descriptionEditor.unsupported':
'This description contains formatting that requires Markdown editing. Its original content is preserved.',
'nativeApp.title': 'App',
'nativeApp.description': 'Manage the KanbanGit app on this device.',
'nativeApp.workspace': 'Workspace',
'nativeApp.server': 'Server address',
'nativeApp.serverHelp':
'Use an HTTPS installation address without login details, a query or a fragment. This choice is saved on this device.',
'nativeApp.switch': 'Change workspace',
'nativeApp.confirmSwitch': 'Switch workspace',
'nativeApp.switchWarning':
'Switching closes your current workspace and discards unsaved inputs. You will sign in separately on the new server.',
'nativeApp.cancel': 'Cancel',
'nativeApp.unavailable':
'App settings are available in a compatible KanbanGit app.',
'nativeApp.updates': 'Software updates',
'nativeApp.version': 'Installed version {version} (build {build})',
'nativeApp.mobileVersion': 'App version {version}',
'nativeApp.availableVersion': 'Available version: {version}',
'nativeApp.automatic': 'Automatically check for updates',
'nativeApp.interval': 'Check interval',
'nativeApp.hours': 'Every {hours} hours',
'nativeApp.updateHelp':
'Checks run at startup and at the selected interval. Installation and restart require your confirmation.',
'nativeApp.check': 'Check for updates now',
'nativeApp.install': 'Install update',
'nativeApp.installRestart': 'Install and restart',
'nativeApp.installWarning':
'Save your work before continuing. KanbanGit will download and verify the update, then install it and restart. Unsaved inputs will be lost. Your workspace settings and stored sessions are kept.',
'nativeApp.progress': 'Update progress',
'nativeApp.recovery': 'Open native server settings',
'nativeApp.bridgeError':
'The app connection is unavailable or incompatible. Open the native server settings to reconnect, or restart the app.',
'nativeApp.invalidServer':
'Enter a valid HTTPS installation address without login details, a query or a fragment.',
'nativeApp.storageError':
'The workspace could not be saved. Check local storage permissions and try again.',
'nativeApp.networkError':
'The update service or download is unreachable. Check your connection and try again.',
'nativeApp.metadataError':
'The update information is invalid or does not match this Mac. Try again later.',
'nativeApp.signatureError':
'The update signature could not be verified. Nothing was installed. Try again or contact support.',
'nativeApp.verificationError':
'The downloaded app does not match its announced version or publisher. Nothing was installed.',
'nativeApp.installationError':
'The update could not be installed. Move KanbanGit to Applications and check disk space and permissions.',
'nativeApp.preferencesError':
'Update preferences could not be saved. Check local storage permissions and try again.',
'nativeApp.updateBusy':
'An update operation is already running. Please wait.',
'nativeApp.phase.idle': 'Check for the latest stable version of KanbanGit.',
'nativeApp.phase.checking': 'Checking for updates…',
'nativeApp.phase.available': 'A new version of KanbanGit is available.',
'nativeApp.phase.current': 'KanbanGit is up to date.',
'nativeApp.phase.downloading': 'Downloading update…',
'nativeApp.phase.verifying': 'Verifying update…',
'nativeApp.phase.installing': 'Installing update…',
'nativeApp.phase.restarting': 'Restarting KanbanGit…',
'nativeApp.phase.error': 'The update could not be completed.',
'projectAccess.pageTitle': '{project} · Project access', 'projectAccess.pageTitle': '{project} · Project access',
'projectAccess.title': 'Project access', 'projectAccess.title': 'Project access',
'projectAccess.description': 'projectAccess.description':
@@ -1127,7 +1213,7 @@ export const en = {
'ticketDetail.linkedTickets': 'Linked tickets', 'ticketDetail.linkedTickets': 'Linked tickets',
'ticketDetail.linkedTicketsDescription': 'ticketDetail.linkedTicketsDescription':
'Track cross-ticket relationships with project-defined link labels.', 'Track cross-ticket relationships with project-defined link labels.',
'ticketDetail.linkType': 'Link type', 'ticketDetail.linkAction': 'Action',
'ticketDetail.linkedTicket': 'Linked ticket', 'ticketDetail.linkedTicket': 'Linked ticket',
'ticketDetail.saveLink': 'Save link', 'ticketDetail.saveLink': 'Save link',
'ticketDetail.noLinks': 'ticketDetail.noLinks':
@@ -1141,10 +1227,6 @@ export const en = {
'Open a child ticket to change its parent. Expand a group to see its descendants.', 'Open a child ticket to change its parent. Expand a group to see its descendants.',
'ticketRelationships.addLink': 'Add or update a link', 'ticketRelationships.addLink': 'Add or update a link',
'ticketRelationships.selectTicket': 'Select a ticket', 'ticketRelationships.selectTicket': 'Select a ticket',
'ticketRelationships.directionHelp':
'The selected relationship points from this ticket to the linked ticket. Saving an existing pair updates its type and direction.',
'ticketRelationships.outgoing': 'Outgoing',
'ticketRelationships.incoming': 'Incoming',
'ticketRelationships.removeLink': 'Remove link to {key}', 'ticketRelationships.removeLink': 'Remove link to {key}',
'ticketRelationships.noTypes': 'ticketRelationships.noTypes':
'No link types are configured for this project.', 'No link types are configured for this project.',
+313
View File
@@ -0,0 +1,313 @@
<script setup lang="ts">
import { Head, Link, usePage } from '@inertiajs/vue3';
import { computed, onMounted, onUnmounted, ref, watch } from '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 { useNativeApp } from '@/composables/useNativeApp';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import { home } from '@/routes';
import { edit } from '@/routes/native-app';
import type { TranslationKey } from '@/types/localization';
const { t } = useLocalization();
const { state, busy, error, act, poll } = useNativeApp();
const page = usePage();
const signedIn = computed(() => !!page.props.auth.user);
const address = ref('');
const confirmSwitch = ref(false);
const confirmInstall = ref(false);
const updates = computed(() => state.value?.updates);
watch(
() => updates.value?.version,
() => {
confirmInstall.value = false;
},
);
const breadcrumbs = computed(() => [
{ title: t('nativeApp.title'), href: edit() },
]);
watch(
() => state.value?.workspace,
(workspace) => {
if (workspace) {
address.value = workspace;
}
},
{ immediate: true },
);
watch(address, () => {
confirmSwitch.value = false;
});
const knownErrors: Record<string, TranslationKey> = {
invalidServer: 'nativeApp.invalidServer',
storageError: 'nativeApp.storageError',
networkError: 'nativeApp.networkError',
metadataError: 'nativeApp.metadataError',
signatureError: 'nativeApp.signatureError',
verificationError: 'nativeApp.verificationError',
installationError: 'nativeApp.installationError',
preferencesError: 'nativeApp.preferencesError',
updateBusy: 'nativeApp.updateBusy',
};
const errorMessage = computed(() => {
const value = error.value ?? updates.value?.error;
return value ? t(knownErrors[value] ?? 'nativeApp.bridgeError') : null;
});
const progress = computed(() =>
updates.value?.total
? Math.min(
100,
Math.round(
(updates.value.downloaded / updates.value.total) * 100,
),
)
: undefined,
);
let timer: ReturnType<typeof setTimeout> | undefined;
let active = true;
async function refresh() {
if (state.value) {
await poll();
}
if (active) {
timer = setTimeout(refresh, 1500);
}
}
onMounted(() => {
void refresh();
});
onUnmounted(() => {
active = false;
clearTimeout(timer);
});
async function switchWorkspace() {
if (await act('setWorkspace', { server: address.value })) {
confirmSwitch.value = false;
}
}
async function installUpdate() {
const version = updates.value?.version;
if (version && (await act('installUpdate', { version }))) {
confirmInstall.value = false;
}
}
function setAutomatic(event: Event) {
void act('setUpdatePreferences', {
automatic: (event.target as HTMLInputElement).checked,
intervalHours: updates.value?.preferences.intervalHours,
});
}
function setIntervalHours(event: Event) {
void act('setUpdatePreferences', {
automatic: updates.value?.preferences.automatic,
intervalHours: Number((event.target as HTMLSelectElement).value),
});
}
</script>
<template>
<component
:is="signedIn ? AppLayout : 'main'"
:breadcrumbs="breadcrumbs"
class="min-h-dvh"
>
<Head :title="t('nativeApp.title')" />
<component
:is="signedIn ? SettingsLayout : 'div'"
:class="signedIn ? '' : 'mx-auto grid max-w-3xl gap-6 p-6'"
>
<Link
v-if="!signedIn"
:href="home()"
class="text-primary underline"
>{{ t('auth.home') }}</Link
>
<header class="grid gap-2">
<h2 class="text-title">{{ t('nativeApp.title') }}</h2>
<p class="text-muted-foreground">
{{ t('nativeApp.description') }}
</p>
</header>
<p v-if="!state" role="status">{{ t('nativeApp.unavailable') }}</p>
<template v-else>
<form class="grid gap-4" @submit.prevent="confirmSwitch = true">
<h3 class="text-lg font-semibold">
{{ t('nativeApp.workspace') }}
</h3>
<Label for="native-server">{{
t('nativeApp.server')
}}</Label>
<Input
id="native-server"
v-model="address"
type="url"
required
autocomplete="url"
:disabled="busy"
/>
<p class="text-muted-foreground text-sm">
{{ t('nativeApp.serverHelp') }}
</p>
<Button
type="submit"
:disabled="busy || address === state.workspace"
class="justify-self-start"
>{{ t('nativeApp.switch') }}</Button
>
<div
v-if="confirmSwitch"
class="grid gap-3 rounded-lg border p-4"
role="group"
:aria-label="t('nativeApp.switch')"
>
<p>{{ t('nativeApp.switchWarning') }}</p>
<div class="flex flex-wrap gap-2">
<Button
type="button"
:disabled="busy"
@click="switchWorkspace"
>{{ t('nativeApp.confirmSwitch') }}</Button
>
<Button
type="button"
variant="outline"
:disabled="busy"
@click="confirmSwitch = false"
>{{ t('nativeApp.cancel') }}</Button
>
</div>
</div>
</form>
<section
v-if="updates"
class="grid gap-4 border-t pt-6"
aria-labelledby="native-updates"
>
<h3 id="native-updates" class="text-lg font-semibold">
{{ t('nativeApp.updates') }}
</h3>
<p>
{{
t('nativeApp.version', {
version: updates.currentVersion,
build: updates.build,
})
}}
</p>
<p role="status" aria-live="polite">
{{ t(`nativeApp.phase.${updates.phase}`) }}
</p>
<template
v-if="
['downloading', 'verifying', 'installing'].includes(
updates.phase,
)
"
>
<progress
:value="progress"
max="100"
class="w-full"
:aria-label="t('nativeApp.progress')"
/>
<span v-if="progress !== undefined"
>{{ progress }}%</span
>
</template>
<Label class="flex items-center gap-3">
<input
type="checkbox"
:checked="updates.preferences.automatic"
:disabled="busy"
@change="setAutomatic"
/>
{{ t('nativeApp.automatic') }}
</Label>
<Label for="native-update-interval">{{
t('nativeApp.interval')
}}</Label>
<select
id="native-update-interval"
:value="updates.preferences.intervalHours"
:disabled="busy"
class="bg-background rounded-md border p-2"
@change="setIntervalHours"
>
<option
v-for="hours in [1, 6, 24]"
:key="hours"
:value="hours"
>
{{ t('nativeApp.hours', { hours }) }}
</option>
</select>
<p class="text-muted-foreground text-sm">
{{ t('nativeApp.updateHelp') }}
</p>
<Button
:disabled="busy"
class="justify-self-start"
@click="act('checkForUpdates')"
>{{ t('nativeApp.check') }}</Button
>
<template v-if="updates.version">
<p>
{{
t('nativeApp.availableVersion', {
version: updates.version,
})
}}
</p>
<p v-if="updates.notes" class="whitespace-pre-wrap">
{{ updates.notes }}
</p>
<Button
:disabled="busy"
class="justify-self-start"
@click="confirmInstall = true"
>{{ t('nativeApp.install') }}</Button
>
<div
v-if="confirmInstall"
class="grid gap-3 rounded-lg border p-4"
role="group"
:aria-label="t('nativeApp.install')"
>
<p>{{ t('nativeApp.installWarning') }}</p>
<div class="flex flex-wrap gap-2">
<Button
:disabled="busy"
@click="installUpdate"
>{{ t('nativeApp.installRestart') }}</Button
>
<Button
variant="outline"
:disabled="busy"
@click="confirmInstall = false"
>{{ t('nativeApp.cancel') }}</Button
>
</div>
</div>
</template>
</section>
<p v-else class="text-muted-foreground text-sm">
{{
t('nativeApp.mobileVersion', { version: state.version })
}}
</p>
<p v-if="errorMessage" class="text-destructive" role="alert">
{{ errorMessage }}
</p>
<Button
variant="outline"
class="justify-self-start"
@click="act('openSettings')"
>{{ t('nativeApp.recovery') }}</Button
>
</template>
</component>
</component>
</template>
+7
View File
@@ -0,0 +1,7 @@
import type { NativeBridge } from './native-app';
declare global {
interface Window {
kanbanNative?: NativeBridge;
}
}
+44
View File
@@ -0,0 +1,44 @@
export type NativeUpdatePhase =
| 'idle'
| 'checking'
| 'available'
| 'current'
| 'downloading'
| 'verifying'
| 'installing'
| 'restarting'
| 'error';
export type NativeUpdates = {
phase: NativeUpdatePhase;
currentVersion: string;
build: string;
version: string | null;
notes: string | null;
downloaded: number;
total: number | null;
error: string | null;
preferences: { automatic: boolean; intervalHours: 1 | 6 | 24 };
};
export type NativeAppState = {
apiVersion: 1;
platform: 'macos' | 'ios';
version: string;
workspace: string;
capabilities: string[];
updates?: NativeUpdates;
};
export type NativeAction =
| 'getState'
| 'settingsReady'
| 'openSettings'
| 'setWorkspace'
| 'checkForUpdates'
| 'setUpdatePreferences'
| 'installUpdate';
export type NativeBridge = {
version: number;
request: (
action: NativeAction,
payload?: Record<string, unknown>,
) => Promise<unknown>;
};
+19
View File
@@ -0,0 +1,19 @@
import type { ChainedCommands } from '@tiptap/core';
import type { Component } from 'vue';
import type { TranslationKey } from '@/types/localization';
export type TicketDescriptionEditorProps = {
id: string;
label: string;
error?: string;
disabled?: boolean;
placeholder?: string;
};
export type DescriptionToolbarAction = {
label: TranslationKey;
icon: Component;
active?: string;
attributes?: Record<string, unknown>;
run: (chain: ChainedCommands) => ChainedCommands;
};
+11
View File
@@ -67,3 +67,14 @@ export type TicketBranchFormData = {
branch_name: string; branch_name: string;
repository_connection_id: string; repository_connection_id: string;
}; };
export type TicketLinkFormData = {
ticket_link_type_id: number | string;
link_direction: 'source' | 'counterpart';
linked_ticket_id: number | string;
};
export type TicketLinkActionOption = {
value: string;
label: string;
ticketLinkTypeId: number;
direction: TicketLinkFormData['link_direction'];
};
+1 -1
View File
@@ -27,7 +27,7 @@ Route::prefix('projects')->name('projects.')->group(function (): void {
}); });
Route::delete('/', [ProjectController::class, 'destroy']) Route::delete('/', [ProjectController::class, 'destroy'])
->middleware('can:access-admin') ->middleware('can:admin-project,project')
->name('destroy'); ->name('destroy');
Route::middleware('can:admin-project,project')->group(function (): void { Route::middleware('can:admin-project,project')->group(function (): void {
+4
View File
@@ -6,6 +6,10 @@ use App\Http\Controllers\Settings\SecurityController;
use App\Http\Controllers\Settings\UserApplicationController; use App\Http\Controllers\Settings\UserApplicationController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::prefix('settings')->group(function (): void {
Route::inertia('app', 'settings/App')->name('native-app.edit');
});
Route::middleware('auth') Route::middleware('auth')
->prefix('settings') ->prefix('settings')
->group(function (): void { ->group(function (): void {
+8 -1
View File
@@ -291,7 +291,14 @@ class ProjectApiTest extends TestCase
->assertOk() ->assertOk()
->assertJsonPath('data.key', 'CORE'); ->assertJsonPath('data.key', 'CORE');
$this->syncGlobalPermissions($admin, ['admin']); $this->withApplicationKey($user)
->deleteJson(route('api.projects.destroy', $project), [
'confirmation_key' => 'CORE',
])
->assertForbidden();
$this->syncProjectPermissions($project, $admin, ['admin']);
$this->assertTrue($admin->can('admin-project', $project));
$this->withApplicationKey($admin) $this->withApplicationKey($admin)
->deleteJson(route('api.projects.destroy', $project), [ ->deleteJson(route('api.projects.destroy', $project), [
+43
View File
@@ -293,6 +293,49 @@ class TicketApiTest extends TestCase
$this->assertDatabaseCount('ticket_worklog_entries', 0); $this->assertDatabaseCount('ticket_worklog_entries', 0);
} }
public function test_counterpart_link_direction_is_reflected_on_both_tickets_in_the_api(): void
{
[$user, $project] = $this->projectContext();
$status = $project->ticketStatuses()->firstOrFail();
$blockingTicket = $this->ticket($project, $status);
$blockedTicket = $this->ticket($project, $status, 2);
$linkType = TicketLinkType::factory()->for($project)->create([
'label' => 'Blocks',
'counterpart_label' => 'Blocked by',
]);
$this->withApplicationKey($user)
->postJson(route('api.projects.tickets.links.store', [$project, $blockedTicket]), [
'linked_ticket_id' => $blockingTicket->id,
'ticket_link_type_id' => $linkType->id,
'link_direction' => 'counterpart',
])
->assertCreated()
->assertJsonPath('data.source_ticket_id', $blockingTicket->id)
->assertJsonPath('data.target_ticket_id', $blockedTicket->id)
->assertJsonPath('data.ticket_link_type.label', 'Blocked by')
->assertJsonPath('meta.message', 'Blocked by link was saved.');
$this->withApplicationKey($user)
->getJson(route('api.projects.tickets.show', [$project, $blockingTicket]))
->assertOk()
->assertJsonPath('data.linkedTickets.0.ticket_link_type.label', 'Blocks');
$this->withApplicationKey($user)
->getJson(route('api.projects.tickets.show', [$project, $blockedTicket]))
->assertOk()
->assertJsonPath('data.linkedTickets.0.ticket_link_type.label', 'Blocked by');
$this->withApplicationKey($user)
->postJson(route('api.projects.tickets.links.store', [$project, $blockedTicket]), [
'linked_ticket_id' => $blockingTicket->id,
'ticket_link_type_id' => $linkType->id,
'link_direction' => 'invalid',
])
->assertUnprocessable()
->assertJsonValidationErrors('link_direction');
}
public function test_ticket_links_and_branch_endpoints_are_available(): void public function test_ticket_links_and_branch_endpoints_are_available(): void
{ {
Http::preventStrayRequests(); Http::preventStrayRequests();
@@ -0,0 +1,178 @@
<?php
namespace Tests\Feature\Mcp;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Actions\Tickets\Support\TicketDescriptionFormat;
use App\Mcp\Servers\KanbanGitServer;
use App\Mcp\Tools\CreateTicket;
use App\Mcp\Tools\GetTicket;
use App\Mcp\Tools\UpdateTicket;
use App\Mcp\Tools\UpdateTicketDescription;
use App\Models\Project;
use App\Models\Ticket;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Testing\Fluent\AssertableJson;
use Inertia\Testing\AssertableInertia as Assert;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class TicketDescriptionContractTest extends TestCase
{
use RefreshDatabase;
private Project $project;
private User $user;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
$this->project = Project::factory()->create(['key' => 'CORE']);
app(InitializeProjectTicketMetadata::class)->handle($this->project);
$this->syncProjectPermissions($this->project, $this->user, ['view', 'write']);
}
public function test_discovery_exposes_the_shared_contract_for_every_writing_tool_and_field_for_any_key_owner(): void
{
foreach ([$this->user, User::factory()->create()] as $user) {
$this->syncProjectPermissions($this->project, $user, ['view', 'write']);
$this->withApplicationKey($user);
$this->postJson('/mcp/kanbangit', [
'jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize',
'params' => ['protocolVersion' => '2025-06-18', 'capabilities' => [], 'clientInfo' => ['name' => 'Contract test', 'version' => '1.0']],
])->assertOk()->assertJsonPath('result.instructions', fn (string $value): bool => str_contains($value, TicketDescriptionFormat::instructions()));
$response = $this->postJson('/mcp/kanbangit', [
'jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/list', 'params' => [],
])->assertOk();
$writers = [];
foreach ($response->json('result.tools') as $tool) {
$fields = $tool['inputSchema']['properties'];
if (! ($tool['annotations']['readOnlyHint'] ?? false)) {
$writers[] = $tool['name'];
$this->assertStringContainsString(TicketDescriptionFormat::instructions(), $tool['description'], $tool['name']);
}
if (isset($fields['description'])) {
$this->assertStringContainsString(TicketDescriptionFormat::instructions(), $fields['description']['description'], $tool['name']);
}
if (isset($fields['acceptance_criteria'])) {
$this->assertStringContainsString(TicketDescriptionFormat::criteriaInstructions(), $fields['acceptance_criteria']['description'], $tool['name']);
}
}
$this->assertContains('create_ticket', $writers);
$this->assertContains('update_ticket', $writers);
$this->assertContains('update_ticket_description', $writers);
}
}
#[DataProvider('descriptionCases')]
public function test_backend_and_editor_share_compatibility_cases(string $value, bool $supported): void
{
$this->assertSame($supported, TicketDescriptionFormat::unsupportedContent($value) === []);
}
public static function descriptionCases(): array
{
$cases = json_decode(file_get_contents(__DIR__.'/../../Fixtures/ticket-description-cases.json'), true, flags: JSON_THROW_ON_ERROR);
return collect($cases)->mapWithKeys(fn (array $case): array => [$case['name'] => [$case['value'], $case['supported']]])->all();
}
#[DataProvider('unsupportedDescriptions')]
public function test_incompatible_description_rejects_creation_and_every_update_without_partial_changes(string $description): void
{
$projectBefore = $this->project->fresh()->getAttributes();
KanbanGitServer::actingAs($this->user)->tool(CreateTicket::class, $this->createPayload($description))
->assertHasErrors(['description', 'unsupported', 'acceptance_criteria']);
$this->assertDatabaseCount('tickets', 0);
$this->assertDatabaseCount('ticket_worklog_entries', 0);
$this->assertSame($projectBefore, $this->project->fresh()->getAttributes());
$ticket = $this->ticket();
$before = $ticket->fresh()->getAttributes();
foreach ([UpdateTicketDescription::class, UpdateTicket::class] as $tool) {
KanbanGitServer::actingAs($this->user)->tool($tool, [
'ticket_id' => $ticket->id, 'description' => $description,
'title' => 'Must not change', 'story_points' => 99,
'acceptance_criteria' => [['text' => 'Must not replace']],
])->assertHasErrors(['description', 'unsupported', 'acceptance_criteria']);
$this->assertSame($before, $ticket->fresh()->getAttributes());
$this->assertSame('Separate criterion', $ticket->acceptanceCriteria()->sole()->text);
$this->assertDatabaseCount('ticket_worklog_entries', 0);
}
}
public static function unsupportedDescriptions(): array
{
return array_map(fn (array $case): array => [$case[0]], array_filter(self::descriptionCases(), fn (array $case): bool => ! $case[1]));
}
public function test_compatible_descriptions_are_stored_verbatim_and_criteria_remain_separate_and_null_clears(): void
{
$description = TicketDescriptionFormat::contract()['example'];
KanbanGitServer::actingAs($this->user)->tool(CreateTicket::class, [
...$this->createPayload($description), 'acceptance_criteria' => [['text' => 'Separate criterion']],
])->assertOk();
$ticket = $this->project->tickets()->sole();
$this->assertSame($description, $ticket->description);
$this->assertSame('Separate criterion', $ticket->acceptanceCriteria()->sole()->text);
$this->assertSame([], TicketDescriptionFormat::unsupportedContent($description));
$this->withoutVite();
$this->actingAs($this->user)->get(route('projects.tickets.show', [$this->project, $ticket]))
->assertInertia(fn (Assert $page) => $page->where('ticket.description', $description)->where('ticket.acceptance_criteria.0.text', 'Separate criterion'));
$process = new Process(['node', 'tests/Fixtures/ticket-description.mjs', '--stored-description'], base_path());
$process->setInput(json_encode(['description' => $ticket->description, 'criteria' => $ticket->acceptanceCriteria()->get()->toArray()], JSON_THROW_ON_ERROR));
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
foreach ([UpdateTicketDescription::class, UpdateTicket::class] as $tool) {
$updated = "> Quote\n\n**Updated**\n\n```markdown\n- [x] literal example\n```";
KanbanGitServer::actingAs($this->user)->tool($tool, ['ticket_id' => $ticket->id, 'description' => $updated])->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.ticket.description', $updated)->etc());
$this->assertSame($updated, $ticket->fresh()->description);
KanbanGitServer::actingAs($this->user)->tool($tool, ['ticket_id' => $ticket->id, 'description' => null])->assertOk();
$this->assertNull($ticket->fresh()->description);
$this->assertSame('Separate criterion', $ticket->acceptanceCriteria()->sole()->text);
}
}
public function test_updates_without_description_preserve_legacy_source_and_checklist_identity(): void
{
$ticket = $this->ticket();
$legacy = "## Legacy\n\n- [x] Keep verbatim\n\n<table></table>";
$ticket->update(['description' => $legacy]);
$criterion = $ticket->acceptanceCriteria()->sole();
KanbanGitServer::actingAs($this->user)->tool(UpdateTicket::class, ['ticket_id' => $ticket->id, 'title' => 'Updated title'])
->assertOk();
$this->assertSame($legacy, $ticket->fresh()->description);
$this->assertSame($criterion->id, $ticket->acceptanceCriteria()->sole()->id);
KanbanGitServer::actingAs($this->user)->tool(UpdateTicketDescription::class, ['ticket_id' => $ticket->id])->assertOk();
$this->assertSame($legacy, $ticket->fresh()->description);
KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, ['ticket_id' => $ticket->id])->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.ticket.description', $legacy)->etc());
}
private function createPayload(?string $description): array
{
return [
'project_id' => $this->project->id, 'title' => 'Formatted ticket', 'description' => $description,
'ticket_type_id' => $this->project->ticketTypes()->where('is_parent', false)->firstOrFail()->id,
'ticket_status_id' => $this->project->ticketStatuses()->firstOrFail()->id,
'ticket_priority_id' => $this->project->ticketPriorities()->firstOrFail()->id,
];
}
private function ticket(): Ticket
{
$ticket = Ticket::factory()->create([
...collect($this->createPayload('Original description'))->except('project_id')->all(),
'project_id' => $this->project->id, 'ticket_key' => 'CORE-1', 'sequence_number' => 1,
]);
$ticket->acceptanceCriteria()->create(['text' => 'Separate criterion', 'checked' => false, 'indent' => 0, 'position' => 1]);
return $ticket;
}
}
+225
View File
@@ -0,0 +1,225 @@
<?php
namespace Tests\Feature\Mcp;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Mcp\Servers\KanbanGitServer;
use App\Mcp\Tools\GetProjectConfiguration;
use App\Mcp\Tools\GetTicket;
use App\Mcp\Tools\ListProjects;
use App\Mcp\Tools\SearchTickets;
use App\Models\BoardColumn;
use App\Models\Project;
use App\Models\Team;
use App\Models\Ticket;
use App\Models\TicketFieldDefinition;
use App\Models\TicketStatus;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Testing\Fluent\AssertableJson;
use Illuminate\Testing\TestResponse;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
class TicketDiscoveryTest extends TestCase
{
use RefreshDatabase;
private User $user;
private Project $project;
private Project $hidden;
private Ticket $ticket;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
$this->project = Project::factory()->create(['key' => 'CORE', 'name' => 'Visible']);
$this->hidden = Project::factory()->create(['key' => 'HIDE']);
app(InitializeProjectTicketMetadata::class)->handle($this->project);
app(InitializeProjectTicketMetadata::class)->handle($this->hidden);
$this->syncProjectPermissions($this->project, $this->user, ['view']);
$this->ticket = $this->newTicket($this->project, 1, ['title' => 'Special needle', 'description' => 'First description', 'assignee_user_id' => $this->user->id]);
$this->newTicket($this->project, 2, ['title' => 'Other title', 'description' => 'Needle in description', 'parent_ticket_id' => $this->ticket->id]);
$this->newTicket($this->hidden, 1, ['title' => 'Hidden needle']);
}
public function test_exact_key_and_existing_id_lookup_work_and_hidden_tickets_are_denied(): void
{
foreach ([['ticket_id' => $this->ticket->id], ['ticket_key' => 'CORE-1']] as $input) {
KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, $input)->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.ticket.id', $this->ticket->id)->etc());
}
KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, ['ticket_key' => 'HIDE-1'])->assertHasErrors(['cannot view']);
KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, ['ticket_key' => 'CORE-99'])->assertHasErrors(['not found']);
}
#[DataProvider('invalidSelectors')]
public function test_key_lookup_requires_exactly_one_valid_selector(array $input): void
{
KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, $input)->assertHasErrors();
}
public static function invalidSelectors(): array
{
return [
[[]], [['ticket_key' => 'CORE']], [['ticket_key' => 'core-1']], [['ticket_key' => 'CORE-0']],
[['ticket_key' => 'CORE-1', 'ticket_id' => 1]], [['ticket_key' => null]],
];
}
public function test_search_combines_filters_and_paginates_only_visible_tickets_with_compact_payloads(): void
{
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['query' => 'needle', 'per_page' => 1])
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json
->where('data.0.id', $this->ticket->id)->count('data', 1)
->where('data.0.status.id', $this->ticket->ticket_status_id)
->missing('data.0.description')->missing('data.0.project')
->where('meta.total', 2)->where('meta.last_page', 2)->where('meta.has_more_pages', true)->etc());
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['query' => 'needle', 'page' => 2, 'per_page' => 1])
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json
->where('data.0.ticket_key', 'CORE-2')->where('meta.current_page', 2)->where('meta.has_more_pages', false)->etc());
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, [
'project_id' => $this->project->id, 'ticket_key' => 'CORE-1', 'ticket_status_id' => $this->ticket->ticket_status_id,
'ticket_type_id' => $this->ticket->ticket_type_id, 'assignee_user_id' => $this->user->id, 'parent_ticket_id' => null,
])->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 1)->where('data.0.id', $this->ticket->id)->etc());
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['parent_ticket_id' => $this->ticket->id, 'assignee_user_id' => null])
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 1)->where('data.0.ticket_key', 'CORE-2')->etc());
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['ticket_type_id' => $this->hidden->ticketTypes()->firstOrFail()->id])
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 0)->etc());
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['project_id' => $this->hidden->id])->assertHasErrors(['cannot view']);
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['page' => 10])->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 0)->where('meta.total', 2)->etc());
}
public function test_search_returns_tickets_without_a_status(): void
{
$this->ticket->update(['ticket_status_id' => null]);
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['ticket_key' => 'CORE-1'])->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.0.id', $this->ticket->id)
->where('data.0.ticket_status_id', null)->where('data.0.status', null)->etc());
}
public function test_literal_search_does_not_expand_sql_wildcards(): void
{
$this->ticket->update(['title' => 'A 50%_! literal']);
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['query' => '%_!'])
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 1)->where('data.0.id', $this->ticket->id)->etc());
}
public function test_project_list_is_compact_paginated_and_independent_of_assignments_with_team_and_global_access(): void
{
$this->ticket->update(['assignee_user_id' => null]);
KanbanGitServer::actingAs($this->user)->tool(ListProjects::class)->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 1)->where('data.0.key', 'CORE')->missing('data.0.template_state')->where('meta.total', 1)->etc());
$team = Team::factory()->create();
$team->users()->attach($this->user);
$this->syncProjectPermissions($this->hidden, $team, ['view']);
KanbanGitServer::actingAs($this->user)->tool(ListProjects::class, ['per_page' => 1, 'page' => 2])->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.0.id', $this->hidden->id)->where('meta.total', 2)->etc());
$global = User::factory()->create();
$this->syncGlobalPermissions($global, ['admin']);
KanbanGitServer::actingAs($global)->tool(SearchTickets::class)->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json->where('meta.total', 3)->etc());
KanbanGitServer::actingAs(User::factory()->create())->tool(ListProjects::class)->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 0)->where('meta.total', 0)->etc());
}
public function test_configuration_returns_current_workflow_and_allowed_people_without_full_resources(): void
{
$first = $this->project->ticketStatuses()->firstOrFail();
$column = BoardColumn::factory()->for($this->project)->create(['position' => 1]);
$second = TicketStatus::factory()->for($this->project)->for($column)->create(['position' => 1]);
TicketFieldDefinition::factory()->for($this->project)->create(['field_type' => 'user', 'position' => 1]);
$first->allowedTargetStatuses()->sync([$second->id]);
$first->update(['name' => 'Fresh workflow']);
$otherViewer = User::factory()->create();
$this->syncProjectPermissions($this->project, $otherViewer, ['view']);
User::factory()->create();
KanbanGitServer::actingAs($this->user)->tool(GetProjectConfiguration::class, ['project_id' => $this->project->id, 'people_per_page' => 1])
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json
->where('data.project.id', $this->project->id)->missing('data.project.template_state')
->where('data.permissions.can_write', false)->where('data.permissions.can_change_author', false)
->where('data.ticket_statuses.0.id', $first->id)->where('data.ticket_statuses.0.slug', 'fresh-workflow')
->where('data.ticket_statuses.0.allowed_target_status_ids', [$second->id])
->where('data.ticket_statuses.0.board_column_id', $first->board_column_id)
->has('data.ticket_field_definitions.0.field_type')->has('data.ticket_types.0.is_parent')->has('data.ticket_priorities.0.id')->has('data.board_columns.0.position')
->count('data.people', 1)->missing('data.people.0.email')->missing('data.people.0.avatar')
->where('meta.people.total', 2)->etc());
KanbanGitServer::actingAs($this->user)->tool(GetProjectConfiguration::class, ['project_id' => $this->project->id, 'people_page' => 2, 'people_per_page' => 1])
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.people.0.id', $otherViewer->id)->etc());
KanbanGitServer::actingAs($this->user)->tool(GetProjectConfiguration::class, ['project_id' => $this->hidden->id])->assertHasErrors(['cannot view']);
}
public function test_numeric_project_keys_and_configured_column_order_are_supported(): void
{
$this->project->update(['key' => '42']);
$this->ticket->update(['ticket_key' => '42-1']);
KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, ['ticket_key' => '42-1'])->assertOk();
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['ticket_key' => '42-1'])->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.0.id', $this->ticket->id)->etc());
$later = BoardColumn::factory()->for($this->project)->create(['position' => 2]);
$earlier = BoardColumn::factory()->for($this->project)->create(['position' => 1]);
$laterStatus = TicketStatus::factory()->for($this->project)->for($later)->create(['position' => 1]);
$earlierStatus = TicketStatus::factory()->for($this->project)->for($earlier)->create(['position' => 1]);
KanbanGitServer::actingAs($this->user)->tool(GetProjectConfiguration::class, ['project_id' => $this->project->id])->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json
->where('data.board_columns.0.id', $earlier->id)
->where('data.ticket_statuses.1.id', $earlierStatus->id)
->where('data.ticket_statuses.2.id', $laterStatus->id)->etc());
}
public function test_pagination_limits_and_missing_projects_are_validated(): void
{
foreach ([ListProjects::class, SearchTickets::class] as $tool) {
KanbanGitServer::actingAs($this->user)->tool($tool, ['per_page' => 101])->assertHasErrors();
KanbanGitServer::actingAs($this->user)->tool($tool, ['page' => 0])->assertHasErrors();
}
KanbanGitServer::actingAs($this->user)->tool(GetProjectConfiguration::class, ['project_id' => 999])->assertHasErrors(['not found']);
KanbanGitServer::actingAs($this->user)->tool(GetProjectConfiguration::class, ['project_id' => $this->project->id, 'people_per_page' => 101])->assertHasErrors();
}
public function test_application_key_discovers_new_schemas_and_completes_refinement_without_browser_login(): void
{
$this->syncProjectPermissions($this->project, $this->user, ['write']);
$this->withApplicationKey($this->user);
$response = $this->postJson('/mcp/kanbangit', ['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list', 'params' => []])->assertOk();
$tools = collect($response->json('result.tools'))->keyBy('name');
foreach (['list_projects', 'search_tickets', 'get_project_configuration'] as $name) {
$this->assertTrue($tools[$name]['annotations']['readOnlyHint']);
}
$this->assertSame(['ticket_id'], $tools['update_ticket']['inputSchema']['required']);
$this->assertArrayHasKey('ticket_key', $tools['get_ticket']['inputSchema']['properties']);
$this->assertFalse($tools['update_ticket']['annotations']['destructiveHint']);
$this->rpc('update_ticket', ['ticket_id' => $this->ticket->id, 'title' => 'Refined via MCP', 'description' => 'Refinement', 'acceptance_criteria' => [['text' => 'Real criterion']]]);
$ticket = $this->rpc('get_ticket', ['ticket_key' => 'CORE-1'])->json('result.structuredContent.data.ticket');
$this->assertSame('Refined via MCP', $ticket['title']);
$this->assertSame('Real criterion', $ticket['acceptance_criteria'][0]['text']);
$this->rpc('update_ticket', ['ticket_id' => $this->ticket->id, 'acceptance_criteria' => [['id' => $ticket['acceptance_criteria'][0]['id'], 'text' => 'Criterion checked', 'checked' => true]]]);
$target = TicketStatus::factory()->for($this->project)->create(['position' => 2]);
$this->ticket->ticketStatus->allowedTargetStatuses()->sync([$target->id]);
$this->rpc('move_ticket_status', ['ticket_id' => $this->ticket->id, 'ticket_status_id' => $target->id]);
$this->assertSame($target->id, $this->ticket->fresh()->ticket_status_id);
}
private function rpc(string $name, array $arguments): TestResponse
{
return $this->postJson('/mcp/kanbangit', [
'jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/call', 'params' => ['name' => $name, 'arguments' => $arguments],
])->assertOk()->assertJsonPath('result.isError', false);
}
private function newTicket(Project $project, int $sequence, array $attributes = []): Ticket
{
return Ticket::factory()->create([
'project_id' => $project->id, 'ticket_key' => "{$project->key}-{$sequence}", 'sequence_number' => $sequence,
'ticket_status_id' => $project->ticketStatuses()->firstOrFail()->id,
'ticket_type_id' => $project->ticketTypes()->where('is_parent', $sequence === 1)->firstOrFail()->id,
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
...$attributes,
]);
}
}
+235
View File
@@ -0,0 +1,235 @@
<?php
namespace Tests\Feature\Mcp;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
use App\Actions\Tickets\SyncProjectTicketFieldValues;
use App\Mcp\Servers\KanbanGitServer;
use App\Mcp\Tools\GetTicket;
use App\Mcp\Tools\UpdateTicket;
use App\Models\Project;
use App\Models\Ticket;
use App\Models\TicketFieldDefinition;
use App\Models\TicketPriority;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Testing\Fluent\AssertableJson;
use Laravel\Mcp\Server\Testing\TestResponse;
use PHPUnit\Framework\Attributes\DataProvider;
use RuntimeException;
use Tests\TestCase;
class UpdateTicketTest extends TestCase
{
use RefreshDatabase;
private User $user;
private Project $project;
private Ticket $ticket;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
$this->project = Project::factory()->create(['key' => 'CORE']);
app(InitializeProjectTicketMetadata::class)->handle($this->project);
$this->syncProjectPermissions($this->project, $this->user, ['admin']);
$this->ticket = $this->newTicket(['description' => 'Original', 'story_points' => 5, 'author_user_id' => $this->user->id]);
AcceptanceCriteriaChecklistMapper::syncTicket($this->ticket, [
['text' => 'First', 'checked' => false, 'indent' => 0],
['text' => 'Second', 'checked' => true, 'indent' => 0],
]);
}
public function test_partial_update_and_no_op_preserve_checklist_ids_and_unrelated_fields(): void
{
$before = AcceptanceCriteriaChecklistMapper::fromModelsWithIds($this->ticket->acceptanceCriteria()->get());
$this->updateTicket(['title' => 'Refined'])->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json
->where('data.ticket.description', 'Original')->where('data.ticket.story_points', 5)
->where('data.ticket.acceptance_criteria', $before)->where('meta.updated', true)->etc());
$this->updateTicket(['title' => 'Refined'])->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->where('meta.updated', false)->etc());
$this->assertSame(1, $this->ticket->worklogEntries()->count());
$this->assertSame('Refined', $this->ticket->fresh()->title);
}
public function test_checklist_replacement_adds_reorders_indents_checks_and_removes_by_identity(): void
{
$before = AcceptanceCriteriaChecklistMapper::fromModelsWithIds($this->ticket->acceptanceCriteria()->get());
$replacement = [
['text' => 'New root', 'checked' => false, 'indent' => 0],
['id' => $before[1]['id'], 'text' => 'Second edited', 'checked' => false, 'indent' => 1],
['id' => $before[0]['id'], 'text' => 'First', 'checked' => true, 'indent' => 0],
];
$this->updateTicket(['acceptance_criteria' => $replacement])->assertOk();
$after = AcceptanceCriteriaChecklistMapper::fromModelsWithIds($this->ticket->acceptanceCriteria()->get());
$this->assertNotContains($after[0]['id'], array_column($before, 'id'));
$this->assertSame($replacement[1], ['id' => $after[1]['id'], 'text' => $after[1]['text'], 'checked' => $after[1]['checked'], 'indent' => $after[1]['indent']]);
$this->assertSame($before[0]['id'], $after[2]['id']);
$this->updateTicket(['acceptance_criteria' => $after])->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->where('meta.updated', false)->etc());
$this->updateTicket(['acceptance_criteria' => [$after[2]]])->assertOk();
KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, ['ticket_key' => 'CORE-1'])
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->count('data.ticket.acceptance_criteria', 1)->where('data.ticket.acceptance_criteria.0.id', $before[0]['id'])->etc());
$this->updateTicket(['acceptance_criteria' => []])->assertOk();
$this->assertSame(0, $this->ticket->acceptanceCriteria()->count());
}
public function test_scalar_null_and_custom_field_replacement_have_explicit_clear_semantics(): void
{
$number = TicketFieldDefinition::factory()->for($this->project)->create(['field_type' => 'number']);
$person = TicketFieldDefinition::factory()->for($this->project)->create(['field_type' => 'user', 'position' => 2]);
$this->updateTicket(['field_values' => [
['ticket_field_definition_id' => $number->id, 'value_number' => 0],
['ticket_field_definition_id' => $person->id, 'value_user_id' => $this->user->id],
]])->assertOk();
$this->updateTicket(['description' => null, 'story_points' => null, 'assignee_user_id' => null, 'author_user_id' => null])->assertOk();
$this->assertSame(2, $this->ticket->fieldValues()->count());
$this->assertNull($this->ticket->fresh()->description);
$this->assertNull($this->ticket->fresh()->story_points);
$this->updateTicket(['field_values' => [['ticket_field_definition_id' => $number->id, 'value_number' => 3]]])->assertOk();
$this->assertSame(1, $this->ticket->fieldValues()->count());
$this->updateTicket(['field_values' => []])->assertOk();
$this->assertSame(0, $this->ticket->fieldValues()->count());
}
#[DataProvider('invalidInputs')]
public function test_invalid_payload_does_not_partially_change_ticket(array $input): void
{
$before = $this->ticket->fresh()->getAttributes();
$this->updateTicket(['title' => 'Must not change', ...$input])->assertHasErrors();
$this->assertSame($before, $this->ticket->fresh()->getAttributes());
$this->assertSame(0, $this->ticket->worklogEntries()->count());
$this->assertSame(2, $this->ticket->acceptanceCriteria()->count());
}
public static function invalidInputs(): array
{
return [
'null title' => [['title' => null]],
'null type' => [['ticket_type_id' => null]],
'null priority' => [['ticket_priority_id' => null]],
'null blocked state' => [['is_blocked' => null]],
'invalid criterion text' => [['acceptance_criteria' => [['text' => []]]]],
'null criteria' => [['acceptance_criteria' => null]],
'null fields' => [['field_values' => null]],
'invalid criterion' => [['acceptance_criteria' => [['id' => 99999, 'text' => 'Foreign']]]],
'blank criterion' => [['acceptance_criteria' => [['text' => ' ']]]],
'skipped indent' => [['acceptance_criteria' => [['text' => 'Child', 'indent' => 1]]]],
'negative points' => [['story_points' => -1]],
'missing blocker' => [['is_blocked' => true]],
'status not editable' => [['ticket_status_id' => 999]],
];
}
public function test_foreign_and_duplicate_criterion_ids_are_rejected_atomically(): void
{
$other = $this->newTicket(['ticket_key' => 'CORE-2', 'sequence_number' => 2]);
AcceptanceCriteriaChecklistMapper::syncTicket($other, [['text' => 'Other', 'checked' => false, 'indent' => 0]]);
$foreign = $other->acceptanceCriteria()->firstOrFail()->id;
$this->updateTicket(['title' => 'Rejected', 'acceptance_criteria' => [['id' => $foreign, 'text' => 'Steal']]])->assertHasErrors();
$id = $this->ticket->acceptanceCriteria()->firstOrFail()->id;
$this->updateTicket(['acceptance_criteria' => [['id' => $id, 'text' => 'One'], ['id' => $id, 'text' => 'Two']]])->assertHasErrors();
$this->assertSame('Original', $this->ticket->fresh()->description);
$this->assertSame('Other', $other->acceptanceCriteria()->firstOrFail()->text);
}
public function test_project_read_and_write_permissions_and_author_rules_are_enforced(): void
{
$this->syncProjectPermissions($this->project, $this->user, ['view']);
$this->updateTicket(['title' => 'Denied'])->assertHasErrors(['cannot update tickets']);
$this->syncProjectPermissions($this->project, $this->user, ['write']);
$this->updateTicket(['title' => 'Allowed'])->assertOk();
$this->updateTicket(['author_user_id' => null])->assertHasErrors(['Only project admins']);
$this->syncProjectPermissions($this->project, $this->user, []);
$this->updateTicket(['title' => 'Denied'])->assertHasErrors(['cannot update tickets']);
$this->assertSame('Allowed', $this->ticket->fresh()->title);
}
public function test_invalid_people_foreign_metadata_custom_types_and_parent_relations_are_rejected(): void
{
$hiddenUser = User::factory()->create();
$this->updateTicket(['assignee_user_id' => $hiddenUser->id])->assertHasErrors(['person']);
$this->updateTicket(['author_user_id' => $hiddenUser->id])->assertHasErrors(['person']);
$otherProject = Project::factory()->create();
app(InitializeProjectTicketMetadata::class)->handle($otherProject);
$this->updateTicket(['ticket_type_id' => $otherProject->ticketTypes()->firstOrFail()->id])->assertHasErrors();
$this->updateTicket(['ticket_priority_id' => $otherProject->ticketPriorities()->firstOrFail()->id])->assertHasErrors();
$foreignTicket = Ticket::factory()->for($otherProject)->create();
$this->updateTicket(['parent_ticket_id' => $foreignTicket->id])->assertHasErrors();
$field = TicketFieldDefinition::factory()->for($this->project)->create(['field_type' => 'user']);
$this->updateTicket(['field_values' => [['ticket_field_definition_id' => $field->id, 'value_user_id' => $hiddenUser->id]]])->assertHasErrors(['person']);
$this->updateTicket(['field_values' => [['ticket_field_definition_id' => $field->id, 'value_number' => 2]]])->assertHasErrors(['field type']);
$this->updateTicket(['parent_ticket_id' => $this->ticket->id])->assertHasErrors(['own parent']);
$task = $this->newTicket(['ticket_key' => 'CORE-2', 'sequence_number' => 2]);
$this->updateTicket(['parent_ticket_id' => $task->id])->assertHasErrors(['parent-enabled']);
$parentType = $this->project->ticketTypes()->where('is_parent', true)->firstOrFail();
$this->updateTicket(['ticket_type_id' => $parentType->id])->assertOk();
$task->update(['parent_ticket_id' => $this->ticket->id, 'ticket_type_id' => $parentType->id]);
$this->updateTicket(['parent_ticket_id' => $task->id])->assertHasErrors(['descendants']);
$this->updateTicket(['ticket_type_id' => $this->project->ticketTypes()->where('is_parent', false)->firstOrFail()->id])->assertHasErrors(['child tickets']);
}
public function test_all_editable_scalar_fields_accept_valid_project_values(): void
{
$person = User::factory()->create();
$this->syncProjectPermissions($this->project, $person, ['view']);
$parentType = $this->project->ticketTypes()->where('is_parent', true)->firstOrFail();
$parent = $this->newTicket(['ticket_key' => 'CORE-2', 'sequence_number' => 2, 'ticket_type_id' => $parentType->id]);
$priority = TicketPriority::factory()->for($this->project)->create(['position' => 2]);
$input = [
'title' => 'All scalar fields', 'description' => 'Updated description',
'ticket_type_id' => $parentType->id, 'ticket_priority_id' => $priority->id,
'assignee_user_id' => $person->id, 'author_user_id' => $person->id,
'parent_ticket_id' => $parent->id, 'story_points' => 0,
'is_blocked' => true, 'blocked_comment' => 'Dependency',
];
$this->updateTicket($input)->assertOk();
foreach ($input as $field => $value) {
$this->assertSame($value, $this->ticket->fresh()->getAttribute($field));
}
$this->assertSame(1, $this->ticket->worklogEntries()->count());
$this->updateTicket(['parent_ticket_id' => null])->assertOk();
$this->assertNull($this->ticket->fresh()->parent_ticket_id);
}
public function test_blocker_validation_uses_preserved_state(): void
{
$this->updateTicket(['is_blocked' => true, 'blocked_comment' => 'Waiting'])->assertOk();
$this->updateTicket(['title' => 'Still blocked'])->assertOk();
$this->updateTicket(['blocked_comment' => null])->assertHasErrors(['blocker comment']);
$this->updateTicket(['is_blocked' => false])->assertOk();
$this->assertNull($this->ticket->fresh()->blocked_comment);
}
public function test_action_failure_rolls_back_scalars_checklist_and_worklog(): void
{
$this->mock(SyncProjectTicketFieldValues::class)->shouldReceive('handle')->once()->andThrow(new RuntimeException('Sync failed'));
try {
$this->updateTicket(['title' => 'Rollback', 'acceptance_criteria' => []]);
$this->fail('Expected action failure.');
} catch (RuntimeException $exception) {
$this->assertSame('Sync failed', $exception->getMessage());
}
$this->assertNotSame('Rollback', $this->ticket->fresh()->title);
$this->assertSame(2, $this->ticket->acceptanceCriteria()->count());
$this->assertSame(0, $this->ticket->worklogEntries()->count());
}
private function updateTicket(array $input): TestResponse
{
return KanbanGitServer::actingAs($this->user)->tool(UpdateTicket::class, ['ticket_id' => $this->ticket->id, ...$input]);
}
private function newTicket(array $attributes = []): Ticket
{
return Ticket::factory()->create([
'project_id' => $this->project->id, 'ticket_key' => 'CORE-1', 'sequence_number' => 1,
'ticket_status_id' => $this->project->ticketStatuses()->firstOrFail()->id,
'ticket_type_id' => $this->project->ticketTypes()->where('is_parent', false)->firstOrFail()->id,
'ticket_priority_id' => $this->project->ticketPriorities()->firstOrFail()->id,
...$attributes,
]);
}
}
@@ -805,6 +805,9 @@ class ProjectControllerTest extends TestCase
$this->assertDatabaseMissing('board_columns', [ $this->assertDatabaseMissing('board_columns', [
'id' => $boardColumn->id, 'id' => $boardColumn->id,
]); ]);
$this->assertDatabaseMissing('ticket_statuses', [
'id' => $ticketStatus->id,
]);
$this->assertDatabaseMissing('tickets', [ $this->assertDatabaseMissing('tickets', [
'id' => $ticket->id, 'id' => $ticket->id,
]); ]);
@@ -817,6 +820,75 @@ class ProjectControllerTest extends TestCase
]); ]);
} }
public function test_project_admins_can_delete_their_own_projects_and_reuse_the_key(): void
{
$user = User::factory()->create();
$this->syncGlobalPermissions($user, ['create']);
$this->actingAs($user)
->post(route('projects.store'), [
'creation_mode' => 'scratch',
'name' => 'Core Platform',
'key' => 'CORE',
])
->assertRedirect();
$project = Project::query()->where('key', 'CORE')->firstOrFail();
$baseTemplateId = $project->project_template_id;
$this->assertTrue($user->can('admin-project', $project));
$this->actingAs($user)
->delete(route('projects.destroy', $project), [
'confirmation_key' => 'CORE',
])
->assertRedirect(route('projects.index'));
$this->assertDatabaseMissing('projects', ['id' => $project->id]);
$this->assertDatabaseMissing('project_templates', ['id' => $baseTemplateId]);
$this->actingAs($user)
->post(route('projects.store'), [
'creation_mode' => 'scratch',
'name' => 'New Core Platform',
'key' => 'CORE',
])
->assertRedirect();
$this->assertDatabaseHas('projects', [
'key' => 'CORE',
'name' => 'New Core Platform',
]);
}
public function test_deleting_a_project_preserves_its_published_template_and_linked_projects(): void
{
$admin = User::factory()->admin()->create();
$project = Project::factory()->create(['key' => 'CORE']);
$otherProject = Project::factory()->create(['key' => 'OTHER']);
$template = ProjectTemplate::factory()->create([
'source_project_id' => $project->id,
'is_published' => true,
]);
$project->update(['project_template_id' => $template->id]);
$otherProject->update(['project_template_id' => $template->id]);
$this->actingAs($admin)
->delete(route('projects.destroy', $project), [
'confirmation_key' => 'CORE',
])
->assertRedirect(route('projects.index'));
$this->assertDatabaseHas('project_templates', [
'id' => $template->id,
'source_project_id' => null,
]);
$this->assertDatabaseHas('projects', [
'id' => $otherProject->id,
'project_template_id' => $template->id,
]);
}
/** /**
* Ensure project deletion requires the exact project key confirmation. * Ensure project deletion requires the exact project key confirmation.
*/ */
@@ -841,16 +913,18 @@ class ProjectControllerTest extends TestCase
} }
/** /**
* Ensure non-admin users cannot delete projects. * Ensure users without project administration cannot delete projects.
*/ */
public function test_non_admin_users_cannot_delete_projects(): void public function test_project_readers_cannot_delete_projects(): void
{ {
$user = User::factory()->create(); $user = User::factory()->create();
$otherProject = Project::factory()->create();
$project = Project::factory()->create([ $project = Project::factory()->create([
'key' => 'CORE', 'key' => 'CORE',
]); ]);
$this->syncProjectPermissions($project, $user, ['admin']); $this->syncProjectPermissions($otherProject, $user, ['admin']);
$this->syncProjectPermissions($project, $user, ['view']);
$this->actingAs($user) $this->actingAs($user)
->delete(route('projects.destroy', $project), [ ->delete(route('projects.destroy', $project), [
@@ -83,6 +83,74 @@ class ProjectTicketLinkControllerTest extends TestCase
]); ]);
} }
public function test_counterpart_label_can_be_selected_from_the_current_ticket(): void
{
$user = User::factory()->create();
$project = Project::factory()->create(['key' => 'CORE']);
app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = $project->ticketPriorities()->firstOrFail();
$taskType = $project->ticketTypes()->where('is_parent', false)->firstOrFail();
$ticketLinkType = TicketLinkType::factory()->for($project)->create([
'label' => 'Blocks',
'counterpart_label' => 'Blocked by',
]);
$blockingTicket = Ticket::factory()->create([
'project_id' => $project->id,
'sequence_number' => 1,
'ticket_key' => 'CORE-1',
'ticket_priority_id' => $priority->id,
'ticket_type_id' => $taskType->id,
]);
$blockedTicket = Ticket::factory()->create([
'project_id' => $project->id,
'sequence_number' => 2,
'ticket_key' => 'CORE-2',
'ticket_priority_id' => $priority->id,
'ticket_type_id' => $taskType->id,
]);
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user)
->post(route('projects.tickets.links.store', [$project, $blockedTicket]), [
'linked_ticket_id' => $blockingTicket->id,
'ticket_link_type_id' => $ticketLinkType->id,
'link_direction' => 'counterpart',
])
->assertRedirect(route('projects.tickets.show', [$project, $blockedTicket]))
->assertSessionHas('status', 'Blocked by link was saved.');
$this->assertDatabaseHas('ticket_links', [
'source_ticket_id' => $blockingTicket->id,
'target_ticket_id' => $blockedTicket->id,
'ticket_link_type_id' => $ticketLinkType->id,
]);
$this->assertDatabaseHas('ticket_worklog_entries', [
'ticket_id' => $blockedTicket->id,
'summary' => 'Linked CORE-1 as Blocked by.',
]);
$this->assertDatabaseHas('ticket_worklog_entries', [
'ticket_id' => $blockingTicket->id,
'summary' => 'Linked CORE-2 as Blocks.',
]);
$this->actingAs($user)
->post(route('projects.tickets.links.store', [$project, $blockedTicket]), [
'linked_ticket_id' => $blockingTicket->id,
'ticket_link_type_id' => $ticketLinkType->id,
'link_direction' => 'source',
])
->assertSessionHas('status', 'Blocks link was saved.');
$this->assertSame(1, TicketLink::query()->count());
$this->assertDatabaseHas('ticket_links', [
'source_ticket_id' => $blockedTicket->id,
'target_ticket_id' => $blockingTicket->id,
]);
}
/** /**
* Ensure ticket links cannot target the current ticket or a ticket from another project. * Ensure ticket links cannot target the current ticket or a ticket from another project.
*/ */
@@ -144,6 +212,15 @@ class ProjectTicketLinkControllerTest extends TestCase
]) ])
->assertRedirect(route('projects.tickets.show', [$project, $ticket])) ->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
->assertSessionHasErrors('linked_ticket_id'); ->assertSessionHasErrors('linked_ticket_id');
$this->actingAs($user)
->from(route('projects.tickets.show', [$project, $ticket]))
->post(route('projects.tickets.links.store', [$project, $ticket]), [
'linked_ticket_id' => $foreignTicket->id,
'ticket_link_type_id' => $ticketLinkType->id,
'link_direction' => 'sideways',
])
->assertSessionHasErrors('link_direction');
} }
/** /**
@@ -0,0 +1,33 @@
<?php
namespace Tests\Feature\Settings;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class NativeAppSettingsTest extends TestCase
{
use RefreshDatabase;
public function test_app_settings_are_accessible_before_and_after_login_without_server_side_device_state(): void
{
$this->withoutVite();
$this->get(route('native-app.edit'))->assertOk()->assertInertia(fn (Assert $page) => $page
->component('settings/App')->where('auth.user', null)->missing('workspace')->missing('updates'));
$this->actingAs(User::factory()->unverified()->create(['must_change_password_on_next_login' => true]))
->get(route('native-app.edit'))->assertOk()->assertInertia(fn (Assert $page) => $page
->component('settings/App')->missing('workspace')->missing('updates'));
}
public function test_native_capabilities_rendering_and_actions(): void
{
$process = new Process(['node', 'tests/Fixtures/native-app.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Native app settings checks passed.', $process->getOutput());
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace Tests\Feature;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\Project;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class TicketDescriptionTest extends TestCase
{
use RefreshDatabase;
public function test_description_formatting_and_safe_rendering(): void
{
$process = new Process(['node', 'tests/Fixtures/ticket-description.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Ticket description checks passed.', $process->getOutput());
}
public function test_markdown_descriptions_can_be_created_read_updated_and_cleared(): void
{
$this->withoutVite();
$user = User::factory()->create();
$project = Project::factory()->create();
app(InitializeProjectTicketMetadata::class)->handle($project);
$this->syncProjectPermissions($project, $user, ['view', 'write']);
$description = "## Plan\n\n**Bold** and *italic*\n\n- First\n- [Second](https://example.test)\n\n```php\necho '<tag>';\n```";
$payload = [
'title' => 'Formatted description',
'description' => $description,
'ticket_type_id' => $project->ticketTypes()->where('is_parent', false)->firstOrFail()->id,
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
'ticket_status_id' => $project->ticketStatuses()->whereNull('board_column_id')->firstOrFail()->id,
];
$this->actingAs($user)->post(route('projects.tickets.store', $project), $payload)
->assertSessionHasNoErrors()->assertRedirect();
$ticket = $project->tickets()->sole();
$this->assertSame($description, $ticket->description);
$this->get(route('projects.tickets.show', [$project, $ticket]))
->assertInertia(fn (Assert $page) => $page->where('ticket.description', $description));
$updated = "> Updated quote\n\n1. One\n2. Two\n\n`inline`";
$this->patch(route('projects.tickets.update', [$project, $ticket]), [
...$payload, 'description' => $updated, 'is_blocked' => false,
])->assertSessionHasNoErrors()->assertRedirect();
$this->assertSame($updated, $ticket->fresh()->description);
$this->patch(route('projects.tickets.update', [$project, $ticket]), [
...$payload, 'description' => ['invalid'], 'is_blocked' => false,
])->assertSessionHasErrors('description');
$this->assertSame($updated, $ticket->fresh()->description);
$this->patch(route('projects.tickets.update', [$project, $ticket]), [
...$payload, 'description' => '', 'is_blocked' => false,
])->assertSessionHasNoErrors()->assertRedirect();
$this->assertNull($ticket->fresh()->description);
}
}
+248
View File
@@ -0,0 +1,248 @@
import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url';
import vue from '@vitejs/plugin-vue';
import { createServer } from 'vite';
import { createSSRApp, h } from 'vue';
import { renderToString } from 'vue/server-renderer';
import { createInertiaApp } from '@inertiajs/vue3';
const server = await createServer({
configFile: false,
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: { middlewareMode: true, hmr: false, watch: null },
appType: 'custom',
});
try {
const { createLocalization } = await server.ssrLoadModule(
'/resources/js/lib/localization.ts',
);
const { createAppearance } = await server.ssrLoadModule(
'/resources/js/composables/useAppearance.ts',
);
const { initializeNativeApp, useNativeApp, registerNativeRecovery } =
await server.ssrLoadModule('/resources/js/composables/useNativeApp.ts');
const { isNativeAppState } = await server.ssrLoadModule(
'/resources/js/lib/native-app.ts',
);
const App = (
await server.ssrLoadModule('/resources/js/pages/settings/App.vue')
).default;
const Appearance = (
await server.ssrLoadModule(
'/resources/js/pages/settings/Appearance.vue',
)
).default;
const render = async (signedIn = false, component = App) => {
const result = await createInertiaApp({
page: {
component: 'settings/App',
url: '/settings/app',
version: null,
clearHistory: false,
encryptHistory: false,
props: {
name: 'KanbanGit',
errors: {},
sidebarOpen: true,
appearance: 'system',
auth: {
user: signedIn
? { name: 'Test', email: 'test@example.test' }
: null,
permissions: {
globalAdmin: false,
projectCreate: false,
canManageTeams: false,
},
},
navigation: { project: null },
},
},
resolve: () => component,
render: renderToString,
setup: ({ App, props, plugin }) =>
createSSRApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization())
.use(createAppearance('system')),
});
return result.body;
};
await initializeNativeApp();
assert.match(await render(), /compatible KanbanGit app/);
assert.doesNotMatch(
await render(true, Appearance),
/href="\/settings\/app"/,
);
let calls = [];
let current = {
apiVersion: 1,
platform: 'ios',
version: '1.0.0',
workspace: 'https://example.test/app/',
capabilities: ['workspace'],
};
const bridge = {
version: 2,
request: async (action, payload) => {
calls.push([action, payload]);
return structuredClone(current);
},
};
globalThis.window = { kanbanNative: bridge };
await initializeNativeApp();
assert.equal(calls.length, 0);
bridge.version = 1;
current.apiVersion = 2;
await initializeNativeApp();
assert.equal(useNativeApp().state.value, null);
current.apiVersion = 1;
await initializeNativeApp();
assert.equal(calls.at(-1)[0], 'settingsReady');
delete globalThis.window;
let html = await render();
assert.match(html, /value="https:\/\/example.test\/app\/"/);
assert.doesNotMatch(
html,
/Check for updates now|Install update|native-update-interval/,
);
assert.match(await render(true, Appearance), /href="\/settings\/app"/);
current = {
...current,
platform: 'macos',
capabilities: ['workspace', 'updates'],
updates: {
phase: 'available',
currentVersion: '1.0.0',
build: '1',
version: '1.1.0',
notes: '<script>bad()</script>',
downloaded: 0,
total: null,
error: null,
preferences: { automatic: true, intervalHours: 6 },
},
};
globalThis.window = { kanbanNative: bridge };
await useNativeApp().poll();
delete globalThis.window;
html = await render(true);
assert.match(html, /Check for updates now/);
assert.match(html, /Install update/);
assert.match(html, /&lt;script&gt;bad/);
assert.doesNotMatch(html, /<script>bad/);
assert.match(html, /id="native-update-interval"[^>]*value="6"/);
for (const phase of [
'checking',
'downloading',
'verifying',
'installing',
'restarting',
'error',
'current',
]) {
current.updates.phase = phase;
current.updates.downloaded = 40;
current.updates.total = 100;
globalThis.window = { kanbanNative: bridge };
await useNativeApp().poll();
if (
[
'checking',
'downloading',
'verifying',
'installing',
'restarting',
].includes(phase)
) {
const before = calls.length;
assert.equal(await useNativeApp().act('checkForUpdates'), false);
assert.equal(calls.length, before);
}
delete globalThis.window;
html = await render();
if (phase === 'downloading') {
assert.match(html, /40%/);
}
}
for (const invalid of [
null,
{},
{ ...current, apiVersion: 2 },
{ ...current, platform: 'unsupported' },
{ ...current, updates: { ...current.updates, phase: 'madeUp' } },
]) {
assert.equal(isNativeAppState(invalid), false);
}
globalThis.window = { kanbanNative: bridge };
calls = [];
const listeners = {};
globalThis.window.addEventListener = (name, callback) => {
listeners[name] = callback;
};
globalThis.document = {
addEventListener: (name, callback) => {
listeners[name] = callback;
},
};
registerNativeRecovery();
for (const name of ['offline', 'inertia:exception', 'inertia:invalid']) {
listeners[name]({ detail: { response: { status: 503 } } });
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(calls.at(-1)[0], 'openSettings');
}
const count = calls.length;
listeners['inertia:invalid']({ detail: { response: { status: 200 } } });
assert.equal(calls.length, count);
delete globalThis.document;
calls = [];
await useNativeApp().act('setUpdatePreferences', {
automatic: false,
intervalHours: 24,
});
assert.deepEqual(calls[0], [
'setUpdatePreferences',
{ automatic: false, intervalHours: 24 },
]);
await useNativeApp().act('setWorkspace', { server: 'https://new.test/' });
assert.deepEqual(calls.at(-1), [
'setWorkspace',
{ server: 'https://new.test/' },
]);
let resolve;
bridge.request = () =>
new Promise((done) => {
resolve = done;
});
const first = useNativeApp().act('setWorkspace', {
server: 'https://new.test/',
});
assert.equal(
await useNativeApp().act('setWorkspace', {
server: 'https://duplicate.test/',
}),
false,
);
resolve(null);
await first;
bridge.request = async () => {
throw new Error('invalidServer');
};
assert.equal(
await useNativeApp().act('setWorkspace', {
server: 'http://invalid.test',
}),
false,
);
delete globalThis.window;
assert.match(await render(), /Enter a valid HTTPS/);
console.log('Native app settings checks passed.');
} finally {
delete globalThis.window;
await server.close();
}
@@ -0,0 +1,236 @@
import { createApp, h, nextTick, reactive, ref } from 'vue';
import TicketDescriptionEditor from '../../resources/js/components/tickets/TicketDescriptionEditor.vue';
import TicketDescription from '../../resources/js/components/tickets/TicketDescription.vue';
import { createLocalization } from '../../resources/js/lib/localization';
import '../../resources/css/app.css';
import descriptionFormat from '../../resources/js/lib/ticket-description-format.json';
const original = 'First line\nSecond line\n\n**Existing bold**';
const state = reactive({
value: original,
disabled: false,
error: '',
result: 'Running browser checks…',
updates: 0,
});
const component = ref(null);
const app = createApp({
render: () =>
h('main', { class: 'mx-auto max-w-3xl space-y-6 p-6' }, [
h(
'h1',
{ class: 'text-xl font-semibold' },
'Ticket description editor',
),
h('p', { id: 'test-result', role: 'status' }, state.result),
h(TicketDescriptionEditor, {
ref: component,
id: 'description',
label: 'Description',
modelValue: state.value,
disabled: state.disabled,
error: state.error,
'onUpdate:modelValue': (value) => {
state.value = value;
state.updates++;
},
}),
h('h2', { class: 'font-semibold' }, 'Saved description preview'),
h(TicketDescription, { description: state.value }),
]),
}).use(createLocalization());
app.mount('#app');
const tick = async () => {
await nextTick();
await new Promise((resolve) => requestAnimationFrame(resolve));
};
const assert = (condition, message) => {
if (!condition) {
throw new Error(message);
}
};
const button = (name) =>
[...document.querySelectorAll('button')].find(
(element) =>
element.getAttribute('aria-label') === name ||
element.textContent.trim() === name,
);
const click = async (name) => {
const element = button(name);
assert(element && !element.disabled, `Button available: ${name}`);
element.click();
await tick();
};
try {
await tick();
const editor = component.value.$.setupState.editor;
assert(editor, 'Editor mounted');
assert(
state.value === original && state.updates === 0,
'Mount preserves exact source',
);
assert(
document
.querySelector('#description')
.getAttribute('contenteditable') === 'true',
'Visual textbox is editable',
);
assert(
editor.getText().includes('First line\nSecond line'),
'Legacy newlines retained',
);
state.value = 'Format this';
await tick();
editor.commands.selectAll();
for (const name of ['Bold', 'Italic', 'Strikethrough']) {
await click(name);
assert(
button(name).getAttribute('aria-pressed') === 'true',
`${name} active`,
);
}
assert(
state.value.includes('**') && state.value.includes('~~'),
'Formatting emits Markdown',
);
await click('Undo');
assert(!editor.isActive('strike'), 'Undo restores prior state');
await click('Redo');
assert(editor.isActive('strike'), 'Redo restores formatting');
await click('Markdown');
const source = document.querySelector('textarea#description');
assert(source.value === state.value, 'Source matches the editor output');
source.value = '## From source\n\n- One\n- Two';
source.dispatchEvent(new Event('input', { bubbles: true }));
await tick();
await click('Visual editor');
assert(
document.querySelector('#description h2')?.textContent ===
'From source',
'Source updates visual editor',
);
assert(
document.querySelectorAll('#description li').length === 2,
'Lists render after source switch',
);
for (const [name, selector] of [
['Bullet list', 'ul li'],
['Numbered list', 'ol li'],
['Quote', 'blockquote'],
['Inline code', 'code'],
['Code block', 'pre code'],
['Heading 2', 'h2'],
['Heading 3', 'h3'],
]) {
state.value = `Check ${name}`;
await tick();
editor.commands.selectAll();
await click(name);
assert(
document.querySelector(`#description ${selector}`),
`${name} renders`,
);
}
state.value = 'Link text';
await tick();
editor.commands.selectAll();
await click('Add or edit link');
assert(
document.activeElement.id === 'description-link',
'Link field receives focus',
);
const input = document.querySelector('#description-link');
input.value = 'javascript:alert(1)';
input.dispatchEvent(new Event('input', { bubbles: true }));
await tick();
await click('Apply link');
assert(
document
.querySelector('#description-link-error')
.textContent.includes('Enter a valid'),
'Unsafe link rejected',
);
input.value = 'https://example.test/docs';
input.dispatchEvent(new Event('input', { bubbles: true }));
await tick();
await click('Apply link');
assert(
state.value === '[Link text](https://example.test/docs)',
'Selection becomes a Markdown link',
);
await click('Remove link');
assert(state.value === 'Link text', 'Link can be removed');
state.disabled = true;
state.error = 'Description rejected.';
await tick();
assert(
document
.querySelector('#description')
.getAttribute('contenteditable') === 'false',
'Processing disables editing',
);
assert(
document.querySelector('#description').getAttribute('aria-invalid') ===
'true',
'Server error reflected on editor',
);
assert(
button('Bold').disabled && button('Markdown').disabled,
'Processing disables controls',
);
assert(state.value === 'Link text', 'Validation retains draft');
state.disabled = false;
state.error = '';
state.value = 'Reset draft';
await tick();
assert(editor.getText() === 'Reset draft', 'External reset syncs editor');
editor.commands.selectAll();
await click('Heading 1');
assert(state.value.startsWith('# '), 'Heading command saves Markdown');
editor.commands.clearContent();
await tick();
assert(state.value === '', 'Clearing editor saves an empty description');
state.value = '| A | B |\n| - | - |\n| a | b |';
await tick();
assert(
document.querySelector('textarea#description').value === state.value,
'Unsupported content retained as source',
);
assert(button('Visual editor').disabled, 'Lossy conversion is unavailable');
state.value =
'# Plan\n\nUse **bold** and *italic* formatting, lists and [links](https://example.test).\n\n- Create ticket\n- Save description\n\n> Keep existing descriptions readable.';
await tick();
state.value = descriptionFormat.example;
await tick();
assert(
document.querySelector('#description h2')?.textContent === 'Goal',
'MCP example opens formatted in WYSIWYG',
);
assert(
document.querySelector('#description strong')?.textContent ===
'context',
'MCP emphasis is formatted',
);
assert(
document.querySelectorAll('#description li').length === 2,
'MCP ordinary lists render',
);
assert(
!document.querySelector('textarea#description'),
'MCP example avoids source fallback',
);
state.value = '## Legacy\n\n- [ ] Criterion';
await tick();
assert(
document.querySelector('textarea#description')?.value === state.value,
'Legacy task checkboxes retain original source',
);
state.value = descriptionFormat.example;
await tick();
state.result =
'Browser checks passed: formatting, undo/redo, source round-trip, links, errors, disabled state, reset, clearing and legacy content.';
} catch (error) {
state.result = `FAILED: ${error.message}`;
console.error(error);
}
window.addEventListener('pagehide', () => app.unmount(), { once: true });
@@ -0,0 +1,167 @@
[
{
"name": "plain text",
"value": "München 🚀 & details\nSecond line",
"supported": true
},
{
"name": "formatting",
"value": "# Title\n\n**Bold** and *italic* and ~~strike~~\n\n- One\n - Nested\n\n3. Third\n4. Fourth\n\n> Quote\n\n`inline`\n\n```js\n<tag>\n```\n\n[Link](https://example.test/path)\n\n---",
"supported": true
},
{
"name": "setext heading",
"value": "Title\n===\n\nText",
"supported": true
},
{
"name": "safe links",
"value": "[http](http://example.test) [mail](mailto:test@example.test) [root](/projects/1) [fragment](#part)",
"supported": true
},
{
"name": "reference link",
"value": "[Guide][ref]\n\n[ref]: https://example.test",
"supported": true
},
{
"name": "autolinks",
"value": "<https://example.test> and https://example.test and test@example.test",
"supported": true
},
{
"name": "literal code",
"value": "```markdown\n- [x] Done\n<table></table>\n![image](bad)\n[a](javascript:bad)\n```\n\n`- [ ] task`\n\n <script>example</script>",
"supported": true
},
{
"name": "escaped text",
"value": "\\<tag> and \\![image](https://example.test)\n\n- \\[x] ordinary list",
"supported": true
},
{
"name": "entities",
"value": "&lt;img src=x onerror=alert(1)&gt;",
"supported": true
},
{
"name": "empty",
"value": "",
"supported": true
},
{
"name": "task",
"value": "## Plan\n\n- [ ] Criteria",
"supported": false
},
{
"name": "checked",
"value": "- [x] Done",
"supported": false
},
{
"name": "nested task",
"value": "> 1. [X] Done",
"supported": false
},
{
"name": "table",
"value": "| A | B |\n| - | - |\n| a | b |",
"supported": false
},
{
"name": "html",
"value": "<script>alert(1)</script>",
"supported": false
},
{
"name": "inline html",
"value": "Text <b>bold</b>",
"supported": false
},
{
"name": "html comment",
"value": "<!-- comment -->",
"supported": false
},
{
"name": "image",
"value": "![Image](https://example.test/image.png)",
"supported": false
},
{
"name": "reference image",
"value": "![alt][img]\n\n[img]: https://example.test/image.png",
"supported": false
},
{
"name": "javascript",
"value": "[unsafe](javascript:alert%281%29)",
"supported": false
},
{
"name": "data",
"value": "[unsafe](data:text/html,test)",
"supported": false
},
{
"name": "protocol relative",
"value": "[unsafe](//evil.test)",
"supported": false
},
{
"name": "relative",
"value": "[unsafe](other/page)",
"supported": false
},
{
"name": "unsafe reference",
"value": "[unsafe][ref]\n\n[ref]: javascript:alert(1)",
"supported": false
},
{
"name": "malformed https",
"value": "[unsafe](https://)",
"supported": false
},
{
"name": "backslash",
"value": "[unsafe](https://example.test/a\\\\b)",
"supported": false
},
{
"name": "invalid ipv6",
"value": "[unsafe](https://[invalid]/)",
"supported": false
},
{
"name": "encoded backslash",
"value": "[unsafe](https://example.test/%5cpath)",
"supported": false
},
{
"name": "encoded whitespace",
"value": "[unsafe](https://example.test/a%20b)",
"supported": false
},
{
"name": "invalid encoding",
"value": "[unsafe](https://example.test/%ff)",
"supported": false
},
{
"name": "too large port",
"value": "[unsafe](https://example.test:99999)",
"supported": false
},
{
"name": "ipv6",
"value": "[safe](https://[::1]/path)",
"supported": true
},
{
"name": "international domain",
"value": "[safe](https://münchen.example/🚀)",
"supported": true
}
]
+254
View File
@@ -0,0 +1,254 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import vue from '@vitejs/plugin-vue';
import tailwind from '@tailwindcss/vite';
import { createServer } from 'vite';
import { createSSRApp, h } from 'vue';
import { renderToString } from 'vue/server-renderer';
const serve = process.argv.includes('--serve');
const server = await createServer({
configFile: false,
plugins: [vue(), ...(serve ? [tailwind()] : [])],
optimizeDeps: {
include: [
'@tiptap/core',
'@tiptap/pm/state',
'@tiptap/vue-3',
'@tiptap/markdown',
'@tiptap/starter-kit',
],
},
resolve: {
dedupe: [
'vue',
'@tiptap/core',
'@tiptap/pm',
'prosemirror-state',
'prosemirror-model',
'prosemirror-view',
],
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: serve
? { host: '127.0.0.1', port: 4186, strictPort: true, hmr: false }
: { middlewareMode: true, hmr: false, watch: null },
appType: 'custom',
});
if (serve) {
server.middlewares.use(async (request, response) => {
response.setHeader('Content-Type', 'text/html');
response.end(
await server.transformIndexHtml(
request.url,
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app"></div><script type="module" src="/tests/Fixtures/ticket-description-browser.mjs"></script></body></html>',
),
);
});
await server.listen();
console.log('Description fixture ready on port 4186');
} else {
try {
const {
safeDescriptionLink,
supportsVisualDescription,
parseDescription,
renderDescriptionNode,
} = await server.ssrLoadModule(
'/resources/js/lib/ticket-description.ts',
);
const { default: Description } = await server.ssrLoadModule(
'/resources/js/components/tickets/TicketDescription.vue',
);
const { default: Editor } = await server.ssrLoadModule(
'/resources/js/components/tickets/TicketDescriptionEditor.vue',
);
const { createLocalization } = await server.ssrLoadModule(
'/resources/js/lib/localization.ts',
);
const render = (description) =>
renderToString(
createSSRApp({ render: () => h(Description, { description }) }),
);
const cases = JSON.parse(
readFileSync(
new URL('./ticket-description-cases.json', import.meta.url),
'utf8',
),
);
const contract = JSON.parse(
readFileSync(
new URL(
'../../resources/js/lib/ticket-description-format.json',
import.meta.url,
),
'utf8',
),
);
cases.push({
name: 'MCP contract example',
value: contract.example,
supported: true,
});
for (const item of cases) {
assert.equal(
supportsVisualDescription(item.value),
item.supported,
item.name,
);
if (item.supported) {
assert.ok(parseDescription(item.value));
await render(item.value);
}
}
if (process.argv.includes('--stored-description')) {
const saved = JSON.parse(readFileSync(0, 'utf8'));
assert.equal(supportsVisualDescription(saved.description), true);
const html = await render(saved.description);
assert.match(html, /<h2>Goal<\/h2>/);
assert.match(html, /<strong>context<\/strong>/);
assert.equal(
parseDescription(saved.description).content[0].type,
'heading',
);
for (const criterion of saved.criteria) {
assert.ok(
!html.includes(criterion.text),
'Criteria stay in the separate checklist',
);
}
}
const exampleHtml = await render(contract.example);
assert.match(exampleHtml, /<h2>Goal<\/h2>/);
assert.match(exampleHtml, /<strong>context<\/strong>/);
assert.doesNotMatch(exampleHtml, /acceptance_criteria|task checkboxes/);
const markdown =
'# Title\n\n**Bold** and *italic* and ~~strike~~\n\n- One\n - Nested\n\n3. Third\n4. Fourth\n\n> Quote\n\n`inline`\n\n```js\n<tag>\n```\n\n[Link](https://example.test/path)\n\n---';
assert.equal(supportsVisualDescription(markdown), true);
const rendered = await render(markdown);
for (const snippet of [
'<h1>Title</h1>',
'<strong>Bold</strong>',
'<em>italic</em>',
'<s>strike</s>',
'<ul>',
'Nested',
'<ol start="3">',
'<blockquote>',
'<code>inline</code>',
'&lt;tag&gt;',
'rel="noopener noreferrer"',
'<hr>',
]) {
assert.ok(
rendered.includes(snippet),
`Missing formatted output: ${snippet}`,
);
}
assert.match(await render('First\nSecond'), /First<br>Second/);
assert.match(
await render('München 🚀 & details'),
/München 🚀 &amp; details/,
);
assert.match(
await render('&lt;img src=x onerror=alert(1)&gt;'),
/&lt;img/,
);
for (const value of [
'https://example.test/a?b=1&c=2',
'http://example.test',
'mailto:test@example.test',
'/projects/1',
'#section',
]) {
assert.equal(safeDescriptionLink(value), value);
}
for (const value of [
'javascript:alert(1)',
'JaVaScRiPt:alert(1)',
'data:text/html,test',
'vbscript:test',
'//evil.test',
'/\\evil.test',
'\u0000https://example.test',
'https://',
'https://exa mple.test',
'java\nscript:alert(1)',
null,
]) {
assert.equal(safeDescriptionLink(value), null);
}
for (const value of [
'<script>alert(1)</script>',
'<img src=x onerror=alert(1)>',
'| A | B |\n| - | - |\n| a | b |',
'![Image](https://example.test/image.png)',
'- [x] Done',
'[unsafe](javascript:alert%281%29)',
]) {
assert.equal(supportsVisualDescription(value), false, value);
const html = await render(value);
assert.doesNotMatch(html, /<script|<img|href="javascript:/);
assert.ok(
html.includes(
value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;'),
),
'Original source is retained',
);
}
const maliciousNode = {
type: 'paragraph',
content: [
{
type: 'text',
text: '<script>bad</script>',
marks: [
{ type: 'link', attrs: { href: 'javascript:bad()' } },
],
},
],
};
const safe = await renderToString(
createSSRApp({
render: () =>
h('div', {}, [renderDescriptionNode(maliciousNode)]),
}),
);
assert.doesNotMatch(safe, /<script|<a/);
assert.match(safe, /&lt;script&gt;/);
assert.deepEqual(parseDescription(''), { type: 'doc', content: [] });
let updates = 0;
const initial = await renderToString(
createSSRApp({
render: () =>
h(Editor, {
id: 'description',
label: 'Description',
modelValue: markdown,
error: 'Invalid description.',
disabled: true,
'onUpdate:modelValue': () => updates++,
}),
}).use(createLocalization()),
);
assert.match(initial, /id="description"/);
assert.match(initial, /aria-invalid="true"/);
assert.match(initial, /id="description-error"/);
assert.match(initial, /disabled/);
assert.equal(
updates,
0,
'Mounting must not rewrite existing descriptions',
);
console.log('Ticket description checks passed.');
} finally {
await server.close();
}
}
+60 -3
View File
@@ -5,7 +5,7 @@ import tailwind from '@tailwindcss/vite';
import vue from '@vitejs/plugin-vue'; import vue from '@vitejs/plugin-vue';
import { createInertiaApp } from '@inertiajs/vue3'; import { createInertiaApp } from '@inertiajs/vue3';
import { createServer } from 'vite'; import { createServer } from 'vite';
import { createSSRApp, h } from 'vue'; import { createSSRApp, h, reactive, ref } from 'vue';
import { renderToString } from 'vue/server-renderer'; import { renderToString } from 'vue/server-renderer';
const serve = process.argv.includes('--serve'); const serve = process.argv.includes('--serve');
@@ -398,6 +398,11 @@ if (serve) {
} else { } else {
params.delete('noLinks'); params.delete('noLinks');
params.delete('linkRejected'); params.delete('linkRejected');
if (data.link_direction === 'counterpart') {
params.set('incoming', '1');
} else {
params.delete('incoming');
}
} }
} else { } else {
assert.ok( assert.ok(
@@ -458,6 +463,40 @@ if (serve) {
); );
} else { } else {
try { try {
const { useTicketLinkActions } = await server.ssrLoadModule(
'/resources/js/composables/useTicketLinkActions.ts',
);
const linkTypes = ref([
{ id: 1, label: 'Blocks', counterpart_label: 'Blocked by' },
{ id: 2, label: 'Relates to', counterpart_label: 'Relates to' },
]);
const linkForm = reactive({
ticket_link_type_id: 1,
link_direction: 'source',
linked_ticket_id: 41,
});
const { actions, selectedAction, selectedActionLabel } =
useTicketLinkActions(linkTypes, linkForm);
assert.deepEqual(
actions.value.map((action) => action.label),
['Blocks', 'Blocked by', 'Relates to'],
);
assert.equal(selectedActionLabel.value, 'Blocks');
selectedAction.value = '1:counterpart';
assert.equal(selectedActionLabel.value, 'Blocked by');
assert.deepEqual(
{ ...linkForm },
{
ticket_link_type_id: 1,
link_direction: 'counterpart',
linked_ticket_id: 41,
},
);
selectedAction.value = '2:source';
assert.equal(linkForm.ticket_link_type_id, 2);
assert.equal(linkForm.link_direction, 'source');
selectedAction.value = 'unknown';
assert.equal(selectedActionLabel.value, 'Relates to');
const { createLocalization } = await server.ssrLoadModule( const { createLocalization } = await server.ssrLoadModule(
'/resources/js/lib/localization.ts', '/resources/js/lib/localization.ts',
); );
@@ -486,6 +525,24 @@ if (serve) {
return result.body.split('id="main-content"')[1]; return result.body.split('id="main-content"')[1];
}; };
const html = await render(); const html = await render();
assert.ok(!html.includes('id="link_direction"'));
assert.ok(html.includes('Blocked by'));
const linkFields = [
...html.matchAll(/<fieldset\b[^>]*>[\s\S]*?<\/fieldset>/g),
].find(([fieldset]) =>
fieldset.includes('id="ticket_link_type_id"'),
)?.[0];
assert.ok(linkFields);
assert.equal((linkFields.match(/<select\b/g) ?? []).length, 2);
assert.match(
linkFields,
/<option value="1:source"[^>]*>Blocks<\/option>/,
);
assert.match(
linkFields,
/<option value="1:counterpart"[^>]*>Blocked by<\/option>/,
);
assert.doesNotMatch(linkFields, /Blocks \/ Blocked by/);
for (const text of [ for (const text of [
ticket.title, ticket.title,
'Keep every existing ticket action available.', 'Keep every existing ticket action available.',
@@ -563,9 +620,9 @@ if (serve) {
/<a(?=[^>]*href="https:\/\/git\.example\.test\/Engineering\/platform\/pulls\/12")(?=[^>]*target="_blank")(?=[^>]*rel="noopener noreferrer")[^>]*>/, /<a(?=[^>]*href="https:\/\/git\.example\.test\/Engineering\/platform\/pulls\/12")(?=[^>]*target="_blank")(?=[^>]*rel="noopener noreferrer")[^>]*>/,
); );
assert.match(html, /Save link/); assert.match(html, /Save link/);
assert.match(html, /Outgoing/); assert.doesNotMatch(html, /Outgoing/);
const incoming = await render('?incoming'); const incoming = await render('?incoming');
assert.match(incoming, /Incoming/); assert.doesNotMatch(incoming, /Incoming/);
assert.match(incoming, /Blocked by/); assert.match(incoming, /Blocked by/);
assert.match(await render('?noTypes'), /No link types are configured/); assert.match(await render('?noTypes'), /No link types are configured/);
assert.match( assert.match(
@@ -41,8 +41,6 @@ try {
'Platform delivery', 'Platform delivery',
'Child tickets', 'Child tickets',
'Nested child with a long title that must wrap on mobile', 'Nested child with a long title that must wrap on mobile',
'Incoming',
'Blocked by',
]) { ]) {
assert.equal( assert.equal(
await section() await section()
@@ -52,6 +50,13 @@ try {
text, text,
); );
} }
assert.equal(
await section()
.locator('[data-ticket-link]')
.getByText('Blocked by', { exact: true })
.isVisible(),
true,
);
const collapse = section().getByRole('button', { const collapse = section().getByRole('button', {
name: 'Collapse children of ENG-41', name: 'Collapse children of ENG-41',
}); });
@@ -68,7 +73,7 @@ try {
); );
await page.keyboard.press('Space'); await page.keyboard.press('Space');
assert.equal(await collapse.getAttribute('aria-expanded'), 'true'); assert.equal(await collapse.getAttribute('aria-expanded'), 'true');
await control('Link type').focus(); await control('Action').focus();
await page.keyboard.press('Tab'); await page.keyboard.press('Tab');
assert.equal( assert.equal(
await control('Linked ticket').evaluate( await control('Linked ticket').evaluate(
@@ -118,9 +123,13 @@ try {
await save().click(); await save().click();
assert.deepEqual((await request).postDataJSON(), { assert.deepEqual((await request).postDataJSON(), {
ticket_link_type_id: 1, ticket_link_type_id: 1,
link_direction: 'source',
linked_ticket_id: 41, linked_ticket_id: 41,
}); });
await section().getByText('Outgoing', { exact: true }).waitFor(); await section()
.locator('[data-ticket-link]')
.getByText('Blocks', { exact: true })
.waitFor();
await page.waitForFunction( await page.waitForFunction(
() => document.querySelector('#linked_ticket_id')?.value === '', () => document.querySelector('#linked_ticket_id')?.value === '',
); );
@@ -141,6 +150,27 @@ try {
document.activeElement?.textContent.includes('Linked tickets'), document.activeElement?.textContent.includes('Linked tickets'),
); );
await visit('?noLinks');
await control('Action').selectOption('1:counterpart');
await control('Linked ticket').selectOption('41');
await section()
.locator('p[aria-live="polite"]')
.getByText('Blocked by', { exact: true })
.waitFor();
request = page.waitForRequest(
(r) => r.method() === 'POST' && r.url().endsWith('/links'),
);
await save().click();
assert.deepEqual((await request).postDataJSON(), {
ticket_link_type_id: 1,
link_direction: 'counterpart',
linked_ticket_id: 41,
});
await section()
.locator('[data-ticket-link]')
.getByText('Blocked by', { exact: true })
.waitFor();
await visit('?rejectLink'); await visit('?rejectLink');
await control('Linked ticket').selectOption('41'); await control('Linked ticket').selectOption('41');
await save().click(); await save().click();
@@ -148,7 +178,7 @@ try {
.getByText('Choose another ticket.', { exact: true }) .getByText('Choose another ticket.', { exact: true })
.waitFor(); .waitFor();
assert.equal(await control('Linked ticket').inputValue(), '41'); assert.equal(await control('Linked ticket').inputValue(), '41');
for (const label of ['Link type', 'Linked ticket']) { for (const label of ['Action', 'Linked ticket']) {
assert.equal(await control(label).getAttribute('aria-invalid'), 'true'); assert.equal(await control(label).getAttribute('aria-invalid'), 'true');
} }
await page.route('**/tickets/42/links', (route) => route.abort('failed')); await page.route('**/tickets/42/links', (route) => route.abort('failed'));