Compare commits
13
Commits
752ba2cd38
...
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"
|
||||
description: "Run Pint and the Pest test suite"
|
||||
name: test_and_check_app
|
||||
description: Build the test image and run all application checks
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 #v2.37.2
|
||||
with:
|
||||
php-version: "8.5"
|
||||
extensions: mbstring, pdo_sqlite
|
||||
coverage: none
|
||||
tools: composer:v2
|
||||
|
||||
- name: Verify Composer lockfile
|
||||
- name: Test and check app
|
||||
shell: bash
|
||||
run: |
|
||||
if [ ! -f composer.lock ]; then
|
||||
echo "::error::composer.lock not found"
|
||||
exit 1
|
||||
fi
|
||||
shell: bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
- name: Get Composer cache directory
|
||||
id: composer_cache
|
||||
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
|
||||
shell: bash
|
||||
|
||||
- name: Cache Composer packages
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: ${{ steps.composer_cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-php-8.5-${{ hashFiles('composer.lock') }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v11.7.0
|
||||
with:
|
||||
package-json-file: package.json
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "pnpm-lock.yaml"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
shell: bash
|
||||
|
||||
- name: Prepare environment file
|
||||
run: cp .env.example .env
|
||||
shell: bash
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --no-interaction --prefer-dist --no-progress
|
||||
shell: bash
|
||||
|
||||
- name: Generate application key
|
||||
run: php artisan key:generate
|
||||
shell: bash
|
||||
|
||||
- name: Generate Wayfinder routes and actions
|
||||
run: php artisan wayfinder:generate --with-form
|
||||
shell: bash
|
||||
|
||||
- name: Run independent application checks
|
||||
run: |
|
||||
pnpm exec concurrently \
|
||||
--names frontend,backend \
|
||||
--prefix-colors cyan,magenta \
|
||||
--kill-others-on-fail \
|
||||
"composer run ci:frontend" \
|
||||
"composer run ci:backend"
|
||||
shell: bash
|
||||
|
||||
- name: Run Laravel tests
|
||||
run: composer run ci:test
|
||||
shell: bash
|
||||
docker buildx build \
|
||||
--file Dockerfile \
|
||||
--target test \
|
||||
--tag "local/kanbangit-test:${GITHUB_SHA}" \
|
||||
--progress plain \
|
||||
--load \
|
||||
.
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Models\Project;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DeleteProject
|
||||
{
|
||||
@@ -10,7 +11,12 @@ class DeleteProject
|
||||
{
|
||||
$projectName = $project->name;
|
||||
|
||||
$project->delete();
|
||||
DB::transaction(function () use ($project): void {
|
||||
$baseTemplate = $project->baseTemplate()->first();
|
||||
|
||||
$project->delete();
|
||||
$baseTemplate?->delete();
|
||||
});
|
||||
|
||||
return $projectName;
|
||||
}
|
||||
|
||||
@@ -17,18 +17,21 @@ class StoreProjectTicketLink
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{linked_ticket_id: int, ticket_link_type_id: int} $validated
|
||||
* @param array{linked_ticket_id: int, ticket_link_type_id: int, link_direction?: 'source'|'counterpart'} $validated
|
||||
*/
|
||||
public function handle(Project $project, Ticket $ticket, User $user, array $validated): TicketLink
|
||||
{
|
||||
$linkedTicketId = Arr::get($validated, 'linked_ticket_id');
|
||||
$ticketLinkTypeId = Arr::get($validated, 'ticket_link_type_id');
|
||||
$isSourceTicket = Arr::get($validated, 'link_direction', 'source') === 'source';
|
||||
|
||||
$linkedTicket = Ticket::query()
|
||||
->where('project_id', $project->id)
|
||||
->findOrFail($linkedTicketId);
|
||||
$sourceTicket = $isSourceTicket ? $ticket : $linkedTicket;
|
||||
$targetTicket = $isSourceTicket ? $linkedTicket : $ticket;
|
||||
|
||||
return DB::transaction(function () use ($linkedTicket, $user, $ticketLinkTypeId, $ticket): TicketLink {
|
||||
return DB::transaction(function () use ($linkedTicket, $user, $ticketLinkTypeId, $ticket, $sourceTicket, $targetTicket, $isSourceTicket): TicketLink {
|
||||
Ticket::query()
|
||||
->whereKey([$ticket->id, $linkedTicket->id])
|
||||
->orderBy('id')
|
||||
@@ -50,31 +53,31 @@ class StoreProjectTicketLink
|
||||
|
||||
if ($ticketLink === null) {
|
||||
$ticketLink = TicketLink::query()->create([
|
||||
'source_ticket_id' => $ticket->id,
|
||||
'target_ticket_id' => $linkedTicket->id,
|
||||
'source_ticket_id' => $sourceTicket->id,
|
||||
'target_ticket_id' => $targetTicket->id,
|
||||
'ticket_link_type_id' => $ticketLinkTypeId,
|
||||
]);
|
||||
} else {
|
||||
$ticketLink->update([
|
||||
'source_ticket_id' => $ticket->id,
|
||||
'target_ticket_id' => $linkedTicket->id,
|
||||
'source_ticket_id' => $sourceTicket->id,
|
||||
'target_ticket_id' => $targetTicket->id,
|
||||
'ticket_link_type_id' => $ticketLinkTypeId,
|
||||
]);
|
||||
}
|
||||
|
||||
$ticketLink->load('ticketLinkType');
|
||||
|
||||
$sourceLabel = $ticketLink->ticketLinkType->labelForSource(true);
|
||||
$targetLabel = $ticketLink->ticketLinkType->labelForSource(false);
|
||||
$ticketLabel = $ticketLink->ticketLinkType->labelForSource($isSourceTicket);
|
||||
$linkedTicketLabel = $ticketLink->ticketLinkType->labelForSource(! $isSourceTicket);
|
||||
|
||||
$this->createTicketWorklogEntry->handle(
|
||||
$ticket,
|
||||
$user,
|
||||
TicketWorklogEntryType::LinkUpdated,
|
||||
"Linked {$linkedTicket->ticket_key} as {$sourceLabel}.",
|
||||
"Linked {$linkedTicket->ticket_key} as {$ticketLabel}.",
|
||||
[
|
||||
'linked_ticket_key' => $linkedTicket->ticket_key,
|
||||
'ticket_link_type' => $sourceLabel,
|
||||
'ticket_link_type' => $ticketLabel,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -82,10 +85,10 @@ class StoreProjectTicketLink
|
||||
$linkedTicket,
|
||||
$user,
|
||||
TicketWorklogEntryType::LinkUpdated,
|
||||
"Linked {$ticket->ticket_key} as {$targetLabel}.",
|
||||
"Linked {$ticket->ticket_key} as {$linkedTicketLabel}.",
|
||||
[
|
||||
'linked_ticket_key' => $ticket->ticket_key,
|
||||
'ticket_link_type' => $targetLabel,
|
||||
'ticket_link_type' => $linkedTicketLabel,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -36,11 +36,12 @@ class ProjectTicketLinkController extends ApiController
|
||||
|
||||
$ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated());
|
||||
$ticketLink->setAttribute('current_ticket_id', $ticket->id);
|
||||
$label = $ticketLink->ticketLinkType->labelForSource($ticketLink->source_ticket_id === $ticket->id);
|
||||
|
||||
return TicketLinkResource::make($ticketLink->load(['sourceTicket', 'targetTicket', 'ticketLinkType']))
|
||||
->additional([
|
||||
'meta' => [
|
||||
'message' => "{$ticketLink->ticketLinkType->label} link was saved.",
|
||||
'message' => "{$label} link was saved.",
|
||||
],
|
||||
])
|
||||
->response()
|
||||
|
||||
@@ -34,9 +34,10 @@ class ProjectTicketLinkController extends Controller
|
||||
);
|
||||
|
||||
$ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated());
|
||||
$label = $ticketLink->ticketLinkType->labelForSource($ticketLink->source_ticket_id === $ticket->id);
|
||||
|
||||
return to_route('projects.tickets.show', [$project, $ticket])
|
||||
->with('status', "{$ticketLink->ticketLinkType->label} link was saved.");
|
||||
->with('status', "{$label} link was saved.");
|
||||
}
|
||||
|
||||
public function destroy(
|
||||
|
||||
@@ -23,6 +23,7 @@ class RequirePasswordChange
|
||||
|
||||
if ($request->routeIs([
|
||||
'logout',
|
||||
'native-app.edit',
|
||||
'password.force-change.edit',
|
||||
'user-password.update',
|
||||
])) {
|
||||
|
||||
@@ -11,7 +11,7 @@ class DeleteProjectRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('access-admin') ?? false;
|
||||
return $this->user()?->can('admin-project', $this->route('project')) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -42,6 +42,7 @@ class StoreTicketLinkRequest extends FormRequest
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
),
|
||||
],
|
||||
'link_direction' => ['sometimes', 'required', Rule::in(['source', 'counterpart'])],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Generated
+26
-22
@@ -1451,20 +1451,20 @@
|
||||
},
|
||||
{
|
||||
"name": "laravel/framework",
|
||||
"version": "v13.26.1",
|
||||
"version": "v13.34.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/framework.git",
|
||||
"reference": "e4a1bc52ef551d52e60244bb004256d6861da7ab"
|
||||
"reference": "c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/framework/zipball/e4a1bc52ef551d52e60244bb004256d6861da7ab",
|
||||
"reference": "e4a1bc52ef551d52e60244bb004256d6861da7ab",
|
||||
"url": "https://api.github.com/repos/laravel/framework/zipball/c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4",
|
||||
"reference": "c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19",
|
||||
"brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0",
|
||||
"composer-runtime-api": "^2.2",
|
||||
"doctrine/inflector": "^2.0.5",
|
||||
"dragonmantank/cron-expression": "^3.4",
|
||||
@@ -1576,18 +1576,20 @@
|
||||
"league/flysystem-sftp-v3": "^3.25.1",
|
||||
"mockery/mockery": "^1.6.10",
|
||||
"opis/json-schema": "^2.4.1",
|
||||
"orchestra/testbench-core": "^11.0.0",
|
||||
"orchestra/testbench-core": "^11.5.0",
|
||||
"pda/pheanstalk": "^7.0.0 || ^8.0.0",
|
||||
"php-http/discovery": "^1.15",
|
||||
"phpstan/phpstan": "^2.0",
|
||||
"phpstan/phpstan": "2.2.15",
|
||||
"phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0.3",
|
||||
"predis/predis": "^2.3 || ^3.0",
|
||||
"rector/rector": "^2.3",
|
||||
"rector/rector": "2.6.3",
|
||||
"resend/resend-php": "^1.0",
|
||||
"symfony/cache": "^7.4.0 || ^8.0.0",
|
||||
"symfony/http-client": "^7.4.0 || ^8.0.0",
|
||||
"symfony/mercure": "^0.8.0",
|
||||
"symfony/psr-http-message-bridge": "^7.4.0 || ^8.0.0",
|
||||
"symfony/translation": "^7.4.0 || ^8.0.0"
|
||||
"symfony/translation": "^7.4.0 || ^8.0.0",
|
||||
"web-token/jwt-library": "^4.1"
|
||||
},
|
||||
"suggest": {
|
||||
"ably/ably-php": "Required to use the Ably broadcast driver (^1.0).",
|
||||
@@ -1623,8 +1625,10 @@
|
||||
"symfony/filesystem": "Required to enable support for relative symbolic links (^7.4 || ^8.0).",
|
||||
"symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.4 || ^8.0).",
|
||||
"symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.4 || ^8.0).",
|
||||
"symfony/mercure": "Required to use the Mercure broadcast driver (^0.8).",
|
||||
"symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.4 || ^8.0).",
|
||||
"symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0)."
|
||||
"symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0).",
|
||||
"web-token/jwt-library": "Required to sign Mercure JWTs and use end-to-end encrypted channels (^4.1)."
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
@@ -1674,7 +1678,7 @@
|
||||
"issues": "https://github.com/laravel/framework/issues",
|
||||
"source": "https://github.com/laravel/framework"
|
||||
},
|
||||
"time": "2026-08-18T20:31:28+00:00"
|
||||
"time": "2026-09-29T12:46:21+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/mcp",
|
||||
@@ -2003,16 +2007,16 @@
|
||||
},
|
||||
{
|
||||
"name": "league/commonmark",
|
||||
"version": "2.10.0",
|
||||
"version": "2.10.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/commonmark.git",
|
||||
"reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4"
|
||||
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
|
||||
"reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
|
||||
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6efbd9c472b91db0a3350fcd601c8332c2382e1f",
|
||||
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -2106,7 +2110,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-08-11T16:06:25+00:00"
|
||||
"time": "2026-09-21T13:07:34+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/config",
|
||||
@@ -2192,16 +2196,16 @@
|
||||
},
|
||||
{
|
||||
"name": "league/flysystem",
|
||||
"version": "3.35.2",
|
||||
"version": "3.36.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/flysystem.git",
|
||||
"reference": "b277b5dc3d56650b68904117124e79c851e12376"
|
||||
"reference": "f7fb152932f30072d573510cbd4dd657d6475b25"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/b277b5dc3d56650b68904117124e79c851e12376",
|
||||
"reference": "b277b5dc3d56650b68904117124e79c851e12376",
|
||||
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f7fb152932f30072d573510cbd4dd657d6475b25",
|
||||
"reference": "f7fb152932f30072d573510cbd4dd657d6475b25",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -2269,9 +2273,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/thephpleague/flysystem/issues",
|
||||
"source": "https://github.com/thephpleague/flysystem/tree/3.35.2"
|
||||
"source": "https://github.com/thephpleague/flysystem/tree/3.36.0"
|
||||
},
|
||||
"time": "2026-07-06T14:42:07+00:00"
|
||||
"time": "2026-09-02T08:00:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/flysystem-aws-s3-v3",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kanbangit-native",
|
||||
"version": "0.2.5",
|
||||
"version": "0.2.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -25,5 +25,5 @@
|
||||
"devDependencies": {
|
||||
"@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]]
|
||||
name = "kanbangit-native"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "kanbangit-native"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
description = "KanbanGit native application"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
@@ -9,8 +9,10 @@ fn build_navigation() {
|
||||
use std::{env, path::PathBuf, process::Command};
|
||||
|
||||
println!("cargo:rerun-if-changed=src/navigation.m");
|
||||
println!("cargo:rerun-if-changed=src/settings.m");
|
||||
let output = PathBuf::from(env::var_os("OUT_DIR").unwrap());
|
||||
let object = output.join("navigation.o");
|
||||
let settings_object = output.join("settings.o");
|
||||
let archive = output.join("libkanban_navigation.a");
|
||||
let arch = match env::var("CARGO_CFG_TARGET_ARCH").unwrap().as_str() {
|
||||
"aarch64" => "arm64",
|
||||
@@ -37,10 +39,31 @@ fn build_navigation() {
|
||||
.status()
|
||||
.expect("Xcode clang is required")
|
||||
.success());
|
||||
assert!(Command::new("xcrun")
|
||||
.args([
|
||||
"--sdk",
|
||||
"macosx",
|
||||
"clang",
|
||||
"-fobjc-arc",
|
||||
"-fblocks",
|
||||
"-Wall",
|
||||
"-Werror",
|
||||
"-mmacosx-version-min=14.0",
|
||||
"-arch",
|
||||
arch,
|
||||
"-c",
|
||||
"src/settings.m",
|
||||
"-o"
|
||||
])
|
||||
.arg(&settings_object)
|
||||
.status()
|
||||
.expect("Xcode clang is required")
|
||||
.success());
|
||||
assert!(Command::new("xcrun")
|
||||
.args(["ar", "crs"])
|
||||
.arg(&archive)
|
||||
.arg(&object)
|
||||
.arg(&settings_object)
|
||||
.status()
|
||||
.expect("Xcode ar is required")
|
||||
.success());
|
||||
|
||||
@@ -65,3 +65,32 @@ struct WorkspaceConnection {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
enum NativeSettingsError: Error { case invalidRequest, incompatibleVersion, invalidServer }
|
||||
|
||||
struct NativeSettingsRequest {
|
||||
let id: Int
|
||||
let action: String
|
||||
let payload: [String: Any]
|
||||
|
||||
static func parse(_ body: String) throws -> NativeSettingsRequest {
|
||||
guard body.utf8.count <= 8192,
|
||||
let value = try JSONSerialization.jsonObject(with: Data(body.utf8)) as? [String: Any],
|
||||
Set(value.keys) == Set(["version", "id", "action", "payload"]),
|
||||
let version = value["version"] as? NSNumber, CFGetTypeID(version) != CFBooleanGetTypeID(),
|
||||
version == 1 else { throw NativeSettingsError.incompatibleVersion }
|
||||
guard let id = value["id"] as? NSNumber, CFGetTypeID(id) != CFBooleanGetTypeID(),
|
||||
id.doubleValue >= 0, id.doubleValue <= 9007199254740991, id.doubleValue.rounded() == id.doubleValue,
|
||||
let action = value["action"] as? String,
|
||||
let payload = value["payload"] as? [String: Any] else { throw NativeSettingsError.invalidRequest }
|
||||
switch action {
|
||||
case "getState", "settingsReady", "openSettings":
|
||||
guard payload.isEmpty else { throw NativeSettingsError.invalidRequest }
|
||||
case "setWorkspace":
|
||||
guard Set(payload.keys) == Set(["server"]), payload["server"] is String else { throw NativeSettingsError.invalidRequest }
|
||||
default: throw NativeSettingsError.invalidRequest
|
||||
}
|
||||
return NativeSettingsRequest(id: id.intValue, action: action, payload: payload)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
private let connectButton = UIButton(type: .system)
|
||||
private let resumeButton = UIButton(type: .system)
|
||||
private let toolbar = UIToolbar()
|
||||
private var settingsAvailable = false
|
||||
private var toolbarHeight: NSLayoutConstraint!
|
||||
private var appearance: UISegmentedControl!
|
||||
|
||||
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))
|
||||
settings.accessibilityIdentifier = "showServerSettings"
|
||||
toolbar.items = [UIBarButtonItem(systemItem: .flexibleSpace), settings]
|
||||
toolbarHeight = toolbar.heightAnchor.constraint(equalToConstant: 44)
|
||||
NSLayoutConstraint.activate([
|
||||
toolbarHeight,
|
||||
toolbar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||
toolbar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
||||
toolbar.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
||||
@@ -201,6 +205,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
timeout?.cancel()
|
||||
workspace?.stopLoading()
|
||||
workspace?.navigationDelegate = nil
|
||||
workspace?.configuration.userContentController.removeScriptMessageHandler(forName: "kanbanSettings", contentWorld: .page)
|
||||
workspace?.removeFromSuperview()
|
||||
connection = WorkspaceConnection()
|
||||
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 script = try! String(contentsOf: scriptURL, encoding: .utf8)
|
||||
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)
|
||||
webview.navigationDelegate = self
|
||||
webview.uiDelegate = self
|
||||
@@ -269,10 +278,48 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
loading.stopAnimating()
|
||||
view.endEditing(true)
|
||||
launcher.isHidden = true
|
||||
toolbar.isHidden = false
|
||||
toolbar.isHidden = settingsAvailable
|
||||
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) {
|
||||
guard webView === workspace, let url = action.request.url else { decisionHandler(.cancel); return }
|
||||
guard server?.allows(url) == true else {
|
||||
@@ -299,6 +346,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
|
||||
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||
guard webView === workspace else { return }
|
||||
setSettingsAvailable(false)
|
||||
pendingNavigation = navigation
|
||||
beginNavigation()
|
||||
}
|
||||
@@ -338,3 +386,13 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
showSettings()
|
||||
}
|
||||
}
|
||||
|
||||
private final class NativeSettingsHandler: NSObject, WKScriptMessageHandlerWithReply {
|
||||
weak var controller: WorkspaceViewController?
|
||||
init(controller: WorkspaceViewController) { self.controller = controller }
|
||||
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage,
|
||||
replyHandler: @escaping (Any?, String?) -> Void) {
|
||||
guard let controller else { replyHandler(nil, "bridgeUnavailable"); return }
|
||||
controller.handleSettings(message, reply: replyHandler)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,83 @@ final class WorkspaceTests: XCTestCase {
|
||||
"Changing workspaces must retain native gesture navigation")
|
||||
}
|
||||
|
||||
func testSettingsProtocolRejectsUnsupportedActionsAndInvalidPayloads() throws {
|
||||
for body in [
|
||||
#"{"version":2,"id":1,"action":"getState","payload":{}}"#,
|
||||
#"{"version":true,"id":1,"action":"getState","payload":{}}"#,
|
||||
#"{"version":1,"id":1,"action":"installUpdate","payload":{"version":"1"}}"#,
|
||||
#"{"version":1,"id":1,"action":"setWorkspace","payload":{"server":12}}"#,
|
||||
#"{"version":1,"id":1,"action":"getState","payload":{"extra":1}}"#,
|
||||
#"{"version":1,"id":1,"action":"shell","payload":{}}"#,
|
||||
] { XCTAssertThrowsError(try NativeSettingsRequest.parse(body), body) }
|
||||
XCTAssertThrowsError(try NativeSettingsRequest.parse(String(repeating: "x", count: 8193)))
|
||||
XCTAssertEqual(try NativeSettingsRequest.parse(#"{"version":1,"id":2,"action":"getState","payload":{}}"#).id, 2)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testSettingsBridgeHidesToolbarOnlyAfterHandshakeAndRejectsFramesAndOldWorkspaces() async throws {
|
||||
let name = "KAN70-settings-\(UUID())"
|
||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
||||
defer { defaults.removePersistentDomain(forName: name) }
|
||||
let controller = WorkspaceViewController(defaults: defaults)
|
||||
controller.loadViewIfNeeded()
|
||||
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||
let url = URL(string: "https://example.invalid/")!
|
||||
controller.open(url)
|
||||
let originalProfile = controller.server!.profile
|
||||
let webview = try XCTUnwrap(controller.workspace)
|
||||
webview.stopLoading()
|
||||
webview.loadSimulatedRequest(URLRequest(url: url.appendingPathComponent("login")), responseHTML: "<html><body>Login<iframe srcdoc='<p>frame</p>'></iframe></body></html>")
|
||||
try await waitFor(webview, "document.readyState === 'complete' && typeof window.kanbanNative === 'object'")
|
||||
let state = try await callScript(webview, "return await window.kanbanNative.request('getState')") as? [String: Any]
|
||||
XCTAssertEqual(state?["platform"] as? String, "ios")
|
||||
XCTAssertEqual(state?["workspace"] as? String, url.absoluteString)
|
||||
XCTAssertEqual(state?["capabilities"] as? [String], ["workspace"])
|
||||
XCTAssertNil(state?["updates"])
|
||||
let toolbar = try XCTUnwrap(controller.view.subviews.compactMap { $0 as? UIToolbar }.first)
|
||||
XCTAssertFalse(toolbar.isHidden, "Older pages must retain native recovery")
|
||||
let rejected = try await callScript(webview, """
|
||||
try {
|
||||
await window.frames[0].webkit.messageHandlers.kanbanSettings.postMessage(JSON.stringify({version:1,id:2,action:'settingsReady',payload:{}}));
|
||||
return false;
|
||||
} catch { return true; }
|
||||
""")
|
||||
XCTAssertEqual(rejected as? Bool, true)
|
||||
XCTAssertFalse(toolbar.isHidden)
|
||||
_ = try await callScript(webview, "return await window.kanbanNative.request('settingsReady')")
|
||||
controller.view.layoutIfNeeded()
|
||||
XCTAssertTrue(toolbar.isHidden)
|
||||
XCTAssertEqual(toolbar.frame.height, 0)
|
||||
XCTAssertEqual(webview.frame.minY, controller.view.safeAreaLayoutGuide.layoutFrame.minY, accuracy: 1)
|
||||
let invalid = try await callScript(webview, "try { await window.kanbanNative.request('setWorkspace', {server:'http://bad.test'}); return false; } catch { return true; }")
|
||||
XCTAssertEqual(invalid as? Bool, true)
|
||||
XCTAssertEqual(controller.server?.url, url)
|
||||
_ = try await callScript(webview, "return await window.kanbanNative.request('openSettings')")
|
||||
XCTAssertFalse(controller.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" }!.isHidden)
|
||||
_ = try await callScript(webview, "return await window.kanbanNative.request('setWorkspace', {server:'https://other.invalid/app/'})")
|
||||
for _ in 0..<50 {
|
||||
if controller.workspace !== webview { break }
|
||||
try await Task.sleep(for: .milliseconds(20))
|
||||
}
|
||||
XCTAssertFalse(controller.workspace === webview)
|
||||
XCTAssertNotEqual(controller.server?.profile, originalProfile)
|
||||
let restored = WorkspaceViewController(defaults: defaults)
|
||||
restored.loadViewIfNeeded()
|
||||
XCTAssertEqual(restored.server?.url.absoluteString, "https://other.invalid/app/")
|
||||
let stale = try await callScript(webview, "try { await window.kanbanNative.request('setWorkspace', {server:'https://stale.invalid/'}); return false; } catch { return true; }")
|
||||
XCTAssertEqual(stale as? Bool, true)
|
||||
XCTAssertEqual(controller.server?.url.absoluteString, "https://other.invalid/app/")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func callScript(_ webview: WKWebView, _ script: String) async throws -> Any {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
webview.callAsyncJavaScript(script, arguments: [:], in: nil, in: .page) { result in
|
||||
continuation.resume(with: result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func waitFor(_ webview: WKWebView, _ expression: String) async throws {
|
||||
for _ in 0..<100 {
|
||||
@@ -201,7 +278,7 @@ final class WorkspaceTests: XCTestCase {
|
||||
controller.open(url)
|
||||
let server = try XCTUnwrap(controller.server)
|
||||
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)
|
||||
restored.loadViewIfNeeded()
|
||||
XCTAssertEqual(restored.server, server)
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
73954B7AADE8C37663BEF288 /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CA65E67046326EA008175045 /* Metal.framework */; };
|
||||
98D5B23C9BE2F62C23DF397A /* WorkspacePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 554AB059B4228085924F4959 /* WorkspacePolicy.swift */; };
|
||||
99291BB0C1DE7714FB32F554 /* workspace-navigation.js in Resources */ = {isa = PBXBuildFile; fileRef = 7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */; };
|
||||
99D76D98A2416CAA713186C2 /* workspace-settings.js in Resources */ = {isa = PBXBuildFile; fileRef = BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */; };
|
||||
A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C8C02BC0E757897278EB954A /* Localizable.strings */; };
|
||||
B939982700E800053A9C7663 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE3B71153677F722CAC6DCF7 /* Security.framework */; };
|
||||
C362C102D4950A81D89BB8C8 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */; };
|
||||
@@ -27,6 +28,7 @@
|
||||
C53E7EC983C008DF98244C74 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 2AB6F9398EA3CB62A68287D2 /* InfoPlist.strings */; };
|
||||
CD64D99EBD87E6FFC0C9001C /* MetalKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D581BB90601D7A89F8F188E2 /* MetalKit.framework */; };
|
||||
D8772A5E508092D6F831058A /* libapp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 91991DFDB0C064B06DF854ED /* libapp.a */; };
|
||||
EAD1D5A29503456CEED3B324 /* workspace-settings.js in Resources */ = {isa = PBXBuildFile; fileRef = BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */; };
|
||||
EB37EAF992C1A156795CAEC0 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
@@ -45,12 +47,14 @@
|
||||
05A753935B6CB0316DA56D96 /* desktop.rs */ = {isa = PBXFileReference; path = desktop.rs; sourceTree = "<group>"; };
|
||||
08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTests.swift; sourceTree = "<group>"; };
|
||||
0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; };
|
||||
1CDE5C303A96423E2E6A194D /* settings.rs */ = {isa = PBXFileReference; path = settings.rs; sourceTree = "<group>"; };
|
||||
23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = "kanbangit-native_iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
2B96972F58CC158AB3DFF94A /* lib.rs */ = {isa = PBXFileReference; path = lib.rs; sourceTree = "<group>"; };
|
||||
31630170C358EA8657E34854 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
47C531812A31207F97A89C5F /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
||||
4C76059D31C50B198779A5F7 /* en.lproj */ = {isa = PBXFileReference; path = en.lproj; sourceTree = "<group>"; };
|
||||
554AB059B4228085924F4959 /* WorkspacePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacePolicy.swift; sourceTree = "<group>"; };
|
||||
5F196E34D1B9E14FBD7191F4 /* settings.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = settings.m; sourceTree = "<group>"; };
|
||||
5F71F06AB639140AB9714425 /* metadata.rs */ = {isa = PBXFileReference; path = metadata.rs; sourceTree = "<group>"; };
|
||||
63685B1189795CAEA85F7AB7 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; };
|
||||
6610D376CF901AC1733D394E /* updates.rs */ = {isa = PBXFileReference; path = updates.rs; sourceTree = "<group>"; };
|
||||
@@ -65,6 +69,7 @@
|
||||
9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
AEBAD9ABE758897C50BC1F2E /* locale.rs */ = {isa = PBXFileReference; path = locale.rs; sourceTree = "<group>"; };
|
||||
BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "workspace-settings.js"; sourceTree = "<group>"; };
|
||||
CA65E67046326EA008175045 /* Metal.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Metal.framework; path = System/Library/Frameworks/Metal.framework; sourceTree = SDKROOT; };
|
||||
CAF08AD60D2F29A879CDAEBB /* ios.rs */ = {isa = PBXFileReference; path = ios.rs; sourceTree = "<group>"; };
|
||||
D13B8D854BEB7757AB95231B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
@@ -156,6 +161,8 @@
|
||||
5F71F06AB639140AB9714425 /* metadata.rs */,
|
||||
8C0F963909020429B3D257D5 /* navigation.m */,
|
||||
E8BA104C81071623E1FF367D /* server.rs */,
|
||||
5F196E34D1B9E14FBD7191F4 /* settings.m */,
|
||||
1CDE5C303A96423E2E6A194D /* settings.rs */,
|
||||
6610D376CF901AC1733D394E /* updates.rs */,
|
||||
);
|
||||
name = src;
|
||||
@@ -193,6 +200,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */,
|
||||
BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */,
|
||||
);
|
||||
name = ui;
|
||||
path = ../../../ui;
|
||||
@@ -334,6 +342,7 @@
|
||||
34C189B72A1B9EBE33D700D1 /* LaunchScreen.storyboard in Resources */,
|
||||
A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */,
|
||||
99291BB0C1DE7714FB32F554 /* workspace-navigation.js in Resources */,
|
||||
99D76D98A2416CAA713186C2 /* workspace-settings.js in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -342,6 +351,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
0EC5CB2D7F774DBDCA69BCD8 /* workspace-navigation.js in Resources */,
|
||||
EAD1D5A29503456CEED3B324 /* workspace-settings.js in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -441,7 +451,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
CURRENT_PROJECT_VERSION = 6;
|
||||
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||
ENABLE_BITCODE = 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=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_MODULE_NAME = KanbanGitNative;
|
||||
PRODUCT_NAME = KanbanGit;
|
||||
@@ -618,7 +628,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
CURRENT_PROJECT_VERSION = 6;
|
||||
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||
ENABLE_BITCODE = 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=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_MODULE_NAME = KanbanGitNative;
|
||||
PRODUCT_NAME = KanbanGit;
|
||||
|
||||
@@ -18,8 +18,8 @@ settingGroups:
|
||||
DEVELOPMENT_TEAM: ZZX2KZGTHU
|
||||
ENABLE_USER_SCRIPT_SANDBOXING: false
|
||||
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios
|
||||
MARKETING_VERSION: "0.2.5"
|
||||
CURRENT_PROJECT_VERSION: "5"
|
||||
MARKETING_VERSION: "0.2.6"
|
||||
CURRENT_PROJECT_VERSION: "6"
|
||||
configs:
|
||||
debug:
|
||||
CODE_SIGN_IDENTITY: Apple Development
|
||||
@@ -43,6 +43,7 @@ targets:
|
||||
sources:
|
||||
- path: Sources
|
||||
- path: ../../../ui/workspace-navigation.js
|
||||
- path: ../../../ui/workspace-settings.js
|
||||
- path: Assets.xcassets
|
||||
- path: kanbangit-native_iOS
|
||||
- path: LaunchScreen.storyboard
|
||||
@@ -116,6 +117,7 @@ targets:
|
||||
platform: iOS
|
||||
sources:
|
||||
- path: ../../../ui/workspace-navigation.js
|
||||
- path: ../../../ui/workspace-settings.js
|
||||
- path: Tests
|
||||
- path: Sources/kanbangit-native/WorkspacePolicy.swift
|
||||
- path: Sources/kanbangit-native/WorkspaceViewController.swift
|
||||
|
||||
@@ -17,6 +17,15 @@ struct Connection {
|
||||
}
|
||||
|
||||
impl Connection {
|
||||
#[cfg(target_os = "macos")]
|
||||
fn allows_settings(&self, label: &str, url: &Url, main: bool) -> bool {
|
||||
main && self.label.as_deref() == Some(label)
|
||||
&& self
|
||||
.server
|
||||
.as_ref()
|
||||
.is_some_and(|server| allows_server_navigation(&server.url, url))
|
||||
}
|
||||
|
||||
fn begin_navigation(&mut self) -> u64 {
|
||||
self.revision += 1;
|
||||
self.phase = "connecting";
|
||||
@@ -237,6 +246,8 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
|
||||
install_workspace_navigation(webview.inner().cast());
|
||||
})
|
||||
.map_err(|_| "connectionError")?;
|
||||
#[cfg(target_os = "macos")]
|
||||
crate::settings::install(app, &window).map_err(|_| "connectionError")?;
|
||||
let handle = app.clone();
|
||||
window.on_window_event(move |event| {
|
||||
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||
@@ -256,6 +267,54 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn allows_settings(app: &AppHandle, label: &str, url: &Url, main: bool) -> bool {
|
||||
let state = app.state::<Mutex<Connection>>();
|
||||
let state = state.lock().unwrap();
|
||||
state.allows_settings(label, url, main)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn settings_action(
|
||||
app: &AppHandle,
|
||||
action: crate::settings::Action,
|
||||
) -> Result<serde_json::Value, &'static str> {
|
||||
use crate::settings::Action;
|
||||
match action {
|
||||
Action::GetState {} => {
|
||||
let workspace = app
|
||||
.state::<Mutex<Connection>>()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.server
|
||||
.as_ref()
|
||||
.map(|server| server.url.to_string())
|
||||
.ok_or("bridgeUnavailable")?;
|
||||
return Ok(
|
||||
serde_json::json!({"apiVersion":1, "platform":"macos", "workspace":workspace,
|
||||
"version":app.package_info().version.to_string(), "capabilities":["workspace", "updates"],
|
||||
"updates":crate::updates::snapshot(app)}),
|
||||
);
|
||||
}
|
||||
Action::SettingsReady {} => {}
|
||||
Action::OpenSettings {} => show_launcher(app),
|
||||
Action::SetWorkspace { server } => {
|
||||
if let Err(error) = open_workspace(app, &server) {
|
||||
app.state::<Mutex<Connection>>().lock().unwrap().phase = error;
|
||||
show_launcher(app);
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
Action::CheckForUpdates {} => crate::updates::check_inline(app),
|
||||
Action::SetUpdatePreferences {
|
||||
automatic,
|
||||
interval_hours,
|
||||
} => crate::updates::save_preferences(app, automatic, interval_hours)?,
|
||||
Action::InstallUpdate { version } => crate::updates::install(app, Some(&version)),
|
||||
}
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
fn handle_launcher_action(app: &AppHandle, destination: &Url) {
|
||||
let result = match destination.path() {
|
||||
"/action/updates" => {
|
||||
@@ -464,6 +523,30 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn settings_only_accept_the_current_workspace_main_frame() {
|
||||
let mut state = Connection {
|
||||
server: Some(Server::new(validate_url("https://example.com/app/").unwrap()).unwrap()),
|
||||
label: Some("workspace-1".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let url = Url::parse("https://example.com/settings/app").unwrap();
|
||||
assert!(state.allows_settings("workspace-1", &url, true));
|
||||
assert!(!state.allows_settings("workspace-1", &url, false));
|
||||
assert!(!state.allows_settings("workspace-0", &url, true));
|
||||
for address in [
|
||||
"https://evil.test",
|
||||
"http://example.com",
|
||||
"https://example.com:444/",
|
||||
"file:///tmp/page",
|
||||
] {
|
||||
assert!(!state.allows_settings("workspace-1", &Url::parse(address).unwrap(), true));
|
||||
}
|
||||
state.label = Some("workspace-2".into());
|
||||
assert!(!state.allows_settings("workspace-1", &url, true));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn about_shows_app_version_followed_by_the_actual_build_number() {
|
||||
|
||||
@@ -27,3 +27,6 @@ pub use updates::refresh_menu as refresh_update_menu_for_smoke_test;
|
||||
pub fn run() {
|
||||
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 {
|
||||
show(app);
|
||||
}
|
||||
check_with_mode(app, manual);
|
||||
}
|
||||
pub fn check_inline(app: &AppHandle) {
|
||||
check_with_mode(app, true);
|
||||
}
|
||||
fn check_with_mode(app: &AppHandle, manual: bool) {
|
||||
let begin = app
|
||||
.state::<Mutex<State>>()
|
||||
.lock()
|
||||
@@ -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")?])?;
|
||||
Ok(())
|
||||
}
|
||||
fn install(app: &AppHandle, confirmed_version: Option<&str>) {
|
||||
pub fn install(app: &AppHandle, confirmed_version: Option<&str>) {
|
||||
let update = {
|
||||
let state = app.state::<Mutex<State>>();
|
||||
let mut state = state.lock().unwrap();
|
||||
@@ -400,24 +406,49 @@ fn action(app: &AppHandle, url: &Url) {
|
||||
fail(app, "preferencesError");
|
||||
return;
|
||||
}
|
||||
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(())
|
||||
})();
|
||||
if saved.is_err() {
|
||||
fail(app, "preferencesError");
|
||||
return;
|
||||
if let Err(error) =
|
||||
save_preferences(app, preferences.automatic, preferences.interval_hours)
|
||||
{
|
||||
fail(app, error);
|
||||
}
|
||||
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<()> {
|
||||
app.plugin(
|
||||
tauri_plugin_updater::Builder::new()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "KanbanGit",
|
||||
"version": "0.2.5",
|
||||
"version": "0.2.6",
|
||||
"identifier": "de.kanbangit.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../ui"
|
||||
@@ -25,7 +25,7 @@
|
||||
],
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "14.0",
|
||||
"bundleVersion": "10",
|
||||
"bundleVersion": "11",
|
||||
"dmg": {
|
||||
"windowSize": {
|
||||
"width": 660,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"iOS": {
|
||||
"minimumSystemVersion": "17.0",
|
||||
"developmentTeam": "ZZX2KZGTHU",
|
||||
"bundleVersion": "5"
|
||||
"bundleVersion": "6"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ fn main() {
|
||||
.setup(move |app, _| {
|
||||
let directory = app.path().app_config_dir()?;
|
||||
std::fs::create_dir_all(&directory)?;
|
||||
std::fs::write(directory.join("update-preferences.json"), r#"{"automatic":false,"intervalHours":6}"#)?;
|
||||
if needs_setup {
|
||||
if startup == "invalid" {
|
||||
std::fs::write(directory.join("active-server"), "invalid configuration")?;
|
||||
@@ -118,9 +119,25 @@ fn main() {
|
||||
const rendered = document.querySelector('#app')?.innerText.trim().length > 100;
|
||||
let denied = true;
|
||||
for (const cmd of ['plugin:path|resolve_directory', 'plugin:window|get_all_windows']) {
|
||||
try { await window.__TAURI_INTERNALS__.invoke(cmd); denied = false; }
|
||||
try { await Promise.race([window.__TAURI_INTERNALS__.invoke(cmd), new Promise((_, reject) => setTimeout(() => reject(new Error('IPC timeout')), 3000))]); denied = false; }
|
||||
catch (error) { denied = denied && String(error).includes('not allowed'); }
|
||||
}
|
||||
const api = window.kanbanNative;
|
||||
if (api?.version !== 1) throw new Error('Versioned settings bridge missing');
|
||||
const initial = await api.request('getState');
|
||||
if (initial.platform !== 'macos' || initial.workspace !== 'https://kanbangit.de/' || !initial.capabilities.includes('updates')) throw new Error('Incorrect native settings');
|
||||
await api.request('setUpdatePreferences', {automatic:false, intervalHours:24});
|
||||
const changed = await api.request('getState');
|
||||
if (changed.updates.preferences.automatic || changed.updates.preferences.intervalHours !== 24) throw new Error('Update preferences did not persist');
|
||||
const invalid = await Promise.race([window.webkit.messageHandlers.kanbanSettings.postMessage(JSON.stringify({version:1,id:5,action:'shell',payload:{}})), new Promise((_, reject) => setTimeout(() => reject(new Error('Raw bridge timeout')), 3000))]);
|
||||
if (!JSON.parse(invalid).error) throw new Error('Unknown native action accepted');
|
||||
const frame = document.createElement('iframe');
|
||||
document.body.append(frame);
|
||||
if (!frame.contentWindow.webkit?.messageHandlers.kanbanSettings) throw new Error('Frame bridge fixture missing');
|
||||
let frameDenied = false;
|
||||
try { await Promise.race([frame.contentWindow.webkit.messageHandlers.kanbanSettings.postMessage(JSON.stringify({version:1,id:6,action:'getState',payload:{}})), new Promise((_, reject) => setTimeout(() => reject(new Error('Frame bridge timeout')), 3000))]); }
|
||||
catch (error) { frameDenied = String(error).includes('untrustedFrame'); }
|
||||
if (!frameDenied) throw new Error('Subframe settings access accepted');
|
||||
const input = document.createElement('input');
|
||||
input.id = 'kan52-unsaved'; input.value = 'unsaved input'; document.body.append(input);
|
||||
document.cookie = 'kan52_fixture=isolated; Path=/; Secure; SameSite=Lax';
|
||||
@@ -129,7 +146,7 @@ fn main() {
|
||||
if (getComputedStyle(document.documentElement).overscrollBehaviorY !== 'none' || getComputedStyle(document.documentElement).overscrollBehaviorX !== 'auto') throw new Error('Scroll boundary policy missing');
|
||||
const ok = rendered && denied && typeof window.__TAURI__ === 'undefined' && location.pathname === '/login' && history.length === 1;
|
||||
location.href = '/__native_smoke_result?stage=loaded&passed=' + ok;
|
||||
})();
|
||||
})().catch(error => { location.href = '/__native_smoke_result?passed=false&reason=' + encodeURIComponent(String(error)); });
|
||||
"#).unwrap();
|
||||
} else {
|
||||
webview.eval(r#"
|
||||
@@ -161,12 +178,10 @@ fn main() {
|
||||
location.href = '/__native_smoke_result?passed=false&stage=preservation';
|
||||
}
|
||||
"#).unwrap();
|
||||
let launcher = app.get_webview_window("launcher").unwrap();
|
||||
launcher.show().unwrap();
|
||||
launcher.eval(r#"
|
||||
document.querySelector('#server-url').value = 'https://kanbangit.de/login/';
|
||||
document.querySelector('#server-form').requestSubmit();
|
||||
document.querySelector('#confirm-switch').click();
|
||||
window.eval(r#"
|
||||
window.kanbanNative.request('setWorkspace', {server:'https://kanbangit.de/login/'}).catch(error => {
|
||||
location.href = '/__native_smoke_result?passed=false&stage=settings-switch&reason=' + encodeURIComponent(error.message);
|
||||
});
|
||||
"#).unwrap();
|
||||
});
|
||||
} else {
|
||||
@@ -240,6 +255,19 @@ fn main() {
|
||||
closed.load(Ordering::SeqCst),
|
||||
"The close button must quit the application"
|
||||
);
|
||||
if online {
|
||||
assert!(std::fs::read_to_string(config_dir.join("active-server"))
|
||||
.unwrap()
|
||||
.starts_with("https://kanbangit.de/login/\n"));
|
||||
let preferences: serde_json::Value = serde_json::from_str(
|
||||
&std::fs::read_to_string(config_dir.join("update-preferences.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
preferences,
|
||||
serde_json::json!({"automatic": false, "intervalHours": 24})
|
||||
);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(config_dir);
|
||||
assert!(
|
||||
passed.load(Ordering::SeqCst),
|
||||
|
||||
@@ -0,0 +1,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",
|
||||
"lint": "eslint . --fix",
|
||||
"lint:check": "eslint .",
|
||||
"types:check": "vue-tsc --noEmit"
|
||||
"types:check": "php artisan wayfinder:generate --with-form --no-interaction && vue-tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
|
||||
Generated
+25
-17
@@ -203,6 +203,14 @@ settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
overrides:
|
||||
axios@>=1.13.0 <1.20.0: ^1.20.0
|
||||
axios@>=1.15.0 <1.20.0: ^1.20.0
|
||||
axios@>=1.15.1 <1.20.0: ^1.20.0
|
||||
axios@>=1.15.2 <1.20.0: ^1.20.0
|
||||
axios@>=1.16.1 <1.20.0: ^1.20.0
|
||||
axios@>=1.17.0 <1.20.0: ^1.20.0
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
@@ -1075,8 +1083,8 @@ packages:
|
||||
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
axios@1.19.0:
|
||||
resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==}
|
||||
axios@1.20.0:
|
||||
resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==}
|
||||
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
@@ -1088,14 +1096,14 @@ packages:
|
||||
boolbase@1.0.0:
|
||||
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
||||
|
||||
brace-expansion@1.1.18:
|
||||
resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
|
||||
brace-expansion@1.1.21:
|
||||
resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==}
|
||||
|
||||
brace-expansion@2.1.4:
|
||||
resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==}
|
||||
brace-expansion@2.1.7:
|
||||
resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==}
|
||||
|
||||
brace-expansion@5.0.9:
|
||||
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
|
||||
brace-expansion@5.0.12:
|
||||
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
braces@3.0.3:
|
||||
@@ -2671,7 +2679,7 @@ snapshots:
|
||||
'@inertiajs/core@2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)':
|
||||
dependencies:
|
||||
'@types/lodash-es': 4.17.12
|
||||
axios: 1.19.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
||||
axios: 1.20.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
||||
laravel-precognition: 1.0.2(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
||||
lodash-es: 4.18.1
|
||||
qs: 6.16.0
|
||||
@@ -3287,7 +3295,7 @@ snapshots:
|
||||
dependencies:
|
||||
possible-typed-array-names: 1.1.0
|
||||
|
||||
axios@1.19.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1):
|
||||
axios@1.20.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1):
|
||||
dependencies:
|
||||
follow-redirects: 1.16.0(debug@4.4.3(supports-color@8.1.1))
|
||||
form-data: 4.0.6
|
||||
@@ -3303,16 +3311,16 @@ snapshots:
|
||||
|
||||
boolbase@1.0.0: {}
|
||||
|
||||
brace-expansion@1.1.18:
|
||||
brace-expansion@1.1.21:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
concat-map: 0.0.1
|
||||
|
||||
brace-expansion@2.1.4:
|
||||
brace-expansion@2.1.7:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
|
||||
brace-expansion@5.0.9:
|
||||
brace-expansion@5.0.12:
|
||||
dependencies:
|
||||
balanced-match: 4.0.4
|
||||
|
||||
@@ -4055,7 +4063,7 @@ snapshots:
|
||||
|
||||
laravel-precognition@1.0.2(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1):
|
||||
dependencies:
|
||||
axios: 1.19.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
||||
axios: 1.20.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
||||
lodash-es: 4.18.1
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
@@ -4206,15 +4214,15 @@ snapshots:
|
||||
|
||||
minimatch@10.2.4:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.9
|
||||
brace-expansion: 5.0.12
|
||||
|
||||
minimatch@3.1.5:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.18
|
||||
brace-expansion: 1.1.21
|
||||
|
||||
minimatch@9.0.9:
|
||||
dependencies:
|
||||
brace-expansion: 2.1.4
|
||||
brace-expansion: 2.1.7
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
minimumReleaseAge: 10080
|
||||
# Security patches for GHSA-q2hr-2g5m-vwhr, GHSA-qhr7-859c-m2p7 and GHSA-6j4f-fj2g-mc7p.
|
||||
minimumReleaseAgeExclude:
|
||||
- brace-expansion@1.1.21
|
||||
- brace-expansion@2.1.7
|
||||
- brace-expansion@5.0.12
|
||||
allowBuilds:
|
||||
unrs-resolver: true
|
||||
vue-demi: true
|
||||
overrides:
|
||||
axios@>=1.13.0 <1.20.0: ^1.20.0
|
||||
axios@>=1.15.0 <1.20.0: ^1.20.0
|
||||
axios@>=1.15.1 <1.20.0: ^1.20.0
|
||||
axios@>=1.15.2 <1.20.0: ^1.20.0
|
||||
axios@>=1.16.1 <1.20.0: ^1.20.0
|
||||
axios@>=1.17.0 <1.20.0: ^1.20.0
|
||||
|
||||
@@ -3,6 +3,10 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
|
||||
import type { DefineComponent } from 'vue';
|
||||
import { createApp, h } from 'vue';
|
||||
import '../css/app.css';
|
||||
import {
|
||||
initializeNativeApp,
|
||||
registerNativeRecovery,
|
||||
} from '@/composables/useNativeApp';
|
||||
import { createAppearance } from '@/composables/useAppearance';
|
||||
import { createLocalization } from '@/lib/localization';
|
||||
|
||||
@@ -21,6 +25,11 @@ createInertiaApp({
|
||||
.use(createLocalization(props.initialPage.props.localization))
|
||||
.use(createAppearance(props.initialPage.props.appearance))
|
||||
.mount(el);
|
||||
registerNativeRecovery();
|
||||
window.addEventListener('kanban-native-ready', () => {
|
||||
void initializeNativeApp();
|
||||
});
|
||||
void initializeNativeApp();
|
||||
},
|
||||
progress: {
|
||||
color: 'var(--primary)',
|
||||
|
||||
@@ -35,6 +35,7 @@ const canAddLink = computed(
|
||||
);
|
||||
const form = useForm({
|
||||
ticket_link_type_id: props.ticketLinkTypes[0]?.id ?? '',
|
||||
link_direction: 'source',
|
||||
linked_ticket_id: '',
|
||||
});
|
||||
const protection = useFormProtection(form, {
|
||||
@@ -220,7 +221,7 @@ function submit(): void {
|
||||
/>
|
||||
<fieldset
|
||||
: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
|
||||
id="ticket_link_type_id"
|
||||
@@ -230,13 +231,33 @@ function submit(): void {
|
||||
:options="
|
||||
ticketLinkTypes.map((type) => ({
|
||||
value: type.id,
|
||||
label: type.label,
|
||||
label: `${type.label} / ${type.counterpart_label}`,
|
||||
}))
|
||||
"
|
||||
:error="form.errors.ticket_link_type_id"
|
||||
:disabled="form.processing"
|
||||
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
|
||||
id="linked_ticket_id"
|
||||
v-model="form.linked_ticket_id"
|
||||
@@ -267,7 +288,11 @@ function submit(): void {
|
||||
<span class="font-mono text-xs">{{
|
||||
ticket.ticket_key
|
||||
}}</span
|
||||
><span>{{ selectedType.label }}</span
|
||||
><span>{{
|
||||
form.link_direction === 'source'
|
||||
? selectedType.label
|
||||
: selectedType.counterpart_label
|
||||
}}</span
|
||||
><ArrowRight class="size-4" aria-hidden="true" /><span
|
||||
class="font-mono text-xs"
|
||||
>{{ 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 { Button } from '@/components/ui/button';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { useNativeApp } from '@/composables/useNativeApp';
|
||||
import { edit as editNativeApp } from '@/routes/native-app';
|
||||
import { useMacDownload } from '@/composables/useMacDownload';
|
||||
import { home } from '@/routes/index';
|
||||
|
||||
defineProps<{ title: string; description: string; icon?: Component }>();
|
||||
const { t } = useLocalization();
|
||||
const { state: nativeApp } = useNativeApp();
|
||||
const { macDownloadUrl, showMacDownload } = useMacDownload();
|
||||
</script>
|
||||
|
||||
@@ -25,6 +28,9 @@ const { macDownloadUrl, showMacDownload } = useMacDownload();
|
||||
class="focus-visible:ring-ring/35 rounded-md outline-none focus-visible:ring-2"
|
||||
><AppLogo
|
||||
/></Link>
|
||||
<Button v-if="nativeApp" as-child variant="outline" size="sm">
|
||||
<Link :href="editNativeApp()">{{ t('nativeApp.title') }}</Link>
|
||||
</Button>
|
||||
<Button
|
||||
v-if="showMacDownload"
|
||||
as-child
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import {
|
||||
AppWindow,
|
||||
GitBranch,
|
||||
KeyRound,
|
||||
Palette,
|
||||
@@ -10,6 +11,8 @@ import {
|
||||
} from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||
import { useNativeApp } from '@/composables/useNativeApp';
|
||||
import { edit as editNativeApp } from '@/routes/native-app';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { toUrl } from '@/lib/utils';
|
||||
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';
|
||||
|
||||
const { t } = useLocalization();
|
||||
const { state: nativeApp } = useNativeApp();
|
||||
const sidebarNavItems = computed<NavItem[]>(() => [
|
||||
{
|
||||
title: t('navigation.profile'),
|
||||
@@ -41,6 +45,15 @@ const sidebarNavItems = computed<NavItem[]>(() => [
|
||||
href: editApplications(),
|
||||
icon: KeyRound,
|
||||
},
|
||||
...(nativeApp.value
|
||||
? [
|
||||
{
|
||||
title: t('nativeApp.title'),
|
||||
href: editNativeApp(),
|
||||
icon: AppWindow,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: t('navigation.appearance'),
|
||||
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 = {
|
||||
'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.title': 'Project access',
|
||||
'projectAccess.description':
|
||||
@@ -1141,8 +1200,9 @@ export const en = {
|
||||
'Open a child ticket to change its parent. Expand a group to see its descendants.',
|
||||
'ticketRelationships.addLink': 'Add or update a link',
|
||||
'ticketRelationships.selectTicket': 'Select a ticket',
|
||||
'ticketRelationships.direction': 'This ticket',
|
||||
'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.incoming': 'Incoming',
|
||||
'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'])
|
||||
->middleware('can:access-admin')
|
||||
->middleware('can:admin-project,project')
|
||||
->name('destroy');
|
||||
|
||||
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 Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::prefix('settings')->group(function (): void {
|
||||
Route::inertia('app', 'settings/App')->name('native-app.edit');
|
||||
});
|
||||
|
||||
Route::middleware('auth')
|
||||
->prefix('settings')
|
||||
->group(function (): void {
|
||||
|
||||
@@ -291,7 +291,14 @@ class ProjectApiTest extends TestCase
|
||||
->assertOk()
|
||||
->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)
|
||||
->deleteJson(route('api.projects.destroy', $project), [
|
||||
|
||||
@@ -293,6 +293,49 @@ class TicketApiTest extends TestCase
|
||||
$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
|
||||
{
|
||||
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.
|
||||
*/
|
||||
@@ -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();
|
||||
$otherProject = Project::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'key' => 'CORE',
|
||||
]);
|
||||
|
||||
$this->syncProjectPermissions($project, $user, ['admin']);
|
||||
$this->syncProjectPermissions($otherProject, $user, ['admin']);
|
||||
$this->syncProjectPermissions($project, $user, ['view']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->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.
|
||||
*/
|
||||
@@ -144,6 +212,15 @@ class ProjectTicketLinkControllerTest extends TestCase
|
||||
])
|
||||
->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
|
||||
->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 {
|
||||
params.delete('noLinks');
|
||||
params.delete('linkRejected');
|
||||
if (data.link_direction === 'counterpart') {
|
||||
params.set('incoming', '1');
|
||||
} else {
|
||||
params.delete('incoming');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
assert.ok(
|
||||
@@ -486,6 +491,8 @@ if (serve) {
|
||||
return result.body.split('id="main-content"')[1];
|
||||
};
|
||||
const html = await render();
|
||||
assert.ok(html.includes('id="link_direction"'));
|
||||
assert.ok(html.includes('Blocked by'));
|
||||
for (const text of [
|
||||
ticket.title,
|
||||
'Keep every existing ticket action available.',
|
||||
|
||||
@@ -70,6 +70,15 @@ try {
|
||||
assert.equal(await collapse.getAttribute('aria-expanded'), 'true');
|
||||
await control('Link type').focus();
|
||||
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(
|
||||
await control('Linked ticket').evaluate(
|
||||
(el) =>
|
||||
@@ -118,6 +127,7 @@ try {
|
||||
await save().click();
|
||||
assert.deepEqual((await request).postDataJSON(), {
|
||||
ticket_link_type_id: 1,
|
||||
link_direction: 'source',
|
||||
linked_ticket_id: 41,
|
||||
});
|
||||
await section().getByText('Outgoing', { exact: true }).waitFor();
|
||||
@@ -141,6 +151,25 @@ try {
|
||||
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 control('Linked ticket').selectOption('41');
|
||||
await save().click();
|
||||
|
||||
Reference in New Issue
Block a user