Compare commits

...
13 Commits
Author SHA1 Message Date
Twyco 704fe5d241 fix
CI/CD / preconditions (push) Successful in 1s
CI/CD / test_and_check_app (push) Successful in 10s
CI/CD / Publish immutable image (push) Successful in 11s
2026-10-01 21:15:43 +02:00
Twyco 73f6ab6918 fix
CI/CD / preconditions (push) Successful in 2s
macOS checks / Native Rust tests and unsigned build (push) Canceled after 0s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m1s
CI/CD / test_and_check_app (push) Failing after 55s
CI/CD / Publish immutable image (push) Skipped
2026-10-01 21:03:01 +02:00
Twyco 6d4a823fe5 fix
CI/CD / preconditions (push) Successful in 2s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m9s
CI/CD / test_and_check_app (push) Failing after 57s
CI/CD / Publish immutable image (push) Skipped
macOS checks / Native Rust tests and unsigned build (push) Canceled after 0s
2026-10-01 20:45:34 +02:00
Twyco 6054690607 Merge pull request 'KAN-64: Allow project admins to delete projects' (#171) from fix/KAN-64-cant-delete-project into main
CI/CD / preconditions (push) Successful in 1s
CI/CD / test_and_check_app (push) Failing after 54s
CI/CD / Publish immutable image (push) Skipped
Reviewed-on: #171
2026-10-01 15:16:03 +00:00
Twyco 723285f820 fix: allow project admins to delete projects (KAN-64) 2026-10-01 15:16:03 +00:00
Twyco 393d074bb7 Merge pull request 'KAN-12: Support both ticket link directions' (#174) from feature/KAN-12-bidirectional-ticket-links into main
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Failing after 11s
CI/CD / Publish immutable image (push) Skipped
Reviewed-on: #174
2026-10-01 15:15:32 +00:00
Twyco 3ec59ef367 feat: support both ticket link directions (KAN-12)
CI/CD / preconditions (pull_request) Successful in 1s
CI/CD / test_and_check_app (pull_request) Failing after 59s
CI/CD / Publish immutable image (pull_request) Skipped
2026-10-01 08:13:54 +02:00
Twyco 10f88f6029 Merge pull request 'Fix dependency CVEs and provide shared PR base' (#173) from fix/dependency-cves into main
CI/CD / preconditions (push) Successful in 2s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m8s
CI/CD / test_and_check_app (push) Successful in 10s
CI/CD / Publish immutable image (push) Successful in 35s
macOS checks / Native Rust tests and unsigned build (push) Canceled after 0s
Reviewed-on: #173
2026-09-30 10:07:53 +00:00
Twyco 3b110f08ae fix
CI/CD / preconditions (pull_request) Successful in 1s
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 0s
Native JavaScript checks / Native launcher and release tests (pull_request) Canceled after 51s
CI/CD / test_and_check_app (pull_request) Successful in 5m49s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-30 11:57:19 +02:00
Twyco 1a022c257f fix
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 59s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 4m37s
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 0s
2026-09-30 11:53:42 +02:00
Twyco ce36060ba1 fix: patch vulnerable framework and build dependencies
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m7s
CI/CD / test_and_check_app (pull_request) Successful in 6m26s
CI/CD / Publish immutable image (pull_request) Skipped
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 0s
2026-09-30 11:19:21 +02:00
Twyco f4434a3203 Merge pull request 'KAN-70: Integrate native workspace and update settings' (#170) from feature/KAN-70-native-app-workspace-settings into main
Native JavaScript checks / Native launcher and release tests (push) Successful in 59s
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m30s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 21s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 3m23s
CI/CD / test_and_check_app (push) Successful in 6m57s
CI/CD / preconditions (push) Successful in 1s
CI/CD / Publish immutable image (push) Successful in 13s
Reviewed-on: #170
2026-09-29 15:24:44 +00:00
Twyco 912cfc28c2 feat(KAN-70): integrate native workspace and update settings
CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m2s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m38s
CI/CD / test_and_check_app (pull_request) Successful in 6m47s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-29 17:11:10 +02:00
51 changed files with 2024 additions and 175 deletions
+12 -72
View File
@@ -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 \
.
+7 -1
View File
@@ -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;
}
+15 -12
View File
@@ -17,18 +17,21 @@ class StoreProjectTicketLink
) {}
/**
* @param array{linked_ticket_id: int, ticket_link_type_id: int} $validated
* @param array{linked_ticket_id: int, ticket_link_type_id: int, link_direction?: 'source'|'counterpart'} $validated
*/
public function handle(Project $project, Ticket $ticket, User $user, array $validated): TicketLink
{
$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',
])) {
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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."
}
+1 -1
View File
@@ -1770,7 +1770,7 @@ dependencies = [
[[package]]
name = "kanbangit-native"
version = "0.2.5"
version = "0.2.6"
dependencies = [
"semver",
"serde",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "kanbangit-native"
version = "0.2.5"
version = "0.2.6"
description = "KanbanGit native application"
edition = "2021"
publish = false
+23
View File
@@ -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;
+4 -2
View File
@@ -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
+83
View File
@@ -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() {
+3
View File
@@ -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;
+47
View File
@@ -0,0 +1,47 @@
#import <WebKit/WebKit.h>
typedef char *(*SettingsCallback)(const char *, const char *, const char *, bool);
typedef void (*SettingsFree)(char *);
@interface KanbanSettingsHandler : NSObject <WKScriptMessageHandlerWithReply>
@property(nonatomic, weak) WKWebView *webview;
@property(nonatomic, copy) NSString *label;
@property(nonatomic) SettingsCallback callback;
@property(nonatomic) SettingsFree releaseReply;
@end
@implementation KanbanSettingsHandler
- (void)userContentController:(WKUserContentController *)controller
didReceiveScriptMessage:(WKScriptMessage *)message
replyHandler:(void (^)(id, NSString *))replyHandler {
if (message.webView != self.webview || !message.frameInfo.isMainFrame ||
![message.body isKindOfClass:NSString.class]) {
replyHandler(nil, @"untrustedFrame");
return;
}
char *reply = self.callback(self.label.UTF8String,
message.frameInfo.request.URL.absoluteString.UTF8String,
[(NSString *)message.body UTF8String], message.frameInfo.isMainFrame);
if (!reply) { replyHandler(nil, @"bridgeUnavailable"); return; }
NSString *value = [NSString stringWithUTF8String:reply];
self.releaseReply(reply);
replyHandler(value, nil);
}
@end
void install_workspace_settings(void *pointer, const char *label, const char *script,
SettingsCallback callback, SettingsFree releaseReply) {
WKWebView *webview = (__bridge WKWebView *)pointer;
KanbanSettingsHandler *handler = [KanbanSettingsHandler new];
handler.webview = webview;
handler.label = [NSString stringWithUTF8String:label];
handler.callback = callback;
handler.releaseReply = releaseReply;
[webview.configuration.userContentController addScriptMessageHandlerWithReply:handler
contentWorld:WKContentWorld.pageWorld name:@"kanbanSettings"];
[webview.configuration.userContentController addUserScript:[[WKUserScript alloc]
initWithSource:[NSString stringWithUTF8String:script]
injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:YES]];
// The initial document may already have started loading before the handler is attached.
[webview evaluateJavaScript:[NSString stringWithUTF8String:script] completionHandler:nil];
}
+147
View File
@@ -0,0 +1,147 @@
use serde::Deserialize;
use serde_json::{json, Value};
use std::ffi::{c_char, CStr, CString};
use std::sync::OnceLock;
use tauri::{AppHandle, Url};
static APP: OnceLock<AppHandle> = OnceLock::new();
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Request {
pub version: u8,
pub id: u64,
pub action: String,
pub payload: Value,
}
#[derive(Debug, Deserialize)]
#[serde(
tag = "action",
content = "payload",
rename_all = "camelCase",
deny_unknown_fields
)]
pub enum Action {
GetState {},
SettingsReady {},
OpenSettings {},
SetWorkspace {
server: String,
},
CheckForUpdates {},
SetUpdatePreferences {
automatic: bool,
#[serde(rename = "intervalHours")]
interval_hours: u64,
},
InstallUpdate {
version: String,
},
}
pub fn parse(value: &str) -> Result<(u64, Action), &'static str> {
if value.len() > 8192 {
return Err("invalidRequest");
}
let request: Request = serde_json::from_str(value).map_err(|_| "invalidRequest")?;
if request.version != 1 {
return Err("incompatibleVersion");
}
let action =
serde_json::from_value(json!({"action": request.action, "payload": request.payload}))
.map_err(|_| "invalidRequest")?;
Ok((request.id, action))
}
pub fn install(app: &AppHandle, window: &tauri::WebviewWindow) -> tauri::Result<()> {
let _ = APP.set(app.clone());
let label = CString::new(window.label()).unwrap();
let script = CString::new(include_str!("../../ui/workspace-settings.js")).unwrap();
window.with_webview(move |view| unsafe {
unsafe extern "C" {
fn install_workspace_settings(
view: *mut std::ffi::c_void,
label: *const c_char,
script: *const c_char,
callback: extern "C" fn(
*const c_char,
*const c_char,
*const c_char,
bool,
) -> *mut c_char,
free: extern "C" fn(*mut c_char),
);
}
install_workspace_settings(
view.inner().cast(),
label.as_ptr(),
script.as_ptr(),
receive,
release,
);
})
}
extern "C" fn release(value: *mut c_char) {
if !value.is_null() {
unsafe {
drop(CString::from_raw(value));
}
}
}
extern "C" fn receive(
label: *const c_char,
url: *const c_char,
body: *const c_char,
main: bool,
) -> *mut c_char {
let result = std::panic::catch_unwind(|| -> Result<Value, &'static str> {
if label.is_null() || url.is_null() || body.is_null() {
return Err("invalidRequest");
}
let label = unsafe { CStr::from_ptr(label) }
.to_str()
.map_err(|_| "invalidRequest")?;
let url = unsafe { CStr::from_ptr(url) }
.to_str()
.map_err(|_| "invalidRequest")?;
let body = unsafe { CStr::from_ptr(body) }
.to_str()
.map_err(|_| "invalidRequest")?;
let app = APP.get().ok_or("bridgeUnavailable")?;
let url = Url::parse(url).map_err(|_| "untrustedFrame")?;
if !crate::desktop::allows_settings(app, label, &url, main) {
return Err("untrustedFrame");
}
let (id, action) = parse(body)?;
let result = crate::desktop::settings_action(app, action)?;
Ok(json!({"id": id, "result": result}))
})
.unwrap_or(Err("bridgeUnavailable"));
let value = result.unwrap_or_else(|error| json!({"error": error}));
CString::new(value.to_string()).unwrap().into_raw()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn protocol_rejects_unknown_versions_actions_and_malformed_payloads() {
for value in [
r#"{"version":2,"id":1,"action":"getState","payload":{}}"#,
r#"{"version":1,"id":1,"action":"shell","payload":{}}"#,
r#"{"version":1,"id":1,"action":"setWorkspace","payload":{"server":3}}"#,
r#"{"version":1,"id":1,"action":"setUpdatePreferences","payload":{"automatic":"true","intervalHours":6}}"#,
r#"{"version":1,"id":1,"action":"installUpdate","payload":{"version":"1.2.3","url":"https://evil.test"}}"#,
] {
assert!(parse(value).is_err(), "{value}");
}
assert!(parse(&"x".repeat(8193)).is_err());
assert!(matches!(
parse(r#"{"version":1,"id":1,"action":"getState","payload":{}}"#),
Ok((1, Action::GetState {}))
));
}
}
+45 -14
View File
@@ -188,6 +188,12 @@ pub fn check(app: &AppHandle, manual: bool) {
if manual {
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()
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -5,7 +5,7 @@
"iOS": {
"minimumSystemVersion": "17.0",
"developmentTeam": "ZZX2KZGTHU",
"bundleVersion": "5"
"bundleVersion": "6"
}
}
}
+36 -8
View File
@@ -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),
+75
View File
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
import test from 'node:test';
const script = readFileSync(
new URL('../ui/workspace-settings.js', import.meta.url),
'utf8',
);
function context(platform = 'apple', frame = false, protocol = 'https:') {
const calls = [];
const window = { dispatchEvent() {} };
window.top = frame ? {} : window;
if (platform === 'apple') {
window.webkit = {
messageHandlers: {
kanbanSettings: {
postMessage: async (body) => {
const request = JSON.parse(body);
calls.push(request);
return JSON.stringify({
id: request.id,
result: { workspace: 'https://example.test/' },
});
},
},
},
};
}
vm.runInNewContext(script, {
window,
location: { protocol },
Event,
setTimeout,
clearTimeout,
});
return { window, calls };
}
test('Apple WebViews expose a versioned request/reply API without Tauri', async () => {
const { window, calls } = context('apple');
assert.equal(window.kanbanNative.version, 1);
assert.equal(
(await window.kanbanNative.request('getState')).workspace,
'https://example.test/',
);
assert.deepEqual(calls[0], {
version: 1,
id: 1,
action: 'getState',
payload: {},
});
await assert.rejects(
window.kanbanNative.request('shell'),
/unsupportedAction/,
);
assert.equal(calls.length, 1);
assert.equal(window.__TAURI_INTERNALS__, undefined);
assert.equal(context('apple', true).window.kanbanNative, undefined);
assert.equal(
context('apple', false, 'http:').window.kanbanNative,
undefined,
);
});
test('normal browsers receive no native settings API', () => {
assert.equal(context('browser').window.kanbanNative, undefined);
});
test('native errors reject the caller without losing future requests', async () => {
const { window } = context();
window.webkit.messageHandlers.kanbanSettings.postMessage = async () =>
JSON.stringify({ error: 'invalidServer' });
await assert.rejects(
window.kanbanNative.request('setWorkspace', { server: 'bad' }),
/invalidServer/,
);
});
+58
View File
@@ -0,0 +1,58 @@
(() => {
if (
window !== window.top ||
location.protocol !== 'https:' ||
window.kanbanNative
) {
return;
}
const handler = window.webkit?.messageHandlers?.kanbanSettings;
if (!handler) {
return;
}
let sequence = 0;
const actions = new Set([
'getState',
'settingsReady',
'openSettings',
'setWorkspace',
'checkForUpdates',
'setUpdatePreferences',
'installUpdate',
]);
Object.defineProperty(window, 'kanbanNative', {
value: Object.freeze({
version: 1,
async request(action, payload = {}) {
if (!actions.has(action)) {
throw new Error('unsupportedAction');
}
const message = JSON.stringify({
version: 1,
id: ++sequence,
action,
payload,
});
let timer;
try {
const response = await Promise.race([
handler.postMessage(message).then(JSON.parse),
new Promise((_, reject) => {
timer = setTimeout(
() => reject(new Error('bridgeTimeout')),
10000,
);
}),
]);
if (response.error) {
throw new Error(response.error);
}
return response.result;
} finally {
clearTimeout(timer);
}
},
}),
});
window.dispatchEvent(new Event('kanban-native-ready'));
})();
+1 -1
View File
@@ -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",
+25 -17
View File
@@ -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: {}
+12
View File
@@ -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
+9
View File
@@ -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
+136
View File
@@ -0,0 +1,136 @@
import { computed, readonly, ref } from 'vue';
import { isNativeAppState, updateIsBusy } from '@/lib/native-app';
import type { NativeAction, NativeAppState } from '@/types/native-app';
const state = ref<NativeAppState | null>(null);
const pending = ref(false);
const error = ref<string | null>(null);
let initializing = false;
let refreshing: Promise<void> | null = null;
async function request(
action: NativeAction,
payload: Record<string, unknown> = {},
) {
if (typeof window === 'undefined' || window.kanbanNative?.version !== 1) {
throw new Error('bridgeUnavailable');
}
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
window.kanbanNative.request(action, payload),
new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error('bridgeTimeout')),
11000,
);
}),
]);
} finally {
clearTimeout(timer);
}
}
async function refresh() {
if (refreshing) {
return refreshing;
}
refreshing = (async () => {
const result = await request('getState');
if (!isNativeAppState(result)) {
throw new Error('incompatibleVersion');
}
state.value = result;
})();
try {
await refreshing;
} finally {
refreshing = null;
}
}
export async function initializeNativeApp() {
if (
initializing ||
state.value ||
typeof window === 'undefined' ||
window.kanbanNative?.version !== 1
) {
return;
}
initializing = true;
try {
await refresh();
await request('settingsReady');
} catch {
state.value = null;
} finally {
initializing = false;
}
}
export function useNativeApp() {
const busy = computed(
() => pending.value || updateIsBusy(state.value?.updates),
);
async function act(
action: NativeAction,
payload: Record<string, unknown> = {},
) {
if (
pending.value ||
(updateIsBusy(state.value?.updates) && action !== 'openSettings')
) {
return false;
}
pending.value = true;
error.value = null;
try {
await request(action, payload);
if (action !== 'setWorkspace') {
await refresh();
}
return true;
} catch (cause) {
error.value =
cause instanceof Error ? cause.message : 'bridgeUnavailable';
return false;
} finally {
pending.value = false;
}
}
async function poll() {
try {
await refresh();
} catch (cause) {
error.value =
cause instanceof Error ? cause.message : 'bridgeUnavailable';
}
}
return { state: readonly(state), busy, error: readonly(error), act, poll };
}
export async function recoverNativeWorkspace() {
if (!state.value) {
return;
}
try {
await request('openSettings');
} catch {
/* Native recovery remains available from the app menu or toolbar. */
}
}
export function registerNativeRecovery() {
window.addEventListener('offline', () => {
void recoverNativeWorkspace();
});
document.addEventListener('inertia:exception', () => {
void recoverNativeWorkspace();
});
document.addEventListener('inertia:invalid', (event) => {
if (event.detail.response.status >= 400) {
void recoverNativeWorkspace();
}
});
}
@@ -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
+13
View File
@@ -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(),
+69
View File
@@ -0,0 +1,69 @@
import type { NativeAppState, NativeUpdates } from '@/types/native-app';
export function isNativeAppState(value: unknown): value is NativeAppState {
if (!value || typeof value !== 'object') {
return false;
}
const state = value as NativeAppState;
if (
state.apiVersion !== 1 ||
!['macos', 'ios'].includes(state.platform) ||
typeof state.version !== 'string' ||
typeof state.workspace !== 'string' ||
!Array.isArray(state.capabilities) ||
!state.capabilities.includes('workspace')
) {
return false;
}
try {
if (new URL(state.workspace).protocol !== 'https:') {
return false;
}
} catch {
return false;
}
if (!state.capabilities.includes('updates')) {
return state.updates === undefined;
}
if (state.platform !== 'macos' || !state.updates) {
return false;
}
const updates = state.updates;
return (
[
'idle',
'checking',
'available',
'current',
'downloading',
'verifying',
'installing',
'restarting',
'error',
].includes(updates.phase) &&
typeof updates.currentVersion === 'string' &&
typeof updates.build === 'string' &&
(updates.version === null || typeof updates.version === 'string') &&
(updates.notes === null || typeof updates.notes === 'string') &&
(updates.error === null || typeof updates.error === 'string') &&
Number.isFinite(updates.downloaded) &&
updates.downloaded >= 0 &&
(updates.total === null ||
(Number.isFinite(updates.total) && updates.total >= 0)) &&
typeof updates.preferences?.automatic === 'boolean' &&
[1, 6, 24].includes(updates.preferences.intervalHours)
);
}
export function updateIsBusy(updates?: NativeUpdates): boolean {
return (
!!updates &&
[
'checking',
'downloading',
'verifying',
'installing',
'restarting',
].includes(updates.phase)
);
}
+61 -1
View File
@@ -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}',
+313
View File
@@ -0,0 +1,313 @@
<script setup lang="ts">
import { Head, Link, usePage } from '@inertiajs/vue3';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useLocalization } from '@/composables/useLocalization';
import { useNativeApp } from '@/composables/useNativeApp';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import { home } from '@/routes';
import { edit } from '@/routes/native-app';
import type { TranslationKey } from '@/types/localization';
const { t } = useLocalization();
const { state, busy, error, act, poll } = useNativeApp();
const page = usePage();
const signedIn = computed(() => !!page.props.auth.user);
const address = ref('');
const confirmSwitch = ref(false);
const confirmInstall = ref(false);
const updates = computed(() => state.value?.updates);
watch(
() => updates.value?.version,
() => {
confirmInstall.value = false;
},
);
const breadcrumbs = computed(() => [
{ title: t('nativeApp.title'), href: edit() },
]);
watch(
() => state.value?.workspace,
(workspace) => {
if (workspace) {
address.value = workspace;
}
},
{ immediate: true },
);
watch(address, () => {
confirmSwitch.value = false;
});
const knownErrors: Record<string, TranslationKey> = {
invalidServer: 'nativeApp.invalidServer',
storageError: 'nativeApp.storageError',
networkError: 'nativeApp.networkError',
metadataError: 'nativeApp.metadataError',
signatureError: 'nativeApp.signatureError',
verificationError: 'nativeApp.verificationError',
installationError: 'nativeApp.installationError',
preferencesError: 'nativeApp.preferencesError',
updateBusy: 'nativeApp.updateBusy',
};
const errorMessage = computed(() => {
const value = error.value ?? updates.value?.error;
return value ? t(knownErrors[value] ?? 'nativeApp.bridgeError') : null;
});
const progress = computed(() =>
updates.value?.total
? Math.min(
100,
Math.round(
(updates.value.downloaded / updates.value.total) * 100,
),
)
: undefined,
);
let timer: ReturnType<typeof setTimeout> | undefined;
let active = true;
async function refresh() {
if (state.value) {
await poll();
}
if (active) {
timer = setTimeout(refresh, 1500);
}
}
onMounted(() => {
void refresh();
});
onUnmounted(() => {
active = false;
clearTimeout(timer);
});
async function switchWorkspace() {
if (await act('setWorkspace', { server: address.value })) {
confirmSwitch.value = false;
}
}
async function installUpdate() {
const version = updates.value?.version;
if (version && (await act('installUpdate', { version }))) {
confirmInstall.value = false;
}
}
function setAutomatic(event: Event) {
void act('setUpdatePreferences', {
automatic: (event.target as HTMLInputElement).checked,
intervalHours: updates.value?.preferences.intervalHours,
});
}
function setIntervalHours(event: Event) {
void act('setUpdatePreferences', {
automatic: updates.value?.preferences.automatic,
intervalHours: Number((event.target as HTMLSelectElement).value),
});
}
</script>
<template>
<component
:is="signedIn ? AppLayout : 'main'"
:breadcrumbs="breadcrumbs"
class="min-h-dvh"
>
<Head :title="t('nativeApp.title')" />
<component
:is="signedIn ? SettingsLayout : 'div'"
:class="signedIn ? '' : 'mx-auto grid max-w-3xl gap-6 p-6'"
>
<Link
v-if="!signedIn"
:href="home()"
class="text-primary underline"
>{{ t('auth.home') }}</Link
>
<header class="grid gap-2">
<h2 class="text-title">{{ t('nativeApp.title') }}</h2>
<p class="text-muted-foreground">
{{ t('nativeApp.description') }}
</p>
</header>
<p v-if="!state" role="status">{{ t('nativeApp.unavailable') }}</p>
<template v-else>
<form class="grid gap-4" @submit.prevent="confirmSwitch = true">
<h3 class="text-lg font-semibold">
{{ t('nativeApp.workspace') }}
</h3>
<Label for="native-server">{{
t('nativeApp.server')
}}</Label>
<Input
id="native-server"
v-model="address"
type="url"
required
autocomplete="url"
:disabled="busy"
/>
<p class="text-muted-foreground text-sm">
{{ t('nativeApp.serverHelp') }}
</p>
<Button
type="submit"
:disabled="busy || address === state.workspace"
class="justify-self-start"
>{{ t('nativeApp.switch') }}</Button
>
<div
v-if="confirmSwitch"
class="grid gap-3 rounded-lg border p-4"
role="group"
:aria-label="t('nativeApp.switch')"
>
<p>{{ t('nativeApp.switchWarning') }}</p>
<div class="flex flex-wrap gap-2">
<Button
type="button"
:disabled="busy"
@click="switchWorkspace"
>{{ t('nativeApp.confirmSwitch') }}</Button
>
<Button
type="button"
variant="outline"
:disabled="busy"
@click="confirmSwitch = false"
>{{ t('nativeApp.cancel') }}</Button
>
</div>
</div>
</form>
<section
v-if="updates"
class="grid gap-4 border-t pt-6"
aria-labelledby="native-updates"
>
<h3 id="native-updates" class="text-lg font-semibold">
{{ t('nativeApp.updates') }}
</h3>
<p>
{{
t('nativeApp.version', {
version: updates.currentVersion,
build: updates.build,
})
}}
</p>
<p role="status" aria-live="polite">
{{ t(`nativeApp.phase.${updates.phase}`) }}
</p>
<template
v-if="
['downloading', 'verifying', 'installing'].includes(
updates.phase,
)
"
>
<progress
:value="progress"
max="100"
class="w-full"
:aria-label="t('nativeApp.progress')"
/>
<span v-if="progress !== undefined"
>{{ progress }}%</span
>
</template>
<Label class="flex items-center gap-3">
<input
type="checkbox"
:checked="updates.preferences.automatic"
:disabled="busy"
@change="setAutomatic"
/>
{{ t('nativeApp.automatic') }}
</Label>
<Label for="native-update-interval">{{
t('nativeApp.interval')
}}</Label>
<select
id="native-update-interval"
:value="updates.preferences.intervalHours"
:disabled="busy"
class="bg-background rounded-md border p-2"
@change="setIntervalHours"
>
<option
v-for="hours in [1, 6, 24]"
:key="hours"
:value="hours"
>
{{ t('nativeApp.hours', { hours }) }}
</option>
</select>
<p class="text-muted-foreground text-sm">
{{ t('nativeApp.updateHelp') }}
</p>
<Button
:disabled="busy"
class="justify-self-start"
@click="act('checkForUpdates')"
>{{ t('nativeApp.check') }}</Button
>
<template v-if="updates.version">
<p>
{{
t('nativeApp.availableVersion', {
version: updates.version,
})
}}
</p>
<p v-if="updates.notes" class="whitespace-pre-wrap">
{{ updates.notes }}
</p>
<Button
:disabled="busy"
class="justify-self-start"
@click="confirmInstall = true"
>{{ t('nativeApp.install') }}</Button
>
<div
v-if="confirmInstall"
class="grid gap-3 rounded-lg border p-4"
role="group"
:aria-label="t('nativeApp.install')"
>
<p>{{ t('nativeApp.installWarning') }}</p>
<div class="flex flex-wrap gap-2">
<Button
:disabled="busy"
@click="installUpdate"
>{{ t('nativeApp.installRestart') }}</Button
>
<Button
variant="outline"
:disabled="busy"
@click="confirmInstall = false"
>{{ t('nativeApp.cancel') }}</Button
>
</div>
</div>
</template>
</section>
<p v-else class="text-muted-foreground text-sm">
{{
t('nativeApp.mobileVersion', { version: state.version })
}}
</p>
<p v-if="errorMessage" class="text-destructive" role="alert">
{{ errorMessage }}
</p>
<Button
variant="outline"
class="justify-self-start"
@click="act('openSettings')"
>{{ t('nativeApp.recovery') }}</Button
>
</template>
</component>
</component>
</template>
+7
View File
@@ -0,0 +1,7 @@
import type { NativeBridge } from './native-app';
declare global {
interface Window {
kanbanNative?: NativeBridge;
}
}
+44
View File
@@ -0,0 +1,44 @@
export type NativeUpdatePhase =
| 'idle'
| 'checking'
| 'available'
| 'current'
| 'downloading'
| 'verifying'
| 'installing'
| 'restarting'
| 'error';
export type NativeUpdates = {
phase: NativeUpdatePhase;
currentVersion: string;
build: string;
version: string | null;
notes: string | null;
downloaded: number;
total: number | null;
error: string | null;
preferences: { automatic: boolean; intervalHours: 1 | 6 | 24 };
};
export type NativeAppState = {
apiVersion: 1;
platform: 'macos' | 'ios';
version: string;
workspace: string;
capabilities: string[];
updates?: NativeUpdates;
};
export type NativeAction =
| 'getState'
| 'settingsReady'
| 'openSettings'
| 'setWorkspace'
| 'checkForUpdates'
| 'setUpdatePreferences'
| 'installUpdate';
export type NativeBridge = {
version: number;
request: (
action: NativeAction,
payload?: Record<string, unknown>,
) => Promise<unknown>;
};
+1 -1
View File
@@ -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 {
+4
View File
@@ -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 {
+8 -1
View File
@@ -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), [
+43
View File
@@ -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());
}
}
+248
View File
@@ -0,0 +1,248 @@
import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url';
import vue from '@vitejs/plugin-vue';
import { createServer } from 'vite';
import { createSSRApp, h } from 'vue';
import { renderToString } from 'vue/server-renderer';
import { createInertiaApp } from '@inertiajs/vue3';
const server = await createServer({
configFile: false,
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: { middlewareMode: true, hmr: false, watch: null },
appType: 'custom',
});
try {
const { createLocalization } = await server.ssrLoadModule(
'/resources/js/lib/localization.ts',
);
const { createAppearance } = await server.ssrLoadModule(
'/resources/js/composables/useAppearance.ts',
);
const { initializeNativeApp, useNativeApp, registerNativeRecovery } =
await server.ssrLoadModule('/resources/js/composables/useNativeApp.ts');
const { isNativeAppState } = await server.ssrLoadModule(
'/resources/js/lib/native-app.ts',
);
const App = (
await server.ssrLoadModule('/resources/js/pages/settings/App.vue')
).default;
const Appearance = (
await server.ssrLoadModule(
'/resources/js/pages/settings/Appearance.vue',
)
).default;
const render = async (signedIn = false, component = App) => {
const result = await createInertiaApp({
page: {
component: 'settings/App',
url: '/settings/app',
version: null,
clearHistory: false,
encryptHistory: false,
props: {
name: 'KanbanGit',
errors: {},
sidebarOpen: true,
appearance: 'system',
auth: {
user: signedIn
? { name: 'Test', email: 'test@example.test' }
: null,
permissions: {
globalAdmin: false,
projectCreate: false,
canManageTeams: false,
},
},
navigation: { project: null },
},
},
resolve: () => component,
render: renderToString,
setup: ({ App, props, plugin }) =>
createSSRApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization())
.use(createAppearance('system')),
});
return result.body;
};
await initializeNativeApp();
assert.match(await render(), /compatible KanbanGit app/);
assert.doesNotMatch(
await render(true, Appearance),
/href="\/settings\/app"/,
);
let calls = [];
let current = {
apiVersion: 1,
platform: 'ios',
version: '1.0.0',
workspace: 'https://example.test/app/',
capabilities: ['workspace'],
};
const bridge = {
version: 2,
request: async (action, payload) => {
calls.push([action, payload]);
return structuredClone(current);
},
};
globalThis.window = { kanbanNative: bridge };
await initializeNativeApp();
assert.equal(calls.length, 0);
bridge.version = 1;
current.apiVersion = 2;
await initializeNativeApp();
assert.equal(useNativeApp().state.value, null);
current.apiVersion = 1;
await initializeNativeApp();
assert.equal(calls.at(-1)[0], 'settingsReady');
delete globalThis.window;
let html = await render();
assert.match(html, /value="https:\/\/example.test\/app\/"/);
assert.doesNotMatch(
html,
/Check for updates now|Install update|native-update-interval/,
);
assert.match(await render(true, Appearance), /href="\/settings\/app"/);
current = {
...current,
platform: 'macos',
capabilities: ['workspace', 'updates'],
updates: {
phase: 'available',
currentVersion: '1.0.0',
build: '1',
version: '1.1.0',
notes: '<script>bad()</script>',
downloaded: 0,
total: null,
error: null,
preferences: { automatic: true, intervalHours: 6 },
},
};
globalThis.window = { kanbanNative: bridge };
await useNativeApp().poll();
delete globalThis.window;
html = await render(true);
assert.match(html, /Check for updates now/);
assert.match(html, /Install update/);
assert.match(html, /&lt;script&gt;bad/);
assert.doesNotMatch(html, /<script>bad/);
assert.match(html, /id="native-update-interval"[^>]*value="6"/);
for (const phase of [
'checking',
'downloading',
'verifying',
'installing',
'restarting',
'error',
'current',
]) {
current.updates.phase = phase;
current.updates.downloaded = 40;
current.updates.total = 100;
globalThis.window = { kanbanNative: bridge };
await useNativeApp().poll();
if (
[
'checking',
'downloading',
'verifying',
'installing',
'restarting',
].includes(phase)
) {
const before = calls.length;
assert.equal(await useNativeApp().act('checkForUpdates'), false);
assert.equal(calls.length, before);
}
delete globalThis.window;
html = await render();
if (phase === 'downloading') {
assert.match(html, /40%/);
}
}
for (const invalid of [
null,
{},
{ ...current, apiVersion: 2 },
{ ...current, platform: 'unsupported' },
{ ...current, updates: { ...current.updates, phase: 'madeUp' } },
]) {
assert.equal(isNativeAppState(invalid), false);
}
globalThis.window = { kanbanNative: bridge };
calls = [];
const listeners = {};
globalThis.window.addEventListener = (name, callback) => {
listeners[name] = callback;
};
globalThis.document = {
addEventListener: (name, callback) => {
listeners[name] = callback;
},
};
registerNativeRecovery();
for (const name of ['offline', 'inertia:exception', 'inertia:invalid']) {
listeners[name]({ detail: { response: { status: 503 } } });
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(calls.at(-1)[0], 'openSettings');
}
const count = calls.length;
listeners['inertia:invalid']({ detail: { response: { status: 200 } } });
assert.equal(calls.length, count);
delete globalThis.document;
calls = [];
await useNativeApp().act('setUpdatePreferences', {
automatic: false,
intervalHours: 24,
});
assert.deepEqual(calls[0], [
'setUpdatePreferences',
{ automatic: false, intervalHours: 24 },
]);
await useNativeApp().act('setWorkspace', { server: 'https://new.test/' });
assert.deepEqual(calls.at(-1), [
'setWorkspace',
{ server: 'https://new.test/' },
]);
let resolve;
bridge.request = () =>
new Promise((done) => {
resolve = done;
});
const first = useNativeApp().act('setWorkspace', {
server: 'https://new.test/',
});
assert.equal(
await useNativeApp().act('setWorkspace', {
server: 'https://duplicate.test/',
}),
false,
);
resolve(null);
await first;
bridge.request = async () => {
throw new Error('invalidServer');
};
assert.equal(
await useNativeApp().act('setWorkspace', {
server: 'http://invalid.test',
}),
false,
);
delete globalThis.window;
assert.match(await render(), /Enter a valid HTTPS/);
console.log('Native app settings checks passed.');
} finally {
delete globalThis.window;
await server.close();
}
+7
View File
@@ -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();