Compare commits
13
Commits
macos-v0.2.5
...
v0.3.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
704fe5d241 | ||
|
|
73f6ab6918 | ||
|
|
6d4a823fe5 | ||
|
|
6054690607 | ||
|
|
723285f820 | ||
|
|
393d074bb7 | ||
|
|
3ec59ef367 | ||
|
|
10f88f6029 | ||
|
|
3b110f08ae | ||
|
|
1a022c257f | ||
|
|
ce36060ba1 | ||
|
|
f4434a3203 | ||
|
|
912cfc28c2 |
@@ -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
|
|
||||||
|
|||||||
@@ -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,12 @@ class DeleteProject
|
|||||||
{
|
{
|
||||||
$projectName = $project->name;
|
$projectName = $project->name;
|
||||||
|
|
||||||
$project->delete();
|
DB::transaction(function () use ($project): void {
|
||||||
|
$baseTemplate = $project->baseTemplate()->first();
|
||||||
|
|
||||||
|
$project->delete();
|
||||||
|
$baseTemplate?->delete();
|
||||||
|
});
|
||||||
|
|
||||||
return $projectName;
|
return $projectName;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,18 +17,21 @@ class StoreProjectTicketLink
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{linked_ticket_id: int, ticket_link_type_id: int} $validated
|
* @param array{linked_ticket_id: int, ticket_link_type_id: int, link_direction?: 'source'|'counterpart'} $validated
|
||||||
*/
|
*/
|
||||||
public function handle(Project $project, Ticket $ticket, User $user, array $validated): TicketLink
|
public function handle(Project $project, Ticket $ticket, User $user, array $validated): TicketLink
|
||||||
{
|
{
|
||||||
$linkedTicketId = Arr::get($validated, 'linked_ticket_id');
|
$linkedTicketId = Arr::get($validated, 'linked_ticket_id');
|
||||||
$ticketLinkTypeId = Arr::get($validated, 'ticket_link_type_id');
|
$ticketLinkTypeId = Arr::get($validated, 'ticket_link_type_id');
|
||||||
|
$isSourceTicket = Arr::get($validated, 'link_direction', 'source') === 'source';
|
||||||
|
|
||||||
$linkedTicket = Ticket::query()
|
$linkedTicket = Ticket::query()
|
||||||
->where('project_id', $project->id)
|
->where('project_id', $project->id)
|
||||||
->findOrFail($linkedTicketId);
|
->findOrFail($linkedTicketId);
|
||||||
|
$sourceTicket = $isSourceTicket ? $ticket : $linkedTicket;
|
||||||
|
$targetTicket = $isSourceTicket ? $linkedTicket : $ticket;
|
||||||
|
|
||||||
return DB::transaction(function () use ($linkedTicket, $user, $ticketLinkTypeId, $ticket): TicketLink {
|
return DB::transaction(function () use ($linkedTicket, $user, $ticketLinkTypeId, $ticket, $sourceTicket, $targetTicket, $isSourceTicket): TicketLink {
|
||||||
Ticket::query()
|
Ticket::query()
|
||||||
->whereKey([$ticket->id, $linkedTicket->id])
|
->whereKey([$ticket->id, $linkedTicket->id])
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
@@ -50,31 +53,31 @@ class StoreProjectTicketLink
|
|||||||
|
|
||||||
if ($ticketLink === null) {
|
if ($ticketLink === null) {
|
||||||
$ticketLink = TicketLink::query()->create([
|
$ticketLink = TicketLink::query()->create([
|
||||||
'source_ticket_id' => $ticket->id,
|
'source_ticket_id' => $sourceTicket->id,
|
||||||
'target_ticket_id' => $linkedTicket->id,
|
'target_ticket_id' => $targetTicket->id,
|
||||||
'ticket_link_type_id' => $ticketLinkTypeId,
|
'ticket_link_type_id' => $ticketLinkTypeId,
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
$ticketLink->update([
|
$ticketLink->update([
|
||||||
'source_ticket_id' => $ticket->id,
|
'source_ticket_id' => $sourceTicket->id,
|
||||||
'target_ticket_id' => $linkedTicket->id,
|
'target_ticket_id' => $targetTicket->id,
|
||||||
'ticket_link_type_id' => $ticketLinkTypeId,
|
'ticket_link_type_id' => $ticketLinkTypeId,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$ticketLink->load('ticketLinkType');
|
$ticketLink->load('ticketLinkType');
|
||||||
|
|
||||||
$sourceLabel = $ticketLink->ticketLinkType->labelForSource(true);
|
$ticketLabel = $ticketLink->ticketLinkType->labelForSource($isSourceTicket);
|
||||||
$targetLabel = $ticketLink->ticketLinkType->labelForSource(false);
|
$linkedTicketLabel = $ticketLink->ticketLinkType->labelForSource(! $isSourceTicket);
|
||||||
|
|
||||||
$this->createTicketWorklogEntry->handle(
|
$this->createTicketWorklogEntry->handle(
|
||||||
$ticket,
|
$ticket,
|
||||||
$user,
|
$user,
|
||||||
TicketWorklogEntryType::LinkUpdated,
|
TicketWorklogEntryType::LinkUpdated,
|
||||||
"Linked {$linkedTicket->ticket_key} as {$sourceLabel}.",
|
"Linked {$linkedTicket->ticket_key} as {$ticketLabel}.",
|
||||||
[
|
[
|
||||||
'linked_ticket_key' => $linkedTicket->ticket_key,
|
'linked_ticket_key' => $linkedTicket->ticket_key,
|
||||||
'ticket_link_type' => $sourceLabel,
|
'ticket_link_type' => $ticketLabel,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -82,10 +85,10 @@ class StoreProjectTicketLink
|
|||||||
$linkedTicket,
|
$linkedTicket,
|
||||||
$user,
|
$user,
|
||||||
TicketWorklogEntryType::LinkUpdated,
|
TicketWorklogEntryType::LinkUpdated,
|
||||||
"Linked {$ticket->ticket_key} as {$targetLabel}.",
|
"Linked {$ticket->ticket_key} as {$linkedTicketLabel}.",
|
||||||
[
|
[
|
||||||
'linked_ticket_key' => $ticket->ticket_key,
|
'linked_ticket_key' => $ticket->ticket_key,
|
||||||
'ticket_link_type' => $targetLabel,
|
'ticket_link_type' => $linkedTicketLabel,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -36,11 +36,12 @@ class ProjectTicketLinkController extends ApiController
|
|||||||
|
|
||||||
$ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated());
|
$ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated());
|
||||||
$ticketLink->setAttribute('current_ticket_id', $ticket->id);
|
$ticketLink->setAttribute('current_ticket_id', $ticket->id);
|
||||||
|
$label = $ticketLink->ticketLinkType->labelForSource($ticketLink->source_ticket_id === $ticket->id);
|
||||||
|
|
||||||
return TicketLinkResource::make($ticketLink->load(['sourceTicket', 'targetTicket', 'ticketLinkType']))
|
return TicketLinkResource::make($ticketLink->load(['sourceTicket', 'targetTicket', 'ticketLinkType']))
|
||||||
->additional([
|
->additional([
|
||||||
'meta' => [
|
'meta' => [
|
||||||
'message' => "{$ticketLink->ticketLinkType->label} link was saved.",
|
'message' => "{$label} link was saved.",
|
||||||
],
|
],
|
||||||
])
|
])
|
||||||
->response()
|
->response()
|
||||||
|
|||||||
@@ -34,9 +34,10 @@ class ProjectTicketLinkController extends Controller
|
|||||||
);
|
);
|
||||||
|
|
||||||
$ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated());
|
$ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated());
|
||||||
|
$label = $ticketLink->ticketLinkType->labelForSource($ticketLink->source_ticket_id === $ticket->id);
|
||||||
|
|
||||||
return to_route('projects.tickets.show', [$project, $ticket])
|
return to_route('projects.tickets.show', [$project, $ticket])
|
||||||
->with('status', "{$ticketLink->ticketLinkType->label} link was saved.");
|
->with('status', "{$label} link was saved.");
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(
|
public function destroy(
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class RequirePasswordChange
|
|||||||
|
|
||||||
if ($request->routeIs([
|
if ($request->routeIs([
|
||||||
'logout',
|
'logout',
|
||||||
|
'native-app.edit',
|
||||||
'password.force-change.edit',
|
'password.force-change.edit',
|
||||||
'user-password.update',
|
'user-password.update',
|
||||||
])) {
|
])) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class DeleteProjectRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
return $this->user()?->can('access-admin') ?? false;
|
return $this->user()?->can('admin-project', $this->route('project')) ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class StoreTicketLinkRequest extends FormRequest
|
|||||||
fn ($query) => $query->where('project_id', $project->id),
|
fn ($query) => $query->where('project_id', $project->id),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
'link_direction' => ['sometimes', 'required', Rule::in(['source', 'counterpart'])],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+26
-22
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "kanbangit-native",
|
"name": "kanbangit-native",
|
||||||
"version": "0.2.5",
|
"version": "0.2.6",
|
||||||
"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": "Use WebKit's interactive back and forward gestures on iOS and macOS while retaining vertical scroll bounds."
|
"releaseNotes": "Manage local workspace settings in the web app on macOS and iOS, with shared macOS update controls and native recovery for older or unavailable servers."
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1
-1
@@ -1770,7 +1770,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kanbangit-native"
|
name = "kanbangit-native"
|
||||||
version = "0.2.5"
|
version = "0.2.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "kanbangit-native"
|
name = "kanbangit-native"
|
||||||
version = "0.2.5"
|
version = "0.2.6"
|
||||||
description = "KanbanGit native application"
|
description = "KanbanGit native application"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
publish = false
|
publish = false
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ 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 settingsAvailable = false
|
||||||
|
private var toolbarHeight: NSLayoutConstraint!
|
||||||
private var appearance: UISegmentedControl!
|
private var appearance: UISegmentedControl!
|
||||||
|
|
||||||
init(defaults: UserDefaults = .standard) {
|
init(defaults: UserDefaults = .standard) {
|
||||||
@@ -165,7 +167,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),
|
||||||
@@ -201,6 +205,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
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
|
||||||
@@ -209,7 +214,11 @@ 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
|
||||||
@@ -269,10 +278,48 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func setSettingsAvailable(_ available: Bool) {
|
||||||
|
settingsAvailable = available
|
||||||
|
toolbarHeight.constant = available ? 0 : 44
|
||||||
|
toolbar.isHidden = available || !launcher.isHidden || !connection.ready
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSettings(_ message: WKScriptMessage, reply: @escaping (Any?, String?) -> Void) {
|
||||||
|
guard message.webView === workspace, message.frameInfo.isMainFrame,
|
||||||
|
let source = message.frameInfo.request.url, server?.allows(source) == true,
|
||||||
|
let current = workspace?.url, server?.allows(current) == true,
|
||||||
|
let body = message.body as? String else { reply(nil, "untrustedFrame"); return }
|
||||||
|
do {
|
||||||
|
let request = try NativeSettingsRequest.parse(body)
|
||||||
|
var result: Any = NSNull()
|
||||||
|
switch request.action {
|
||||||
|
case "getState":
|
||||||
|
result = ["apiVersion": 1, "platform": "ios", "workspace": server!.url.absoluteString,
|
||||||
|
"version": Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown",
|
||||||
|
"capabilities": ["workspace"]] as [String: Any]
|
||||||
|
case "settingsReady": setSettingsAvailable(true)
|
||||||
|
case "openSettings": showSettings()
|
||||||
|
case "setWorkspace":
|
||||||
|
guard let address = request.payload["server"] as? String,
|
||||||
|
let url = WorkspaceServer.normalize(address) else { throw NativeSettingsError.invalidServer }
|
||||||
|
let owner = workspace
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self, self.workspace === owner else { return }
|
||||||
|
self.open(url)
|
||||||
|
}
|
||||||
|
default: throw NativeSettingsError.invalidRequest
|
||||||
|
}
|
||||||
|
let data = try JSONSerialization.data(withJSONObject: ["id": request.id, "result": result])
|
||||||
|
reply(String(decoding: data, as: UTF8.self), nil)
|
||||||
|
} catch {
|
||||||
|
reply(nil, String(describing: error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||||
guard webView === workspace, let url = action.request.url else { decisionHandler(.cancel); return }
|
guard webView === workspace, let url = action.request.url else { decisionHandler(.cancel); return }
|
||||||
guard server?.allows(url) == true else {
|
guard server?.allows(url) == true else {
|
||||||
@@ -299,6 +346,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()
|
||||||
}
|
}
|
||||||
@@ -338,3 +386,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -135,6 +135,83 @@ final class WorkspaceTests: XCTestCase {
|
|||||||
"Changing workspaces must retain native gesture navigation")
|
"Changing workspaces must retain native gesture navigation")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testSettingsProtocolRejectsUnsupportedActionsAndInvalidPayloads() throws {
|
||||||
|
for body in [
|
||||||
|
#"{"version":2,"id":1,"action":"getState","payload":{}}"#,
|
||||||
|
#"{"version":true,"id":1,"action":"getState","payload":{}}"#,
|
||||||
|
#"{"version":1,"id":1,"action":"installUpdate","payload":{"version":"1"}}"#,
|
||||||
|
#"{"version":1,"id":1,"action":"setWorkspace","payload":{"server":12}}"#,
|
||||||
|
#"{"version":1,"id":1,"action":"getState","payload":{"extra":1}}"#,
|
||||||
|
#"{"version":1,"id":1,"action":"shell","payload":{}}"#,
|
||||||
|
] { XCTAssertThrowsError(try NativeSettingsRequest.parse(body), body) }
|
||||||
|
XCTAssertThrowsError(try NativeSettingsRequest.parse(String(repeating: "x", count: 8193)))
|
||||||
|
XCTAssertEqual(try NativeSettingsRequest.parse(#"{"version":1,"id":2,"action":"getState","payload":{}}"#).id, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testSettingsBridgeHidesToolbarOnlyAfterHandshakeAndRejectsFramesAndOldWorkspaces() async throws {
|
||||||
|
let name = "KAN70-settings-\(UUID())"
|
||||||
|
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
||||||
|
defer { defaults.removePersistentDomain(forName: name) }
|
||||||
|
let controller = WorkspaceViewController(defaults: defaults)
|
||||||
|
controller.loadViewIfNeeded()
|
||||||
|
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||||
|
let url = URL(string: "https://example.invalid/")!
|
||||||
|
controller.open(url)
|
||||||
|
let originalProfile = controller.server!.profile
|
||||||
|
let webview = try XCTUnwrap(controller.workspace)
|
||||||
|
webview.stopLoading()
|
||||||
|
webview.loadSimulatedRequest(URLRequest(url: url.appendingPathComponent("login")), responseHTML: "<html><body>Login<iframe srcdoc='<p>frame</p>'></iframe></body></html>")
|
||||||
|
try await waitFor(webview, "document.readyState === 'complete' && typeof window.kanbanNative === 'object'")
|
||||||
|
let state = try await callScript(webview, "return await window.kanbanNative.request('getState')") as? [String: Any]
|
||||||
|
XCTAssertEqual(state?["platform"] as? String, "ios")
|
||||||
|
XCTAssertEqual(state?["workspace"] as? String, url.absoluteString)
|
||||||
|
XCTAssertEqual(state?["capabilities"] as? [String], ["workspace"])
|
||||||
|
XCTAssertNil(state?["updates"])
|
||||||
|
let toolbar = try XCTUnwrap(controller.view.subviews.compactMap { $0 as? UIToolbar }.first)
|
||||||
|
XCTAssertFalse(toolbar.isHidden, "Older pages must retain native recovery")
|
||||||
|
let rejected = try await callScript(webview, """
|
||||||
|
try {
|
||||||
|
await window.frames[0].webkit.messageHandlers.kanbanSettings.postMessage(JSON.stringify({version:1,id:2,action:'settingsReady',payload:{}}));
|
||||||
|
return false;
|
||||||
|
} catch { return true; }
|
||||||
|
""")
|
||||||
|
XCTAssertEqual(rejected as? Bool, true)
|
||||||
|
XCTAssertFalse(toolbar.isHidden)
|
||||||
|
_ = try await callScript(webview, "return await window.kanbanNative.request('settingsReady')")
|
||||||
|
controller.view.layoutIfNeeded()
|
||||||
|
XCTAssertTrue(toolbar.isHidden)
|
||||||
|
XCTAssertEqual(toolbar.frame.height, 0)
|
||||||
|
XCTAssertEqual(webview.frame.minY, controller.view.safeAreaLayoutGuide.layoutFrame.minY, accuracy: 1)
|
||||||
|
let invalid = try await callScript(webview, "try { await window.kanbanNative.request('setWorkspace', {server:'http://bad.test'}); return false; } catch { return true; }")
|
||||||
|
XCTAssertEqual(invalid as? Bool, true)
|
||||||
|
XCTAssertEqual(controller.server?.url, url)
|
||||||
|
_ = try await callScript(webview, "return await window.kanbanNative.request('openSettings')")
|
||||||
|
XCTAssertFalse(controller.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" }!.isHidden)
|
||||||
|
_ = try await callScript(webview, "return await window.kanbanNative.request('setWorkspace', {server:'https://other.invalid/app/'})")
|
||||||
|
for _ in 0..<50 {
|
||||||
|
if controller.workspace !== webview { break }
|
||||||
|
try await Task.sleep(for: .milliseconds(20))
|
||||||
|
}
|
||||||
|
XCTAssertFalse(controller.workspace === webview)
|
||||||
|
XCTAssertNotEqual(controller.server?.profile, originalProfile)
|
||||||
|
let restored = WorkspaceViewController(defaults: defaults)
|
||||||
|
restored.loadViewIfNeeded()
|
||||||
|
XCTAssertEqual(restored.server?.url.absoluteString, "https://other.invalid/app/")
|
||||||
|
let stale = try await callScript(webview, "try { await window.kanbanNative.request('setWorkspace', {server:'https://stale.invalid/'}); return false; } catch { return true; }")
|
||||||
|
XCTAssertEqual(stale as? Bool, true)
|
||||||
|
XCTAssertEqual(controller.server?.url.absoluteString, "https://other.invalid/app/")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func callScript(_ webview: WKWebView, _ script: String) async throws -> Any {
|
||||||
|
try await withCheckedThrowingContinuation { continuation in
|
||||||
|
webview.callAsyncJavaScript(script, arguments: [:], in: nil, in: .page) { result in
|
||||||
|
continuation.resume(with: result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func waitFor(_ webview: WKWebView, _ expression: String) async throws {
|
private func waitFor(_ webview: WKWebView, _ expression: String) async throws {
|
||||||
for _ in 0..<100 {
|
for _ in 0..<100 {
|
||||||
@@ -201,7 +278,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)
|
||||||
|
|||||||
@@ -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,6 +28,7 @@
|
|||||||
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 */
|
||||||
|
|
||||||
@@ -45,12 +47,14 @@
|
|||||||
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; };
|
||||||
|
1CDE5C303A96423E2E6A194D /* settings.rs */ = {isa = PBXFileReference; path = settings.rs; sourceTree = "<group>"; };
|
||||||
23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = "kanbangit-native_iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = "kanbangit-native_iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
2B96972F58CC158AB3DFF94A /* lib.rs */ = {isa = PBXFileReference; path = lib.rs; sourceTree = "<group>"; };
|
2B96972F58CC158AB3DFF94A /* lib.rs */ = {isa = PBXFileReference; path = lib.rs; sourceTree = "<group>"; };
|
||||||
31630170C358EA8657E34854 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
31630170C358EA8657E34854 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
47C531812A31207F97A89C5F /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
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>"; };
|
||||||
@@ -65,6 +69,7 @@
|
|||||||
9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
AEBAD9ABE758897C50BC1F2E /* locale.rs */ = {isa = PBXFileReference; path = locale.rs; sourceTree = "<group>"; };
|
AEBAD9ABE758897C50BC1F2E /* locale.rs */ = {isa = PBXFileReference; path = locale.rs; sourceTree = "<group>"; };
|
||||||
|
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 +161,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;
|
||||||
@@ -193,6 +200,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;
|
||||||
@@ -334,6 +342,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 +351,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;
|
||||||
};
|
};
|
||||||
@@ -441,7 +451,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 = 5;
|
CURRENT_PROJECT_VERSION = 6;
|
||||||
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
@@ -457,7 +467,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.5;
|
MARKETING_VERSION = 0.2.6;
|
||||||
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 +628,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 = 5;
|
CURRENT_PROJECT_VERSION = 6;
|
||||||
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
@@ -634,7 +644,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.5;
|
MARKETING_VERSION = 0.2.6;
|
||||||
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;
|
||||||
|
|||||||
@@ -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.5"
|
MARKETING_VERSION: "0.2.6"
|
||||||
CURRENT_PROJECT_VERSION: "5"
|
CURRENT_PROJECT_VERSION: "6"
|
||||||
configs:
|
configs:
|
||||||
debug:
|
debug:
|
||||||
CODE_SIGN_IDENTITY: Apple Development
|
CODE_SIGN_IDENTITY: Apple Development
|
||||||
@@ -43,6 +43,7 @@ targets:
|
|||||||
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
|
||||||
@@ -116,6 +117,7 @@ targets:
|
|||||||
platform: iOS
|
platform: 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
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -27,3 +27,6 @@ pub use updates::refresh_menu as refresh_update_menu_for_smoke_test;
|
|||||||
pub fn run() {
|
pub fn run() {
|
||||||
builder().run(context()).expect("Could not start KanbanGit");
|
builder().run(context()).expect("Could not start KanbanGit");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
mod settings;
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
#import <WebKit/WebKit.h>
|
||||||
|
|
||||||
|
typedef char *(*SettingsCallback)(const char *, const char *, const char *, bool);
|
||||||
|
typedef void (*SettingsFree)(char *);
|
||||||
|
|
||||||
|
@interface KanbanSettingsHandler : NSObject <WKScriptMessageHandlerWithReply>
|
||||||
|
@property(nonatomic, weak) WKWebView *webview;
|
||||||
|
@property(nonatomic, copy) NSString *label;
|
||||||
|
@property(nonatomic) SettingsCallback callback;
|
||||||
|
@property(nonatomic) SettingsFree releaseReply;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation KanbanSettingsHandler
|
||||||
|
- (void)userContentController:(WKUserContentController *)controller
|
||||||
|
didReceiveScriptMessage:(WKScriptMessage *)message
|
||||||
|
replyHandler:(void (^)(id, NSString *))replyHandler {
|
||||||
|
if (message.webView != self.webview || !message.frameInfo.isMainFrame ||
|
||||||
|
![message.body isKindOfClass:NSString.class]) {
|
||||||
|
replyHandler(nil, @"untrustedFrame");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
char *reply = self.callback(self.label.UTF8String,
|
||||||
|
message.frameInfo.request.URL.absoluteString.UTF8String,
|
||||||
|
[(NSString *)message.body UTF8String], message.frameInfo.isMainFrame);
|
||||||
|
if (!reply) { replyHandler(nil, @"bridgeUnavailable"); return; }
|
||||||
|
NSString *value = [NSString stringWithUTF8String:reply];
|
||||||
|
self.releaseReply(reply);
|
||||||
|
replyHandler(value, nil);
|
||||||
|
}
|
||||||
|
@end
|
||||||
|
|
||||||
|
void install_workspace_settings(void *pointer, const char *label, const char *script,
|
||||||
|
SettingsCallback callback, SettingsFree releaseReply) {
|
||||||
|
WKWebView *webview = (__bridge WKWebView *)pointer;
|
||||||
|
KanbanSettingsHandler *handler = [KanbanSettingsHandler new];
|
||||||
|
handler.webview = webview;
|
||||||
|
handler.label = [NSString stringWithUTF8String:label];
|
||||||
|
handler.callback = callback;
|
||||||
|
handler.releaseReply = releaseReply;
|
||||||
|
[webview.configuration.userContentController addScriptMessageHandlerWithReply:handler
|
||||||
|
contentWorld:WKContentWorld.pageWorld name:@"kanbanSettings"];
|
||||||
|
[webview.configuration.userContentController addUserScript:[[WKUserScript alloc]
|
||||||
|
initWithSource:[NSString stringWithUTF8String:script]
|
||||||
|
injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:YES]];
|
||||||
|
// The initial document may already have started loading before the handler is attached.
|
||||||
|
[webview evaluateJavaScript:[NSString stringWithUTF8String:script] completionHandler:nil];
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::ffi::{c_char, CStr, CString};
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use tauri::{AppHandle, Url};
|
||||||
|
|
||||||
|
static APP: OnceLock<AppHandle> = OnceLock::new();
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Request {
|
||||||
|
pub version: u8,
|
||||||
|
pub id: u64,
|
||||||
|
pub action: String,
|
||||||
|
pub payload: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(
|
||||||
|
tag = "action",
|
||||||
|
content = "payload",
|
||||||
|
rename_all = "camelCase",
|
||||||
|
deny_unknown_fields
|
||||||
|
)]
|
||||||
|
pub enum Action {
|
||||||
|
GetState {},
|
||||||
|
SettingsReady {},
|
||||||
|
OpenSettings {},
|
||||||
|
SetWorkspace {
|
||||||
|
server: String,
|
||||||
|
},
|
||||||
|
CheckForUpdates {},
|
||||||
|
SetUpdatePreferences {
|
||||||
|
automatic: bool,
|
||||||
|
#[serde(rename = "intervalHours")]
|
||||||
|
interval_hours: u64,
|
||||||
|
},
|
||||||
|
InstallUpdate {
|
||||||
|
version: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(value: &str) -> Result<(u64, Action), &'static str> {
|
||||||
|
if value.len() > 8192 {
|
||||||
|
return Err("invalidRequest");
|
||||||
|
}
|
||||||
|
let request: Request = serde_json::from_str(value).map_err(|_| "invalidRequest")?;
|
||||||
|
if request.version != 1 {
|
||||||
|
return Err("incompatibleVersion");
|
||||||
|
}
|
||||||
|
let action =
|
||||||
|
serde_json::from_value(json!({"action": request.action, "payload": request.payload}))
|
||||||
|
.map_err(|_| "invalidRequest")?;
|
||||||
|
Ok((request.id, action))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn install(app: &AppHandle, window: &tauri::WebviewWindow) -> tauri::Result<()> {
|
||||||
|
let _ = APP.set(app.clone());
|
||||||
|
let label = CString::new(window.label()).unwrap();
|
||||||
|
let script = CString::new(include_str!("../../ui/workspace-settings.js")).unwrap();
|
||||||
|
window.with_webview(move |view| unsafe {
|
||||||
|
unsafe extern "C" {
|
||||||
|
fn install_workspace_settings(
|
||||||
|
view: *mut std::ffi::c_void,
|
||||||
|
label: *const c_char,
|
||||||
|
script: *const c_char,
|
||||||
|
callback: extern "C" fn(
|
||||||
|
*const c_char,
|
||||||
|
*const c_char,
|
||||||
|
*const c_char,
|
||||||
|
bool,
|
||||||
|
) -> *mut c_char,
|
||||||
|
free: extern "C" fn(*mut c_char),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
install_workspace_settings(
|
||||||
|
view.inner().cast(),
|
||||||
|
label.as_ptr(),
|
||||||
|
script.as_ptr(),
|
||||||
|
receive,
|
||||||
|
release,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" fn release(value: *mut c_char) {
|
||||||
|
if !value.is_null() {
|
||||||
|
unsafe {
|
||||||
|
drop(CString::from_raw(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" fn receive(
|
||||||
|
label: *const c_char,
|
||||||
|
url: *const c_char,
|
||||||
|
body: *const c_char,
|
||||||
|
main: bool,
|
||||||
|
) -> *mut c_char {
|
||||||
|
let result = std::panic::catch_unwind(|| -> Result<Value, &'static str> {
|
||||||
|
if label.is_null() || url.is_null() || body.is_null() {
|
||||||
|
return Err("invalidRequest");
|
||||||
|
}
|
||||||
|
let label = unsafe { CStr::from_ptr(label) }
|
||||||
|
.to_str()
|
||||||
|
.map_err(|_| "invalidRequest")?;
|
||||||
|
let url = unsafe { CStr::from_ptr(url) }
|
||||||
|
.to_str()
|
||||||
|
.map_err(|_| "invalidRequest")?;
|
||||||
|
let body = unsafe { CStr::from_ptr(body) }
|
||||||
|
.to_str()
|
||||||
|
.map_err(|_| "invalidRequest")?;
|
||||||
|
let app = APP.get().ok_or("bridgeUnavailable")?;
|
||||||
|
let url = Url::parse(url).map_err(|_| "untrustedFrame")?;
|
||||||
|
if !crate::desktop::allows_settings(app, label, &url, main) {
|
||||||
|
return Err("untrustedFrame");
|
||||||
|
}
|
||||||
|
let (id, action) = parse(body)?;
|
||||||
|
let result = crate::desktop::settings_action(app, action)?;
|
||||||
|
Ok(json!({"id": id, "result": result}))
|
||||||
|
})
|
||||||
|
.unwrap_or(Err("bridgeUnavailable"));
|
||||||
|
let value = result.unwrap_or_else(|error| json!({"error": error}));
|
||||||
|
CString::new(value.to_string()).unwrap().into_raw()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn protocol_rejects_unknown_versions_actions_and_malformed_payloads() {
|
||||||
|
for value in [
|
||||||
|
r#"{"version":2,"id":1,"action":"getState","payload":{}}"#,
|
||||||
|
r#"{"version":1,"id":1,"action":"shell","payload":{}}"#,
|
||||||
|
r#"{"version":1,"id":1,"action":"setWorkspace","payload":{"server":3}}"#,
|
||||||
|
r#"{"version":1,"id":1,"action":"setUpdatePreferences","payload":{"automatic":"true","intervalHours":6}}"#,
|
||||||
|
r#"{"version":1,"id":1,"action":"installUpdate","payload":{"version":"1.2.3","url":"https://evil.test"}}"#,
|
||||||
|
] {
|
||||||
|
assert!(parse(value).is_err(), "{value}");
|
||||||
|
}
|
||||||
|
assert!(parse(&"x".repeat(8193)).is_err());
|
||||||
|
assert!(matches!(
|
||||||
|
parse(r#"{"version":1,"id":1,"action":"getState","payload":{}}"#),
|
||||||
|
Ok((1, Action::GetState {}))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -188,6 +188,12 @@ pub fn check(app: &AppHandle, manual: bool) {
|
|||||||
if manual {
|
if manual {
|
||||||
show(app);
|
show(app);
|
||||||
}
|
}
|
||||||
|
check_with_mode(app, manual);
|
||||||
|
}
|
||||||
|
pub fn check_inline(app: &AppHandle) {
|
||||||
|
check_with_mode(app, true);
|
||||||
|
}
|
||||||
|
fn check_with_mode(app: &AppHandle, manual: bool) {
|
||||||
let begin = app
|
let begin = app
|
||||||
.state::<Mutex<State>>()
|
.state::<Mutex<State>>()
|
||||||
.lock()
|
.lock()
|
||||||
@@ -297,7 +303,7 @@ fn verify_bundle(archive: &Path, directory: &Path, version: &str) -> Result<(),
|
|||||||
command("/usr/bin/codesign", &["--verify", "--deep", "--strict", "-R", "=anchor apple generic and identifier \"de.kanbangit.desktop\" and certificate leaf[subject.OU] = \"ZZX2KZGTHU\" and certificate leaf[field.1.2.840.113635.100.6.1.13] exists", app.to_str().ok_or("verificationError")?])?;
|
command("/usr/bin/codesign", &["--verify", "--deep", "--strict", "-R", "=anchor apple generic and identifier \"de.kanbangit.desktop\" and certificate leaf[subject.OU] = \"ZZX2KZGTHU\" and certificate leaf[field.1.2.840.113635.100.6.1.13] exists", app.to_str().ok_or("verificationError")?])?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
fn install(app: &AppHandle, confirmed_version: Option<&str>) {
|
pub fn install(app: &AppHandle, confirmed_version: Option<&str>) {
|
||||||
let update = {
|
let update = {
|
||||||
let state = app.state::<Mutex<State>>();
|
let state = app.state::<Mutex<State>>();
|
||||||
let mut state = state.lock().unwrap();
|
let mut state = state.lock().unwrap();
|
||||||
@@ -400,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()
|
||||||
|
|||||||
@@ -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.5",
|
"version": "0.2.6",
|
||||||
"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": "10",
|
"bundleVersion": "11",
|
||||||
"dmg": {
|
"dmg": {
|
||||||
"windowSize": {
|
"windowSize": {
|
||||||
"width": 660,
|
"width": 660,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"iOS": {
|
"iOS": {
|
||||||
"minimumSystemVersion": "17.0",
|
"minimumSystemVersion": "17.0",
|
||||||
"developmentTeam": "ZZX2KZGTHU",
|
"developmentTeam": "ZZX2KZGTHU",
|
||||||
"bundleVersion": "5"
|
"bundleVersion": "6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,9 +119,25 @@ 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';
|
||||||
@@ -129,7 +146,7 @@ fn main() {
|
|||||||
if (getComputedStyle(document.documentElement).overscrollBehaviorY !== 'none' || getComputedStyle(document.documentElement).overscrollBehaviorX !== 'auto') throw new Error('Scroll boundary policy missing');
|
if (getComputedStyle(document.documentElement).overscrollBehaviorY !== 'none' || getComputedStyle(document.documentElement).overscrollBehaviorX !== 'auto') throw new Error('Scroll boundary policy missing');
|
||||||
const ok = rendered && denied && typeof window.__TAURI__ === 'undefined' && location.pathname === '/login' && history.length === 1;
|
const ok = rendered && denied && typeof window.__TAURI__ === 'undefined' && location.pathname === '/login' && history.length === 1;
|
||||||
location.href = '/__native_smoke_result?stage=loaded&passed=' + ok;
|
location.href = '/__native_smoke_result?stage=loaded&passed=' + ok;
|
||||||
})();
|
})().catch(error => { location.href = '/__native_smoke_result?passed=false&reason=' + encodeURIComponent(String(error)); });
|
||||||
"#).unwrap();
|
"#).unwrap();
|
||||||
} else {
|
} else {
|
||||||
webview.eval(r#"
|
webview.eval(r#"
|
||||||
@@ -161,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 {
|
||||||
@@ -240,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),
|
||||||
|
|||||||
@@ -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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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'));
|
||||||
|
})();
|
||||||
+1
-1
@@ -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",
|
||||||
|
|||||||
Generated
+25
-17
@@ -203,6 +203,14 @@ settings:
|
|||||||
autoInstallPeers: true
|
autoInstallPeers: true
|
||||||
excludeLinksFromLockfile: false
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
overrides:
|
||||||
|
axios@>=1.13.0 <1.20.0: ^1.20.0
|
||||||
|
axios@>=1.15.0 <1.20.0: ^1.20.0
|
||||||
|
axios@>=1.15.1 <1.20.0: ^1.20.0
|
||||||
|
axios@>=1.15.2 <1.20.0: ^1.20.0
|
||||||
|
axios@>=1.16.1 <1.20.0: ^1.20.0
|
||||||
|
axios@>=1.17.0 <1.20.0: ^1.20.0
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
@@ -1075,8 +1083,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
|
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
axios@1.19.0:
|
axios@1.20.0:
|
||||||
resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==}
|
resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==}
|
||||||
|
|
||||||
balanced-match@1.0.2:
|
balanced-match@1.0.2:
|
||||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||||
@@ -1088,14 +1096,14 @@ packages:
|
|||||||
boolbase@1.0.0:
|
boolbase@1.0.0:
|
||||||
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
||||||
|
|
||||||
brace-expansion@1.1.18:
|
brace-expansion@1.1.21:
|
||||||
resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
|
resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==}
|
||||||
|
|
||||||
brace-expansion@2.1.4:
|
brace-expansion@2.1.7:
|
||||||
resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==}
|
resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==}
|
||||||
|
|
||||||
brace-expansion@5.0.9:
|
brace-expansion@5.0.12:
|
||||||
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
|
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
|
||||||
engines: {node: 20 || >=22}
|
engines: {node: 20 || >=22}
|
||||||
|
|
||||||
braces@3.0.3:
|
braces@3.0.3:
|
||||||
@@ -2671,7 +2679,7 @@ snapshots:
|
|||||||
'@inertiajs/core@2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)':
|
'@inertiajs/core@2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/lodash-es': 4.17.12
|
'@types/lodash-es': 4.17.12
|
||||||
axios: 1.19.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
axios: 1.20.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
||||||
laravel-precognition: 1.0.2(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
laravel-precognition: 1.0.2(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
||||||
lodash-es: 4.18.1
|
lodash-es: 4.18.1
|
||||||
qs: 6.16.0
|
qs: 6.16.0
|
||||||
@@ -3287,7 +3295,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
possible-typed-array-names: 1.1.0
|
possible-typed-array-names: 1.1.0
|
||||||
|
|
||||||
axios@1.19.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1):
|
axios@1.20.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
follow-redirects: 1.16.0(debug@4.4.3(supports-color@8.1.1))
|
follow-redirects: 1.16.0(debug@4.4.3(supports-color@8.1.1))
|
||||||
form-data: 4.0.6
|
form-data: 4.0.6
|
||||||
@@ -3303,16 +3311,16 @@ snapshots:
|
|||||||
|
|
||||||
boolbase@1.0.0: {}
|
boolbase@1.0.0: {}
|
||||||
|
|
||||||
brace-expansion@1.1.18:
|
brace-expansion@1.1.21:
|
||||||
dependencies:
|
dependencies:
|
||||||
balanced-match: 1.0.2
|
balanced-match: 1.0.2
|
||||||
concat-map: 0.0.1
|
concat-map: 0.0.1
|
||||||
|
|
||||||
brace-expansion@2.1.4:
|
brace-expansion@2.1.7:
|
||||||
dependencies:
|
dependencies:
|
||||||
balanced-match: 1.0.2
|
balanced-match: 1.0.2
|
||||||
|
|
||||||
brace-expansion@5.0.9:
|
brace-expansion@5.0.12:
|
||||||
dependencies:
|
dependencies:
|
||||||
balanced-match: 4.0.4
|
balanced-match: 4.0.4
|
||||||
|
|
||||||
@@ -4055,7 +4063,7 @@ snapshots:
|
|||||||
|
|
||||||
laravel-precognition@1.0.2(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1):
|
laravel-precognition@1.0.2(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
axios: 1.19.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
axios: 1.20.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
||||||
lodash-es: 4.18.1
|
lodash-es: 4.18.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- debug
|
- debug
|
||||||
@@ -4206,15 +4214,15 @@ snapshots:
|
|||||||
|
|
||||||
minimatch@10.2.4:
|
minimatch@10.2.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 5.0.9
|
brace-expansion: 5.0.12
|
||||||
|
|
||||||
minimatch@3.1.5:
|
minimatch@3.1.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 1.1.18
|
brace-expansion: 1.1.21
|
||||||
|
|
||||||
minimatch@9.0.9:
|
minimatch@9.0.9:
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 2.1.4
|
brace-expansion: 2.1.7
|
||||||
|
|
||||||
minimist@1.2.8: {}
|
minimist@1.2.8: {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,16 @@
|
|||||||
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:
|
||||||
|
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
|
||||||
|
|||||||
@@ -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)',
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const canAddLink = computed(
|
|||||||
);
|
);
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
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, {
|
||||||
@@ -220,7 +221,7 @@ function submit(): void {
|
|||||||
/>
|
/>
|
||||||
<fieldset
|
<fieldset
|
||||||
:disabled="form.processing"
|
:disabled="form.processing"
|
||||||
class="grid min-w-0 gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]"
|
class="grid min-w-0 gap-4 sm:grid-cols-3"
|
||||||
>
|
>
|
||||||
<TicketFormField
|
<TicketFormField
|
||||||
id="ticket_link_type_id"
|
id="ticket_link_type_id"
|
||||||
@@ -230,13 +231,33 @@ function submit(): void {
|
|||||||
:options="
|
:options="
|
||||||
ticketLinkTypes.map((type) => ({
|
ticketLinkTypes.map((type) => ({
|
||||||
value: type.id,
|
value: type.id,
|
||||||
label: type.label,
|
label: `${type.label} / ${type.counterpart_label}`,
|
||||||
}))
|
}))
|
||||||
"
|
"
|
||||||
:error="form.errors.ticket_link_type_id"
|
:error="form.errors.ticket_link_type_id"
|
||||||
:disabled="form.processing"
|
:disabled="form.processing"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<TicketFormField
|
||||||
|
id="link_direction"
|
||||||
|
v-model="form.link_direction"
|
||||||
|
kind="select"
|
||||||
|
:label="t('ticketRelationships.direction')"
|
||||||
|
:options="[
|
||||||
|
{
|
||||||
|
value: 'source',
|
||||||
|
label: selectedType?.label ?? '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'counterpart',
|
||||||
|
label:
|
||||||
|
selectedType?.counterpart_label ?? '',
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
:error="form.errors.link_direction"
|
||||||
|
:disabled="form.processing"
|
||||||
|
required
|
||||||
|
/>
|
||||||
<TicketFormField
|
<TicketFormField
|
||||||
id="linked_ticket_id"
|
id="linked_ticket_id"
|
||||||
v-model="form.linked_ticket_id"
|
v-model="form.linked_ticket_id"
|
||||||
@@ -267,7 +288,11 @@ function submit(): void {
|
|||||||
<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>{{
|
||||||
|
form.link_direction === 'source'
|
||||||
|
? selectedType.label
|
||||||
|
: selectedType.counterpart_label
|
||||||
|
}}</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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,63 @@
|
|||||||
export const en = {
|
export const en = {
|
||||||
|
'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':
|
||||||
@@ -1141,8 +1200,9 @@ 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.direction': 'This ticket',
|
||||||
'ticketRelationships.directionHelp':
|
'ticketRelationships.directionHelp':
|
||||||
'The selected relationship points from this ticket to the linked ticket. Saving an existing pair updates its type and direction.',
|
'Choose how this ticket relates to the linked ticket. The reverse label appears on the other ticket. Saving an existing pair updates its type and direction.',
|
||||||
'ticketRelationships.outgoing': 'Outgoing',
|
'ticketRelationships.outgoing': 'Outgoing',
|
||||||
'ticketRelationships.incoming': 'Incoming',
|
'ticketRelationships.incoming': 'Incoming',
|
||||||
'ticketRelationships.removeLink': 'Remove link to {key}',
|
'ticketRelationships.removeLink': 'Remove link to {key}',
|
||||||
|
|||||||
@@ -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
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NativeBridge } from './native-app';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
kanbanNative?: NativeBridge;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
};
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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), [
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -817,6 +817,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 +910,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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, /<script>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();
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
@@ -486,6 +491,8 @@ 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'));
|
||||||
for (const text of [
|
for (const text of [
|
||||||
ticket.title,
|
ticket.title,
|
||||||
'Keep every existing ticket action available.',
|
'Keep every existing ticket action available.',
|
||||||
|
|||||||
@@ -70,6 +70,15 @@ try {
|
|||||||
assert.equal(await collapse.getAttribute('aria-expanded'), 'true');
|
assert.equal(await collapse.getAttribute('aria-expanded'), 'true');
|
||||||
await control('Link type').focus();
|
await control('Link type').focus();
|
||||||
await page.keyboard.press('Tab');
|
await page.keyboard.press('Tab');
|
||||||
|
assert.equal(
|
||||||
|
await control('This ticket').evaluate(
|
||||||
|
(el) =>
|
||||||
|
el === document.activeElement &&
|
||||||
|
el.matches(':focus-visible'),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await control('Linked ticket').evaluate(
|
await control('Linked ticket').evaluate(
|
||||||
(el) =>
|
(el) =>
|
||||||
@@ -118,6 +127,7 @@ 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().getByText('Outgoing', { exact: true }).waitFor();
|
||||||
@@ -141,6 +151,25 @@ try {
|
|||||||
document.activeElement?.textContent.includes('Linked tickets'),
|
document.activeElement?.textContent.includes('Linked tickets'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await visit('?noLinks');
|
||||||
|
await control('This ticket').selectOption('counterpart');
|
||||||
|
await control('Linked ticket').selectOption('41');
|
||||||
|
await section()
|
||||||
|
.locator('form')
|
||||||
|
.last()
|
||||||
|
.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().getByText('Incoming', { 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();
|
||||||
|
|||||||
Reference in New Issue
Block a user