Compare commits

..
30 Commits
Author SHA1 Message Date
Twyco d8ca4975e5 Merge pull request 'KAN-58: Handle empty S3 bucket versioning response' (#156) from fix/KAN-58-empty-s3-versioning into main
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Failing after 3m24s
CI/CD / preconditions (push) Successful in 1s
Native JavaScript checks / Native launcher and release tests (push) Successful in 54s
CI/CD / test_and_check_app (push) Successful in 6m26s
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m17s
CI/CD / Publish immutable image (push) Successful in 1m23s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 20s
Reviewed-on: #156
2026-09-27 10:06:22 +00:00
Twyco f2e1bd6a0d fix(KAN-58): accept empty unversioned bucket response
CI/CD / test_and_check_app (pull_request) Successful in 6m34s
CI/CD / Publish immutable image (pull_request) Skipped
CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 55s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m23s
2026-09-27 12:05:11 +02:00
Twyco b0cc9dad14 Merge pull request 'KAN-58: Fix macOS release runner selection' (#155) from fix/KAN-58-macos-runner-selection into main
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
Native JavaScript checks / Native launcher and release tests (push) Canceled after 0s
macOS checks / Native Rust tests and unsigned build (push) Canceled after 6s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 21s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Failing after 3m11s
Reviewed-on: #155
2026-09-27 09:30:31 +00:00
Twyco 52304c7eb2 fix(KAN-58): select macOS runner from repository variable
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 36s
Native JavaScript checks / Native launcher and release tests (pull_request) Canceled after 42s
CI/CD / preconditions (pull_request) Successful in 1s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 14s
2026-09-27 11:30:18 +02:00
Twyco b4cd6a5808 Merge pull request 'KAN-58: Fix macOS release token permissions' (#154) from fix/KAN-58-release-token-permissions into main
macOS checks / Native Rust tests and unsigned build (push) Canceled after 1m17s
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Native JavaScript checks / Native launcher and release tests (push) Canceled after 0s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 44s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Canceled after 0s
Reviewed-on: #154
2026-09-27 09:17:12 +00:00
Twyco f52cffeaeb fix(KAN-58): avoid admin API in macOS release workflow
CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 58s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 3m22s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 4m18s
2026-09-27 11:13:13 +02:00
Twyco 1c620654a3 Merge pull request 'KAN-58: macOS Actions release to Hetzner Object Storage' (#153) from feature/KAN-58-macos-release-pipeline into main
CI/CD / preconditions (push) Successful in 2s
Native JavaScript checks / Native launcher and release tests (push) Successful in 58s
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m27s
CI/CD / test_and_check_app (push) Successful in 6m34s
CI/CD / Publish immutable image (push) Successful in 1m18s
macOS release to Hetzner / Validate release ref and configuration (push) Failing after 23s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Skipped
Reviewed-on: #153
2026-09-27 08:58:30 +00:00
Twyco fcba7673c2 feat(KAN-58): add macOS actions and Hetzner release pipeline
CI/CD / preconditions (pull_request) Successful in 2s
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 0s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 57s
CI/CD / test_and_check_app (pull_request) Successful in 6m33s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-27 08:08:57 +00:00
Twyco 2f99b8e65c Merge pull request 'KAN-56: Sign and notarize macOS releases' (#152) from feature/KAN-56-signed-macos-release into main
CI/CD / test_and_check_app (push) Successful in 6m39s
CI/CD / Publish immutable image (push) Successful in 1m21s
CI/CD / preconditions (push) Successful in 1s
Reviewed-on: #152
2026-09-27 08:08:47 +00:00
Twyco 2b85517965 feat(KAN-56): sign and notarize macOS releases
CI/CD / preconditions (pull_request) Successful in 1s
CI/CD / test_and_check_app (pull_request) Successful in 6m43s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-27 00:16:29 +02:00
Twyco 7001776c48 Merge pull request 'KAN-55: App links, external links and file selection' (#151) from feature/KAN-55-app-links-file-selection into main
CI/CD / preconditions (push) Successful in 1s
CI/CD / test_and_check_app (push) Successful in 6m26s
CI/CD / Publish immutable image (push) Successful in 1m18s
Reviewed-on: #151
2026-09-26 19:01:06 +00:00
Twyco 9d472b5091 feat(KAN-55): support app links, file selection and clipboard
CI/CD / preconditions (pull_request) Successful in 1s
CI/CD / test_and_check_app (pull_request) Successful in 6m33s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-26 20:58:29 +02:00
Twyco 8bed02a484 Merge pull request 'KAN-54: Add local iOS and iPadOS Xcode builds' (#150) from feature/KAN-54-ios-ipados-local-builds into main
CI/CD / preconditions (push) Successful in 1s
CI/CD / test_and_check_app (push) Successful in 6m36s
CI/CD / Publish immutable image (push) Successful in 1m21s
Reviewed-on: #150
2026-09-26 18:54:17 +00:00
Twyco d07b17ebdb feat(KAN-54): add local iOS and iPadOS Xcode builds
CI/CD / preconditions (pull_request) Successful in 2s
CI/CD / test_and_check_app (pull_request) Successful in 6m59s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-26 20:52:56 +02:00
Twyco 859229fa21 Merge pull request 'KAN-53: Add Android app and platform integration' (#149) from feature/KAN-53-android-app-platform into main
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Successful in 6m33s
CI/CD / Publish immutable image (push) Successful in 1m24s
Reviewed-on: #149
2026-09-26 18:46:18 +00:00
Twyco 32dac91620 feat(KAN-53): add Android app and platform integration
CI/CD / preconditions (pull_request) Successful in 2s
CI/CD / test_and_check_app (pull_request) Successful in 6m22s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-26 20:45:58 +02:00
Twyco 39d0887180 Merge pull request 'KAN-52: Add server selection, isolated sessions and connection recovery' (#148) from feature/KAN-52-server-sessions-connection-errors into main
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Successful in 6m31s
CI/CD / Publish immutable image (push) Successful in 1m21s
Reviewed-on: #148
2026-09-26 18:44:47 +00:00
Twyco 340e6bfff1 feat(KAN-52): add server selection and isolated native sessions
CI/CD / preconditions (pull_request) Successful in 2s
CI/CD / test_and_check_app (pull_request) Successful in 6m31s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-26 20:44:27 +02:00
Twyco f3f1ffdc52 Merge pull request 'KAN-51: Add Tauri scaffold and macOS development build' (#147) from feature/KAN-51-tauri-macos-scaffold into main
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Successful in 6m27s
CI/CD / Publish immutable image (push) Successful in 1m20s
Reviewed-on: #147
2026-09-26 18:41:04 +00:00
Twyco 2a56d03dbf KAN-51: add isolated Tauri macOS app scaffold 2026-09-26 18:41:04 +00:00
Twyco 7e19364757 Merge pull request 'faster test app action' (#145) from feature/faster-test-app-action into main
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Successful in 7m14s
CI/CD / Publish immutable image (push) Successful in 1m19s
Reviewed-on: #145
2026-09-26 18:40:46 +00:00
Twyco de0c19a86b test concurrently 2026-09-26 18:40:46 +00:00
Twyco ce250d8be5 use paratest 2026-09-26 18:40:46 +00:00
Twyco 5c2b62de2a fix 2026-09-26 18:40:46 +00:00
Twyco b5030e6acc faster action test 2026-09-26 18:40:46 +00:00
Twyco 6224880219 faster test app action 2026-09-26 18:40:46 +00:00
Twyco 19d102d140 Merge pull request 'KAN-48: Rebuild project access and permission assignment' (#146) from feature/KAN-48-project-access-permissions into main
CI/CD / Publish immutable image (push) Successful in 12s
CI/CD / preconditions (push) Successful in 1s
CI/CD / test_and_check_app (push) Successful in 10s
Reviewed-on: #146
2026-09-16 11:16:44 +00:00
Twyco e70b6b4de0 feat(KAN-48): rebuild project access and permission assignment 2026-09-16 11:16:44 +00:00
Twyco d9a1288d73 Merge pull request 'KAN-47: Redesign teams and membership management' (#141) from feature/KAN-47-teams-and-membership into main
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #141
2026-09-16 11:16:35 +00:00
Twyco 1d9df6897d feat(KAN-47): redesign teams and membership management
CI/CD / preconditions (pull_request) Successful in 1s
CI/CD / test_and_check_app (pull_request) Successful in 6m8s
CI/CD / Publish immutable image (pull_request) Skipped
# Conflicts:
#	resources/js/locales/en.ts
2026-09-16 09:51:26 +02:00
93 changed files with 7444 additions and 1193 deletions
@@ -1,394 +0,0 @@
---
name: inertia-vue-development
description: "Develops Inertia.js v2 Vue client-side applications. Activates when creating Vue pages, forms, or navigation; using <Link>, <Form>, useForm, or router; working with deferred props, prefetching, or polling; or when user mentions Vue with Inertia, Vue pages, Vue forms, or Vue navigation."
license: MIT
metadata:
author: laravel
---
# Inertia Vue Development
## When to Apply
Activate this skill when:
- Creating or modifying Vue page components for Inertia
- Working with forms in Vue (using `<Form>` or `useForm`)
- Implementing client-side navigation with `<Link>` or `router`
- Using v2 features: deferred props, prefetching, WhenVisible, InfiniteScroll, once props, flash data, or polling
- Building Vue-specific features with the Inertia protocol
## Documentation
Use `search-docs` for detailed Inertia v2 Vue patterns and documentation.
## Basic Usage
### Page Components Location
Vue page components should be placed in the `resources/js/pages` directory.
### Page Component Structure
<!-- Basic Vue Page Component -->
```vue
<script setup>
defineProps({
users: Array
})
</script>
<template>
<div>
<h1>Users</h1>
<ul>
<li v-for="user in users" :key="user.id">
{{ user.name }}
</li>
</ul>
</div>
</template>
```
## Client-Side Navigation
### Basic Link Component
Use `<Link>` for client-side navigation instead of traditional `<a>` tags:
<!-- Inertia Vue Navigation -->
```vue
<script setup>
import { Link } from '@inertiajs/vue3'
</script>
<template>
<div>
<Link href="/">Home</Link>
<Link href="/users">Users</Link>
<Link :href="`/users/${user.id}`">View User</Link>
</div>
</template>
```
### Link with Method
<!-- Link with POST Method -->
```vue
<script setup>
import { Link } from '@inertiajs/vue3'
</script>
<template>
<Link href="/logout" method="post" as="button">
Logout
</Link>
</template>
```
### Prefetching
Prefetch pages to improve perceived performance:
<!-- Prefetch on Hover -->
```vue
<script setup>
import { Link } from '@inertiajs/vue3'
</script>
<template>
<Link href="/users" prefetch>
Users
</Link>
</template>
```
### Programmatic Navigation
<!-- Router Visit -->
```vue
<script setup>
import { router } from '@inertiajs/vue3'
function handleClick() {
router.visit('/users')
}
// Or with options
function createUser() {
router.visit('/users', {
method: 'post',
data: { name: 'John' },
onSuccess: () => console.log('Done'),
})
}
</script>
<template>
<Link href="/users">Users</Link>
<Link href="/logout" method="post" as="button">Logout</Link>
</template>
```
## Form Handling
### Form Component (Recommended)
The recommended way to build forms is with the `<Form>` component:
<!-- Form Component Example -->
```vue
<script setup>
import { Form } from '@inertiajs/vue3'
</script>
<template>
<Form action="/users" method="post" #default="{ errors, processing, wasSuccessful }">
<input type="text" name="name" />
<div v-if="errors.name">{{ errors.name }}</div>
<input type="email" name="email" />
<div v-if="errors.email">{{ errors.email }}</div>
<button type="submit" :disabled="processing">
{{ processing ? 'Creating...' : 'Create User' }}
</button>
<div v-if="wasSuccessful">User created!</div>
</Form>
</template>
```
### Form Component With All Props
<!-- Form Component Full Example -->
```vue
<script setup>
import { Form } from '@inertiajs/vue3'
</script>
<template>
<Form
action="/users"
method="post"
#default="{
errors,
hasErrors,
processing,
progress,
wasSuccessful,
recentlySuccessful,
setError,
clearErrors,
resetAndClearErrors,
defaults,
isDirty,
reset,
submit
}"
>
<input type="text" name="name" :value="defaults.name" />
<div v-if="errors.name">{{ errors.name }}</div>
<button type="submit" :disabled="processing">
{{ processing ? 'Saving...' : 'Save' }}
</button>
<progress v-if="progress" :value="progress.percentage" max="100">
{{ progress.percentage }}%
</progress>
<div v-if="wasSuccessful">Saved!</div>
</Form>
</template>
```
### Form Component Reset Props
The `<Form>` component supports automatic resetting:
- `resetOnError` - Reset form data when the request fails
- `resetOnSuccess` - Reset form data when the request succeeds
- `setDefaultsOnSuccess` - Update default values on success
Use the `search-docs` tool with a query of `form component resetting` for detailed guidance.
<!-- Form with Reset Props -->
```vue
<script setup>
import { Form } from '@inertiajs/vue3'
</script>
<template>
<Form
action="/users"
method="post"
reset-on-success
set-defaults-on-success
#default="{ errors, processing, wasSuccessful }"
>
<input type="text" name="name" />
<div v-if="errors.name">{{ errors.name }}</div>
<button type="submit" :disabled="processing">
Submit
</button>
</Form>
</template>
```
Forms can also be built using the `useForm` composable for more programmatic control. Use the `search-docs` tool with a query of `useForm helper` for guidance.
### `useForm` Composable
For more programmatic control or to follow existing conventions, use the `useForm` composable:
<!-- useForm Composable Example -->
```vue
<script setup>
import { useForm } from '@inertiajs/vue3'
const form = useForm({
name: '',
email: '',
password: '',
})
function submit() {
form.post('/users', {
onSuccess: () => form.reset('password'),
})
}
</script>
<template>
<form @submit.prevent="submit">
<input type="text" v-model="form.name" />
<div v-if="form.errors.name">{{ form.errors.name }}</div>
<input type="email" v-model="form.email" />
<div v-if="form.errors.email">{{ form.errors.email }}</div>
<input type="password" v-model="form.password" />
<div v-if="form.errors.password">{{ form.errors.password }}</div>
<button type="submit" :disabled="form.processing">
Create User
</button>
</form>
</template>
```
## Inertia v2 Features
### Deferred Props
Use deferred props to load data after initial page render:
<!-- Deferred Props with Empty State -->
```vue
<script setup>
defineProps({
users: Array
})
</script>
<template>
<div>
<h1>Users</h1>
<div v-if="!users" class="animate-pulse">
<div class="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-1/2"></div>
</div>
<ul v-else>
<li v-for="user in users" :key="user.id">
{{ user.name }}
</li>
</ul>
</div>
</template>
```
### Polling
Automatically refresh data at intervals:
<!-- Polling Example -->
```vue
<script setup>
import { router } from '@inertiajs/vue3'
import { onMounted, onUnmounted } from 'vue'
defineProps({
stats: Object
})
let interval
onMounted(() => {
interval = setInterval(() => {
router.reload({ only: ['stats'] })
}, 5000) // Poll every 5 seconds
})
onUnmounted(() => {
clearInterval(interval)
})
</script>
<template>
<div>
<h1>Dashboard</h1>
<div>Active Users: {{ stats.activeUsers }}</div>
</div>
</template>
```
### WhenVisible
Lazy-load a prop when an element scrolls into view. Useful for deferring expensive data that sits below the fold:
<!-- WhenVisible Example -->
```vue
<script setup>
import { WhenVisible } from '@inertiajs/vue3'
defineProps({
stats: Object
})
</script>
<template>
<div>
<h1>Dashboard</h1>
<!-- stats prop is loaded only when this section scrolls into view -->
<WhenVisible data="stats" :buffer="200">
<template #fallback>
<div class="animate-pulse">Loading stats...</div>
</template>
<template #default="{ fetching }">
<div>
<p>Total Users: {{ stats.total_users }}</p>
<p>Revenue: {{ stats.revenue }}</p>
<span v-if="fetching">Refreshing...</span>
</div>
</template>
</WhenVisible>
</div>
</template>
```
## Server-Side Patterns
Server-side patterns (Inertia::render, props, middleware) are covered in inertia-laravel guidelines.
## Common Pitfalls
- Using traditional `<a>` links instead of Inertia's `<Link>` component (breaks SPA behavior)
- Forgetting that Vue components must have a single root element
- Forgetting to add loading states (skeleton screens) when using deferred props
- Not handling the `undefined` state of deferred props before data loads
- Using `<form>` without preventing default submission (use `<Form>` component or `@submit.prevent`)
- Forgetting to check if `<Form>` component is available in your Inertia version
@@ -1,119 +0,0 @@
---
name: tailwindcss-development
description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS."
license: MIT
metadata:
author: laravel
---
# Tailwind CSS Development
## Documentation
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
## Basic Usage
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
## Tailwind CSS v4 Specifics
- Always use Tailwind CSS v4 and avoid deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
### CSS-First Configuration
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
<!-- CSS-First Config -->
```css
@theme {
--color-brand: oklch(0.72 0.11 178);
}
```
### Import Syntax
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
<!-- v4 Import Syntax -->
```diff
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
```
### Replaced Utilities
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
| Deprecated | Replacement |
|------------|-------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
## Spacing
Use `gap` utilities instead of margins for spacing between siblings:
<!-- Gap Utilities -->
```html
<div class="flex gap-8">
<div>Item 1</div>
<div>Item 2</div>
</div>
```
## Dark Mode
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
<!-- Dark Mode -->
```html
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
Content adapts to color scheme
</div>
```
## Common Patterns
### Flexbox Layout
<!-- Flexbox Layout -->
```html
<div class="flex items-center justify-between gap-4">
<div>Left content</div>
<div>Right content</div>
</div>
```
### Grid Layout
<!-- Grid Layout -->
```html
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>Card 1</div>
<div>Card 2</div>
<div>Card 3</div>
</div>
```
## Common Pitfalls
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
- Using `@tailwind` directives instead of `@import "tailwindcss"`
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
- Using margins for spacing between siblings instead of gap utilities
- Forgetting to add dark mode variants when the project uses dark mode
@@ -61,10 +61,14 @@ show(1, { query: { page: 1 } }) // "/posts/1?page=1"
## Wayfinder + Inertia
Use Wayfinder with the `<Form>` component:
<!-- Wayfinder Form (Vue) -->
```vue
<Form v-bind="store.form()"><input name="title" /></Form>
Use Wayfinder with `useForm`:
<!-- Wayfinder useForm -->
```typescript
import { store } from "@/actions/App/Http/Controllers/ExampleController";
const form = useForm({ name: "My Big Post" });
form.submit(store());
```
## Verification
+73 -13
View File
@@ -1,18 +1,78 @@
name: test_and_check_app
description: Build the test image and run all application checks
name: "Lint and Test APP"
description: "Run Pint and the Pest test suite"
runs:
using: composite
steps:
- name: Test and check app
shell: bash
run: |
set -Eeuo pipefail
- 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
docker buildx build \
--file Dockerfile \
--target test \
--tag "local/kanbangit-test:${GITHUB_SHA}" \
--progress plain \
--load \
.
- name: Verify Composer lockfile
run: |
if [ ! -f composer.lock ]; then
echo "::error::composer.lock not found"
exit 1
fi
shell: bash
- 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
+58
View File
@@ -0,0 +1,58 @@
name: macOS checks
on:
push:
branches: [main]
paths: ["native/**", ".github/**", "package.json", "pnpm-lock.yaml", "eslint.config.mjs", "resources/css/app.css"]
pull_request:
paths: ["native/**", ".github/**", "package.json", "pnpm-lock.yaml", "eslint.config.mjs", "resources/css/app.css"]
workflow_dispatch:
permissions:
contents: read
jobs:
macos:
name: Native Rust tests and unsigned build
runs-on: ${{ vars.MACOS_CHECK_RUNNER || vars.MACOS_RUNNER || 'macos-arm64' }}
timeout-minutes: 45
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- 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
- name: Setup Rust
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: 1.98.1
components: rustfmt, clippy
- name: Install locked native dependencies
run: pnpm --dir native install --frozen-lockfile
- name: Run native tests
run: pnpm --dir native test
- name: Check Rust formatting and warnings
run: pnpm --dir native check
- name: Build unsigned macOS app without release credentials
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
with:
projectPath: native
tauriScript: pnpm exec tauri
args: --ci --debug --no-sign --bundles app -- --locked
uploadUpdaterJson: false
uploadUpdaterSignatures: false
uploadWorkflowArtifacts: false
+220
View File
@@ -0,0 +1,220 @@
name: macOS release to Hetzner
on:
push:
# Gitea enforces the configured macos-v* protection when tags are pushed.
# Reading /tag_protections requires admin rights unavailable to job tokens.
tags:
- "macos-v*"
workflow_dispatch:
inputs:
tag:
description: "Existing protected macos-vMAJOR.MINOR.PATCH tag on main"
required: true
type: string
permissions:
contents: read
concurrency:
group: macos-release
cancel-in-progress: false
env:
APPLE_SIGNING_IDENTITY: "Developer ID Application: Nico Schröder (ZZX2KZGTHU)"
APPLE_TEAM_ID: ZZX2KZGTHU
MACOS_RELEASE_TARGET: ${{ vars.MACOS_RELEASE_TARGET || 'aarch64-apple-darwin' }}
S3_BUCKET: ${{ vars.S3_BUCKET }}
S3_REGION: ${{ vars.S3_REGION }}
S3_PREFIX: ${{ vars.S3_PREFIX || 'releases/macos' }}
S3_PUBLIC_BASE_URL: ${{ vars.S3_PUBLIC_BASE_URL }}
jobs:
prepare:
name: Validate release ref and configuration
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
commit: ${{ steps.ref.outputs.commit }}
tag: ${{ steps.ref.outputs.tag }}
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Select an existing version tag contained in main
id: ref
shell: bash
env:
REQUESTED_TAG: ${{ inputs.tag }}
MACOS_RUNNER: ${{ vars.MACOS_RUNNER }}
run: |
set -Eeuo pipefail
if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then
[[ "$GITHUB_REF" == refs/heads/main ]] || { echo 'Dispatch this workflow from main.' >&2; exit 1; }
tag="$REQUESTED_TAG"
else
tag="${GITHUB_REF#refs/tags/}"
fi
[[ "$tag" =~ ^macos-v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || { echo 'Invalid macOS release tag.' >&2; exit 1; }
[[ "$MACOS_RUNNER" =~ ^[a-zA-Z0-9_-]+$ ]] || { echo 'Set MACOS_RUNNER to a registered macOS runner label.' >&2; exit 1; }
git fetch --no-tags origin main:refs/remotes/origin/main
commit="$(git rev-parse --verify "refs/tags/$tag^{commit}")"
git merge-base --is-ancestor "$commit" origin/main || { echo 'Release tag must belong to main.' >&2; exit 1; }
git checkout --detach "$commit"
printf 'commit=%s\ntag=%s\n' "$commit" "$tag" >> "$GITHUB_OUTPUT"
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
- name: Validate app versions and Hetzner configuration
env:
RELEASE_TAG: ${{ steps.ref.outputs.tag }}
run: |
node native/macos-ci.mjs metadata
node native/publish-macos.mjs config
release:
name: Sign, notarize, verify and publish macOS
needs: prepare
# Gitea resolves runner labels before outputs from prepare are available.
runs-on: ${{ vars.MACOS_RUNNER }}
timeout-minutes: 120
env:
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
steps:
- name: Check out the validated commit
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ needs.prepare.outputs.commit }}
persist-credentials: false
- name: Validate runner and required secrets
shell: bash
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
S3_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
run: |
set -Eeuo pipefail
[[ "$(uname -s)" == Darwin ]] || { echo 'A macOS runner is required.' >&2; exit 1; }
case "$MACOS_RELEASE_TARGET:$(uname -m)" in
aarch64-apple-darwin:arm64|x86_64-apple-darwin:x86_64) ;;
*) echo 'Runner architecture must match MACOS_RELEASE_TARGET.' >&2; exit 1 ;;
esac
xcodebuild -version
xcrun --find notarytool
aws --version | grep '^aws-cli/2\.' || { echo 'Install AWS CLI v2 permanently on this runner.' >&2; exit 1; }
for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_PASSWORD S3_ACCESS_KEY_ID S3_SECRET_ACCESS_KEY; do
[[ -n "${!name}" ]] || { echo "Missing repository secret: $name" >&2; exit 1; }
done
- 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
- name: Setup Rust
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: 1.98.1
targets: ${{ env.MACOS_RELEASE_TARGET }}
components: rustfmt, clippy
- name: Install locked dependencies
run: |
pnpm install --frozen-lockfile
pnpm --dir native install --frozen-lockfile
- name: Run native checks before importing signing credentials
run: |
pnpm exec eslint native --max-warnings 0
pnpm --dir native test
pnpm --dir native check
- name: Prepare isolated signing keychain
id: signing
shell: bash
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
run: |
set -Eeuo pipefail
umask 077
signing_dir="$(mktemp -d "$RUNNER_TEMP/kanbangit-signing.XXXXXX")"
printf 'SIGNING_DIRECTORY=%s\n' "$signing_dir" >> "$GITHUB_ENV"
security list-keychains -d user > "$signing_dir/original-keychains.txt"
printf '%s' "$APPLE_CERTIFICATE" | base64 --decode > "$signing_dir/certificate.p12"
password="$(openssl rand -hex 32)"
echo "::add-mask::$password"
printf 'keychain=kanbangit-%s-%s\npassword=%s\ncertificate=%s/certificate.p12\n' "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" "$password" "$signing_dir" >> "$GITHUB_OUTPUT"
- name: Import Developer ID certificate
uses: Apple-Actions/import-codesign-certs@5142e029c445c10ffc7149d172e540235a065466 # v7.0.0; deletes its keychain in the post step
with:
keychain: ${{ steps.signing.outputs.keychain }}
keychain-password: ${{ steps.signing.outputs.password }}
p12-filepath: ${{ steps.signing.outputs.certificate }}
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- name: Confirm release signing identity
shell: bash
run: |
set -Eeuo pipefail
security find-identity -v -p codesigning | grep -F "\"$APPLE_SIGNING_IDENTITY\""
- name: Build, sign and notarize with Tauri
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
env:
APPLE_ID: dev@petsch-studios.de
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
with:
projectPath: native
tauriScript: pnpm exec tauri
args: >-
--ci --target ${{ env.MACOS_RELEASE_TARGET }} --bundles app
--config '{"bundle":{"createUpdaterArtifacts":false,"macOS":{"hardenedRuntime":true}}}'
-- --locked
uploadUpdaterJson: false
uploadUpdaterSignatures: false
uploadWorkflowArtifacts: false
- name: Verify signed app and packaged download
id: package
run: node native/macos-ci.mjs package
- name: Publish immutable files and verify public Hetzner downloads
env:
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
AWS_EC2_METADATA_DISABLED: "true"
AWS_REQUEST_CHECKSUM_CALCULATION: when_required
AWS_RESPONSE_CHECKSUM_VALIDATION: when_required
RELEASE_DIRECTORY: ${{ steps.package.outputs.directory }}
run: node native/publish-macos.mjs upload
- name: Restore keychain search list and remove certificate export
if: ${{ always() }}
shell: bash
run: |
python3 - <<'PY'
import os, pathlib, shlex, shutil, subprocess
value = os.environ.get('SIGNING_DIRECTORY')
if value:
directory = pathlib.Path(value)
original = directory / 'original-keychains.txt'
try:
if original.exists():
subprocess.run(['security', 'list-keychains', '-d', 'user', '-s', *shlex.split(original.read_text())], check=True)
finally:
shutil.rmtree(directory)
PY
+57
View File
@@ -0,0 +1,57 @@
name: Native JavaScript checks
on:
push:
branches:
- main
paths:
- "native/**"
- ".github/**"
- "package.json"
- "pnpm-lock.yaml"
- "eslint.config.mjs"
- "resources/css/app.css"
pull_request:
paths:
- "native/**"
- ".github/**"
- "package.json"
- "pnpm-lock.yaml"
- "eslint.config.mjs"
- "resources/css/app.css"
workflow_dispatch:
permissions:
contents: read
jobs:
native_javascript:
name: Native launcher and release tests
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- 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 locked development dependencies
run: pnpm install --frozen-lockfile
- name: Lint native JavaScript
run: pnpm exec eslint native --max-warnings 0
- name: Test native launcher and release safeguards without credentials
run: node --test native/tests/*.test.js
+8
View File
@@ -33,3 +33,11 @@ yarn-error.log
/native/src-tauri/target
/native/src-tauri/gen/*
!/native/src-tauri/gen/android/
!/native/src-tauri/gen/apple/
/native/src-tauri/gen/apple/build/
/native/src-tauri/gen/apple/Externals/
/native/src-tauri/gen/apple/assets/
/native/src-tauri/gen/apple/Pods/
/native/src-tauri/gen/apple/ExportOptions.plist
/native/src-tauri/gen/apple/**/*.xcuserstate
/native/src-tauri/gen/apple/**/xcuserdata/
+3 -19
View File
@@ -15,7 +15,7 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4
- php - 8.5
- inertiajs/inertia-laravel (INERTIA_LARAVEL) - v2
- laravel/fortify (FORTIFY) - v1
- laravel/framework (LARAVEL) - v13
@@ -27,12 +27,6 @@ This application is a Laravel application and its main Laravel ecosystems packag
- laravel/pint (PINT) - v1
- laravel/sail (SAIL) - v1
- phpunit/phpunit (PHPUNIT) - v12
- @inertiajs/vue3 (INERTIA_VUE) - v2
- tailwindcss (TAILWINDCSS) - v4
- vue (VUE) - v3
- @laravel/vite-plugin-wayfinder (WAYFINDER_VITE) - v0
- eslint (ESLINT) - v9
- prettier (PRETTIER) - v3
## Skills Activation
@@ -42,8 +36,6 @@ This project has domain-specific skills available. You MUST activate the relevan
- `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns.
- `mcp-development` — Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP.
- `wayfinder-development` — Use this skill for Laravel Wayfinder which auto-generates typed functions for Laravel controllers and routes. ALWAYS use this skill when frontend code needs to call backend routes or controller actions. Trigger when: connecting any React/Vue/Svelte/Inertia frontend to Laravel controllers, routes, building end-to-end features with both frontend and backend, wiring up forms or links to backend endpoints, fixing route-related TypeScript errors, importing from @/actions or @/routes, or running wayfinder:generate. Use Wayfinder route functions instead of hardcoded URLs. Covers: wayfinder() vite plugin, .url()/.get()/.post()/.form(), query params, route model binding, tree-shaking. Do not use for backend-only task
- `inertia-vue-development` — Develops Inertia.js v2 Vue client-side applications. Activates when creating Vue pages, forms, or navigation; using <Link>, <Form>, useForm, or router; working with deferred props, prefetching, or polling; or when user mentions Vue with Inertia, Vue pages, Vue forms, or Vue navigation.
- `tailwindcss-development` — Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS.
## Conventions
@@ -62,7 +54,7 @@ This project has domain-specific skills available. You MUST activate the relevan
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `pnpm run build`, `pnpm run dev`, or `composer run dev`. Ask them.
## Documentation Files
@@ -136,7 +128,6 @@ This project has domain-specific skills available. You MUST activate the relevan
- Inertia creates fully client-side rendered SPAs without modern SPA complexity, leveraging existing server-side patterns.
- Components live in `resources/js/pages` (unless specified in `vite.config.js`). Use `Inertia::render()` for server-side routing instead of Blade views.
- ALWAYS use `search-docs` tool for version-specific Inertia documentation and updated code examples.
- IMPORTANT: Activate `inertia-vue-development` when working with Inertia Vue client-side patterns.
# Inertia v2
@@ -172,7 +163,7 @@ This project has domain-specific skills available. You MUST activate the relevan
## Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `pnpm run build` or ask the user to run `pnpm run dev` or `composer run dev`.
## Deployment
@@ -209,11 +200,4 @@ Use Wayfinder to generate TypeScript functions for Laravel routes. Import from `
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
=== inertia-vue/core rules ===
# Inertia + Vue
Vue components must have a single root element.
- IMPORTANT: Activate `inertia-vue-development` when working with Inertia Vue client-side patterns.
</laravel-boost-guidelines>
@@ -8,6 +8,7 @@ use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
@@ -113,6 +114,41 @@ class PermissionService
);
}
/**
* @param EloquentCollection<int, User> $users
* @return array{directPermissions: array, teamPermissions: array, effectivePermissions: array, globalAdminUserIds: array<int, int>}
*/
public function projectAccessMap(EloquentCollection $users, Project $project): array
{
$users->loadMissing('teams');
$directPermissions = $this->assignmentMap(User::class, $project);
$teamPermissions = $this->assignmentMap(Team::class, $project);
$globalPermissions = $this->assignmentMap(User::class);
$effectivePermissions = [];
$globalAdminUserIds = [];
foreach ($users as $user) {
$permissions = $directPermissions[$user->id] ?? [];
foreach ($user->teams as $team) {
$permissions = array_merge($permissions, $teamPermissions[$team->id] ?? []);
}
if (in_array(Permission::GlobalAdmin->value, $globalPermissions[$user->id] ?? [], true)) {
$permissions[] = Permission::GlobalAdmin->value;
$globalAdminUserIds[] = $user->id;
}
$expanded = $this->expandPermissions(array_map(Permission::from(...), $permissions));
$effectivePermissions[$user->id] = Permission::values(array_values(array_filter(
Permission::projectAssignable(),
fn (Permission $permission): bool => in_array($permission, $expanded, true),
)));
}
return compact('directPermissions', 'teamPermissions', 'effectivePermissions', 'globalAdminUserIds');
}
/**
* @return array<string, array<int, string>>
*/
+10 -9
View File
@@ -182,25 +182,22 @@ class ProjectController extends Controller
403,
);
$users = User::query()->orderBy('name')->get();
return Inertia::render('projects/Access', [
'directPermissions' => $this->permissionService->assignmentMap(User::class, $project),
...$this->permissionService->projectAccessMap($users, $project),
'permissionOptions' => PermissionOptionResource::collection(
$this->permissionService->permissionOptions(User::class, Project::class),
)->resolve(),
'project' => ProjectResource::make($project)->resolve(),
'status' => session('status'),
'teamPermissions' => $this->permissionService->assignmentMap(Team::class, $project),
'teams' => TeamResource::collection(
Team::query()
->with('users')
->orderBy('name')
->get(),
)->resolve(),
'users' => UserResource::collection(
User::query()
->orderBy('name')
->get(),
)->resolve(),
'users' => UserResource::collection($users)->resolve(),
]);
}
@@ -220,7 +217,9 @@ class ProjectController extends Controller
$this->permissionService->sync($user, $project, $permissions);
return to_route('projects.access', $project)
return ($this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project)
? to_route('projects.access', $project)
: to_route('projects.index'))
->with('status', "{$user->name} permissions were updated.");
}
@@ -239,7 +238,9 @@ class ProjectController extends Controller
$this->permissionService->sync($team, $project, $permissions);
return to_route('projects.access', $project)
return ($this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project)
? to_route('projects.access', $project)
: to_route('projects.index'))
->with('status', "{$team->name} permissions were updated.");
}
+1 -3
View File
@@ -10,8 +10,6 @@
"fortify-development",
"laravel-best-practices",
"mcp-development",
"wayfinder-development",
"inertia-vue-development",
"tailwindcss-development"
"wayfinder-development"
]
}
+15 -2
View File
@@ -20,6 +20,7 @@
"scalar/laravel": "^0.2.1"
},
"require-dev": {
"brianium/paratest": "^7.20",
"fakerphp/faker": "^1.24",
"laravel/boost": "^2.0",
"laravel/pail": "^1.2.5",
@@ -67,15 +68,27 @@
],
"ci:check": [
"Composer\\Config::disableProcessTimeout",
"@ci:frontend",
"@ci:backend",
"@ci:test"
],
"ci:frontend": [
"pnpm audit --audit-level=high",
"pnpm run lint:check",
"pnpm run types:check",
"@test"
"pnpm run build"
],
"ci:backend": [
"@lint:check"
],
"ci:test": [
"@php artisan config:clear --ansi",
"@php artisan test --parallel --processes=2"
],
"test": [
"@php artisan config:clear --ansi",
"@lint:check",
"@php artisan test"
"@php artisan test --parallel --processes=2"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
Generated
+215 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "7d965721310f81816807d6b78c5f9654",
"content-hash": "4d18577105a240f2d8cce1c098398730",
"packages": [
{
"name": "aws/aws-crt-php",
@@ -7020,6 +7020,99 @@
}
],
"packages-dev": [
{
"name": "brianium/paratest",
"version": "v7.20.0",
"source": {
"type": "git",
"url": "https://github.com/paratestphp/paratest.git",
"reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paratestphp/paratest/zipball/81c80677c9ec0ed4ef16b246167f11dec81a6e3d",
"reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-pcre": "*",
"ext-reflection": "*",
"ext-simplexml": "*",
"fidry/cpu-core-counter": "^1.3.0",
"jean85/pretty-package-versions": "^2.1.1",
"php": "~8.3.0 || ~8.4.0 || ~8.5.0",
"phpunit/php-code-coverage": "^12.5.3 || ^13.0.1",
"phpunit/php-file-iterator": "^6.0.1 || ^7",
"phpunit/php-timer": "^8 || ^9",
"phpunit/phpunit": "^12.5.14 || ^13.0.5",
"sebastian/environment": "^8.0.3 || ^9",
"symfony/console": "^7.4.7 || ^8.0.7",
"symfony/process": "^7.4.5 || ^8.0.5"
},
"require-dev": {
"doctrine/coding-standard": "^14.0.0",
"ext-pcntl": "*",
"ext-pcov": "*",
"ext-posix": "*",
"phpstan/phpstan": "^2.1.44",
"phpstan/phpstan-deprecation-rules": "^2.0.4",
"phpstan/phpstan-phpunit": "^2.0.16",
"phpstan/phpstan-strict-rules": "^2.0.10",
"symfony/filesystem": "^7.4.6 || ^8.0.6"
},
"bin": [
"bin/paratest",
"bin/paratest_for_phpstorm"
],
"type": "library",
"autoload": {
"psr-4": {
"ParaTest\\": [
"src/"
]
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Brian Scaturro",
"email": "scaturrob@gmail.com",
"role": "Developer"
},
{
"name": "Filippo Tessarotto",
"email": "zoeslam@gmail.com",
"role": "Developer"
}
],
"description": "Parallel testing for PHP",
"homepage": "https://github.com/paratestphp/paratest",
"keywords": [
"concurrent",
"parallel",
"phpunit",
"testing"
],
"support": {
"issues": "https://github.com/paratestphp/paratest/issues",
"source": "https://github.com/paratestphp/paratest/tree/v7.20.0"
},
"funding": [
{
"url": "https://github.com/sponsors/Slamdunk",
"type": "github"
},
{
"url": "https://paypal.me/filippotessarotto",
"type": "paypal"
}
],
"time": "2026-03-29T15:46:14+00:00"
},
{
"name": "fakerphp/faker",
"version": "v1.24.1",
@@ -7083,6 +7176,67 @@
},
"time": "2024-11-21T13:46:39+00:00"
},
{
"name": "fidry/cpu-core-counter",
"version": "1.3.0",
"source": {
"type": "git",
"url": "https://github.com/theofidry/cpu-core-counter.git",
"reference": "db9508f7b1474469d9d3c53b86f817e344732678"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678",
"reference": "db9508f7b1474469d9d3c53b86f817e344732678",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"require-dev": {
"fidry/makefile": "^0.2.0",
"fidry/php-cs-fixer-config": "^1.1.2",
"phpstan/extension-installer": "^1.2.0",
"phpstan/phpstan": "^2.0",
"phpstan/phpstan-deprecation-rules": "^2.0.0",
"phpstan/phpstan-phpunit": "^2.0",
"phpstan/phpstan-strict-rules": "^2.0",
"phpunit/phpunit": "^8.5.31 || ^9.5.26",
"webmozarts/strict-phpunit": "^7.5"
},
"type": "library",
"autoload": {
"psr-4": {
"Fidry\\CpuCoreCounter\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Théo FIDRY",
"email": "theo.fidry@gmail.com"
}
],
"description": "Tiny utility to get the number of CPU cores.",
"keywords": [
"CPU",
"core"
],
"support": {
"issues": "https://github.com/theofidry/cpu-core-counter/issues",
"source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0"
},
"funding": [
{
"url": "https://github.com/theofidry",
"type": "github"
}
],
"time": "2025-08-14T07:29:31+00:00"
},
{
"name": "filp/whoops",
"version": "2.18.4",
@@ -7205,6 +7359,66 @@
},
"time": "2025-04-30T06:54:44+00:00"
},
{
"name": "jean85/pretty-package-versions",
"version": "2.1.1",
"source": {
"type": "git",
"url": "https://github.com/Jean85/pretty-package-versions.git",
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a",
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a",
"shasum": ""
},
"require": {
"composer-runtime-api": "^2.1.0",
"php": "^7.4|^8.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.2",
"jean85/composer-provided-replaced-stub-package": "^1.0",
"phpstan/phpstan": "^2.0",
"phpunit/phpunit": "^7.5|^8.5|^9.6",
"rector/rector": "^2.0",
"vimeo/psalm": "^4.3 || ^5.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Jean85\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Alessandro Lai",
"email": "alessandro.lai85@gmail.com"
}
],
"description": "A library to get pretty versions strings of installed dependencies",
"keywords": [
"composer",
"package",
"release",
"versions"
],
"support": {
"issues": "https://github.com/Jean85/pretty-package-versions/issues",
"source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1"
},
"time": "2025-03-19T14:43:43+00:00"
},
{
"name": "laravel/boost",
"version": "v2.4.3",
+203
View File
@@ -0,0 +1,203 @@
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import {
appendFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import process from 'node:process';
import { fileURLToPath, pathToFileURL } from 'node:url';
import {
appleTeamId,
bundleIdentifier,
verifySignedMacosApp,
} from './release-macos.mjs';
const nativeDirectory = dirname(fileURLToPath(import.meta.url));
export function releaseMetadata(
env = process.env,
directory = nativeDirectory,
) {
const config = JSON.parse(
readFileSync(join(directory, 'src-tauri/tauri.conf.json'), 'utf8'),
);
const packageVersion = JSON.parse(
readFileSync(join(directory, 'package.json'), 'utf8'),
).version;
const cargoVersion = readFileSync(
join(directory, 'src-tauri/Cargo.toml'),
'utf8',
)
.match(/\[package\]([\s\S]*?)(?:\n\[|$)/)?.[1]
.match(/^version\s*=\s*"([^"]+)"/m)?.[1];
if (
!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(config.version) ||
config.version !== packageVersion ||
config.version !== cargoVersion
) {
throw new Error(
'Tauri, package.json and Cargo.toml must have the same stable release version.',
);
}
if (env.RELEASE_TAG !== `macos-v${config.version}`) {
throw new Error(
'Release tag must be macos-vMAJOR.MINOR.PATCH and match the app version.',
);
}
if (
config.identifier !== bundleIdentifier ||
config.productName !== 'KanbanGit'
) {
throw new Error(
'The macOS product name and bundle identifier must stay unchanged.',
);
}
const target = env.MACOS_RELEASE_TARGET || 'aarch64-apple-darwin';
const arch = {
'aarch64-apple-darwin': 'arm64',
'x86_64-apple-darwin': 'x86_64',
}[target];
if (!arch) {
throw new Error(
'MACOS_RELEASE_TARGET must select Apple Silicon or Intel macOS.',
);
}
const identity = env.APPLE_SIGNING_IDENTITY;
if (
!identity?.startsWith('Developer ID Application: ') ||
!identity.endsWith(` (${appleTeamId})`) ||
/[\r\n"]/.test(identity)
) {
throw new Error(
`A Developer ID Application identity for ${appleTeamId} is required.`,
);
}
return {
version: config.version,
target,
arch,
filename: `KanbanGit-${config.version}-${target}.zip`,
};
}
export function execute(command, args) {
const result = spawnSync(command, args, {
encoding: 'utf8',
maxBuffer: 16 * 1024 * 1024,
});
if (result.error || result.status !== 0) {
throw new Error(
`${command} failed (exit ${result.status ?? 'unknown'}). ${result.stderr || ''}`,
);
}
return result.stdout?.trim() || result.stderr?.trim() || '';
}
export function verifyNotarizedApp(app, metadata, run = execute) {
verifySignedMacosApp({
app,
version: metadata.version,
step: (_label, command, args) => run(command, args),
});
const arch = run('lipo', [
'-archs',
join(app, 'Contents/MacOS/kanbangit-native'),
]).trim();
if (arch !== metadata.arch) {
throw new Error(
'The app architecture does not match its download path.',
);
}
run('xcrun', ['stapler', 'validate', app]);
run('spctl', ['--assess', '--type', 'execute', '--verbose=2', app]);
}
export function packageNotarizedApp({
env = process.env,
directory = nativeDirectory,
run = execute,
} = {}) {
const metadata = releaseMetadata(env, directory);
const root = join(directory, 'src-tauri/target');
const app = join(
root,
metadata.target,
'release/bundle/macos/KanbanGit.app',
);
verifyNotarizedApp(app, metadata, run);
const releaseRoot = join(root, 'macos-release');
const destination = join(
releaseRoot,
`${metadata.version}-${metadata.target}`,
);
if (existsSync(destination)) {
throw new Error(
'Release output already exists; start from a clean checkout.',
);
}
mkdirSync(releaseRoot, { recursive: true });
const staging = mkdtempSync(join(releaseRoot, '.pending-'));
try {
const archive = join(staging, metadata.filename);
run('ditto', [
'-c',
'-k',
'--sequesterRsrc',
'--keepParent',
app,
archive,
]);
const checksum = createHash('sha256')
.update(readFileSync(archive))
.digest('hex');
writeFileSync(
`${archive}.sha256`,
`${checksum} ${metadata.filename}\n`,
);
const extracted = join(staging, 'extracted');
run('ditto', ['-x', '-k', archive, extracted]);
verifyNotarizedApp(join(extracted, 'KanbanGit.app'), metadata, run);
rmSync(extracted, { recursive: true, force: true });
renameSync(staging, destination);
return { ...metadata, directory: destination, checksum };
} finally {
rmSync(staging, { recursive: true, force: true });
}
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
) {
try {
let output;
if (process.argv[2] === 'metadata') {
output = releaseMetadata();
} else if (process.argv[2] === 'package') {
output = packageNotarizedApp();
} else {
throw new Error(
'Use metadata or package. Building and notarizing are handled by tauri-action.',
);
}
if (process.env.GITHUB_OUTPUT) {
appendFileSync(
process.env.GITHUB_OUTPUT,
Object.entries(output)
.map(([key, value]) => `${key}=${value}\n`)
.join(''),
);
}
console.log(JSON.stringify(output));
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
+9 -2
View File
@@ -7,11 +7,18 @@
"dev": "tauri dev",
"build": "tauri build",
"build:macos": "tauri build --debug --bundles app",
"release:macos": "node release-macos.mjs",
"test": "node --test tests/*.test.js && cargo test --manifest-path src-tauri/Cargo.toml --locked",
"check": "cargo fmt --manifest-path src-tauri/Cargo.toml --check && cargo clippy --manifest-path src-tauri/Cargo.toml --locked --all-targets --all-features -- -D warnings",
"smoke:macos": "cargo test --manifest-path src-tauri/Cargo.toml --locked --features smoke-test,custom-protocol --test macos_smoke",
"dev:android": "tauri android dev",
"build:android": "tauri android build --debug --apk --target aarch64"
"build:android": "tauri android build --debug --apk --target aarch64",
"smoke:macos": "cargo test --manifest-path src-tauri/Cargo.toml --locked --features smoke-test,custom-protocol --test macos_smoke",
"dev:ios": "tauri ios dev",
"build:ios:simulator": "rm -rf src-tauri/gen/apple/build/arm64-sim/KanbanGit.app && tauri ios build --debug --target aarch64-sim --no-sign --ci",
"build:ios:device": "tauri ios build --debug --target aarch64 --export-method debugging --archive-only",
"open:ios": "tauri ios dev --open --no-dev-server",
"prepare:ios": "xcodegen generate --spec src-tauri/gen/apple/project.yml",
"test:ios": "xcodebuild -project src-tauri/gen/apple/kanbangit-native.xcodeproj -scheme kanbangit-native_iOS -configuration debug -destination \"platform=iOS Simulator,id=${IOS_SIMULATOR_ID:?Set IOS_SIMULATOR_ID to an installed simulator UUID}\" -parallel-testing-enabled NO CODE_SIGNING_ALLOWED=NO test"
},
"devDependencies": {
"@tauri-apps/cli": "2.11.4"
+237
View File
@@ -0,0 +1,237 @@
import { Buffer } from 'node:buffer';
import { createHash } from 'node:crypto';
import {
appendFileSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import process from 'node:process';
import { pathToFileURL } from 'node:url';
import { execute, releaseMetadata } from './macos-ci.mjs';
export function storageConfiguration(env = process.env) {
const region = env.S3_REGION;
const bucket = env.S3_BUCKET;
const prefix = env.S3_PREFIX || 'releases/macos';
if (!['nbg1', 'fsn1', 'hel1'].includes(region)) {
throw new Error('S3_REGION must be nbg1, fsn1 or hel1.');
}
if (!/^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/.test(bucket || '')) {
throw new Error('Set S3_BUCKET to the Hetzner release bucket name.');
}
if (!/^[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/.test(prefix)) {
throw new Error(
'S3_PREFIX must contain plain path segments without leading or trailing slashes.',
);
}
const endpoint = `https://${region}.your-objectstorage.com`;
const publicBase =
env.S3_PUBLIC_BASE_URL ||
`https://${bucket}.${region}.your-objectstorage.com`;
const url = new URL(publicBase);
if (
url.protocol !== 'https:' ||
url.username ||
url.password ||
url.search ||
url.hash ||
/[\s]/.test(publicBase)
) {
throw new Error(
'S3_PUBLIC_BASE_URL must be a public HTTPS URL without credentials or query parameters.',
);
}
return {
region,
bucket,
prefix,
endpoint,
publicBase: publicBase.replace(/\/$/, ''),
};
}
const sha256 = (data) => createHash('sha256').update(data).digest('hex');
export async function publishMacos({
env = process.env,
directory,
metadata,
run = execute,
fetchPublic = fetch,
} = {}) {
const storage = storageConfiguration(env);
if (!env.AWS_ACCESS_KEY_ID || !env.AWS_SECRET_ACCESS_KEY) {
throw new Error(
'Set the dedicated S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY release secrets.',
);
}
const archive = readFileSync(join(directory, metadata.filename));
const checksum = sha256(archive);
const checksumFile = readFileSync(
join(directory, `${metadata.filename}.sha256`),
);
if (checksumFile.toString() !== `${checksum} ${metadata.filename}\n`) {
throw new Error('Release archive does not match its SHA-256 file.');
}
const common = [
'--endpoint-url',
storage.endpoint,
'--region',
storage.region,
'--no-cli-pager',
's3api',
];
function s3(operation, args) {
return run('aws', [
...common,
operation,
'--bucket',
storage.bucket,
...args,
]);
}
const versioningOutput = s3('get-bucket-versioning', [
'--output',
'json',
]).trim();
let versioning;
try {
// AWS CLI prints nothing for a successful empty VersioningConfiguration.
versioning = JSON.parse(versioningOutput || '{}');
} catch {
throw new Error('Invalid JSON from S3 bucket versioning check.');
}
if (
!versioning ||
typeof versioning !== 'object' ||
Array.isArray(versioning)
) {
throw new Error(
'Invalid S3 bucket versioning response: expected an object.',
);
}
if (versioning.Status) {
throw new Error(
'Use an unversioned release bucket: Hetzner does not support conditional writes on versioned buckets.',
);
}
const staging = mkdtempSync(join(tmpdir(), 'kanbangit-s3-'));
const keyPrefix = `${storage.prefix}/${metadata.target}/${metadata.version}`;
const publicPrefix = `${storage.publicBase}/${keyPrefix}`;
async function verifyPublic(name, contents) {
const response = await fetchPublic(`${publicPrefix}/${name}`, {
redirect: 'error',
signal: AbortSignal.timeout(60000),
});
if (
!response.ok ||
sha256(Buffer.from(await response.arrayBuffer())) !==
sha256(contents)
) {
throw new Error(`Public download verification failed for ${name}.`);
}
}
function putImmutable(name, contents, contentType) {
const file = join(staging, name);
writeFileSync(file, contents);
try {
s3('put-object', [
'--key',
`${keyPrefix}/${name}`,
'--body',
file,
'--if-none-match',
'*',
'--content-type',
contentType,
'--cache-control',
'public, max-age=31536000, immutable',
'--content-md5',
createHash('md5').update(contents).digest('base64'),
'--metadata',
`sha256=${sha256(contents)}`,
]);
} catch (error) {
if (!/\(PreconditionFailed\)|\(412\)/.test(error.message)) {
throw error;
}
const existing = join(staging, `existing-${name}`);
s3('get-object', ['--key', `${keyPrefix}/${name}`, existing]);
if (sha256(readFileSync(existing)) !== sha256(contents)) {
throw new Error(
`Published ${name} differs; bump the app version. Existing objects were not overwritten.`,
);
}
}
}
try {
const files = [
[metadata.filename, archive, 'application/zip'],
[
`${metadata.filename}.sha256`,
checksumFile,
'text/plain; charset=utf-8',
],
];
for (const [name, contents, type] of files) {
putImmutable(name, contents, type);
await verifyPublic(name, contents);
}
// This completion record is written only after both public downloads match.
const manifest = Buffer.from(
JSON.stringify(
{
version: metadata.version,
target: metadata.target,
url: `${publicPrefix}/${metadata.filename}`,
sha256: checksum,
checksumUrl: `${publicPrefix}/${metadata.filename}.sha256`,
},
null,
2,
) + '\n',
);
putImmutable('release.json', manifest, 'application/json');
await verifyPublic('release.json', manifest);
return `${publicPrefix}/${metadata.filename}`;
} finally {
rmSync(staging, { recursive: true, force: true });
}
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
) {
try {
if (process.argv[2] === 'config') {
console.log(JSON.stringify(storageConfiguration()));
} else if (
process.argv[2] === 'upload' &&
process.env.RELEASE_DIRECTORY
) {
const url = await publishMacos({
directory: process.env.RELEASE_DIRECTORY,
metadata: releaseMetadata(),
});
console.log(`Verified public release: ${url}`);
if (process.env.GITHUB_STEP_SUMMARY) {
appendFileSync(
process.env.GITHUB_STEP_SUMMARY,
`Download: [KanbanGit](${url})\n`,
);
}
} else {
throw new Error(
'Use config or upload; upload requires RELEASE_DIRECTORY.',
);
}
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
+450
View File
@@ -0,0 +1,450 @@
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import process from 'node:process';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { parseArgs } from 'node:util';
export const appleTeamId = 'ZZX2KZGTHU';
export const bundleIdentifier = 'de.kanbangit.desktop';
const submissionIdPattern =
/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i;
function execute(command, args, options) {
const result = spawnSync(command, args, {
cwd: options.cwd,
env: options.env,
encoding: 'utf8',
maxBuffer: 16 * 1024 * 1024,
stdio: options.inherit ? 'inherit' : 'pipe',
});
if (result.error || result.status !== 0) {
throw new Error(
`${options.step} failed${result.error ? `: ${result.error.message}` : ` (exit ${result.status ?? result.signal})`}.`,
);
}
return result.stdout?.trim() || result.stderr?.trim() || '';
}
export function verifySignedMacosApp({ app, version, step }) {
step('Verifying Developer ID signature and team', 'codesign', [
'--verify',
'--deep',
'--strict',
'--verbose=2',
'-R',
`=anchor apple generic and identifier "${bundleIdentifier}" and certificate leaf[subject.OU] = "${appleTeamId}" and certificate leaf[field.1.2.840.113635.100.6.1.13] exists`,
app,
]);
const signature = step('Checking hardened runtime', 'codesign', [
'--display',
'--verbose=4',
app,
]);
if (!/^CodeDirectory .*flags=.*\bruntime\b/m.test(signature)) {
throw new Error(
'The release signature does not enable Hardened Runtime.',
);
}
const cdhash = signature.match(/^CDHash=([\da-f]+)$/m)?.[1];
if (!cdhash) {
throw new Error(
'The signed app has no CodeDirectory hash to match against Apple notarization.',
);
}
const bundledVersion = step('Checking bundled release version', 'plutil', [
'-extract',
'CFBundleShortVersionString',
'raw',
'-o',
'-',
join(app, 'Contents/Info.plist'),
]);
if (bundledVersion.trim() !== version) {
throw new Error(
'The built app version does not match the release version.',
);
}
return cdhash;
}
export function releaseMacos({
env = process.env,
platform = process.platform,
arch = process.arch,
directory = dirname(fileURLToPath(import.meta.url)),
run = execute,
log = console.log,
resumeSubmissionId = null,
} = {}) {
if (platform !== 'darwin') {
throw new Error('macOS releases require a Mac with Xcode installed.');
}
if (
resumeSubmissionId !== null &&
!submissionIdPattern.test(resumeSubmissionId)
) {
throw new Error('--resume requires the Apple submission UUID.');
}
const identity = env.APPLE_SIGNING_IDENTITY?.trim();
const profile = env.APPLE_NOTARY_KEYCHAIN_PROFILE?.trim();
if (
!identity?.startsWith('Developer ID Application: ') ||
!identity.endsWith(` (${appleTeamId})`) ||
/[\r\n"]/.test(identity)
) {
throw new Error(
`Set APPLE_SIGNING_IDENTITY to the full Developer ID Application certificate name for team ${appleTeamId}.`,
);
}
if (env.APPLE_TEAM_ID && env.APPLE_TEAM_ID !== appleTeamId) {
throw new Error(`APPLE_TEAM_ID must be ${appleTeamId}.`);
}
if (!profile) {
throw new Error(
'Set APPLE_NOTARY_KEYCHAIN_PROFILE to a notarytool profile created with store-credentials for the new Apple team.',
);
}
const target =
env.MACOS_RELEASE_TARGET ||
{ arm64: 'aarch64-apple-darwin', x64: 'x86_64-apple-darwin' }[arch];
if (!['aarch64-apple-darwin', 'x86_64-apple-darwin'].includes(target)) {
throw new Error(
'MACOS_RELEASE_TARGET must be aarch64-apple-darwin or x86_64-apple-darwin.',
);
}
const config = JSON.parse(
readFileSync(join(directory, 'src-tauri/tauri.conf.json'), 'utf8'),
);
if (
config.identifier !== bundleIdentifier ||
config.productName !== 'KanbanGit'
) {
throw new Error(
'The macOS release must keep the KanbanGit product name and bundle identifier.',
);
}
if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(config.version)) {
throw new Error(
'The macOS release version must be a stable MAJOR.MINOR.PATCH version.',
);
}
const targetDirectory = join(directory, 'src-tauri/target');
const releaseDirectory = join(targetDirectory, 'macos-release');
const destination = join(releaseDirectory, `${config.version}-${target}`);
if (existsSync(destination)) {
throw new Error(
`Release output already exists: ${destination}. Move it aside or use a new version.`,
);
}
// Notarization is handled below with the explicit keychain profile, never ambient Apple credentials.
const buildEnv = Object.fromEntries(
Object.entries(env).filter(
([key]) => !key.startsWith('APPLE_') && key !== 'TAURI_CONFIG',
),
);
buildEnv.APPLE_SIGNING_IDENTITY = identity;
buildEnv.CARGO_TARGET_DIR = targetDirectory;
function step(label, command, args, inherit = false) {
log(label);
return run(command, args, {
cwd: directory,
env: buildEnv,
step: label,
inherit,
});
}
const identities = step('Checking signing certificate', 'security', [
'find-identity',
'-v',
'-p',
'codesigning',
]);
if (
!identities
.split('\n')
.some((line) => line.trim().endsWith(`"${identity}"`))
) {
throw new Error(
`Install the Developer ID Application certificate for ${appleTeamId}, including its private key, in an unlocked keychain.`,
);
}
const notaryCredentials = ['--keychain-profile', profile];
if (env.APPLE_NOTARY_KEYCHAIN?.trim()) {
notaryCredentials.push('--keychain', env.APPLE_NOTARY_KEYCHAIN.trim());
}
step(
'Checking notarization access (check the keychain profile if this fails)',
'xcrun',
[
'notarytool',
'history',
...notaryCredentials,
'--output-format',
'json',
],
);
const app = join(
targetDirectory,
target,
'release/bundle/macos/KanbanGit.app',
);
if (!resumeSubmissionId) {
step(
'Building signed macOS release',
'pnpm',
[
'exec',
'tauri',
'build',
'--ci',
'--target',
target,
'--bundles',
'app',
'--config',
JSON.stringify({
bundle: {
createUpdaterArtifacts: false,
macOS: {
signingIdentity: identity,
hardenedRuntime: true,
},
},
}),
'--',
'--locked',
],
true,
);
}
if (!existsSync(app)) {
throw new Error('The release build did not produce KanbanGit.app.');
}
const cdhash = verifySignedMacosApp({ app, version: config.version, step });
mkdirSync(releaseDirectory, { recursive: true });
const staging = mkdtempSync(join(releaseDirectory, '.pending-'));
try {
const submission = join(staging, 'notarization.zip');
let submissionId = resumeSubmissionId;
if (!submissionId) {
step('Preparing notarization archive', 'ditto', [
'-c',
'-k',
'--sequesterRsrc',
'--keepParent',
app,
submission,
]);
const submitted = JSON.parse(
step('Submitting to Apple', 'xcrun', [
'notarytool',
'submit',
submission,
...notaryCredentials,
'--no-wait',
'--output-format',
'json',
]),
);
submissionId = submitted.id;
if (!submissionIdPattern.test(submissionId ?? '')) {
throw new Error(
'Apple did not return a valid submission UUID. Check notarytool history before retrying.',
);
}
}
const resumeHint = `Resume without rebuilding or uploading: pnpm --dir native release:macos --resume ${submissionId}`;
log(`Apple submission: ${submissionId}. ${resumeHint}`);
writeFileSync(
join(
targetDirectory,
`notarization-${submissionId}.submission.json`,
),
JSON.stringify(
{
submissionId,
version: config.version,
target,
cdhash,
},
null,
2,
) + '\n',
);
let result;
try {
result = JSON.parse(
step('Waiting for Apple notarization', 'xcrun', [
'notarytool',
'wait',
submissionId,
...notaryCredentials,
'--timeout',
'30m',
'--output-format',
'json',
]),
);
} catch (error) {
throw new Error(
`${error.message} Apple may still be processing this submission. ${resumeHint}`,
);
}
if (result.status !== 'Accepted') {
throw new Error(
`Apple did not accept the release (status: ${result.status ?? 'unknown'}, submission: ${submissionId}). Inspect it with notarytool log. ${resumeHint}`,
);
}
const notaryLog = JSON.parse(
step('Checking Apple notarization log', 'xcrun', [
'notarytool',
'log',
submissionId,
...notaryCredentials,
]),
);
writeFileSync(
join(targetDirectory, `notarization-${submissionId}.log.json`),
JSON.stringify(notaryLog, null, 2) + '\n',
);
const expectedArch =
target === 'aarch64-apple-darwin' ? 'arm64' : 'x86_64';
if (
notaryLog.jobId !== submissionId ||
notaryLog.status !== 'Accepted' ||
!notaryLog.ticketContents?.some(
(ticket) =>
ticket.path.endsWith('/KanbanGit.app') &&
ticket.cdhash === cdhash &&
ticket.arch === expectedArch,
)
) {
throw new Error(
'The accepted Apple submission does not match this signed app and architecture. Restore the submitted app or create a new release.',
);
}
if (notaryLog.issues?.length) {
throw new Error(
`Apple reported notarization issues. Review src-tauri/target/notarization-${submissionId}.log.json before distribution.`,
);
}
step('Stapling notarization ticket', 'xcrun', [
'stapler',
'staple',
app,
]);
step('Validating notarization ticket', 'xcrun', [
'stapler',
'validate',
app,
]);
step('Verifying final app signature', 'codesign', [
'--verify',
'--deep',
'--strict',
app,
]);
step('Checking Gatekeeper acceptance', 'spctl', [
'--assess',
'--type',
'execute',
'--verbose=2',
app,
]);
const name = `KanbanGit-${config.version}-${target}.zip`;
const archive = join(staging, name);
step('Packaging notarized download', 'ditto', [
'-c',
'-k',
'--sequesterRsrc',
'--keepParent',
app,
archive,
]);
const checksum = createHash('sha256')
.update(readFileSync(archive))
.digest('hex');
writeFileSync(`${archive}.sha256`, `${checksum} ${name}\n`);
rmSync(submission, { force: true });
renameSync(staging, destination);
log(`Verified release: ${join(destination, name)}`);
return {
archive: join(destination, name),
checksum,
version: config.version,
target,
};
} finally {
rmSync(staging, { recursive: true, force: true });
}
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
) {
try {
const { values } = parseArgs({
options: { resume: { type: 'string' } },
});
releaseMacos({ resumeSubmissionId: values.resume ?? null });
} catch (error) {
console.error(`macOS release failed: ${error.message}`);
process.exitCode = 1;
}
}
@@ -1,6 +1,8 @@
package de.kanbangit.desktop
import android.annotation.SuppressLint
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.res.Configuration
import android.graphics.Bitmap
import android.net.Uri
@@ -8,10 +10,12 @@ import android.net.http.SslError
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.Message
import android.view.View
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.SslErrorHandler
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
@@ -24,8 +28,10 @@ import android.widget.Button
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
@@ -50,6 +56,47 @@ class MainActivity : TauriActivity() {
private var keyboardVisible = false
private var initialized = false
private var fileCallback: ValueCallback<Array<Uri>>? = null
private var fileWorkspace: WebView? = null
private var filePage: String? = null
private var multipleFiles = false
private var filePickerActive = false
private val popups = mutableSetOf<WebView>()
private val filePicker = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
filePickerActive = false
val callback = fileCallback
val owner = fileWorkspace
val page = filePage
fileCallback = null
fileWorkspace = null
filePage = null
val files = WebChromeClient.FileChooserParams.parseResult(result.resultCode, result.data)
val valid = owner != null && owner === workspace && owner.url == page &&
NavigationPolicy.allows(server, page.orEmpty()) && files?.isNotEmpty() == true &&
(multipleFiles || files.size == 1) && files.all { uri ->
NavigationPolicy.selectedFile(uri.toString(), packageName) && runCatching {
contentResolver.openFileDescriptor(uri, "r")?.use { true } == true
}.getOrDefault(false)
}
callback?.onReceiveValue(if (valid) files else null)
}
private fun cancelFileSelection() {
fileCallback?.onReceiveValue(null)
fileCallback = null
fileWorkspace = null
filePage = null
}
private fun openExternal(url: String) {
if (!NavigationPolicy.external(url)) return
try {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addCategory(Intent.CATEGORY_BROWSABLE))
} catch (_: ActivityNotFoundException) {
Toast.makeText(this, R.string.external_link_error, Toast.LENGTH_LONG).show()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
@@ -144,6 +191,7 @@ class MainActivity : TauriActivity() {
showWorkspace()
return
}
cancelFileSelection()
val changed = address != preferences.getString("server", null)
val clearSession = changed || preferences.getBoolean("clearing", false)
if (!preferences.edit().putString("server", address).putBoolean("clearing", clearSession).commit()) {
@@ -187,18 +235,67 @@ class MainActivity : TauriActivity() {
javaScriptEnabled = true
domStorageEnabled = true
allowFileAccess = false
allowContentAccess = false
allowContentAccess = true
mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
javaScriptCanOpenWindowsAutomatically = false
setSupportMultipleWindows(true)
}
CookieManager.getInstance().setAcceptThirdPartyCookies(remote, false)
remote.webChromeClient = WebChromeClient()
remote.webChromeClient = object : WebChromeClient() {
override fun onShowFileChooser(view: WebView, callback: ValueCallback<Array<Uri>>, params: FileChooserParams): Boolean {
if (view !== workspace || !NavigationPolicy.allows(server, view.url.orEmpty()) ||
params.mode !in listOf(FileChooserParams.MODE_OPEN, FileChooserParams.MODE_OPEN_MULTIPLE)) return false
// Keep one outstanding picker; stale results must never reach a different page.
if (filePickerActive) return false
fileCallback = callback
fileWorkspace = view
filePage = view.url
multipleFiles = params.mode == FileChooserParams.MODE_OPEN_MULTIPLE
return try {
filePickerActive = true
filePicker.launch(params.createIntent().addCategory(Intent.CATEGORY_OPENABLE))
true
} catch (_: ActivityNotFoundException) {
filePickerActive = false
cancelFileSelection()
Toast.makeText(this@MainActivity, R.string.file_picker_error, Toast.LENGTH_LONG).show()
true
}
}
override fun onCreateWindow(view: WebView, isDialog: Boolean, isUserGesture: Boolean, result: Message): Boolean {
if (view !== workspace || !isUserGesture || !NavigationPolicy.allows(server, view.url.orEmpty())) return false
val popup = WebView(this@MainActivity)
popups.add(popup)
fun dispose() {
if (popups.remove(popup)) popup.destroy()
}
popup.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(child: WebView, request: WebResourceRequest): Boolean {
if (request.isForMainFrame && workspace === view) {
val url = request.url.toString()
if (NavigationPolicy.allows(server, url)) view.loadUrl(url) else openExternal(url)
}
child.post { dispose() }
return true
}
}
(result.obj as WebView.WebViewTransport).webView = popup
result.sendToTarget()
handler.postDelayed({ dispose() }, 10000)
return true
}
}
remote.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean =
!NavigationPolicy.allows(server, request.url.toString())
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
val url = request.url.toString()
if (NavigationPolicy.allows(server, url)) return false
if (workspace === view && request.isForMainFrame && request.hasGesture()) openExternal(url)
return true
}
override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
if (workspace === view) cancelFileSelection()
if (!NavigationPolicy.allows(server, url)) {
view.stopLoading()
fail(view)
@@ -299,6 +396,9 @@ class MainActivity : TauriActivity() {
override fun onDestroy() {
handler.removeCallbacksAndMessages(null)
cancelFileSelection()
popups.forEach { it.destroy() }
popups.clear()
workspace?.destroy()
workspace = null
super.onDestroy()
@@ -31,6 +31,21 @@ object NavigationPolicy {
target.rawUserInfo == null && !destination.contains('\\')
}.getOrDefault(false)
fun external(destination: String): Boolean = runCatching {
val target = URI(destination)
target.scheme.lowercase(Locale.ROOT) in setOf("https", "http") &&
!target.host.isNullOrBlank() && target.rawUserInfo == null &&
(target.port == -1 || target.port in 1..65535) &&
destination.none { it.isISOControl() || it == '\\' }
}.getOrDefault(false)
fun selectedFile(destination: String, packageName: String): Boolean = runCatching {
val target = URI(destination)
target.scheme == "content" && !target.host.isNullOrBlank() &&
target.rawUserInfo == null && target.port == -1 &&
target.host != packageName && target.host != "$packageName.fileprovider"
}.getOrDefault(false)
fun backAction(keyboard: Boolean, launcher: Boolean, ready: Boolean, history: Boolean): String = when {
keyboard -> "keyboard"
launcher && ready -> "resume"
@@ -3,4 +3,6 @@
<string name="main_activity_title">"KanbanGit"</string>
<string name="server_settings">Server settings</string>
<string name="update_webview">Update Android System WebView to connect to your workspace.</string>
<string name="external_link_error">No browser is available to open this link.</string>
<string name="file_picker_error">No file picker is available. Please try again.</string>
</resources>
@@ -23,6 +23,24 @@ class NavigationPolicyTest {
assertTrue(NavigationPolicy.allows("https://example.com/", "https://example.com:443/login"))
}
@Test fun externalLinksNeverBecomeServerNavigation() {
val server = "https://example.com/"
for (url in listOf("https://git.example.com/org/repo/pulls/1#diff", "http://git.example.com/repo", "https://example.com:8443/other")) {
assertTrue(url, NavigationPolicy.external(url))
assertFalse(url, NavigationPolicy.allows(server, url))
}
for (url in listOf("https://user:password@git.example.com/", "https://example.com:99999/", "javascript:alert(1)", "intent://example.com/", "file:///etc/passwd", "content://provider/file", "data:text/html,test", "mailto:user@example.com", "https://example.com\\evil")) {
assertFalse(url, NavigationPolicy.external(url))
}
}
@Test fun filePickerAcceptsOnlyExternalContentProviders() {
assertTrue(NavigationPolicy.selectedFile("content://com.android.providers.media.documents/document/image%3A1", "de.kanbangit.desktop"))
for (url in listOf("file:///data/data/de.kanbangit.desktop/session", "https://example.com/avatar.png", "content://de.kanbangit.desktop.fileprovider/private/key", "content://de.kanbangit.desktop/private/key", "content://user@provider/path", "content://provider:123/path", "content:/missing-provider")) {
assertFalse(url, NavigationPolicy.selectedFile(url, "de.kanbangit.desktop"))
}
}
@Test fun backClosesKeyboardBeforeNavigatingAndPreservesTheWorkspace() {
assertEquals("keyboard", NavigationPolicy.backAction(true, false, true, true))
assertEquals("resume", NavigationPolicy.backAction(false, true, true, true))
+3
View File
@@ -0,0 +1,3 @@
xcuserdata/
build/
Externals/
Binary file not shown.

After

Width:  |  Height:  |  Size: 422 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 845 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 845 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 955 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,116 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "AppIcon-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "AppIcon-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "AppIcon-29x29@2x-1.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "AppIcon-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "AppIcon-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "AppIcon-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "AppIcon-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "AppIcon-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "AppIcon-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "AppIcon-20x20@2x-1.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "AppIcon-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "AppIcon-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "AppIcon-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "AppIcon-40x40@2x-1.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "AppIcon-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "AppIcon-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "AppIcon-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "AppIcon-512@2x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
}
}
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17150" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="Y6W-OH-hqX">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17122"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="s0d-6b-0kx">
<objects>
<viewController id="Y6W-OH-hqX" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="5EZ-qb-Rvc">
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<viewLayoutGuide key="safeArea" id="vDu-zF-Fre"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="Ief-a0-LHa" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
<resources>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>
+14
View File
@@ -0,0 +1,14 @@
platform :ios, '17.0'
target 'kanbangit-native_iOS' do
# Pods for kanbangit-native_iOS
end
# Inherit the deployment target from the Podfile.
post_install do |installer|
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings.delete 'IPHONEOS_DEPLOYMENT_TARGET'
end
end
end
@@ -0,0 +1,65 @@
import Foundation
struct WorkspaceServer: Codable, Equatable {
let url: URL
let profile: UUID
static func normalize(_ input: String) -> URL? {
let input = input.trimmingCharacters(in: .whitespacesAndNewlines)
guard input.lowercased().hasPrefix("https://"),
!input.contains("\\"),
input.rangeOfCharacter(from: .controlCharacters) == nil,
var components = URLComponents(string: input),
components.scheme?.lowercased() == "https",
let host = components.host, !host.isEmpty,
components.user == nil, components.password == nil,
components.query == nil, components.fragment == nil,
components.port.map({ (1...65535).contains($0) }) ?? true else { return nil }
components.scheme = "https"
components.host = host.lowercased()
if components.port == 443 { components.port = nil }
if !components.path.hasSuffix("/") { components.path += "/" }
return components.url
}
func allows(_ destination: URL) -> Bool {
destination.scheme?.lowercased() == "https"
&& destination.host?.lowercased() == url.host?.lowercased()
&& (destination.port ?? 443) == (url.port ?? 443)
&& destination.user == nil && destination.password == nil
}
static func allowsExternal(_ destination: URL) -> Bool {
["https", "http"].contains(destination.scheme?.lowercased() ?? "")
&& destination.host?.isEmpty == false
&& (destination.port.map({ (1...65535).contains($0) }) ?? true)
&& destination.user == nil && destination.password == nil
}
static func selecting(_ url: URL, current: WorkspaceServer?) -> WorkspaceServer {
current?.url == url ? current! : WorkspaceServer(url: url, profile: UUID())
}
}
struct WorkspaceConnection {
private(set) var revision = 0
private(set) var ready = false
private(set) var connecting = false
mutating func begin() -> Int {
revision += 1
connecting = true
return revision
}
mutating func finish() {
connecting = false
ready = true
}
mutating func fail(_ expectedRevision: Int) -> Bool {
guard expectedRevision == revision, connecting else { return false }
connecting = false
return true
}
}
@@ -0,0 +1,332 @@
import UIKit
import WebKit
@_cdecl("install_ios_workspace")
func installIOSWorkspace() {
DispatchQueue.main.async {
guard let root = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene }).flatMap(\.windows)
.first(where: { $0.isKeyWindow })?.rootViewController
?? UIApplication.shared.windows.first?.rootViewController else { return }
guard !root.children.contains(where: { $0 is WorkspaceViewController }) else { return }
let controller = WorkspaceViewController()
root.view.subviews.forEach { $0.isHidden = true }
root.addChild(controller)
root.view.addSubview(controller.view)
controller.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
controller.view.topAnchor.constraint(equalTo: root.view.topAnchor),
controller.view.bottomAnchor.constraint(equalTo: root.view.bottomAnchor),
controller.view.leadingAnchor.constraint(equalTo: root.view.leadingAnchor),
controller.view.trailingAnchor.constraint(equalTo: root.view.trailingAnchor),
])
controller.didMove(toParent: root)
}
}
final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKUIDelegate, UITextFieldDelegate {
private let defaults: UserDefaults
private let serverKey = "ios.activeServer"
private(set) var server: WorkspaceServer?
private(set) var workspace: WKWebView?
private var connection = WorkspaceConnection()
private var pendingNavigation: WKNavigation?
private var timeout: DispatchWorkItem?
private let launcher = UIScrollView()
private let stack = UIStackView()
private let address = UITextField()
private let status = UILabel()
private let connectButton = UIButton(type: .system)
private let resumeButton = UIButton(type: .system)
private let toolbar = UIToolbar()
private var historyObservation: NSKeyValueObservation?
private var backButton: UIBarButtonItem!
private var appearance: UISegmentedControl!
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
private func text(_ key: String) -> String {
NSLocalizedString(key, comment: "KanbanGit iOS workspace")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
view.tintColor = UIColor(red: 0, green: 0.43, blue: 0.18, alpha: 1)
configureLauncher()
configureToolbar()
if let data = defaults.data(forKey: serverKey) {
if let saved = try? JSONDecoder().decode(WorkspaceServer.self, from: data),
WorkspaceServer.normalize(saved.url.absoluteString) == saved.url {
server = saved
} else {
status.text = text("storageError")
}
}
address.text = server?.url.absoluteString ?? "https://kanbangit.de/"
showSettings()
if let server { open(server.url) }
}
private func label(_ key: String, style: UIFont.TextStyle = .body) -> UILabel {
let label = UILabel()
label.text = text(key)
label.font = .preferredFont(forTextStyle: style)
label.adjustsFontForContentSizeCategory = true
label.numberOfLines = 0
return label
}
private func configureLauncher() {
launcher.accessibilityIdentifier = "serverSettings"
launcher.keyboardDismissMode = .interactive
launcher.alwaysBounceVertical = true
view.addSubview(launcher)
launcher.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
launcher.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
launcher.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
launcher.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
launcher.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
])
stack.axis = .vertical
stack.spacing = 18
stack.translatesAutoresizingMaskIntoConstraints = false
launcher.addSubview(stack)
let width = stack.widthAnchor.constraint(equalTo: launcher.frameLayoutGuide.widthAnchor, constant: -40)
width.priority = .defaultHigh
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: launcher.contentLayoutGuide.topAnchor, constant: 32),
stack.bottomAnchor.constraint(equalTo: launcher.contentLayoutGuide.bottomAnchor, constant: -32),
stack.centerXAnchor.constraint(equalTo: launcher.frameLayoutGuide.centerXAnchor),
stack.leadingAnchor.constraint(greaterThanOrEqualTo: launcher.contentLayoutGuide.leadingAnchor, constant: 20),
stack.trailingAnchor.constraint(lessThanOrEqualTo: launcher.contentLayoutGuide.trailingAnchor, constant: -20),
stack.widthAnchor.constraint(lessThanOrEqualToConstant: 480), width,
])
stack.addArrangedSubview(label("appName", style: .headline))
stack.addArrangedSubview(label("title", style: .largeTitle))
stack.addArrangedSubview(label("description"))
stack.addArrangedSubview(label("serverLabel", style: .headline))
address.borderStyle = .roundedRect
address.keyboardType = .URL
address.textContentType = .URL
address.autocorrectionType = .no
address.autocapitalizationType = .none
address.clearButtonMode = .whileEditing
address.returnKeyType = .go
address.delegate = self
address.accessibilityLabel = text("serverLabel")
address.accessibilityIdentifier = "serverAddress"
address.heightAnchor.constraint(greaterThanOrEqualToConstant: 48).isActive = true
stack.addArrangedSubview(address)
stack.addArrangedSubview(label("serverHelp", style: .footnote))
connectButton.configuration = .filled()
connectButton.setTitle(text("openWorkspace"), for: .normal)
connectButton.accessibilityIdentifier = "openWorkspace"
connectButton.addTarget(self, action: #selector(connect), for: .touchUpInside)
stack.addArrangedSubview(connectButton)
status.numberOfLines = 0
status.accessibilityIdentifier = "connectionStatus"
status.font = .preferredFont(forTextStyle: .body)
status.adjustsFontForContentSizeCategory = true
stack.addArrangedSubview(status)
resumeButton.setTitle(text("resume"), for: .normal)
resumeButton.accessibilityIdentifier = "resumeWorkspace"
resumeButton.addTarget(self, action: #selector(showWorkspace), for: .touchUpInside)
stack.addArrangedSubview(resumeButton)
stack.addArrangedSubview(label("appearance", style: .headline))
appearance = UISegmentedControl(items: [text("system"), text("light"), text("dark")])
appearance.selectedSegmentIndex = min(2, max(0, defaults.integer(forKey: "ios.appearance")))
appearance.accessibilityIdentifier = "appearance"
appearance.addTarget(self, action: #selector(changeAppearance), for: .valueChanged)
stack.addArrangedSubview(appearance)
[connectButton, resumeButton, appearance].forEach {
$0.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).isActive = true
}
changeAppearance()
}
private func configureToolbar() {
toolbar.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(toolbar)
backButton = UIBarButtonItem(title: text("back"), style: .plain, target: self, action: #selector(goBack))
backButton.accessibilityIdentifier = "workspaceBack"
let settings = UIBarButtonItem(title: text("serverSettings"), style: .plain, target: self, action: #selector(showSettings))
settings.accessibilityIdentifier = "showServerSettings"
toolbar.items = [backButton, UIBarButtonItem(systemItem: .flexibleSpace), settings]
NSLayoutConstraint.activate([
toolbar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
toolbar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
toolbar.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
])
}
@objc private func changeAppearance() {
defaults.set(appearance.selectedSegmentIndex, forKey: "ios.appearance")
overrideUserInterfaceStyle = [.unspecified, .light, .dark][appearance.selectedSegmentIndex]
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool { connect(); return true }
@objc private func connect() {
guard let url = WorkspaceServer.normalize(address.text ?? "") else {
status.text = text("invalidServer")
UIAccessibility.post(notification: .announcement, argument: status.text)
return
}
if let server, server.url != url {
let alert = UIAlertController(title: text("confirmSwitch"), message: text("switchWarning"), preferredStyle: .alert)
alert.addAction(UIAlertAction(title: text("cancel"), style: .cancel))
alert.addAction(UIAlertAction(title: text("confirmSwitch"), style: .destructive) { [weak self] _ in self?.open(url) })
present(alert, animated: true)
} else { open(url) }
}
func open(_ url: URL) {
if server?.url == url, connection.ready { showWorkspace(); return }
let selected = WorkspaceServer.selecting(url, current: server)
guard let data = try? JSONEncoder().encode(selected) else { status.text = text("storageError"); return }
defaults.set(data, forKey: serverKey)
timeout?.cancel()
historyObservation = nil
workspace?.stopLoading()
workspace?.navigationDelegate = nil
workspace?.removeFromSuperview()
connection = WorkspaceConnection()
server = selected
let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = WKWebsiteDataStore(forIdentifier: selected.profile)
// Remote content receives a plain WKWebView, with no native message handlers or Tauri bridge.
let webview = WKWebView(frame: .zero, configuration: configuration)
webview.navigationDelegate = self
webview.uiDelegate = self
webview.allowsBackForwardNavigationGestures = true
webview.accessibilityIdentifier = "workspace"
webview.translatesAutoresizingMaskIntoConstraints = false
webview.isHidden = true
view.insertSubview(webview, belowSubview: launcher)
NSLayoutConstraint.activate([
webview.topAnchor.constraint(equalTo: toolbar.bottomAnchor),
webview.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
webview.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
webview.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
])
workspace = webview
historyObservation = webview.observe(\.canGoBack, options: [.initial, .new]) { [weak self] view, _ in
self?.backButton.isEnabled = view.canGoBack
}
view.endEditing(true)
address.text = url.absoluteString
beginNavigation()
pendingNavigation = webview.load(URLRequest(url: url))
}
private func beginNavigation() {
timeout?.cancel()
let revision = connection.begin()
status.text = text("connecting")
connectButton.isEnabled = false
resumeButton.isHidden = !connection.ready
let work = DispatchWorkItem { [weak self] in self?.fail(revision) }
timeout = work
DispatchQueue.main.asyncAfter(deadline: .now() + 15, execute: work)
}
private func fail(_ revision: Int) {
guard connection.fail(revision) else { return }
timeout?.cancel()
status.text = text("connectionError")
connectButton.isEnabled = true
connectButton.setTitle(text("retry"), for: .normal)
showSettings()
}
@objc private func showSettings() {
view.endEditing(true)
launcher.isHidden = false
toolbar.isHidden = true
workspace?.isHidden = true
resumeButton.isHidden = !connection.ready
}
@objc private func showWorkspace() {
guard connection.ready else { return }
view.endEditing(true)
launcher.isHidden = true
toolbar.isHidden = false
workspace?.isHidden = false
backButton.isEnabled = workspace?.canGoBack == true
}
@objc private func goBack() { if workspace?.canGoBack == true { workspace?.goBack() } }
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 {
decisionHandler(.cancel)
if action.navigationType == .linkActivated,
action.sourceFrame.isMainFrame,
action.targetFrame == nil || action.targetFrame?.isMainFrame == true,
WorkspaceServer.allowsExternal(url) {
UIApplication.shared.open(url)
}
return
}
if action.targetFrame == nil {
decisionHandler(.cancel)
webView.load(action.request)
} else { decisionHandler(.allow) }
}
func webView(_ webView: WKWebView, decidePolicyFor response: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
guard webView === workspace, let url = response.response.url,
server?.allows(url) == true else { decisionHandler(.cancel); return }
decisionHandler(.allow)
}
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
guard webView === workspace else { return }
pendingNavigation = navigation
beginNavigation()
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
guard webView === workspace, navigation === pendingNavigation else { return }
let firstLoad = !connection.ready
timeout?.cancel()
connection.finish()
connectButton.isEnabled = true
connectButton.setTitle(text("openWorkspace"), for: .normal)
status.text = text("connected")
resumeButton.isHidden = false
backButton.isEnabled = webView.canGoBack
if firstLoad { showWorkspace() }
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
navigationFailed(webView, navigation, error)
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
navigationFailed(webView, navigation, error)
}
private func navigationFailed(_ webView: WKWebView, _ navigation: WKNavigation?, _ error: Error) {
guard webView === workspace, navigation === pendingNavigation,
(error as NSError).code != NSURLErrorCancelled else { return }
fail(connection.revision)
}
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
guard webView === workspace else { return }
connection = WorkspaceConnection()
status.text = text("processTerminated")
connectButton.isEnabled = true
showSettings()
}
}
@@ -0,0 +1,7 @@
#pragma once
namespace ffi {
extern "C" {
void start_app();
}
}
@@ -0,0 +1,2 @@
"NSCameraUsageDescription" = "Choose a profile picture using your camera.";
"NSPhotoLibraryUsageDescription" = "Choose a profile picture from your photo library.";
@@ -0,0 +1,23 @@
"appName" = "KanbanGit";
"title" = "Choose your workspace.";
"description" = "Connect to your KanbanGit installation over HTTPS.";
"serverLabel" = "Server address";
"serverHelp" = "Use the installation address without login details, a query or a fragment.";
"openWorkspace" = "Open workspace";
"retry" = "Try again";
"resume" = "Return to workspace";
"switchWarning" = "Switching servers closes the current workspace and discards unsaved inputs. You will sign in separately on the new server.";
"confirmSwitch" = "Switch server";
"cancel" = "Cancel";
"connecting" = "Connecting to your server…";
"connected" = "Your workspace is open. Use Server settings to change servers.";
"connectionError" = "The server did not finish loading. Check your connection, server address and HTTPS certificate, then try again. An already open workspace and its inputs are kept.";
"invalidServer" = "Enter a valid HTTPS installation address without login details, a query or a fragment.";
"storageError" = "The saved server setting could not be read. Enter your server address again.";
"processTerminated" = "iOS closed the workspace to free memory. Open it again to continue. Unsaved inputs may have been lost.";
"appearance" = "Appearance";
"system" = "System";
"light" = "Light";
"dark" = "Dark";
"back" = "Back";
"serverSettings" = "Server settings";
@@ -0,0 +1,36 @@
#include "bindings/bindings.h"
#import <UIKit/UIKit.h>
#include <cstdlib>
#include <unistd.h>
extern "C" void install_ios_workspace(void);
int main(int argc, char * argv[]) {
@autoreleasepool {
// Xcode mirrors os_log to stderr. Tauri 2.11 redirects stderr back to os_log,
// which can fill its pipe and block UIKit/XCTest. Keep Xcode's original streams.
const bool debuggerLogging = getenv("OS_ACTIVITY_DT_MODE") != nullptr
|| getenv("XCTestConfigurationFilePath") != nullptr;
__block int originalOut = debuggerLogging ? dup(STDOUT_FILENO) : -1;
__block int originalError = debuggerLogging ? dup(STDERR_FILENO) : -1;
[[NSNotificationCenter defaultCenter]
addObserverForName:UIApplicationDidBecomeActiveNotification
object:nil
queue:NSOperationQueue.mainQueue
usingBlock:^(NSNotification *notification) {
if (originalOut >= 0) {
dup2(originalOut, STDOUT_FILENO);
close(originalOut);
originalOut = -1;
}
if (originalError >= 0) {
dup2(originalError, STDERR_FILENO);
close(originalError);
originalError = -1;
}
install_ios_workspace();
}];
ffi::start_app();
return 0;
}
}
@@ -0,0 +1,166 @@
import XCTest
import WebKit
final class WorkspaceTests: XCTestCase {
func testServerValidationAndOriginBoundary() throws {
let url = try XCTUnwrap(WorkspaceServer.normalize(" HTTPS://Example.com:443/kanban "))
XCTAssertEqual(url.absoluteString, "https://example.com/kanban/")
let server = WorkspaceServer(url: url, profile: UUID())
for value in ["http://example.com/", "https://user:secret@example.com/", "https://example.com/?token=1", "https://example.com/#x", "https://exa\nmple.com", "https://example.com\\evil", "file:///etc/passwd", "javascript:alert(1)", "https://example.com:99999/"] {
XCTAssertNil(WorkspaceServer.normalize(value), value)
}
for value in ["https://example.com/login", "https://example.com/two-factor-challenge", "https://example.com/projects/1?ticket=54#details"] {
XCTAssertTrue(server.allows(try XCTUnwrap(URL(string: value))), value)
}
for value in ["http://example.com/", "https://example.com:8443/", "https://evil.example.com/", "https://user@example.com/", "file:///tmp/index.html", "tauri://localhost/action/open"] {
XCTAssertFalse(server.allows(try XCTUnwrap(URL(string: value))), value)
}
}
func testExternalLinksStayOutsideTheSelectedServer() throws {
let server = WorkspaceServer(url: URL(string: "https://example.com/")!, profile: UUID())
for value in ["https://git.example.com/org/repo/pulls/1#diff", "http://git.example.com/repo", "https://example.com:8443/other"] {
let url = try XCTUnwrap(URL(string: value))
XCTAssertTrue(WorkspaceServer.allowsExternal(url))
XCTAssertFalse(server.allows(url))
}
for value in ["https://user:secret@git.example.com/", "https://example.com:99999/", "javascript:alert(1)", "file:///etc/passwd", "tauri://localhost/action/open", "mailto:user@example.com", "data:text/html,test"] {
XCTAssertFalse(WorkspaceServer.allowsExternal(try XCTUnwrap(URL(string: value))), value)
}
}
@MainActor
func testBackButtonTracksSameDocumentHistory() async throws {
let name = "KAN55-history-\(UUID())"
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
defer { defaults.removePersistentDomain(forName: name) }
let controller = WorkspaceViewController(defaults: defaults)
controller.loadViewIfNeeded()
let url = try XCTUnwrap(WorkspaceServer.normalize("https://example.invalid/"))
controller.open(url)
let webview = try XCTUnwrap(controller.workspace)
webview.stopLoading()
webview.loadHTMLString("<html><body><input id='draft' value='unsaved'></body></html>", baseURL: url)
var loaded = false
for _ in 0..<50 {
if let value = try? await webview.evaluateJavaScript("document.getElementById('draft')?.value"),
value as? String == "unsaved" { loaded = true; break }
try await Task.sleep(for: .milliseconds(100))
}
XCTAssertTrue(loaded)
_ = try await webview.evaluateJavaScript("history.pushState({}, '', '#ticket')")
let toolbar = try XCTUnwrap(controller.view.subviews.compactMap { $0 as? UIToolbar }.first)
for _ in 0..<20 {
if toolbar.items?.first?.isEnabled == true { break }
try await Task.sleep(for: .milliseconds(50))
}
XCTAssertTrue(webview.canGoBack)
XCTAssertTrue(toolbar.items?.first?.isEnabled == true)
webview.goBack()
for _ in 0..<20 {
if let hash = try? await webview.evaluateJavaScript("location.hash"), hash as? String == "" { break }
try await Task.sleep(for: .milliseconds(50))
}
let draft = try await webview.evaluateJavaScript("document.getElementById('draft').value")
XCTAssertEqual(draft as? String, "unsaved")
}
func testServerChangesNeverReusePreviousProfile() throws {
let firstURL = try XCTUnwrap(WorkspaceServer.normalize("https://example.com/"))
let otherURL = try XCTUnwrap(WorkspaceServer.normalize("https://example.com/other/"))
let first = WorkspaceServer.selecting(firstURL, current: nil)
XCTAssertEqual(WorkspaceServer.selecting(firstURL, current: first), first)
let second = WorkspaceServer.selecting(otherURL, current: first)
XCTAssertNotEqual(second.profile, first.profile)
XCTAssertNotEqual(WorkspaceServer.selecting(firstURL, current: second).profile, first.profile)
XCTAssertEqual(try JSONDecoder().decode(WorkspaceServer.self, from: JSONEncoder().encode(first)), first)
}
func testTimeoutDoesNotDiscardLoadedWorkspaceOrAffectLaterNavigation() {
var state = WorkspaceConnection()
let first = state.begin()
let second = state.begin()
XCTAssertFalse(state.fail(first))
state.finish()
XCTAssertFalse(state.fail(second))
let third = state.begin()
XCTAssertTrue(state.fail(third))
XCTAssertTrue(state.ready)
XCTAssertFalse(state.fail(third))
}
@MainActor
func testWKWebViewPersistsCookiesAcrossViewsAndIsolatesServerChanges() async throws {
let profile = UUID()
let first = WKWebViewConfiguration()
first.websiteDataStore = WKWebsiteDataStore(forIdentifier: profile)
let cookie = try XCTUnwrap(HTTPCookie(properties: [
.domain: "example.com", .path: "/", .name: "laravel_session", .value: "test-session",
.secure: "TRUE", .expires: Date().addingTimeInterval(3600),
]))
await first.websiteDataStore.httpCookieStore.setCookie(cookie)
let restored = WKWebViewConfiguration()
restored.websiteDataStore = WKWebsiteDataStore(forIdentifier: profile)
let restoredCookies = await restored.websiteDataStore.httpCookieStore.allCookies()
XCTAssertTrue(restoredCookies.contains { $0.name == "laravel_session" && $0.value == "test-session" })
let other = WKWebsiteDataStore(forIdentifier: UUID())
let otherCookies = await other.httpCookieStore.allCookies()
XCTAssertTrue(otherCookies.isEmpty)
await restored.websiteDataStore.httpCookieStore.deleteCookie(cookie)
let loggedOut = await first.websiteDataStore.httpCookieStore.allCookies()
XCTAssertFalse(loggedOut.contains { $0.name == "laravel_session" })
}
@MainActor
func testControllerRestoresServerAndCreatesIsolatedUnprivilegedWebView() throws {
let name = "KAN54-tests-\(UUID())"
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
defer { defaults.removePersistentDomain(forName: name) }
let controller = WorkspaceViewController(defaults: defaults)
controller.loadViewIfNeeded()
let url = try XCTUnwrap(WorkspaceServer.normalize("https://example.invalid/"))
controller.open(url)
let server = try XCTUnwrap(controller.server)
XCTAssertEqual(controller.workspace?.configuration.websiteDataStore.identifier, server.profile)
XCTAssertTrue(controller.workspace?.configuration.userContentController.userScripts.isEmpty == true)
let restored = WorkspaceViewController(defaults: defaults)
restored.loadViewIfNeeded()
XCTAssertEqual(restored.server, server)
let other = try XCTUnwrap(WorkspaceServer.normalize("https://other.invalid/"))
restored.open(other)
XCTAssertNotEqual(restored.server?.profile, server.profile)
}
@MainActor
func testConnectionFailureKeepsLoadedFormAndAllowsResume() async throws {
let name = "KAN54-form-\(UUID())"
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
defer { defaults.removePersistentDomain(forName: name) }
let controller = WorkspaceViewController(defaults: defaults)
controller.loadViewIfNeeded()
let url = try XCTUnwrap(WorkspaceServer.normalize("https://example.invalid/"))
controller.open(url)
let webview = try XCTUnwrap(controller.workspace)
webview.stopLoading()
let navigation = try XCTUnwrap(webview.loadHTMLString("<html><body><input id='draft' value='unsaved ticket'></body></html>", baseURL: url))
var loaded = false
for _ in 0..<50 {
if let value = try? await webview.evaluateJavaScript("document.getElementById('draft')?.value"),
value as? String == "unsaved ticket" { loaded = true; break }
try await Task.sleep(for: .milliseconds(100))
}
XCTAssertTrue(loaded)
controller.webView(webview, didFinish: navigation)
controller.webView(webview, didStartProvisionalNavigation: navigation)
controller.webView(webview, didFailProvisionalNavigation: navigation,
withError: NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet))
XCTAssertTrue(webview.isHidden)
controller.open(url)
XCTAssertTrue(controller.workspace === webview)
XCTAssertFalse(webview.isHidden)
let value = try await webview.evaluateJavaScript("document.getElementById('draft').value")
XCTAssertEqual(value as? String, "unsaved ticket")
let bridge = try await webview.evaluateJavaScript("typeof window.__TAURI_INTERNALS__")
XCTAssertEqual(bridge as? String, "undefined")
}
}
@@ -0,0 +1,44 @@
import XCTest
final class WorkspaceUITests: XCTestCase {
@MainActor
private func reveal(_ element: XCUIElement, in scrollView: XCUIElement) {
for _ in 0..<8 {
if scrollView.frame.contains(CGPoint(x: element.frame.midX, y: element.frame.midY)), element.isHittable { return }
let start = scrollView.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
let direction: CGFloat = element.frame.midY > scrollView.frame.midY ? 0.2 : 0.8
let end = scrollView.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: direction))
start.press(forDuration: 0.05, thenDragTo: end)
}
XCTAssertTrue(element.isHittable)
}
@MainActor
func testLauncherKeyboardValidationRotationAndAppearance() {
continueAfterFailure = false
XCUIDevice.shared.orientation = .portrait
let app = XCUIApplication()
app.launch()
let settings = app.buttons["showServerSettings"]
if settings.waitForExistence(timeout: 3) { settings.tap() }
let address = app.textFields["serverAddress"]
XCTAssertTrue(address.waitForExistence(timeout: 15))
address.tap()
let clear = address.buttons.firstMatch
if clear.exists { clear.tap() }
address.typeText("http://invalid.example")
app.buttons["openWorkspace"].tap()
XCTAssertTrue(app.staticTexts["connectionStatus"].label.contains("valid HTTPS"))
XCUIDevice.shared.orientation = .landscapeLeft
let launcher = app.scrollViews["serverSettings"]
reveal(address, in: launcher)
let open = app.buttons["openWorkspace"]
reveal(open, in: launcher)
let dark = app.segmentedControls["appearance"].buttons["Dark"]
reveal(dark, in: launcher)
dark.tap()
XCTAssertTrue(dark.isSelected)
app.segmentedControls["appearance"].buttons["System"].tap()
XCUIDevice.shared.orientation = .portrait
}
}
@@ -0,0 +1,701 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
009F8FEC2765B2F19DC12CA8 /* WorkspaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */; };
0FAB3CC7BB466BFF871C0C30 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 31630170C358EA8657E34854 /* Assets.xcassets */; };
205D0CBA33F7D3C59AAD5300 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 63685B1189795CAEA85F7AB7 /* QuartzCore.framework */; };
333E7761EA87AE29CE04F1AC /* main.mm in Sources */ = {isa = PBXBuildFile; fileRef = F46963153D20DB21D97843E9 /* main.mm */; };
34C189B72A1B9EBE33D700D1 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F63BC1BDE65F5AEBE5B13519 /* LaunchScreen.storyboard */; };
5E764E27CA85A3746845F417 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8243D0C3821DAC731FECF105 /* UIKit.framework */; };
6514B0DB3A5CCCDF7385AC18 /* WorkspaceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE63F98563975EF4FF2A10FB /* WorkspaceViewController.swift */; };
70EBA05ACBA5402617A3371C /* WorkspacePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 554AB059B4228085924F4959 /* WorkspacePolicy.swift */; };
7245AD23F39C4620BAA3AA92 /* WorkspaceUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED199829CC1B1598F58EDC9B /* WorkspaceUITests.swift */; };
73954B7AADE8C37663BEF288 /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CA65E67046326EA008175045 /* Metal.framework */; };
98D5B23C9BE2F62C23DF397A /* WorkspacePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 554AB059B4228085924F4959 /* WorkspacePolicy.swift */; };
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 */; };
C44BDCDEA454DBD106D51C9C /* WorkspaceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE63F98563975EF4FF2A10FB /* WorkspaceViewController.swift */; };
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 */; };
EB37EAF992C1A156795CAEC0 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
8665A25D07C5F1A295164797 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = AA6AD2A91A9E86D319D86D59 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 721CF221CEDDC28FF2C5EBD5;
remoteInfo = "kanbangit-native_iOS";
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
01E10FB645809173AF1D4C2D /* bindings.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = bindings.h; sourceTree = "<group>"; };
05A753935B6CB0316DA56D96 /* desktop.rs */ = {isa = PBXFileReference; path = desktop.rs; sourceTree = "<group>"; };
08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTests.swift; sourceTree = "<group>"; };
0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; };
23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = "kanbangit-native_iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
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>"; };
63685B1189795CAEA85F7AB7 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; };
7440C7F0426E6B3977DD1D94 /* main.rs */ = {isa = PBXFileReference; path = main.rs; sourceTree = "<group>"; };
8243D0C3821DAC731FECF105 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; };
91991DFDB0C064B06DF854ED /* libapp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libapp.a; sourceTree = "<group>"; };
92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; };
946BA5CC3FD90AD90D101A36 /* kanbangit-native_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "kanbangit-native_iOS.entitlements"; sourceTree = "<group>"; };
9C56BE6890F0452F69D64795 /* android.rs */ = {isa = PBXFileReference; path = android.rs; sourceTree = "<group>"; };
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>"; };
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>"; };
D581BB90601D7A89F8F188E2 /* MetalKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MetalKit.framework; path = System/Library/Frameworks/MetalKit.framework; sourceTree = SDKROOT; };
DE3B71153677F722CAC6DCF7 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; };
E8BA104C81071623E1FF367D /* server.rs */ = {isa = PBXFileReference; path = server.rs; sourceTree = "<group>"; };
ED199829CC1B1598F58EDC9B /* WorkspaceUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceUITests.swift; sourceTree = "<group>"; };
ED4469441C3E0F37B1123572 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
EE63F98563975EF4FF2A10FB /* WorkspaceViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceViewController.swift; sourceTree = "<group>"; };
F46963153D20DB21D97843E9 /* main.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = main.mm; sourceTree = "<group>"; };
F63BC1BDE65F5AEBE5B13519 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
32E70C778E91CB2E8CD86715 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
D8772A5E508092D6F831058A /* libapp.a in Frameworks */,
EB37EAF992C1A156795CAEC0 /* CoreGraphics.framework in Frameworks */,
73954B7AADE8C37663BEF288 /* Metal.framework in Frameworks */,
CD64D99EBD87E6FFC0C9001C /* MetalKit.framework in Frameworks */,
205D0CBA33F7D3C59AAD5300 /* QuartzCore.framework in Frameworks */,
B939982700E800053A9C7663 /* Security.framework in Frameworks */,
5E764E27CA85A3746845F417 /* UIKit.framework in Frameworks */,
C362C102D4950A81D89BB8C8 /* WebKit.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
140A7DB7E2461E64B1FBFA70 /* Tests */ = {
isa = PBXGroup;
children = (
08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */,
);
path = Tests;
sourceTree = "<group>";
};
157AF88201BDE4D99AA2E14C = {
isa = PBXGroup;
children = (
31630170C358EA8657E34854 /* Assets.xcassets */,
F63BC1BDE65F5AEBE5B13519 /* LaunchScreen.storyboard */,
4058655E6F37D054E25D1F37 /* kanbangit-native_iOS */,
8606F3EE5A736385061692EF /* Sources */,
335B1262A9019C70C7F887A5 /* src */,
140A7DB7E2461E64B1FBFA70 /* Tests */,
3C9F31BB065F210267ECA390 /* UITests */,
3028C3786B437115B6486F1C /* Frameworks */,
59D2DE9FD94E6D2D64CB0117 /* Products */,
);
sourceTree = "<group>";
};
2A11A26F0082A5075CD710C6 /* bindings */ = {
isa = PBXGroup;
children = (
01E10FB645809173AF1D4C2D /* bindings.h */,
);
path = bindings;
sourceTree = "<group>";
};
3028C3786B437115B6486F1C /* Frameworks */ = {
isa = PBXGroup;
children = (
0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */,
91991DFDB0C064B06DF854ED /* libapp.a */,
CA65E67046326EA008175045 /* Metal.framework */,
D581BB90601D7A89F8F188E2 /* MetalKit.framework */,
63685B1189795CAEA85F7AB7 /* QuartzCore.framework */,
DE3B71153677F722CAC6DCF7 /* Security.framework */,
8243D0C3821DAC731FECF105 /* UIKit.framework */,
92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
335B1262A9019C70C7F887A5 /* src */ = {
isa = PBXGroup;
children = (
9C56BE6890F0452F69D64795 /* android.rs */,
05A753935B6CB0316DA56D96 /* desktop.rs */,
CAF08AD60D2F29A879CDAEBB /* ios.rs */,
2B96972F58CC158AB3DFF94A /* lib.rs */,
AEBAD9ABE758897C50BC1F2E /* locale.rs */,
7440C7F0426E6B3977DD1D94 /* main.rs */,
E8BA104C81071623E1FF367D /* server.rs */,
);
name = src;
path = ../../src;
sourceTree = "<group>";
};
3C9F31BB065F210267ECA390 /* UITests */ = {
isa = PBXGroup;
children = (
ED199829CC1B1598F58EDC9B /* WorkspaceUITests.swift */,
);
path = UITests;
sourceTree = "<group>";
};
4058655E6F37D054E25D1F37 /* kanbangit-native_iOS */ = {
isa = PBXGroup;
children = (
D13B8D854BEB7757AB95231B /* Info.plist */,
946BA5CC3FD90AD90D101A36 /* kanbangit-native_iOS.entitlements */,
);
path = "kanbangit-native_iOS";
sourceTree = "<group>";
};
59D2DE9FD94E6D2D64CB0117 /* Products */ = {
isa = PBXGroup;
children = (
23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */,
A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */,
9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
8606F3EE5A736385061692EF /* Sources */ = {
isa = PBXGroup;
children = (
89AA023B8C28CB860513C685 /* kanbangit-native */,
);
path = Sources;
sourceTree = "<group>";
};
89AA023B8C28CB860513C685 /* kanbangit-native */ = {
isa = PBXGroup;
children = (
4C76059D31C50B198779A5F7 /* en.lproj */,
F46963153D20DB21D97843E9 /* main.mm */,
554AB059B4228085924F4959 /* WorkspacePolicy.swift */,
EE63F98563975EF4FF2A10FB /* WorkspaceViewController.swift */,
2A11A26F0082A5075CD710C6 /* bindings */,
2AB6F9398EA3CB62A68287D2 /* InfoPlist.strings */,
C8C02BC0E757897278EB954A /* Localizable.strings */,
);
path = "kanbangit-native";
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */ = {
isa = PBXNativeTarget;
buildConfigurationList = F0C7EA9610FB69A5EA27B950 /* Build configuration list for PBXNativeTarget "kanbangit-native_iOS" */;
buildPhases = (
C74F06A7ECCC2C6C79C74A2D /* Build Rust Code */,
F39C45598E4345705F3820FA /* Sources */,
4A4C7FC1B57785DDD6B301FB /* Resources */,
32E70C778E91CB2E8CD86715 /* Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = "kanbangit-native_iOS";
packageProductDependencies = (
);
productName = "kanbangit-native_iOS";
productReference = 23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */;
productType = "com.apple.product-type.application";
};
988CFF304CB81C8F3A4D6612 /* WorkspaceUITests */ = {
isa = PBXNativeTarget;
buildConfigurationList = E6F418AEF58E5F3D739C68F1 /* Build configuration list for PBXNativeTarget "WorkspaceUITests" */;
buildPhases = (
0258B26407CD2D8C61079F84 /* Sources */,
);
buildRules = (
);
dependencies = (
5658F3B2915DC3A46531EDCE /* PBXTargetDependency */,
);
name = WorkspaceUITests;
packageProductDependencies = (
);
productName = WorkspaceUITests;
productReference = 9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
CBC4561F8018D37DD49A0598 /* WorkspaceTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 26F675D59EF454AE0D263F35 /* Build configuration list for PBXNativeTarget "WorkspaceTests" */;
buildPhases = (
2A0467365B852D44D1A9FD5B /* Sources */,
);
buildRules = (
);
dependencies = (
);
name = WorkspaceTests;
packageProductDependencies = (
);
productName = WorkspaceTests;
productReference = A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
AA6AD2A91A9E86D319D86D59 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1430;
TargetAttributes = {
721CF221CEDDC28FF2C5EBD5 = {
DevelopmentTeam = "";
ProvisioningStyle = Automatic;
};
988CFF304CB81C8F3A4D6612 = {
TestTargetID = 721CF221CEDDC28FF2C5EBD5;
};
};
};
buildConfigurationList = A9CA551DD3F3EBCCF8C41CC1 /* Build configuration list for PBXProject "kanbangit-native" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
Base,
en,
);
mainGroup = 157AF88201BDE4D99AA2E14C;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = 59D2DE9FD94E6D2D64CB0117 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */,
CBC4561F8018D37DD49A0598 /* WorkspaceTests */,
988CFF304CB81C8F3A4D6612 /* WorkspaceUITests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
4A4C7FC1B57785DDD6B301FB /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
0FAB3CC7BB466BFF871C0C30 /* Assets.xcassets in Resources */,
C53E7EC983C008DF98244C74 /* InfoPlist.strings in Resources */,
34C189B72A1B9EBE33D700D1 /* LaunchScreen.storyboard in Resources */,
A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
C74F06A7ECCC2C6C79C74A2D /* Build Rust Code */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Build Rust Code";
outputFileListPaths = (
);
outputPaths = (
"$(SRCROOT)/Externals/x86_64/${CONFIGURATION}/libapp.a",
"$(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "export PATH=\"$(dirname \"$(rustup which cargo)\"):$PATH\"\nexport RUSTC=\"$(rustup which rustc)\"\ncd \"$SRCROOT/../../..\"\n./node_modules/.bin/tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths \"${FRAMEWORK_SEARCH_PATHS:?}\" --header-search-paths \"${HEADER_SEARCH_PATHS:?}\" --gcc-preprocessor-definitions \"${GCC_PREPROCESSOR_DEFINITIONS:-}\" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
0258B26407CD2D8C61079F84 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
7245AD23F39C4620BAA3AA92 /* WorkspaceUITests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
2A0467365B852D44D1A9FD5B /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
98D5B23C9BE2F62C23DF397A /* WorkspacePolicy.swift in Sources */,
009F8FEC2765B2F19DC12CA8 /* WorkspaceTests.swift in Sources */,
6514B0DB3A5CCCDF7385AC18 /* WorkspaceViewController.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
F39C45598E4345705F3820FA /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
70EBA05ACBA5402617A3371C /* WorkspacePolicy.swift in Sources */,
C44BDCDEA454DBD106D51C9C /* WorkspaceViewController.swift in Sources */,
333E7761EA87AE29CE04F1AC /* main.mm in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
5658F3B2915DC3A46531EDCE /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */;
targetProxy = 8665A25D07C5F1A295164797 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
2AB6F9398EA3CB62A68287D2 /* InfoPlist.strings */ = {
isa = PBXVariantGroup;
children = (
47C531812A31207F97A89C5F /* en */,
);
name = InfoPlist.strings;
sourceTree = "<group>";
};
C8C02BC0E757897278EB954A /* Localizable.strings */ = {
isa = PBXVariantGroup;
children = (
ED4469441C3E0F37B1123572 /* en */,
);
name = Localizable.strings;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
0C46162024E99BEFF872CABF /* release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ARCHS = (
arm64,
);
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = "";
ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"\".\"",
);
INFOPLIST_FILE = "kanbangit-native_iOS/Info.plist";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
"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)";
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.desktop;
PRODUCT_MODULE_NAME = KanbanGitNative;
PRODUCT_NAME = "KanbanGit";
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VALID_ARCHS = arm64;
};
name = release;
};
390A27695F28BF3AA9A049C9 /* debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"DEBUG=1",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
};
name = debug;
};
5110A47C3B60906E18EE09D2 /* debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.uitests;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = "kanbangit-native_iOS";
};
name = debug;
};
C81211E40BBB158A79AEBE3D /* release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.0;
};
name = release;
};
D41150B2CAE4512D04C26759 /* debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ARCHS = (
arm64,
);
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = "";
ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"\".\"",
);
INFOPLIST_FILE = "kanbangit-native_iOS/Info.plist";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
"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)";
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.desktop;
PRODUCT_MODULE_NAME = KanbanGitNative;
PRODUCT_NAME = "KanbanGit";
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VALID_ARCHS = arm64;
};
name = debug;
};
EF104E75BD04AFEF735BAA52 /* release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.uitests;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = "kanbangit-native_iOS";
};
name = release;
};
FB6E824120F9ED3CFE30E713 /* debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.tests;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = debug;
};
FF1553DD6765EEA42FF50CDD /* release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.tests;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
26F675D59EF454AE0D263F35 /* Build configuration list for PBXNativeTarget "WorkspaceTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
FB6E824120F9ED3CFE30E713 /* debug */,
FF1553DD6765EEA42FF50CDD /* release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = debug;
};
A9CA551DD3F3EBCCF8C41CC1 /* Build configuration list for PBXProject "kanbangit-native" */ = {
isa = XCConfigurationList;
buildConfigurations = (
390A27695F28BF3AA9A049C9 /* debug */,
C81211E40BBB158A79AEBE3D /* release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = debug;
};
E6F418AEF58E5F3D739C68F1 /* Build configuration list for PBXNativeTarget "WorkspaceUITests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
5110A47C3B60906E18EE09D2 /* debug */,
EF104E75BD04AFEF735BAA52 /* release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = debug;
};
F0C7EA9610FB69A5EA27B950 /* Build configuration list for PBXNativeTarget "kanbangit-native_iOS" */ = {
isa = XCConfigurationList;
buildConfigurations = (
D41150B2CAE4512D04C26759 /* debug */,
0C46162024E99BEFF872CABF /* release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = debug;
};
/* End XCConfigurationList section */
};
rootObject = AA6AD2A91A9E86D319D86D59 /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BuildSystemType</key>
<string>Original</string>
<key>DisableBuildSystemDeprecationDiagnostic</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,144 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "721CF221CEDDC28FF2C5EBD5"
BuildableName = "KanbanGit.app"
BlueprintName = "kanbangit-native_iOS"
ReferencedContainer = "container:kanbangit-native.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "721CF221CEDDC28FF2C5EBD5"
BuildableName = "KanbanGit.app"
BlueprintName = "kanbangit-native_iOS"
ReferencedContainer = "container:kanbangit-native.xcodeproj">
</BuildableReference>
</MacroExpansion>
<EnvironmentVariables>
<EnvironmentVariable
key = "RUST_BACKTRACE"
value = "full"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "RUST_LOG"
value = "info"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CBC4561F8018D37DD49A0598"
BuildableName = "WorkspaceTests.xctest"
BlueprintName = "WorkspaceTests"
ReferencedContainer = "container:kanbangit-native.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "988CFF304CB81C8F3A4D6612"
BuildableName = "WorkspaceUITests.xctest"
BlueprintName = "WorkspaceUITests"
ReferencedContainer = "container:kanbangit-native.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "721CF221CEDDC28FF2C5EBD5"
BuildableName = "KanbanGit.app"
BlueprintName = "kanbangit-native_iOS"
ReferencedContainer = "container:kanbangit-native.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "RUST_BACKTRACE"
value = "full"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "RUST_LOG"
value = "info"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "release"
shouldUseLaunchSchemeArgsEnv = "NO"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "721CF221CEDDC28FF2C5EBD5"
BuildableName = "KanbanGit.app"
BlueprintName = "kanbangit-native_iOS"
ReferencedContainer = "container:kanbangit-native.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "RUST_BACKTRACE"
value = "full"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "RUST_LOG"
value = "info"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleVersion</key>
<string>0.1.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Choose a profile picture using your camera.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Choose a profile picture from your photo library.</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
<string>metal</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>
+124
View File
@@ -0,0 +1,124 @@
name: kanbangit-native
options:
bundleIdPrefix: de.kanbangit.desktop
deploymentTarget:
iOS: 17.0
fileGroups: [../../src]
configs:
debug: debug
release: release
settingGroups:
app:
base:
PRODUCT_NAME: KanbanGit
SWIFT_VERSION: "5.0"
PRODUCT_MODULE_NAME: KanbanGitNative
TARGETED_DEVICE_FAMILY: "1,2"
CODE_SIGN_STYLE: Automatic
DEVELOPMENT_TEAM: ""
ENABLE_USER_SCRIPT_SANDBOXING: false
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.desktop
targetTemplates:
app:
type: application
sources:
- path: Sources
scheme:
environmentVariables:
RUST_BACKTRACE: full
RUST_LOG: info
settings:
groups: [app]
targets:
kanbangit-native_iOS:
type: application
platform: iOS
sources:
- path: Sources
- path: Assets.xcassets
- path: kanbangit-native_iOS
- path: LaunchScreen.storyboard
info:
path: kanbangit-native_iOS/Info.plist
properties:
NSCameraUsageDescription: Choose a profile picture using your camera.
NSPhotoLibraryUsageDescription: Choose a profile picture from your photo library.
LSRequiresIPhoneOS: true
UILaunchStoryboardName: LaunchScreen
UIRequiredDeviceCapabilities: [arm64, metal]
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
UISupportedInterfaceOrientations~ipad:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
CFBundleShortVersionString: 0.1.0
CFBundleVersion: "0.1.0"
entitlements:
path: kanbangit-native_iOS/kanbangit-native_iOS.entitlements
scheme:
environmentVariables:
RUST_BACKTRACE: full
RUST_LOG: info
testTargets:
- WorkspaceTests
- WorkspaceUITests
settings:
base:
ENABLE_BITCODE: false
ARCHS: [arm64]
VALID_ARCHS: arm64
LIBRARY_SEARCH_PATHS[arch=x86_64]: $(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)
LIBRARY_SEARCH_PATHS[arch=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)
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES: true
EXCLUDED_ARCHS[sdk=iphoneos*]: x86_64
groups: [app]
dependencies:
- framework: libapp.a
embed: false
- sdk: CoreGraphics.framework
- sdk: Metal.framework
- sdk: MetalKit.framework
- sdk: QuartzCore.framework
- sdk: Security.framework
- sdk: UIKit.framework
- sdk: WebKit.framework
preBuildScripts:
- script: |
export PATH="$(dirname "$(rustup which cargo)"):$PATH"
export RUSTC="$(rustup which rustc)"
cd "$SRCROOT/../../.."
./node_modules/.bin/tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths "${FRAMEWORK_SEARCH_PATHS:?}" --header-search-paths "${HEADER_SEARCH_PATHS:?}" --gcc-preprocessor-definitions "${GCC_PREPROCESSOR_DEFINITIONS:-}" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}
name: Build Rust Code
basedOnDependencyAnalysis: false
outputFiles:
- $(SRCROOT)/Externals/x86_64/${CONFIGURATION}/libapp.a
- $(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a
WorkspaceTests:
type: bundle.unit-test
platform: iOS
sources:
- path: Tests
- path: Sources/kanbangit-native/WorkspacePolicy.swift
- path: Sources/kanbangit-native/WorkspaceViewController.swift
- path: Sources/kanbangit-native/en.lproj
settings:
base:
GENERATE_INFOPLIST_FILE: YES
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.tests
SWIFT_VERSION: "5.0"
WorkspaceUITests:
type: bundle.ui-testing
platform: iOS
sources: [UITests]
dependencies:
- target: kanbangit-native_iOS
settings:
base:
GENERATE_INFOPLIST_FILE: YES
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.uitests
SWIFT_VERSION: "5.0"
TEST_TARGET_NAME: kanbangit-native_iOS
+74 -1
View File
@@ -39,6 +39,26 @@ fn allows_server_navigation(server: &Url, destination: &Url) -> bool {
&& destination.password().is_none()
}
fn allows_external_navigation(destination: &Url) -> bool {
matches!(destination.scheme(), "https" | "http")
&& destination.host_str().is_some()
&& destination.username().is_empty()
&& destination.password().is_none()
}
fn open_external(destination: &Url) {
if allows_external_navigation(destination) {
// Arguments are passed directly to Launch Services, never through a shell.
let destination = destination.to_string();
std::thread::spawn(move || {
let _ = std::process::Command::new("/usr/bin/open")
.arg("--")
.arg(destination)
.status();
});
}
}
fn allows_launcher_navigation(destination: &Url) -> bool {
destination.scheme() == "tauri"
&& destination.host_str() == Some("localhost")
@@ -146,6 +166,9 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
let handle = app.clone();
let navigation_label = label.clone();
let server_url = server.url.clone();
let popup_server = server_url.clone();
let popup_handle = app.clone();
let popup_label = label.clone();
let window = WebviewWindowBuilder::new(app, &label, WebviewUrl::External(server.url))
.title(en::APP_NAME)
.inner_size(1280.0, 850.0)
@@ -155,12 +178,22 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
.data_store_identifier(server.profile)
.on_navigation(move |destination| {
if !allows_server_navigation(&server_url, destination) {
open_external(destination);
return false;
}
begin_navigation(&handle, &navigation_label);
true
})
.on_new_window(|_, _| NewWindowResponse::Deny)
.on_new_window(move |destination, _| {
if allows_server_navigation(&popup_server, &destination) {
if let Some(window) = popup_handle.get_webview_window(&popup_label) {
let _ = window.navigate(destination);
}
} else {
open_external(&destination);
}
NewWindowResponse::Deny
})
.on_page_load(|window, payload| {
if payload.event() != PageLoadEvent::Finished {
return;
@@ -277,11 +310,31 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
.select_all()
.build()?,
)
.item(
&SubmenuBuilder::new(app, en::NAVIGATION)
.text("workspace-back", en::BACK)
.text("workspace-forward", en::FORWARD)
.build()?,
)
.build()?;
app.set_menu(menu)?;
app.on_menu_event(|app, event| {
if event.id().as_ref() == "server-settings" {
show_launcher(app);
} else if let Some(script) = match event.id().as_ref() {
"workspace-back" => Some("window.history.back()"),
"workspace-forward" => Some("window.history.forward()"),
_ => None,
} {
let label = app
.state::<Mutex<Connection>>()
.lock()
.unwrap()
.label
.clone();
if let Some(window) = label.and_then(|label| app.get_webview_window(&label)) {
let _ = window.eval(script);
}
}
});
let handle = app.handle().clone();
@@ -347,6 +400,26 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
mod tests {
use super::*;
#[test]
fn external_links_accept_only_web_urls_without_credentials() {
for input in [
"https://git.example.com/org/repo/pulls/1#diff",
"http://git.example.com/repo",
] {
assert!(allows_external_navigation(&Url::parse(input).unwrap()));
}
for input in [
"https://user:secret@git.example.com/",
"file:///etc/passwd",
"javascript:alert(1)",
"tauri://localhost/action/open",
"mailto:user@example.com",
"data:text/html,test",
] {
assert!(!allows_external_navigation(&Url::parse(input).unwrap()));
}
}
#[test]
fn navigation_is_limited_to_the_selected_https_origin() {
let server = validate_url("https://example.com:8443/app/").unwrap();
+14
View File
@@ -0,0 +1,14 @@
use tauri::{WebviewUrl, WebviewWindowBuilder};
pub fn context<R: tauri::Runtime>() -> tauri::Context<R> {
tauri::generate_context!()
}
pub fn builder() -> tauri::Builder<tauri::Wry> {
tauri::Builder::default().setup(|app| {
WebviewWindowBuilder::new(app, "host", WebviewUrl::App("index.html".into()))
.on_navigation(|_| false)
.build()?;
Ok(())
})
}
+8 -4
View File
@@ -1,16 +1,20 @@
#[cfg(target_os = "android")]
mod android;
#[cfg(not(target_os = "android"))]
#[cfg(not(any(target_os = "android", target_os = "ios")))]
mod desktop;
#[cfg(not(target_os = "android"))]
#[cfg(target_os = "ios")]
mod ios;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
mod locale;
#[cfg(not(target_os = "android"))]
#[cfg(not(any(target_os = "android", target_os = "ios")))]
mod server;
#[cfg(target_os = "android")]
pub use android::{builder, context};
#[cfg(not(target_os = "android"))]
#[cfg(not(any(target_os = "android", target_os = "ios")))]
pub use desktop::{builder, context};
#[cfg(target_os = "ios")]
pub use ios::{builder, context};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
+3
View File
@@ -1,5 +1,8 @@
pub mod en {
pub const APP_NAME: &str = "KanbanGit";
pub const SERVER_SETTINGS: &str = "Server settings…";
pub const NAVIGATION: &str = "Navigation";
pub const BACK: &str = "Back";
pub const FORWARD: &str = "Forward";
pub const EDIT: &str = "Edit";
}
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://schema.tauri.app/config/2",
"bundle": {
"iOS": { "minimumSystemVersion": "17.0" }
}
}
+193
View File
@@ -0,0 +1,193 @@
import assert from 'node:assert/strict';
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import {
packageNotarizedApp,
releaseMetadata,
verifyNotarizedApp,
} from '../macos-ci.mjs';
function fixture(t) {
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-ci-test-'));
t.after(() => rmSync(directory, { recursive: true, force: true }));
mkdirSync(join(directory, 'src-tauri'));
const config = {
version: '1.2.3',
productName: 'KanbanGit',
identifier: 'de.kanbangit.desktop',
};
writeFileSync(
join(directory, 'src-tauri/tauri.conf.json'),
JSON.stringify(config),
);
writeFileSync(
join(directory, 'package.json'),
JSON.stringify({ version: '1.2.3' }),
);
writeFileSync(
join(directory, 'src-tauri/Cargo.toml'),
'[package]\nname = "kanbangit-native"\nversion = "1.2.3"\n[dependencies]\nother = "2"\n',
);
const env = {
RELEASE_TAG: 'macos-v1.2.3',
APPLE_SIGNING_IDENTITY:
'Developer ID Application: Example (ZZX2KZGTHU)',
};
const calls = [];
const run = (command, args) => {
calls.push({ command, args });
if (command === 'codesign' && args.includes('--display')) {
return 'CodeDirectory flags=0x10000(runtime)\nCDHash=abcdef123456';
}
if (command === 'plutil') {
return '1.2.3';
}
if (command === 'lipo') {
return 'arm64';
}
if (command === 'ditto' && args.includes('-c')) {
writeFileSync(args.at(-1), 'notarized archive');
}
return '';
};
return { directory, env, config, run, calls };
}
test('release metadata binds tag, all app versions, identity and architecture', (t) => {
const f = fixture(t);
assert.deepEqual(releaseMetadata(f.env, f.directory), {
version: '1.2.3',
target: 'aarch64-apple-darwin',
arch: 'arm64',
filename: 'KanbanGit-1.2.3-aarch64-apple-darwin.zip',
});
assert.equal(
releaseMetadata(
{ ...f.env, MACOS_RELEASE_TARGET: 'x86_64-apple-darwin' },
f.directory,
).arch,
'x86_64',
);
});
for (const env of [
{ RELEASE_TAG: 'v1.2.3' },
{ RELEASE_TAG: 'macos-v1.2.4' },
{ RELEASE_TAG: 'macos-v1.2.3; echo bad' },
{ MACOS_RELEASE_TARGET: 'x86_64-unknown-linux-gnu' },
{ APPLE_SIGNING_IDENTITY: '-' },
{ APPLE_SIGNING_IDENTITY: 'Apple Development: Example (ZZX2KZGTHU)' },
{
APPLE_SIGNING_IDENTITY:
'Developer ID Application: Example (OLDTEAM123)',
},
]) {
test(`rejects invalid release inputs ${JSON.stringify(env)}`, (t) => {
const f = fixture(t);
assert.throws(() => releaseMetadata({ ...f.env, ...env }, f.directory));
});
}
for (const [file, contents] of [
['package.json', '{"version":"1.2.4"}'],
['src-tauri/Cargo.toml', '[package]\nversion = "1.2.4"'],
['src-tauri/tauri.conf.json', '{"version":"01.2.3"}'],
[
'src-tauri/tauri.conf.json',
'{"version":"1.2.3","identifier":"other","productName":"KanbanGit"}',
],
]) {
test(`rejects inconsistent metadata in ${file}: ${contents}`, (t) => {
const f = fixture(t);
writeFileSync(join(f.directory, file), contents);
assert.throws(() => releaseMetadata(f.env, f.directory));
});
}
test('packages only a verified app and checks the extracted download again', (t) => {
const f = fixture(t);
const output = packageNotarizedApp(f);
assert.equal(
readFileSync(join(output.directory, output.filename), 'utf8'),
'notarized archive',
);
assert.equal(
readFileSync(
join(output.directory, `${output.filename}.sha256`),
'utf8',
),
`${output.checksum} ${output.filename}\n`,
);
assert.equal(f.calls.filter((call) => call.command === 'spctl').length, 2);
assert.equal(f.calls.filter((call) => call.command === 'xcrun').length, 2);
assert.match(
f.calls.findLast((call) => call.command === 'spctl').args.at(-1),
/extracted\/KanbanGit\.app$/,
);
assert.throws(() => packageNotarizedApp(f), /already exists/);
});
for (const failure of ['codesign', 'lipo', 'xcrun', 'spctl', 'ditto']) {
test(`does not publish local output when ${failure} verification fails`, (t) => {
const f = fixture(t);
const run = f.run;
f.run = (command, args) => {
if (command === failure) {
throw new Error('verification failed');
}
return run(command, args);
};
assert.throws(() => packageNotarizedApp(f), /verification failed/);
assert.equal(
existsSync(
join(
f.directory,
'src-tauri/target/macos-release/1.2.3-aarch64-apple-darwin',
),
),
false,
);
});
}
test('rejects an app built for a different architecture', (t) => {
const f = fixture(t);
const metadata = releaseMetadata(f.env, f.directory);
assert.throws(
() =>
verifyNotarizedApp('KanbanGit.app', metadata, (command, args) =>
command === 'lipo' ? 'x86_64' : f.run(command, args),
),
/architecture/,
);
});
test('failed validation of the extracted app discards staged download', (t) => {
const f = fixture(t);
const run = f.run;
f.run = (command, args) => {
if (command === 'spctl' && args.at(-1).includes('extracted/')) {
throw new Error('roundtrip invalid');
}
return run(command, args);
};
assert.throws(() => packageNotarizedApp(f), /roundtrip invalid/);
assert.equal(
existsSync(
join(
f.directory,
'src-tauri/target/macos-release/1.2.3-aarch64-apple-darwin',
),
),
false,
);
});
+554
View File
@@ -0,0 +1,554 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import test from 'node:test';
import { releaseMacos } from '../release-macos.mjs';
const identity = 'Developer ID Application: Example Developer (ZZX2KZGTHU)';
const submissionId = '3aa7effe-4333-4b03-925c-78427354a3e6';
const cdhash = 'a3850211578ca9130445ec77918a619778cb465d';
function fixture(t, overrides = {}) {
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-release-test-'));
t.after(() => rmSync(directory, { recursive: true, force: true }));
const config = {
productName: 'KanbanGit',
identifier: 'de.kanbangit.desktop',
version: '1.2.3',
};
const configPath = join(directory, 'src-tauri/tauri.conf.json');
mkdirSync(dirname(configPath), { recursive: true });
writeFileSync(configPath, JSON.stringify(config));
const calls = [];
const releaseDirectory = join(directory, 'src-tauri/target/macos-release');
const destination = join(releaseDirectory, '1.2.3-aarch64-apple-darwin');
let stapled = false;
const options = {
directory,
platform: 'darwin',
arch: 'arm64',
log() {},
env: {
APPLE_SIGNING_IDENTITY: identity,
APPLE_NOTARY_KEYCHAIN_PROFILE: 'kanbangit-notary',
},
run(command, args, settings) {
calls.push({ command, args, settings });
if (overrides.failStep === settings.step) {
throw new Error('Simulated external failure');
}
if (command === 'security') {
return (
overrides.identities ??
`1) ABCDEF "${identity}"\n1 valid identities found`
);
}
if (command === 'pnpm' && !overrides.missingApp) {
const target = args[args.indexOf('--target') + 1];
mkdirSync(
join(
directory,
'src-tauri/target',
target,
'release/bundle/macos/KanbanGit.app',
),
{ recursive: true },
);
}
if (command === 'codesign' && args.includes('--display')) {
return (
overrides.signature ??
`CodeDirectory v=20500 size=1024 flags=0x10000(runtime) hashes=10\nCDHash=${cdhash}`
);
}
if (command === 'plutil') {
return overrides.version ?? '1.2.3';
}
if (
command === 'xcrun' &&
args[0] === 'notarytool' &&
args[1] === 'submit'
) {
return (
overrides.submissionResult ??
JSON.stringify({ id: submissionId })
);
}
if (
command === 'xcrun' &&
args[0] === 'notarytool' &&
args[1] === 'wait'
) {
return (
overrides.notaryResult ??
JSON.stringify({ status: 'Accepted', id: submissionId })
);
}
if (
command === 'xcrun' &&
args[0] === 'notarytool' &&
args[1] === 'log'
) {
return (
overrides.notaryLog ??
JSON.stringify({
jobId: submissionId,
status: 'Accepted',
issues: null,
ticketContents: [
{
path: 'notarization.zip/KanbanGit.app',
cdhash,
arch:
options.env.MACOS_RELEASE_TARGET ===
'x86_64-apple-darwin'
? 'x86_64'
: 'arm64',
},
],
})
);
}
if (
command === 'xcrun' &&
args[0] === 'stapler' &&
args[1] === 'staple'
) {
stapled = true;
}
if (command === 'ditto') {
writeFileSync(
args.at(-1),
stapled
? 'archive with stapled ticket'
: 'archive before notarization',
);
}
return '';
},
};
return {
options,
calls,
config,
configPath,
releaseDirectory,
destination,
};
}
test('publishes only the stapled app archive with its exact SHA-256 checksum after verification', (t) => {
const f = fixture(t);
const result = releaseMacos(f.options);
const contents = readFileSync(result.archive, 'utf8');
assert.equal(contents, 'archive with stapled ticket');
assert.equal(
result.checksum,
createHash('sha256').update(contents).digest('hex'),
);
assert.equal(
readFileSync(`${result.archive}.sha256`, 'utf8'),
`${result.checksum} KanbanGit-1.2.3-aarch64-apple-darwin.zip\n`,
);
assert.deepEqual(readdirSync(f.destination).sort(), [
'KanbanGit-1.2.3-aarch64-apple-darwin.zip',
'KanbanGit-1.2.3-aarch64-apple-darwin.zip.sha256',
]);
assert.deepEqual(readdirSync(f.releaseDirectory), [
'1.2.3-aarch64-apple-darwin',
]);
const labels = f.calls.map((call) => call.settings.step);
assert.ok(
labels.indexOf('Checking Gatekeeper acceptance') <
labels.indexOf('Packaging notarized download'),
);
const verify = f.calls.find((call) => call.args.includes('-R'));
assert.ok(verify.args.includes('--strict'));
assert.ok(verify.args.includes('--deep'));
assert.match(
verify.args[verify.args.indexOf('-R') + 1],
/anchor apple generic.*de\.kanbangit\.desktop.*ZZX2KZGTHU.*1\.2\.840\.113635\.100\.6\.1\.13/,
);
});
for (const [name, env, expected] of [
[
'missing identity',
{ APPLE_SIGNING_IDENTITY: '' },
/APPLE_SIGNING_IDENTITY/,
],
[
'ad hoc identity',
{ APPLE_SIGNING_IDENTITY: '-' },
/Developer ID Application/,
],
[
'development certificate',
{ APPLE_SIGNING_IDENTITY: 'Apple Development: Example (ZZX2KZGTHU)' },
/Developer ID Application/,
],
[
'old team certificate',
{
APPLE_SIGNING_IDENTITY:
'Developer ID Application: Example (OLDTEAM123)',
},
/ZZX2KZGTHU/,
],
['old team environment', { APPLE_TEAM_ID: 'OLDTEAM123' }, /APPLE_TEAM_ID/],
[
'missing notary profile',
{ APPLE_NOTARY_KEYCHAIN_PROFILE: ' ' },
/APPLE_NOTARY_KEYCHAIN_PROFILE/,
],
[
'unsupported target',
{ MACOS_RELEASE_TARGET: 'aarch64-apple-ios' },
/MACOS_RELEASE_TARGET/,
],
]) {
test(`${name} fails before running external commands`, (t) => {
const f = fixture(t);
Object.assign(f.options.env, env);
assert.throws(() => releaseMacos(f.options), expected);
assert.equal(f.calls.length, 0);
assert.equal(existsSync(f.destination), false);
});
}
test('does not allow a non-macOS host', (t) => {
const f = fixture(t);
assert.throws(
() => releaseMacos({ ...f.options, platform: 'linux' }),
/require a Mac/,
);
assert.equal(f.calls.length, 0);
});
for (const change of [
{ identifier: 'de.oldteam.app' },
{ productName: '../Other' },
{ version: '../outside' },
{ version: '1.2.3-beta.1' },
{ version: '01.2.3' },
]) {
test(`rejects inconsistent release configuration ${JSON.stringify(change)}`, (t) => {
const f = fixture(t);
writeFileSync(f.configPath, JSON.stringify({ ...f.config, ...change }));
assert.throws(() => releaseMacos(f.options), /release/);
assert.equal(f.calls.length, 0);
});
}
test('requires the exact installed signing identity before building', (t) => {
const f = fixture(t, { identities: '0 valid identities found' });
assert.throws(() => releaseMacos(f.options), /including its private key/);
assert.equal(f.calls.length, 1);
});
test('validates notarization access before the build', (t) => {
const f = fixture(t, {
failStep:
'Checking notarization access (check the keychain profile if this fails)',
});
assert.throws(() => releaseMacos(f.options), /external failure/);
assert.equal(
f.calls.some((call) => call.command === 'pnpm'),
false,
);
});
test('pins release signing, hardening and target directory without inheriting unrelated Apple credentials', (t) => {
const f = fixture(t);
Object.assign(f.options.env, {
APPLE_ID: 'old-account@example.test',
APPLE_PASSWORD: 'must-not-be-passed',
APPLE_API_KEY: 'old-key',
APPLE_CERTIFICATE: 'old-certificate',
APPLE_TEAM_ID: 'ZZX2KZGTHU',
TAURI_CONFIG: '{"bundle":{"macOS":{"hardenedRuntime":false}}}',
CARGO_TARGET_DIR: '/other-build',
APPLE_NOTARY_KEYCHAIN: '/ci/release.keychain-db',
});
const originalEnv = { ...f.options.env };
releaseMacos(f.options);
assert.deepEqual(f.options.env, originalEnv);
const build = f.calls.find((call) => call.command === 'pnpm');
assert.equal(build.args.includes('--debug'), false);
assert.deepEqual(build.args.slice(-2), ['--', '--locked']);
assert.deepEqual(
JSON.parse(build.args[build.args.indexOf('--config') + 1]),
{
bundle: {
createUpdaterArtifacts: false,
macOS: { signingIdentity: identity, hardenedRuntime: true },
},
},
);
assert.equal(build.settings.env.APPLE_SIGNING_IDENTITY, identity);
assert.equal(
build.settings.env.CARGO_TARGET_DIR,
join(f.options.directory, 'src-tauri/target'),
);
for (const key of [
'APPLE_ID',
'APPLE_PASSWORD',
'APPLE_API_KEY',
'APPLE_CERTIFICATE',
'TAURI_CONFIG',
]) {
assert.equal(build.settings.env[key], undefined);
}
for (const call of f.calls.filter(
(call) => call.args[0] === 'notarytool',
)) {
assert.equal(
call.args[call.args.indexOf('--keychain-profile') + 1],
'kanbangit-notary',
);
assert.equal(
call.args[call.args.indexOf('--keychain') + 1],
'/ci/release.keychain-db',
);
}
});
test('supports an explicit Intel target with distinct output naming', (t) => {
const f = fixture(t);
f.options.env.MACOS_RELEASE_TARGET = 'x86_64-apple-darwin';
const result = releaseMacos(f.options);
assert.equal(result.target, 'x86_64-apple-darwin');
assert.match(
result.archive,
/1\.2\.3-x86_64-apple-darwin\/KanbanGit-1\.2\.3-x86_64-apple-darwin\.zip$/,
);
});
for (const [name, override, expected] of [
['missing app', { missingApp: true }, /did not produce/],
[
'missing hardened runtime',
{ signature: 'CodeDirectory flags=0x0(none)' },
/Hardened Runtime/,
],
['mismatched version', { version: '0.9.0' }, /version/],
]) {
test(`${name} prevents submission to Apple`, (t) => {
const f = fixture(t, override);
assert.throws(() => releaseMacos(f.options), expected);
assert.equal(
f.calls.some((call) => call.args[1] === 'submit'),
false,
);
assert.equal(existsSync(f.destination), false);
});
}
for (const failStep of [
'Building signed macOS release',
'Verifying Developer ID signature and team',
'Preparing notarization archive',
'Submitting to Apple',
'Waiting for Apple notarization',
'Checking Apple notarization log',
'Stapling notarization ticket',
'Validating notarization ticket',
'Verifying final app signature',
'Checking Gatekeeper acceptance',
'Packaging notarized download',
]) {
test(`${failStep} failure never publishes a download`, (t) => {
const f = fixture(t, { failStep });
assert.throws(() => releaseMacos(f.options), /external failure/);
assert.equal(existsSync(f.destination), false);
if (existsSync(f.releaseDirectory)) {
assert.deepEqual(readdirSync(f.releaseDirectory), []);
}
});
}
for (const notaryResult of [
'{"status":"Invalid","id":"rejected-id"}',
'{"status":"In Progress","id":"pending-id"}',
'{"id":"no-status"}',
'not valid JSON',
]) {
test(`unaccepted notarization prevents stapling and publication: ${notaryResult}`, (t) => {
const f = fixture(t, { notaryResult });
assert.throws(() => releaseMacos(f.options));
assert.equal(
f.calls.some((call) => call.args[0] === 'stapler'),
false,
);
assert.equal(existsSync(f.destination), false);
assert.deepEqual(readdirSync(f.releaseDirectory), []);
});
}
test('never overwrites an existing verified release', (t) => {
const f = fixture(t);
const result = releaseMacos(f.options);
const original = readFileSync(result.archive);
f.calls.length = 0;
assert.throws(() => releaseMacos(f.options), /already exists/);
assert.deepEqual(readFileSync(result.archive), original);
assert.equal(f.calls.length, 0);
});
test('persists submission ID on timeout and resumes the same app without rebuilding or uploading', (t) => {
const overrides = { failStep: 'Waiting for Apple notarization' };
const f = fixture(t, overrides);
assert.throws(
() => releaseMacos(f.options),
new RegExp(`Apple may still be processing.*--resume ${submissionId}`),
);
const record = JSON.parse(
readFileSync(
join(
f.options.directory,
'src-tauri/target',
`notarization-${submissionId}.submission.json`,
),
'utf8',
),
);
assert.deepEqual(record, {
submissionId,
version: '1.2.3',
target: 'aarch64-apple-darwin',
cdhash,
});
assert.equal(existsSync(f.destination), false);
overrides.failStep = null;
f.calls.length = 0;
const result = releaseMacos({
...f.options,
resumeSubmissionId: submissionId,
});
assert.equal(
readFileSync(result.archive, 'utf8'),
'archive with stapled ticket',
);
assert.equal(
f.calls.some(
(call) => call.command === 'pnpm' || call.args[1] === 'submit',
),
false,
);
assert.equal(
f.calls.find((call) => call.args[1] === 'wait').args[2],
submissionId,
);
});
test('rejects invalid resume arguments before any commands', (t) => {
const f = fixture(t);
assert.throws(
() => releaseMacos({ ...f.options, resumeSubmissionId: '--invalid' }),
/submission UUID/,
);
assert.equal(f.calls.length, 0);
});
test('resume never rebuilds a missing previously submitted app', (t) => {
const f = fixture(t);
assert.throws(
() => releaseMacos({ ...f.options, resumeSubmissionId: submissionId }),
/did not produce/,
);
assert.equal(
f.calls.some(
(call) => call.command === 'pnpm' || call.args[1] === 'submit',
),
false,
);
});
for (const submissionResult of ['{}', '{"id":"not-a-uuid"}', 'invalid JSON']) {
test(`invalid submission response cannot continue: ${submissionResult}`, (t) => {
const f = fixture(t, { submissionResult });
assert.throws(() => releaseMacos(f.options));
assert.equal(
f.calls.some(
(call) => call.args[1] === 'wait' || call.args[0] === 'stapler',
),
false,
);
assert.equal(existsSync(f.destination), false);
});
}
for (const change of [
{ jobId: '00000000-0000-0000-0000-000000000000' },
{ status: 'Invalid' },
{ ticketContents: [] },
{
ticketContents: [
{
path: 'notarization.zip/KanbanGit.app',
cdhash: 'different-code',
arch: 'arm64',
},
],
},
{
ticketContents: [
{ path: 'notarization.zip/KanbanGit.app', cdhash, arch: 'x86_64' },
],
},
{
ticketContents: [
{ path: 'notarization.zip/Other.app', cdhash, arch: 'arm64' },
],
},
{ issues: [{ severity: 'warning', message: 'Review this warning' }] },
]) {
test(`Apple log must approve the exact app without outstanding issues: ${JSON.stringify(change)}`, (t) => {
const notaryLog = JSON.stringify({
jobId: submissionId,
status: 'Accepted',
issues: null,
ticketContents: [
{
path: 'notarization.zip/KanbanGit.app',
cdhash,
arch: 'arm64',
},
],
...change,
});
const f = fixture(t, { notaryLog });
assert.throws(
() => releaseMacos(f.options),
/does not match|notarization issues/,
);
assert.equal(
f.calls.some((call) => call.args[0] === 'stapler'),
false,
);
assert.equal(existsSync(f.destination), false);
});
}
+161
View File
@@ -0,0 +1,161 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import process from 'node:process';
import test from 'node:test';
const workflow = readFileSync(
new URL('../../.github/workflows/macos-release.yml', import.meta.url),
'utf8',
);
const selectRef = workflow
.split(
' - name: Select an existing version tag contained in main\n',
)[1]
.split('\n - name:')[0]
.split(' run: |\n')[1]
.split('\n')
.map((line) => line.replace(/^ {10}/, ''))
.join('\n');
function fixture(t) {
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-release-ref-'));
t.after(() => rmSync(directory, { recursive: true, force: true }));
const remote = join(directory, 'remote');
const checkout = join(directory, 'checkout');
mkdirSync(remote);
const env = {
...process.env,
GIT_AUTHOR_NAME: 'Release test',
GIT_COMMITTER_NAME: 'Release test',
GIT_AUTHOR_EMAIL: 'release@example.invalid',
GIT_COMMITTER_EMAIL: 'release@example.invalid',
GIT_CONFIG_NOSYSTEM: '1',
GIT_CONFIG_GLOBAL: '/dev/null',
};
const git = (cwd, ...args) => {
const result = spawnSync('git', args, { cwd, env, encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr);
return result.stdout.trim();
};
git(remote, 'init', '-b', 'main');
writeFileSync(join(remote, 'app.txt'), 'release');
git(remote, 'add', 'app.txt');
git(remote, '-c', 'commit.gpgsign=false', 'commit', '-m', 'Release');
const commit = git(remote, 'rev-parse', 'HEAD');
git(remote, 'tag', 'macos-v1.2.3');
git(directory, 'clone', remote, checkout);
const output = join(directory, 'outputs');
const run = (overrides = {}) => {
writeFileSync(output, '');
const result = spawnSync(
'bash',
['--noprofile', '--norc', '-c', selectRef],
{
cwd: checkout,
encoding: 'utf8',
env: {
...env,
GITHUB_EVENT_NAME: 'push',
GITHUB_REF: 'refs/tags/macos-v1.2.3',
REQUESTED_TAG: '',
MACOS_RUNNER: 'macos-local',
GITHUB_OUTPUT: output,
...overrides,
},
},
);
return { ...result, output: readFileSync(output, 'utf8') };
};
return { run, git, checkout, commit };
}
test('release selection succeeds using Git alone without an administrative API token', (t) => {
const f = fixture(t);
const result = f.run({
GITEA_TOKEN: '',
GITHUB_TOKEN: '',
GITHUB_API_URL: 'http://127.0.0.1:1',
});
assert.equal(result.status, 0, result.stderr);
assert.equal(result.output, `commit=${f.commit}\ntag=macos-v1.2.3\n`);
assert.equal(f.git(f.checkout, 'rev-parse', 'HEAD'), f.commit);
});
test('macOS scheduling uses a repository variable while release steps remain gated by preparation', () => {
const release = workflow.split('\n release:\n')[1];
const runner = release.match(/^ {4}runs-on: (.+)$/m)?.[1];
assert.equal(runner, '${{ vars.MACOS_RUNNER }}');
assert.match(release, /^ {4}needs: prepare$/m);
assert.match(release, /ref: \$\{\{ needs\.prepare\.outputs\.commit \}\}/);
assert.match(
release,
/RELEASE_TAG: \$\{\{ needs\.prepare\.outputs\.tag \}\}/,
);
});
test('manual release on main accepts an existing tag without moving it', (t) => {
const f = fixture(t);
const result = f.run({
GITHUB_EVENT_NAME: 'workflow_dispatch',
GITHUB_REF: 'refs/heads/main',
REQUESTED_TAG: 'macos-v1.2.3',
});
assert.equal(result.status, 0, result.stderr);
assert.equal(f.git(f.checkout, 'rev-parse', 'macos-v1.2.3'), f.commit);
});
for (const [name, overrides] of [
['web release tag', { GITHUB_REF: 'refs/tags/v1.2.3' }],
['missing tag', { GITHUB_REF: 'refs/tags/macos-v9.9.9' }],
[
'shell characters in tag',
{ GITHUB_REF: 'refs/tags/macos-v1.2.3;exit 0' },
],
['missing runner label', { MACOS_RUNNER: '' }],
[
'output injection in runner label',
{ MACOS_RUNNER: 'macos-local\ncommit=other' },
],
[
'manual release outside main',
{
GITHUB_EVENT_NAME: 'workflow_dispatch',
GITHUB_REF: 'refs/heads/feature',
REQUESTED_TAG: 'macos-v1.2.3',
},
],
]) {
test(`release selection rejects ${name} before producing job outputs`, (t) => {
const result = fixture(t).run(overrides);
assert.notEqual(result.status, 0);
assert.equal(result.output, '');
});
}
test('release selection rejects a tag whose commit is outside main', (t) => {
const f = fixture(t);
f.git(f.checkout, 'switch', '-c', 'unmerged');
f.git(
f.checkout,
'-c',
'commit.gpgsign=false',
'commit',
'--allow-empty',
'-m',
'Unmerged',
);
f.git(f.checkout, 'tag', 'macos-v1.2.4');
const result = f.run({ GITHUB_REF: 'refs/tags/macos-v1.2.4' });
assert.notEqual(result.status, 0);
assert.match(result.stderr, /Release tag must belong to main/);
assert.equal(result.output, '');
});
+259
View File
@@ -0,0 +1,259 @@
import assert from 'node:assert/strict';
import { Buffer } from 'node:buffer';
import { createHash } from 'node:crypto';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { publishMacos, storageConfiguration } from '../publish-macos.mjs';
function fixture(t) {
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-upload-test-'));
t.after(() => rmSync(directory, { recursive: true, force: true }));
const metadata = {
version: '1.2.3',
target: 'aarch64-apple-darwin',
filename: 'KanbanGit-1.2.3-aarch64-apple-darwin.zip',
};
const archive = Buffer.from('verified release');
const checksum = createHash('sha256').update(archive).digest('hex');
writeFileSync(join(directory, metadata.filename), archive);
writeFileSync(
join(directory, `${metadata.filename}.sha256`),
`${checksum} ${metadata.filename}\n`,
);
const env = {
S3_BUCKET: 'release-bucket',
S3_REGION: 'nbg1',
AWS_ACCESS_KEY_ID: 'test-key',
AWS_SECRET_ACCESS_KEY: 'test-secret',
};
const objects = new Map();
const events = [];
const run = (command, args) => {
assert.equal(command, 'aws');
assert.equal(args[1], 'https://nbg1.your-objectstorage.com');
const operation = args[args.indexOf('s3api') + 1];
const value = (name) => args[args.indexOf(name) + 1];
const key = value('--key');
events.push({ operation, key });
if (operation === 'get-bucket-versioning') {
assert.equal(value('--output'), 'json');
return '';
}
if (operation === 'put-object') {
assert.equal(value('--if-none-match'), '*');
if (objects.has(key)) {
throw new Error(
'aws failed: (PreconditionFailed) object exists',
);
}
objects.set(key, readFileSync(value('--body')));
return '{}';
}
if (operation === 'get-object') {
writeFileSync(args.at(-1), objects.get(key));
return '{}';
}
throw new Error(`Unexpected S3 operation: ${operation}`);
};
const fetchPublic = async (url, options) => {
assert.equal(options.redirect, 'error');
assert.equal(
new URL(url).hostname,
'release-bucket.nbg1.your-objectstorage.com',
);
const key = new URL(url).pathname.slice(1);
events.push({ operation: 'public-get', key });
return {
ok: objects.has(key),
arrayBuffer: async () => objects.get(key),
};
};
return { directory, metadata, env, objects, events, run, fetchPublic };
}
test('uploads immutable files and writes completion record after public verification', async (t) => {
const f = fixture(t);
const url = await publishMacos(f);
assert.equal(
url,
`https://release-bucket.nbg1.your-objectstorage.com/releases/macos/aarch64-apple-darwin/1.2.3/${f.metadata.filename}`,
);
assert.deepEqual(
f.events.map((event) => event.operation),
[
'get-bucket-versioning',
'put-object',
'public-get',
'put-object',
'public-get',
'put-object',
'public-get',
],
);
assert.equal(f.objects.size, 3);
assert(f.events.at(-1).key.endsWith('/release.json'));
assert(![...f.objects.keys()].some((key) => key.includes('latest')));
assert.deepEqual(JSON.parse(f.objects.get(f.events.at(-1).key)), {
version: '1.2.3',
target: f.metadata.target,
url,
sha256: createHash('sha256').update('verified release').digest('hex'),
checksumUrl: `${url}.sha256`,
});
});
for (const response of ['', ' \n\t', '{}']) {
test(`unversioned bucket response ${JSON.stringify(response)} permits a verified upload`, async (t) => {
const f = fixture(t);
const run = f.run;
f.run = (command, args) => {
const result = run(command, args);
return args.includes('get-bucket-versioning') ? response : result;
};
await publishMacos(f);
assert.equal(f.objects.size, 3);
assert.equal(f.events.at(-1).operation, 'public-get');
assert(f.events.at(-1).key.endsWith('/release.json'));
});
}
for (const response of ['{', 'null', '[]', '"Enabled"', 'false']) {
test(`invalid versioning response ${response} aborts before any upload`, async (t) => {
const f = fixture(t);
f.run = (_command, args) => {
assert(args.includes('get-bucket-versioning'));
return response;
};
await assert.rejects(publishMacos(f), /Invalid .*versioning/);
assert.equal(f.objects.size, 0);
});
}
test('retry accepts byte-identical objects without replacing them', async (t) => {
const f = fixture(t);
await publishMacos(f);
const originals = [...f.objects.entries()];
await publishMacos(f);
assert.deepEqual([...f.objects.entries()], originals);
assert.equal(
f.events.filter((event) => event.operation === 'get-object').length,
3,
);
});
test('different existing release is never overwritten', async (t) => {
const f = fixture(t);
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
f.objects.set(key, Buffer.from('previous version content'));
await assert.rejects(publishMacos(f), /bump the app version/);
assert.equal(f.objects.get(key).toString(), 'previous version content');
assert.equal(f.objects.size, 1);
});
test('checksum mismatch aborts before contacting S3', async (t) => {
const f = fixture(t);
writeFileSync(join(f.directory, f.metadata.filename), 'tampered');
await assert.rejects(publishMacos(f), /SHA-256/);
assert.equal(f.events.length, 0);
});
for (const status of ['Enabled', 'Suspended']) {
test(`rejects ${status} bucket versioning before any write`, async (t) => {
const f = fixture(t);
f.run = () => JSON.stringify({ Status: status });
await assert.rejects(publishMacos(f), /unversioned/);
assert.equal(f.objects.size, 0);
});
}
for (const operation of ['get-bucket-versioning', 'put-object', 'get-object']) {
test(`${operation} access or network failure cannot be mistaken for a missing object`, async (t) => {
const f = fixture(t);
await publishMacos(f);
const run = f.run;
f.events.length = 0;
f.run = (command, args) => {
if (args.includes(operation)) {
throw new Error('AccessDenied');
}
return run(command, args);
};
await assert.rejects(publishMacos(f), /AccessDenied/);
assert(!f.events.some((event) => event.key.endsWith('/release.json')));
});
}
for (const failure of ['private', 'wrong-content', 'network']) {
test(`public download ${failure} prevents completion record`, async (t) => {
const f = fixture(t);
f.fetchPublic = async () => {
if (failure === 'network') {
throw new Error('network failure');
}
return {
ok: failure !== 'private',
arrayBuffer: async () => Buffer.from('wrong'),
};
};
await assert.rejects(publishMacos(f));
assert(
![...f.objects.keys()].some((key) => key.endsWith('/release.json')),
);
});
}
test('a partial upload can resume with the same files', async (t) => {
const f = fixture(t);
const run = f.run;
f.run = (command, args) => {
if (
args.includes('put-object') &&
args[args.indexOf('--key') + 1].endsWith('.sha256')
) {
throw new Error('upload interrupted');
}
return run(command, args);
};
await assert.rejects(publishMacos(f), /interrupted/);
assert.equal(f.objects.size, 1);
f.run = run;
await publishMacos(f);
assert.equal(f.objects.size, 3);
});
for (const overrides of [
{ S3_REGION: '' },
{ S3_REGION: 'eu-central-1' },
{ S3_BUCKET: '' },
{ S3_BUCKET: 'other/../bucket' },
{ S3_PREFIX: '../other' },
{ S3_PUBLIC_BASE_URL: 'http://downloads.example.com' },
{ S3_PUBLIC_BASE_URL: 'https://secret@downloads.example.com' },
{ S3_PUBLIC_BASE_URL: 'https://downloads.example.com?token=secret' },
{ AWS_ACCESS_KEY_ID: '' },
{ AWS_SECRET_ACCESS_KEY: '' },
]) {
test(`invalid storage configuration is rejected before S3 access: ${JSON.stringify(overrides)}`, async (t) => {
const f = fixture(t);
f.env = { ...f.env, ...overrides };
await assert.rejects(publishMacos(f));
assert.equal(f.events.length, 0);
});
}
test('derives Hetzner endpoints and supports an explicit public proxy URL', () => {
for (const region of ['nbg1', 'fsn1', 'hel1']) {
const config = storageConfiguration({
S3_REGION: region,
S3_BUCKET: 'release-bucket',
S3_PUBLIC_BASE_URL: 'https://downloads.example.com/',
});
assert.equal(
config.endpoint,
`https://${region}.your-objectstorage.com`,
);
assert.equal(config.publicBase, 'https://downloads.example.com');
}
});
@@ -19,6 +19,7 @@ import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
import { copyText } from '@/lib/clipboard';
import { confirm } from '@/routes/two-factor';
const props = defineProps<{
@@ -94,7 +95,7 @@ async function copyKey(): Promise<void> {
}
copyError.value = false;
try {
await navigator.clipboard.writeText(key);
await copyText(key);
if (isOpen.value && manualSetupKey.value === key) {
copied.value = true;
}
@@ -0,0 +1,199 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { computed, watch } from 'vue';
import {
updateTeamPermissions,
updateUserPermissions,
} from '@/actions/App/Http/Controllers/ProjectController';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import type { PermissionOption } from '@/types';
import {
projectPermissionDescriptions,
projectPermissionLabels,
} from '@/types/project-access';
import type { ProjectAccessTarget } from '@/types/project-access';
const props = defineProps<{
projectId: number;
target: ProjectAccessTarget | null;
permissionOptions: PermissionOption[];
}>();
const emit = defineEmits<{ close: [] }>();
const { t } = useLocalization();
const form = useForm({ permissions: [] as string[] });
const protection = useFormProtection(form, {
isDirty: () => props.target !== null && form.isDirty,
});
const selectionError = computed(
() =>
Object.entries(form.errors).find(([key]) =>
key.startsWith('permissions'),
)?.[1],
);
const options = computed(() =>
props.permissionOptions
.filter((option) => option.value in projectPermissionLabels)
.map((option) => ({
value: option.value,
label: t(
projectPermissionLabels[
option.value as keyof typeof projectPermissionLabels
],
),
description: t(
projectPermissionDescriptions[
option.value as keyof typeof projectPermissionDescriptions
],
),
})),
);
watch(
() => props.target,
(target) => {
form.defaults({ permissions: [...(target?.permissions ?? [])] });
form.resetAndClearErrors();
protection.clearFeedback();
},
);
function close(open: boolean): void {
if (open || form.processing || !protection.confirmLeave()) {
return;
}
form.resetAndClearErrors();
emit('close');
}
function submit(): void {
if (!props.target || form.processing) {
return;
}
const route =
props.target.kind === 'user'
? updateUserPermissions([props.projectId, props.target.id])
: updateTeamPermissions([props.projectId, props.target.id]);
protection.submit(route, {
preserveScroll: true,
onSuccess: () => {
form.defaults();
form.resetAndClearErrors();
emit('close');
},
});
}
</script>
<template>
<Dialog :open="target !== null" @update:open="close">
<DialogContent
:show-close-button="!form.processing"
@escape-key-down="form.processing && $event.preventDefault()"
@interact-outside="form.processing && $event.preventDefault()"
>
<DialogHeader>
<DialogTitle class="break-words">{{
t('projectAccess.editFor', { name: target?.name ?? '' })
}}</DialogTitle>
<DialogDescription>{{
t(
target?.kind === 'team'
? 'projectAccess.teamHelp'
: 'projectAccess.directHelp',
)
}}</DialogDescription>
</DialogHeader>
<form class="grid min-w-0 gap-5" @submit.prevent="submit">
<FormFeedback
:state="protection.state.value"
:title="
protection.responseStatus.value === 403
? t('projectAccess.forbidden')
: undefined
"
:description="
protection.responseStatus.value === 403
? t('projectAccess.forbiddenHelp')
: undefined
"
:retryable="protection.responseStatus.value !== 403"
@retry="submit"
/>
<fieldset
:disabled="form.processing"
class="grid min-w-0 gap-3"
:aria-invalid="!!selectionError"
aria-describedby="project-permission-error"
>
<legend class="mb-3 text-sm font-semibold">
{{
t(
target?.kind === 'team'
? 'projectAccess.teamPermissions'
: 'projectAccess.directPermissions',
)
}}
</legend>
<label
v-for="option in options"
:key="option.value"
class="hover:bg-accent flex cursor-pointer items-start gap-3 rounded-lg border p-3"
>
<input
v-model="form.permissions"
type="checkbox"
:value="option.value"
:aria-label="option.label"
class="accent-primary focus-visible:ring-ring mt-0.5 size-5 shrink-0 focus-visible:ring-2"
/>
<span class="grid gap-1"
><span class="text-sm font-medium">{{
option.label
}}</span
><span class="text-muted-foreground text-xs">{{
option.description
}}</span></span
>
</label>
<InputError
id="project-permission-error"
:message="selectionError"
/>
</fieldset>
<p class="text-muted-foreground text-sm">
{{ t('projectAccess.clearHelp') }}
</p>
<p
v-if="target?.globalAdmin"
class="bg-accent text-accent-foreground rounded-lg p-3 text-sm"
>
{{ t('projectAccess.globalHelp') }}
</p>
<p class="text-muted-foreground text-xs">
{{ t('projectAccess.selfHelp') }}
</p>
<DialogFooter>
<Button
type="button"
variant="outline"
:disabled="form.processing"
@click="close(false)"
>{{ t('common.cancel') }}</Button
>
<Button type="submit" :disabled="form.processing">{{
t('projectAccess.save')
}}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,26 @@
<script setup lang="ts">
import { Badge } from '@/components/ui/badge';
import { useLocalization } from '@/composables/useLocalization';
import { projectPermissionLabels } from '@/types/project-access';
defineProps<{ permissions: string[]; emptyLabel: string }>();
const { t } = useLocalization();
</script>
<template>
<div class="flex flex-wrap gap-1.5">
<template
v-for="(label, value) in projectPermissionLabels"
:key="value"
>
<Badge v-if="permissions.includes(value)" variant="secondary">{{
t(label)
}}</Badge>
</template>
<span
v-if="permissions.length === 0"
class="text-muted-foreground text-sm"
>{{ emptyLabel }}</span
>
</div>
</template>
@@ -0,0 +1,351 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import {
store,
updateMembers,
updateUserPermissions,
} from '@/actions/App/Http/Controllers/TeamController';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import type { TeamEditorProps } from '@/types/teams';
const props = defineProps<TeamEditorProps>();
const emit = defineEmits<{ close: [] }>();
const { t } = useLocalization();
const search = ref('');
const userId = ref<number | null>(null);
const form = useForm({
name: '',
user_ids: [] as number[],
permissions: [] as string[],
});
const protection = useFormProtection(form, {
isDirty: () => props.target !== null && form.isDirty,
});
const team = computed(() =>
props.target && props.target.kind !== 'create' ? props.target.team : null,
);
const visibleUsers = computed(() =>
props.users.filter((user) =>
`${user.name} ${user.email}`
.toLocaleLowerCase()
.includes(search.value.trim().toLocaleLowerCase()),
),
);
const title = computed(() =>
t(
props.target?.kind === 'create'
? 'teams.create'
: props.target?.kind === 'members'
? 'teams.manageMembers'
: 'teams.permissions',
),
);
const selectionError = computed(
() =>
Object.entries(form.errors).find(([key]) =>
key.startsWith(
props.target?.kind === 'members' ? 'user_ids' : 'permissions',
),
)?.[1],
);
const permissionLabels = {
'team:invite': 'teams.permissionInvite',
'team:manage': 'teams.permissionManage',
'team:admin': 'teams.permissionAdmin',
} as const;
function permissionLabel(value: string, fallback: string): string {
return value in permissionLabels
? t(permissionLabels[value as keyof typeof permissionLabels])
: fallback;
}
watch(
() => props.target,
() => {
userId.value = null;
search.value = '';
form.defaults({
name: '',
user_ids: [...(team.value?.user_ids ?? [])],
permissions: [],
});
form.resetAndClearErrors();
protection.clearFeedback();
},
);
function chooseUser(event: Event): void {
const select = event.target as HTMLSelectElement;
if (!protection.confirmLeave()) {
select.value = String(userId.value ?? '');
return;
}
userId.value = select.value ? Number(select.value) : null;
form.defaults('permissions', [
...(props.teamPermissions[team.value?.id ?? '']?.[userId.value ?? ''] ??
[]),
]);
form.resetAndClearErrors('permissions');
protection.clearFeedback();
}
function close(open: boolean): void {
if (open || form.processing || !protection.confirmLeave()) {
return;
}
form.resetAndClearErrors();
emit('close');
}
function submit(): void {
const target = props.target;
if (
!target ||
form.processing ||
(target.kind === 'permissions' && userId.value === null)
) {
return;
}
const route =
target.kind === 'create'
? store()
: target.kind === 'members'
? updateMembers(target.team.id)
: updateUserPermissions([target.team.id, userId.value!]);
form.transform((data) =>
target.kind === 'create'
? { name: data.name }
: target.kind === 'members'
? { user_ids: data.user_ids }
: { permissions: data.permissions },
);
protection.submit(route, {
preserveScroll: true,
onSuccess: () => {
form.resetAndClearErrors();
emit('close');
},
});
}
</script>
<template>
<Dialog :open="target !== null" @update:open="close">
<DialogContent
:show-close-button="!form.processing"
@escape-key-down="form.processing && $event.preventDefault()"
@interact-outside="form.processing && $event.preventDefault()"
>
<DialogHeader>
<DialogTitle>{{ title }}</DialogTitle>
<DialogDescription class="break-words">{{
team?.name ?? t('teams.createDescription')
}}</DialogDescription>
</DialogHeader>
<form class="grid min-w-0 gap-5" @submit.prevent="submit">
<FormFeedback
:state="protection.state.value"
:title="
protection.responseStatus.value === 403
? t('teams.forbidden')
: undefined
"
:description="
protection.responseStatus.value === 403
? t('teams.forbiddenDescription')
: undefined
"
:retryable="protection.responseStatus.value !== 403"
@retry="submit"
/>
<fieldset
:disabled="form.processing"
class="grid min-w-0 gap-4"
>
<template v-if="target?.kind === 'create'">
<div class="grid gap-2">
<Label for="team-name">{{ t('teams.name') }}</Label>
<Input
id="team-name"
v-model="form.name"
required
maxlength="255"
:placeholder="t('teams.namePlaceholder')"
:aria-invalid="!!form.errors.name"
aria-describedby="team-name-error"
/>
<InputError
id="team-name-error"
:message="form.errors.name"
/>
</div>
</template>
<template v-else-if="target?.kind === 'members'">
<p class="text-muted-foreground text-sm">
{{ t('teams.membersHelp') }}
</p>
<div class="grid gap-2">
<Label for="member-search">{{
t('teams.searchPeople')
}}</Label>
<Input
id="member-search"
v-model="search"
type="search"
:placeholder="
t('teams.searchPeoplePlaceholder')
"
/>
</div>
<p class="text-muted-foreground text-sm" role="status">
{{
t('teams.selected', {
count: form.user_ids.length,
})
}}
</p>
<div
class="grid max-h-64 gap-1 overflow-y-auto rounded-md border p-2"
role="group"
:aria-label="t('teams.members')"
:aria-invalid="!!selectionError"
aria-describedby="team-selection-error"
>
<label
v-for="user in visibleUsers"
:key="user.id"
class="hover:bg-accent flex min-w-0 cursor-pointer items-center gap-3 rounded-md p-2"
>
<input
v-model="form.user_ids"
type="checkbox"
:value="user.id"
class="accent-primary focus-visible:ring-ring size-5 shrink-0 focus-visible:ring-2"
/>
<UserAvatar
:user="user"
class="size-8 shrink-0"
/>
<span class="grid min-w-0 gap-0.5 text-sm"
><span class="break-words font-medium">{{
user.name
}}</span
><span
class="text-muted-foreground break-all text-xs"
>{{ user.email }}</span
></span
>
</label>
<p
v-if="visibleUsers.length === 0"
class="text-muted-foreground p-3 text-sm"
>
{{ t('teams.noPeople') }}
</p>
</div>
<InputError
id="team-selection-error"
:message="selectionError"
/>
</template>
<template v-else-if="target?.kind === 'permissions'">
<p class="text-muted-foreground text-sm">
{{ t('teams.permissionsHelp') }}
</p>
<div class="grid gap-2">
<Label for="permission-user">{{
t('teams.person')
}}</Label>
<select
id="permission-user"
:value="userId ?? ''"
class="border-input bg-background focus-visible:ring-ring h-11 w-full min-w-0 rounded-md border px-3 text-sm focus-visible:ring-2"
@change="chooseUser"
>
<option value="" disabled>
{{ t('teams.choosePerson') }}
</option>
<option
v-for="user in users"
:key="user.id"
:value="user.id"
>
{{ user.name }} ({{ user.email }})
</option>
</select>
</div>
<fieldset
v-if="userId !== null"
class="grid gap-2"
:aria-invalid="!!selectionError"
aria-describedby="team-selection-error"
>
<legend class="mb-2 text-sm font-medium">
{{ t('teams.directPermissions') }}
</legend>
<label
v-for="option in permissionOptions"
:key="option.value"
class="hover:bg-accent flex cursor-pointer items-center gap-3 rounded-md border p-3 text-sm"
>
<input
v-model="form.permissions"
type="checkbox"
:value="option.value"
class="accent-primary focus-visible:ring-ring size-5 shrink-0 focus-visible:ring-2"
/>
{{
permissionLabel(option.value, option.label)
}}
</label>
<p class="text-muted-foreground text-sm">
{{ t('teams.permissionsClearHelp') }}
</p>
</fieldset>
<InputError
id="team-selection-error"
:message="selectionError"
/>
</template>
</fieldset>
<DialogFooter>
<Button
type="button"
variant="outline"
:disabled="form.processing"
@click="close(false)"
>{{ t('common.cancel') }}</Button
>
<Button
type="submit"
:disabled="
form.processing ||
(target?.kind === 'permissions' && userId === null)
"
>{{
t(
target?.kind === 'create'
? 'teams.create'
: target?.kind === 'members'
? 'teams.saveMembers'
: 'teams.savePermissions',
)
}}</Button
>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
+64
View File
@@ -0,0 +1,64 @@
/** Copy only after a user action; WebViews may not expose the async Clipboard API. */
export async function copyText(text: string): Promise<void> {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return;
} catch {
// Embedded browsers can deny the async API while allowing a selected copy.
}
}
const active = document.activeElement;
const selection = window.getSelection();
const ranges = selection
? Array.from({ length: selection.rangeCount }, (_, index) =>
selection.getRangeAt(index).cloneRange(),
)
: [];
const inputSelection =
active instanceof HTMLInputElement ||
active instanceof HTMLTextAreaElement
? ([
active.selectionStart,
active.selectionEnd,
active.selectionDirection,
] as const)
: null;
const field = document.createElement('textarea');
field.value = text;
field.readOnly = true;
field.style.cssText =
'position:fixed;opacity:0;pointer-events:none;font-size:16px';
(active?.closest('[role="dialog"]') ?? document.body).append(field);
try {
field.focus({ preventScroll: true });
field.select();
field.setSelectionRange(0, text.length);
if (!document.execCommand('copy')) {
throw new Error('Clipboard access unavailable');
}
} finally {
field.remove();
if (active instanceof HTMLElement) {
active.focus({ preventScroll: true });
}
if (selection) {
selection.removeAllRanges();
ranges.forEach((range) => selection.addRange(range));
}
if (
inputSelection &&
inputSelection[0] !== null &&
inputSelection[1] !== null &&
(active instanceof HTMLInputElement ||
active instanceof HTMLTextAreaElement)
) {
active.setSelectionRange(
inputSelection[0],
inputSelection[1],
inputSelection[2] ?? undefined,
);
}
}
}
+100
View File
@@ -1,4 +1,58 @@
export const en = {
'projectAccess.pageTitle': '{project} · Project access',
'projectAccess.title': 'Project access',
'projectAccess.description':
'Manage who can access {project} and see where their permissions come from.',
'projectAccess.back': 'Back to project',
'projectAccess.saved': 'Project permissions saved.',
'projectAccess.howItWorks': 'How project access works',
'projectAccess.additiveHelp':
'Direct user permissions and permissions from every team are combined. Write includes read; administrator includes write and read.',
'projectAccess.limitsHelp':
'Project administrators can change project assignments here. Global permissions and team membership are managed separately.',
'projectAccess.search': 'Search people or teams',
'projectAccess.people': 'People and effective access',
'projectAccess.effective': 'Effective permissions',
'projectAccess.noAccess': 'No project access',
'projectAccess.globalSource':
'Global administrator · all project permissions',
'projectAccess.direct': 'Direct:',
'projectAccess.none': 'None',
'projectAccess.viaTeam': 'Via {name}:',
'projectAccess.editFor': 'Edit access for {name}',
'projectAccess.editDirect': 'Edit direct access',
'projectAccess.editTeam': 'Edit team access',
'projectAccess.teamPermissions': 'Team permissions',
'projectAccess.directPermissions': 'Direct permissions',
'projectAccess.teamHelp':
'These project permissions apply to every current and future member of the team.',
'projectAccess.directHelp':
'Change only this person’s direct project permissions. Team and global permissions continue to apply.',
'projectAccess.noPeopleResults': 'No matching people',
'projectAccess.noPeople': 'No people available',
'projectAccess.noTeamResults': 'No matching teams',
'projectAccess.noTeams': 'No teams yet',
'projectAccess.noTeamsHelp':
'Create a team and add members before assigning shared project access.',
'projectAccess.noTeamAccess': 'No project permissions assigned',
'projectAccess.read': 'Read',
'projectAccess.write': 'Write',
'projectAccess.admin': 'Administrator',
'projectAccess.readHelp': 'View the project and its tickets.',
'projectAccess.writeHelp': 'Create and edit tickets. Includes read access.',
'projectAccess.adminHelp':
'Manage project access and project administration. Includes write and read access.',
'projectAccess.clearHelp':
'Clear all selections to remove this assignment. Access granted by other teams, direct assignments or global administration is preserved.',
'projectAccess.globalHelp':
'This person is a global administrator. Changing these project assignments cannot remove their global access.',
'projectAccess.selfHelp':
'If you remove your own administrator access, you will return to Projects.',
'projectAccess.save': 'Save permissions',
'projectAccess.forbidden': 'Project access can no longer be changed',
'projectAccess.forbiddenHelp':
'Your administrator permission may have changed. Close this dialog and reopen the project to check your current access.',
'notifications.title': 'Notifications',
'notifications.pageTitle': '{project} · Notifications',
'notifications.description':
@@ -172,6 +226,52 @@ export const en = {
'templates.deleteImpact':
'{count} linked projects will stop inheriting from this template. Their current configuration and tickets are kept.',
'teams.eyebrow': 'People & access',
'teams.description':
'Bring people together in reusable teams. Manage membership and the permissions to administer each team.',
'teams.create': 'Create team',
'teams.createDescription':
'Group people into a team to share project access.',
'teams.name': 'Team name',
'teams.namePlaceholder': 'Platform team',
'teams.yourTeams': 'Teams you manage',
'teams.search': 'Search teams',
'teams.clearSearch': 'Clear search',
'teams.empty': 'No teams to manage',
'teams.emptyCreate': 'Create a team to start organizing your people.',
'teams.emptyDescription':
'No teams are available for your current permissions.',
'teams.noResults': 'No matching teams',
'teams.noResultsDescription': 'Try another team name or clear your search.',
'teams.manageMembers': 'Manage members',
'teams.manageMembersFor': 'Manage members of {name}',
'teams.permissionsFor': 'Manage permissions for {name}',
'teams.members': 'Members',
'teams.noMembers': 'No members yet.',
'teams.membersHelp':
'Select the people who belong to this team. Changes apply when you save.',
'teams.selected': '{count} selected',
'teams.searchPeople': 'Find people',
'teams.searchPeoplePlaceholder': 'Search by name or email',
'teams.noPeople': 'No matching people.',
'teams.saveMembers': 'Save members',
'teams.permissions': 'Team permissions',
'teams.permissionsHelp':
'Direct team permissions are separate from membership. Choose any user to review or change their assignments.',
'teams.person': 'User',
'teams.choosePerson': 'Choose a user',
'teams.directPermissions': 'Direct permissions',
'teams.permissionInvite': 'Team invite',
'teams.permissionManage': 'Team manage',
'teams.permissionAdmin': 'Team admin',
'teams.permissionsClearHelp':
'Clear every checkbox to remove direct permissions. Other existing access remains in effect.',
'teams.savePermissions': 'Save permissions',
'teams.updated': 'Team changes saved.',
'teams.forbidden': 'You no longer have permission',
'teams.forbiddenDescription':
'Your changes have not been saved. Close this dialog and reload the page to check your current access.',
'teams.memberCount': { one: '{count} member', other: '{count} members' },
'workflow.title': 'Board workflow',
'workflow.pageTitle': '{project} · Board workflow',
'workflow.description':
+316 -280
View File
@@ -1,308 +1,344 @@
<script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3';
import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
import Heading from '@/components/Heading.vue';
import { Head, Link, usePage } from '@inertiajs/vue3';
import { ArrowLeft, Search, ShieldCheck, Users } from 'lucide-vue-next';
import { computed, ref } from 'vue';
import FeedbackState from '@/components/FeedbackState.vue';
import ProjectAccessEditorDialog from '@/components/projects/ProjectAccessEditorDialog.vue';
import ProjectPermissionBadges from '@/components/projects/ProjectPermissionBadges.vue';
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue';
import { access, index, show } from '@/routes/projects/index';
import { general as generalSettings } from '@/routes/projects/settings';
import { access, index, show } from '@/routes/projects';
import { general } from '@/routes/projects/settings';
import type { BreadcrumbItem, ProjectAccessPageProps } from '@/types';
import type { ProjectAccessTarget } from '@/types/project-access';
const props = defineProps<ProjectAccessPageProps>();
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Projects',
href: index(),
},
{
title: props.project.name,
href: show(props.project.id),
},
{
title: 'Access',
href: access(props.project.id),
},
];
const hasPermission = (
permissions: Record<string, string[]>,
actorId: number,
permission: string,
) => (permissions[actorId] ?? []).includes(permission);
const page = usePage();
const { t } = useLocalization();
const search = ref('');
const target = ref<ProjectAccessTarget | null>(null);
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.projects'), href: index() },
{ title: props.project.name, href: show(props.project.id) },
{ title: t('navigation.access'), href: access(props.project.id) },
]);
const matches = (value: string) =>
value.toLocaleLowerCase().includes(search.value.trim().toLocaleLowerCase());
const visibleUsers = computed(() =>
props.users.filter((user) => matches(`${user.name} ${user.email}`)),
);
const visibleTeams = computed(() =>
props.teams.filter((team) => matches(team.name)),
);
const inheritedTeams = (userId: number) =>
props.teams.filter(
(team) =>
team.user_ids.includes(userId) &&
(props.teamPermissions[team.id] ?? []).length > 0,
);
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head :title="`${project.name} access`" />
<div class="flex flex-1 flex-col gap-6 p-4">
<div
<Head
:title="t('projectAccess.pageTitle', { project: project.name })"
/>
<main
class="mx-auto flex w-full min-w-0 max-w-7xl flex-1 flex-col gap-6 p-4 sm:p-6 lg:p-8"
>
<header
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div class="space-y-3">
<Badge variant="outline" class="px-3 py-1">
<div class="grid min-w-0 gap-2">
<p
class="text-primary break-all text-xs font-semibold uppercase tracking-wider"
>
{{ project.key }}
</Badge>
<Heading
:title="`${project.name} access`"
description="Assign direct user permissions or inherited team permissions for this project."
/>
</p>
<h1 class="text-headline-mobile sm:text-headline">
{{ t('projectAccess.title') }}
</h1>
<p
class="text-muted-foreground max-w-2xl break-words text-sm"
>
{{
t('projectAccess.description', {
project: project.name,
})
}}
</p>
</div>
<div class="flex flex-wrap gap-3">
<div class="flex shrink-0 flex-wrap gap-2">
<Button
v-if="$page.props.auth.permissions.globalAdmin"
v-if="page.props.auth.permissions.globalAdmin"
as-child
variant="outline"
><Link :href="general(project.id)">{{
t('navigation.general')
}}</Link></Button
>
<Button as-child variant="outline"
><Link :href="show(project.id)"
><ArrowLeft class="size-4" aria-hidden="true" />{{
t('projectAccess.back')
}}</Link
></Button
>
<Link :href="generalSettings(project.id)">
General settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="show(project.id)">Back to project</Link>
</Button>
</div>
</div>
<div
</header>
<FeedbackState
v-if="status"
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
state="success"
:title="t('projectAccess.saved')"
/>
<aside
class="bg-accent/60 flex items-start gap-3 rounded-xl border p-4"
:aria-label="t('projectAccess.howItWorks')"
>
{{ status }}
<ShieldCheck
class="text-primary mt-0.5 size-5 shrink-0"
aria-hidden="true"
/>
<div class="grid gap-1">
<h2 class="text-sm font-semibold">
{{ t('projectAccess.howItWorks') }}
</h2>
<p class="text-muted-foreground text-sm">
{{ t('projectAccess.additiveHelp') }}
</p>
<p class="text-muted-foreground text-sm">
{{ t('projectAccess.limitsHelp') }}
</p>
</div>
</aside>
<div class="relative sm:max-w-md">
<Label for="access-search" class="sr-only">{{
t('projectAccess.search')
}}</Label
><Search
class="text-muted-foreground pointer-events-none absolute left-3 top-3 size-4"
aria-hidden="true"
/><Input
id="access-search"
v-model="search"
type="search"
class="pl-9"
:placeholder="t('projectAccess.search')"
/>
</div>
<Card class="gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle>User permissions</CardTitle>
<CardDescription>
Direct assignments apply only to the selected user
inside this project.
</CardDescription>
</CardHeader>
<CardContent class="p-0">
<div class="overflow-x-auto">
<table
class="divide-border min-w-full divide-y text-sm"
>
<thead
class="bg-muted/40 text-muted-foreground text-left"
>
<tr>
<th class="px-4 py-3 font-medium">User</th>
<th class="px-4 py-3 font-medium">Email</th>
<th class="px-4 py-3 font-medium">
Permissions
</th>
<th
class="px-4 py-3 text-right font-medium"
>
Action
</th>
</tr>
</thead>
<tbody class="divide-border divide-y">
<tr
v-for="user in users"
:key="user.id"
class="bg-background align-top"
>
<td
class="text-foreground px-4 py-4 font-medium"
>
{{ user.name }}
</td>
<td class="text-muted-foreground px-4 py-4">
{{ user.email }}
</td>
<td class="px-4 py-4">
<Form
v-bind="
ProjectController.updateUserPermissions.form(
[project.id, user.id],
)
"
class="flex flex-wrap gap-3"
v-slot="{ processing }"
>
<label
v-for="option in permissionOptions"
:key="`${user.id}-${option.value}`"
class="border-border inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm"
>
<input
type="checkbox"
name="permissions[]"
:value="option.value"
:checked="
hasPermission(
directPermissions,
user.id,
option.value,
)
"
class="border-input text-primary focus:ring-ring size-4 rounded"
/>
<span>{{ option.label }}</span>
</label>
<div class="w-full sm:hidden" />
<Button
type="submit"
size="sm"
variant="outline"
:disabled="processing"
>
Save
</Button>
</Form>
</td>
<td
class="text-muted-foreground px-4 py-4 text-right"
>
Direct
</td>
</tr>
</tbody>
</table>
</div>
</CardContent>
</Card>
<Card class="gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle>Team permissions</CardTitle>
<CardDescription>
Team assignments are inherited by every current and
future member of that team.
</CardDescription>
</CardHeader>
<CardContent class="p-0">
<div
v-if="teams.length === 0"
class="text-muted-foreground p-6 text-sm"
<section
class="grid min-w-0 gap-4"
aria-labelledby="people-heading"
>
<div class="flex items-center gap-2">
<h2 id="people-heading" class="text-lg font-semibold">
{{ t('projectAccess.people') }}
</h2>
<Badge variant="secondary">{{ users.length }}</Badge>
</div>
<FeedbackState
v-if="visibleUsers.length === 0"
state="empty"
:title="
t(
users.length
? 'projectAccess.noPeopleResults'
: 'projectAccess.noPeople',
)
"
/>
<ul
v-else
class="bg-card min-w-0 divide-y rounded-xl border"
:aria-label="t('projectAccess.people')"
>
<li
v-for="user in visibleUsers"
:key="user.id"
class="grid min-w-0 gap-4 p-4 sm:p-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.3fr)_auto] lg:items-center"
:data-user-id="user.id"
>
No teams exist yet. Create teams first to assign
inherited permissions.
</div>
<div v-else class="overflow-x-auto">
<table
class="divide-border min-w-full divide-y text-sm"
>
<thead
class="bg-muted/40 text-muted-foreground text-left"
>
<tr>
<th class="px-4 py-3 font-medium">Team</th>
<th class="px-4 py-3 font-medium">
Members
</th>
<th class="px-4 py-3 font-medium">
Permissions
</th>
<th
class="px-4 py-3 text-right font-medium"
>
Action
</th>
</tr>
</thead>
<tbody class="divide-border divide-y">
<tr
v-for="team in teams"
:key="team.id"
class="bg-background align-top"
<div class="flex min-w-0 items-center gap-3">
<UserAvatar :user="user" class="size-10 shrink-0" />
<div class="grid min-w-0 gap-1">
<h3 class="break-words text-sm font-semibold">
{{ user.name }}
</h3>
<p
class="text-muted-foreground break-all text-xs"
>
<td
class="text-foreground px-4 py-4 font-medium"
>
{{ team.name }}
</td>
<td class="px-4 py-4">
<div class="flex flex-wrap gap-2">
<Badge
v-for="member in team.users"
:key="member.id"
variant="secondary"
>
{{ member.name }}
</Badge>
<span
v-if="team.users.length === 0"
class="text-muted-foreground"
>
No members assigned
</span>
</div>
</td>
<td class="px-4 py-4">
<Form
v-bind="
ProjectController.updateTeamPermissions.form(
[project.id, team.id],
)
"
class="flex flex-wrap gap-3"
v-slot="{ processing }"
>
<label
v-for="option in permissionOptions"
:key="`${team.id}-${option.value}`"
class="border-border inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm"
>
<input
type="checkbox"
name="permissions[]"
:value="option.value"
:checked="
hasPermission(
teamPermissions,
team.id,
option.value,
)
"
class="border-input text-primary focus:ring-ring size-4 rounded"
/>
<span>{{ option.label }}</span>
</label>
<div class="w-full sm:hidden" />
<Button
type="submit"
size="sm"
variant="outline"
:disabled="processing"
>
Save
</Button>
</Form>
</td>
<td
class="text-muted-foreground px-4 py-4 text-right"
>
Inherited
</td>
</tr>
</tbody>
</table>
{{ user.email }}
</p>
</div>
</div>
<div class="grid min-w-0 gap-2">
<p
class="text-muted-foreground text-xs font-medium"
>
{{ t('projectAccess.effective') }}
</p>
<ProjectPermissionBadges
:permissions="
effectivePermissions[user.id] ?? []
"
:empty-label="t('projectAccess.noAccess')"
/>
<div
class="text-muted-foreground grid gap-1 text-xs"
>
<span
v-if="globalAdminUserIds.includes(user.id)"
>{{ t('projectAccess.globalSource') }}</span
>
<div class="flex flex-wrap items-center gap-2">
<span>{{ t('projectAccess.direct') }}</span
><ProjectPermissionBadges
:permissions="
directPermissions[user.id] ?? []
"
:empty-label="t('projectAccess.none')"
/>
</div>
<div
v-for="team in inheritedTeams(user.id)"
:key="team.id"
class="flex min-w-0 flex-wrap items-center gap-2"
>
<span class="break-words">{{
t('projectAccess.viaTeam', {
name: team.name,
})
}}</span
><ProjectPermissionBadges
:permissions="
teamPermissions[team.id] ?? []
"
:empty-label="t('projectAccess.none')"
/>
</div>
</div>
</div>
<Button
type="button"
variant="outline"
:aria-label="
t('projectAccess.editFor', { name: user.name })
"
@click="
target = {
kind: 'user',
id: user.id,
name: user.name,
permissions:
directPermissions[user.id] ?? [],
globalAdmin: globalAdminUserIds.includes(
user.id,
),
}
"
>{{ t('projectAccess.editDirect') }}</Button
>
</li>
</ul>
</section>
<section class="grid min-w-0 gap-4" aria-labelledby="teams-heading">
<div class="grid gap-1">
<div class="flex items-center gap-2">
<h2 id="teams-heading" class="text-lg font-semibold">
{{ t('projectAccess.teamPermissions') }}
</h2>
<Badge variant="secondary">{{ teams.length }}</Badge>
</div>
</CardContent>
</Card>
</div>
<p class="text-muted-foreground text-sm">
{{ t('projectAccess.teamHelp') }}
</p>
</div>
<FeedbackState
v-if="visibleTeams.length === 0"
state="empty"
:title="
t(
teams.length
? 'projectAccess.noTeamResults'
: 'projectAccess.noTeams',
)
"
:description="
teams.length
? undefined
: t('projectAccess.noTeamsHelp')
"
/>
<ul
v-else
class="grid min-w-0 gap-4 lg:grid-cols-2"
:aria-label="t('projectAccess.teamPermissions')"
>
<li
v-for="team in visibleTeams"
:key="team.id"
class="bg-card grid min-w-0 gap-4 rounded-xl border p-5"
:data-team-id="team.id"
>
<div class="flex min-w-0 items-center gap-3">
<span
class="bg-accent text-primary flex size-10 shrink-0 items-center justify-center rounded-lg"
><Users class="size-5" aria-hidden="true"
/></span>
<div class="min-w-0">
<h3 class="break-words text-sm font-semibold">
{{ team.name }}
</h3>
<p class="text-muted-foreground text-xs">
{{
t('teams.memberCount', {
count: team.users.length,
})
}}
</p>
</div>
</div>
<ProjectPermissionBadges
:permissions="teamPermissions[team.id] ?? []"
:empty-label="t('projectAccess.noTeamAccess')"
/>
<p class="text-muted-foreground break-words text-xs">
{{
team.users
.map((user) => user.name)
.join(', ') || t('teams.noMembers')
}}
</p>
<Button
type="button"
variant="outline"
:aria-label="
t('projectAccess.editFor', { name: team.name })
"
@click="
target = {
kind: 'team',
id: team.id,
name: team.name,
permissions: teamPermissions[team.id] ?? [],
}
"
>{{ t('projectAccess.editTeam') }}</Button
>
</li>
</ul>
</section>
<ProjectAccessEditorDialog
:project-id="project.id"
:target="target"
:permission-options="permissionOptions"
@close="target = null"
/>
</main>
</AppLayout>
</template>
+2 -1
View File
@@ -23,6 +23,7 @@ import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue';
import { copyText } from '@/lib/clipboard';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import { edit } from '@/routes/applications/index';
import type { BreadcrumbItem, SettingsApplicationsPageProps } from '@/types';
@@ -83,7 +84,7 @@ async function copyKey(): Promise<void> {
}
copyError.value = false;
try {
await navigator.clipboard.writeText(key);
await copyText(key);
if (props.applicationKey === key) {
copied.value = true;
}
+184 -336
View File
@@ -1,356 +1,204 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import { Shield } from 'lucide-vue-next';
import TeamController from '@/actions/App/Http/Controllers/TeamController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import { Head } from '@inertiajs/vue3';
import { Plus, Search, Shield, Users } from 'lucide-vue-next';
import { computed, ref } from 'vue';
import FeedbackState from '@/components/FeedbackState.vue';
import TeamEditorDialog from '@/components/teams/TeamEditorDialog.vue';
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue';
import { index as teamsIndex } from '@/routes/teams';
import { index } from '@/routes/teams';
import type { BreadcrumbItem, TeamsIndexPageProps } from '@/types';
import type { TeamEditorTarget } from '@/types/teams';
const props = defineProps<TeamsIndexPageProps>();
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Teams',
href: teamsIndex(),
},
];
const hasPermission = (
permissions: Record<string, string[]>,
actorId: number,
permission: string,
) => (permissions[actorId] ?? []).includes(permission);
const permissionsForTeam = (teamId: number): Record<string, string[]> =>
props.teamPermissions[teamId] ?? {};
const { t } = useLocalization();
const search = ref('');
const target = ref<TeamEditorTarget | null>(null);
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.teams'), href: index() },
]);
const visibleTeams = computed(() =>
props.teams.filter((team) =>
team.name
.toLocaleLowerCase()
.includes(search.value.trim().toLocaleLowerCase()),
),
);
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head title="Teams" />
<div class="flex flex-1 flex-col gap-6 p-4">
<div
<Head :title="t('navigation.teams')" />
<main
class="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-6 p-4 sm:p-6 lg:p-8"
>
<header
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<Heading
title="Teams"
description="Create reusable teams, manage memberships, and assign team-scoped permissions to users."
/>
<Badge variant="outline" class="gap-1.5 px-3 py-1">
<Shield class="size-3.5" />
{{ canCreateTeams ? 'Global admin' : 'Team-scoped access' }}
</Badge>
</div>
<div
v-if="status"
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
>
{{ status }}
</div>
<div
class="grid gap-6"
:class="
canCreateTeams
? 'xl:grid-cols-[22rem_minmax(0,1fr)]'
: 'xl:grid-cols-1'
"
>
<Card v-if="canCreateTeams">
<CardHeader>
<CardTitle>Create team</CardTitle>
<CardDescription>
Teams group users so project permissions can be
granted once and inherited by every member.
</CardDescription>
</CardHeader>
<CardContent>
<Form
v-bind="TeamController.store.form()"
class="space-y-4"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label for="name">Team name</Label>
<Input
id="name"
name="name"
required
autofocus
placeholder="Platform Team"
/>
<InputError :message="errors.name" />
</div>
<Button type="submit" :disabled="processing">
Create team
</Button>
</Form>
</CardContent>
</Card>
<div class="grid gap-4">
<Card
v-for="team in teams"
:key="team.id"
class="gap-0 overflow-hidden"
<div class="grid min-w-0 gap-2">
<p
class="text-primary text-xs font-semibold uppercase tracking-wider"
>
<CardHeader class="border-b">
<div
class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between"
>
<div>
<CardTitle>{{ team.name }}</CardTitle>
<CardDescription>
{{ team.users.length }} member{{
team.users.length === 1 ? '' : 's'
}}
</CardDescription>
</div>
<div class="flex flex-wrap gap-2">
<Badge
v-for="member in team.users"
:key="member.id"
variant="secondary"
>
{{ member.name }}
</Badge>
</div>
</div>
</CardHeader>
<CardContent class="space-y-6 pt-6">
<Form
v-bind="
TeamController.updateMembers.form(team.id)
"
class="space-y-4"
v-slot="{ processing }"
>
<div class="grid gap-2">
<Label :for="`team-members-${team.id}`">
Members
</Label>
<select
:id="`team-members-${team.id}`"
name="user_ids[]"
multiple
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-h-44 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option
v-for="user in users"
:key="user.id"
:value="user.id"
:selected="
team.user_ids.includes(user.id)
"
>
{{ user.name }} ({{ user.email }})
</option>
</select>
<p class="text-muted-foreground text-sm">
Hold Cmd or Ctrl to select multiple
users.
</p>
</div>
<Button
type="submit"
variant="outline"
:disabled="processing"
>
Save members
</Button>
</Form>
<div class="space-y-4 border-t pt-6">
<div class="space-y-1">
<h3 class="text-foreground font-medium">
Team permissions
</h3>
<p class="text-muted-foreground text-sm">
Direct user permissions on this team are
stored separately from team membership.
</p>
</div>
<div class="overflow-x-auto">
<table
class="divide-border min-w-full divide-y text-sm"
>
<thead
class="bg-muted/40 text-muted-foreground text-left"
>
<tr>
<th
class="px-4 py-3 font-medium"
>
User
</th>
<th
class="px-4 py-3 font-medium"
>
Current permissions
</th>
<th
class="px-4 py-3 text-right font-medium"
>
Action
</th>
</tr>
</thead>
<tbody class="divide-border divide-y">
<tr
v-for="user in users"
:key="`${team.id}-${user.id}`"
class="bg-background align-top"
>
<td class="px-4 py-4">
<div
class="text-foreground font-medium"
>
{{ user.name }}
</div>
<div
class="text-muted-foreground text-xs"
>
{{ user.email }}
</div>
</td>
<td class="px-4 py-4">
<div
class="flex flex-wrap gap-2"
>
<Badge
v-for="option in permissionOptions"
v-show="
hasPermission(
permissionsForTeam(
team.id,
),
user.id,
option.value,
)
"
:key="`${team.id}-${user.id}-${option.value}-badge`"
variant="secondary"
>
{{ option.label }}
</Badge>
<span
v-if="
(
permissionsForTeam(
team.id,
)[
user.id
] ?? []
).length === 0
"
class="text-muted-foreground"
>
No direct team
permissions
</span>
</div>
</td>
<td class="px-4 py-4">
<div
class="flex justify-end"
>
<Form
v-bind="
TeamController.updateUserPermissions.form(
[
team.id,
user.id,
],
)
"
class="flex flex-wrap justify-end gap-3"
v-slot="{
processing,
}"
>
<label
v-for="option in permissionOptions"
:key="`${team.id}-${user.id}-${option.value}`"
class="border-border inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm"
>
<input
type="checkbox"
name="permissions[]"
:value="
option.value
"
:checked="
hasPermission(
permissionsForTeam(
team.id,
),
user.id,
option.value,
)
"
class="border-input text-primary focus:ring-ring size-4 rounded"
/>
<span>
{{
option.label
}}
</span>
</label>
<div
class="w-full sm:hidden"
/>
<Button
type="submit"
size="sm"
variant="outline"
:disabled="
processing
"
>
Save
</Button>
</Form>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</CardContent>
</Card>
<Card v-if="teams.length === 0">
<CardContent class="text-muted-foreground p-6 text-sm">
No teams are available for your current permissions.
</CardContent>
</Card>
{{ t('teams.eyebrow') }}
</p>
<h1 class="text-headline-mobile sm:text-headline">
{{ t('navigation.teams') }}
</h1>
<p class="text-muted-foreground max-w-2xl text-sm">
{{ t('teams.description') }}
</p>
</div>
<Button
v-if="canCreateTeams"
type="button"
class="shrink-0"
@click="target = { kind: 'create' }"
><Plus class="size-4" aria-hidden="true" />{{
t('teams.create')
}}</Button
>
</header>
<FeedbackState
v-if="status"
state="success"
:title="t('teams.updated')"
/>
<div
class="bg-card flex flex-col gap-4 rounded-xl border p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div class="flex items-center gap-2">
<Users class="text-primary size-5" aria-hidden="true" />
<h2 class="font-semibold">{{ t('teams.yourTeams') }}</h2>
<Badge variant="secondary">{{ teams.length }}</Badge>
</div>
<div class="relative sm:w-80">
<Label for="team-search" class="sr-only">{{
t('teams.search')
}}</Label
><Search
class="text-muted-foreground pointer-events-none absolute left-3 top-3 size-4"
aria-hidden="true"
/><Input
id="team-search"
v-model="search"
type="search"
class="pl-9"
:placeholder="t('teams.search')"
/>
</div>
</div>
</div>
<FeedbackState
v-if="teams.length === 0"
state="empty"
:title="t('teams.empty')"
:description="
t(
canCreateTeams
? 'teams.emptyCreate'
: 'teams.emptyDescription',
)
"
/>
<FeedbackState
v-else-if="visibleTeams.length === 0"
state="empty"
:title="t('teams.noResults')"
:description="t('teams.noResultsDescription')"
><Button type="button" variant="outline" @click="search = ''">{{
t('teams.clearSearch')
}}</Button></FeedbackState
>
<ul
v-else
class="grid min-w-0 gap-4 lg:grid-cols-2"
:aria-label="t('teams.yourTeams')"
>
<li
v-for="team in visibleTeams"
:key="team.id"
class="bg-card flex min-w-0 flex-col rounded-xl border"
:data-team-id="team.id"
>
<div class="flex min-w-0 items-start gap-3 p-5">
<span
class="bg-accent text-primary flex size-11 shrink-0 items-center justify-center rounded-lg"
><Users class="size-5" aria-hidden="true"
/></span>
<div class="grid min-w-0 gap-1">
<h3 class="break-words font-semibold">
{{ team.name }}
</h3>
<p class="text-muted-foreground text-sm">
{{
t('teams.memberCount', {
count: team.users.length,
})
}}
</p>
</div>
</div>
<div class="flex-1 px-5 pb-5">
<ul
v-if="team.users.length"
class="flex flex-wrap gap-2"
:aria-label="t('teams.members')"
>
<li
v-for="member in team.users"
:key="member.id"
class="bg-muted/60 flex min-w-0 max-w-full items-center gap-2 rounded-full py-1 pl-1 pr-3"
>
<UserAvatar
:user="member"
class="size-6 shrink-0"
/><span class="min-w-0 break-words text-xs">{{
member.name
}}</span>
</li>
</ul>
<p v-else class="text-muted-foreground text-sm">
{{ t('teams.noMembers') }}
</p>
</div>
<div
class="bg-muted/20 grid gap-2 border-t p-4 sm:grid-cols-2"
>
<Button
type="button"
variant="outline"
:aria-label="
t('teams.manageMembersFor', { name: team.name })
"
@click="target = { kind: 'members', team }"
><Users class="size-4" aria-hidden="true" />{{
t('teams.manageMembers')
}}</Button
><Button
type="button"
variant="ghost"
:aria-label="
t('teams.permissionsFor', { name: team.name })
"
@click="target = { kind: 'permissions', team }"
><Shield class="size-4" aria-hidden="true" />{{
t('teams.permissions')
}}</Button
>
</div>
</li>
</ul>
</main>
<TeamEditorDialog
:target="target"
:users="users"
:permission-options="permissionOptions"
:team-permissions="teamPermissions"
@close="target = null"
/>
</AppLayout>
</template>
+2
View File
@@ -117,6 +117,8 @@ export type PermissionOption = {
export type ProjectAccessPageProps = {
directPermissions: Record<string, string[]>;
effectivePermissions: Record<string, string[]>;
globalAdminUserIds: number[];
permissionOptions: PermissionOption[];
project: ProjectModel;
status?: string | null;
+19
View File
@@ -0,0 +1,19 @@
export const projectPermissionLabels = {
'project:read': 'projectAccess.read',
'project:write': 'projectAccess.write',
'project:admin': 'projectAccess.admin',
} as const;
export const projectPermissionDescriptions = {
'project:read': 'projectAccess.readHelp',
'project:write': 'projectAccess.writeHelp',
'project:admin': 'projectAccess.adminHelp',
} as const;
export type ProjectAccessTarget = {
kind: 'user' | 'team';
id: number;
name: string;
permissions: string[];
globalAdmin?: boolean;
};
+14
View File
@@ -0,0 +1,14 @@
import type { PermissionOption } from '@/types';
import type { TeamModel } from '@/types/models/team-model';
import type { UserModel } from '@/types/models/user-model';
export type TeamEditorTarget =
| { kind: 'create' }
| { kind: 'members' | 'permissions'; team: TeamModel };
export type TeamEditorProps = {
target: TeamEditorTarget | null;
users: UserModel[];
permissionOptions: PermissionOption[];
teamPermissions: Record<string, Record<string, string[]>>;
};
@@ -0,0 +1,153 @@
<?php
namespace Tests\Feature\Projects;
use App\Actions\Permissions\PermissionService;
use App\Enums\Permission;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class ProjectAccessTest extends TestCase
{
use RefreshDatabase;
public function test_access_summary_matches_existing_permission_rules_and_excludes_other_scopes(): void
{
$admin = User::factory()->admin()->create();
$writer = User::factory()->create();
$member = User::factory()->create();
$outsider = User::factory()->create();
$project = Project::factory()->create();
$otherProject = Project::factory()->create();
$team = Team::factory()->create();
$otherTeam = Team::factory()->create();
$team->users()->attach([$writer->id, $member->id]);
$otherTeam->users()->attach($writer);
$service = app(PermissionService::class);
$service->sync($writer, $project, [Permission::ProjectRead]);
$service->sync($team, $project, [Permission::ProjectWrite]);
$service->sync($otherTeam, $project, [Permission::ProjectRead]);
$service->sync($outsider, $otherProject, [Permission::ProjectAdmin]);
$service->sync($outsider, null, [Permission::ProjectCreate]);
$service->sync($outsider, $team, [Permission::TeamAdmin]);
$map = $service->projectAccessMap(User::all(), $project);
foreach (User::all() as $user) {
$expected = array_values(array_filter(Permission::projectAssignable(), fn (Permission $permission): bool => $service->allows($user, $permission, $project)));
$this->assertSame(Permission::values($expected), $map['effectivePermissions'][$user->id]);
}
$this->assertSame([], $map['effectivePermissions'][$outsider->id]);
$this->assertSame(['project:read', 'project:write'], $map['effectivePermissions'][$writer->id]);
$this->assertSame([$admin->id], $map['globalAdminUserIds']);
$this->actingAs($admin)->get(route('projects.access', $project))->assertOk()
->assertInertia(fn (Assert $page) => $page->component('projects/Access')
->where("effectivePermissions.{$writer->id}", ['project:read', 'project:write'])
->where("directPermissions.{$writer->id}", ['project:read'])
->where("teamPermissions.{$team->id}", ['project:write'])
->where('globalAdminUserIds', [$admin->id]));
}
public function test_bulk_access_summary_uses_a_bounded_number_of_queries(): void
{
$project = Project::factory()->create();
User::factory()->create();
$service = app(PermissionService::class);
$countQueries = function () use ($service, $project): int {
$users = User::all();
DB::enableQueryLog();
DB::flushQueryLog();
$service->projectAccessMap($users, $project);
$count = count(DB::getQueryLog());
DB::disableQueryLog();
return $count;
};
$initialCount = $countQueries();
$users = User::factory()->count(10)->create();
$team = Team::factory()->create();
$team->users()->attach($users);
$service->sync($team, $project, [Permission::ProjectWrite]);
$this->assertSame($initialCount, $countQueries());
$this->assertLessThanOrEqual(4, $initialCount);
}
public function test_readers_writers_and_unrelated_admins_cannot_manage_project_access(): void
{
$project = Project::factory()->create();
$other = Project::factory()->create();
$member = User::factory()->create();
$team = Team::factory()->create();
$service = app(PermissionService::class);
$service->sync($member, $project, [Permission::ProjectRead]);
$service->sync($team, $project, [Permission::ProjectWrite]);
foreach ([[], [Permission::ProjectRead], [Permission::ProjectWrite]] as $permissions) {
$actor = User::factory()->create();
$service->sync($actor, $project, $permissions);
$service->sync($actor, $other, [Permission::ProjectAdmin]);
$this->actingAs($actor)->get(route('projects.access', $project))->assertForbidden();
foreach ([['users', $member], ['teams', $team]] as [$kind, $assignee]) {
$this->patch(route("projects.permissions.{$kind}.update", [$project, $assignee]), ['permissions' => ['project:admin']])->assertForbidden();
}
}
$this->assertSame(['project:read'], $service->assignmentMap(User::class, $project)[$member->id]);
$this->assertSame(['project:write'], $service->assignmentMap(Team::class, $project)[$team->id]);
}
public function test_invalid_assignments_preserve_both_user_and_team_permissions(): void
{
$admin = User::factory()->admin()->create();
$project = Project::factory()->create();
$service = app(PermissionService::class);
foreach ([['users', User::factory()->create()], ['teams', Team::factory()->create()]] as [$kind, $assignee]) {
$service->sync($assignee, $project, [Permission::ProjectRead]);
foreach ([['global:admin'], ['team:admin'], ['unknown'], ['project:read', 'project:read']] as $permissions) {
$this->actingAs($admin)->patch(route("projects.permissions.{$kind}.update", [$project, $assignee]), ['permissions' => $permissions])
->assertSessionHasErrors('permissions.0');
$this->assertSame(['project:read'], $service->assignmentMap($assignee::class, $project)[$assignee->id]);
}
}
}
public function test_clearing_assignments_preserves_access_from_other_sources(): void
{
$admin = User::factory()->admin()->create();
$member = User::factory()->create();
$project = Project::factory()->create();
$team = Team::factory()->create();
$team->users()->attach($member);
$service = app(PermissionService::class);
$service->sync($member, $project, [Permission::ProjectRead]);
$service->sync($team, $project, [Permission::ProjectWrite]);
$this->actingAs($admin)->patch(route('projects.permissions.users.update', [$project, $member]), ['permissions' => []])
->assertRedirect(route('projects.access', $project))->assertSessionHasNoErrors();
$this->assertTrue($service->allows($member, Permission::ProjectWrite, $project));
$service->sync($member, $project, [Permission::ProjectRead]);
$this->patch(route('projects.permissions.teams.update', [$project, $team]), ['permissions' => []])
->assertRedirect(route('projects.access', $project))->assertSessionHasNoErrors();
$this->assertTrue($service->allows($member, Permission::ProjectRead, $project));
$this->assertFalse($service->allows($member, Permission::ProjectWrite, $project));
}
public function test_removing_own_admin_access_redirects_to_projects_for_direct_and_team_grants(): void
{
$service = app(PermissionService::class);
foreach (['users', 'teams'] as $kind) {
$actor = User::factory()->create();
$project = Project::factory()->create();
$assignee = $actor;
if ($kind === 'teams') {
$assignee = Team::factory()->create();
$assignee->users()->attach($actor);
}
$service->sync($assignee, $project, [Permission::ProjectAdmin]);
$this->actingAs($actor)->patch(route("projects.permissions.{$kind}.update", [$project, $assignee]), ['permissions' => []])
->assertRedirect(route('projects.index'))->assertSessionHasNoErrors();
$this->assertFalse($service->allows($actor, Permission::ProjectAdmin, $project));
}
}
}
@@ -0,0 +1,19 @@
<?php
namespace Tests\Feature\Projects;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class ProjectAccessUiTest extends TestCase
{
public function test_access_overview_renders_sources_empty_states_and_localization(): void
{
$process = new Process(['node', 'tests/Fixtures/project-access.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Project access presentation checks passed.', $process->getOutput());
}
}
@@ -214,4 +214,89 @@ class TeamControllerTest extends TestCase
'scope_type' => $team->getMorphClass(),
]);
}
public function test_team_managers_only_see_their_manageable_teams(): void
{
$manager = User::factory()->create();
$managed = Team::factory()->create();
$other = Team::factory()->create();
$other->users()->attach($manager);
$this->syncTeamPermissions($managed, $manager, ['team:manage']);
$this->actingAs($manager)->get(route('teams.index'))
->assertInertia(fn (Assert $page) => $page
->has('teams', 1)
->where('teams.0.id', $managed->id)
->where('canCreateTeams', false)
);
$this->actingAs($manager)->patch(route('teams.members.update', $other), ['user_ids' => []])
->assertForbidden();
$this->actingAs($manager)->patch(route('teams.permissions.users.update', [$other, $manager]), ['permissions' => ['team:admin']])
->assertForbidden();
$this->assertDatabaseHas('team_user', ['team_id' => $other->id, 'user_id' => $manager->id]);
$this->assertDatabaseMissing('permission_assignments', ['scope_id' => $other->id, 'scope_type' => $other->getMorphClass()]);
}
public function test_invalid_team_names_are_rejected(): void
{
$admin = User::factory()->admin()->create();
$team = Team::factory()->create();
foreach (['', $team->name, str_repeat('a', 256)] as $name) {
$this->actingAs($admin)->from(route('teams.index'))->post(route('teams.store'), ['name' => $name])
->assertRedirect(route('teams.index'))->assertSessionHasErrors('name');
}
$this->assertDatabaseCount('teams', 1);
}
public function test_invalid_members_preserve_existing_membership(): void
{
$admin = User::factory()->admin()->create();
$member = User::factory()->create();
$team = Team::factory()->create();
$team->users()->attach($member);
foreach ([[$member->id, $member->id], [999999], ['invalid']] as $ids) {
$this->actingAs($admin)->patch(route('teams.members.update', $team), ['user_ids' => $ids])
->assertSessionHasErrors('user_ids.0');
$this->assertSame([$member->id], $team->users()->pluck('users.id')->all());
}
}
public function test_membership_and_direct_permissions_can_be_cleared_independently(): void
{
$manager = User::factory()->create();
$member = User::factory()->create();
$team = Team::factory()->create();
$team->users()->attach($member);
$this->syncTeamPermissions($team, $manager, ['team:manage']);
$this->syncTeamPermissions($team, $member, ['team:invite']);
$this->actingAs($manager)->patch(route('teams.members.update', $team), ['user_ids' => []])
->assertRedirect(route('teams.index'))->assertSessionHasNoErrors();
$this->assertDatabaseMissing('team_user', ['team_id' => $team->id]);
$this->assertDatabaseHas('permission_assignments', ['assignee_id' => $member->id, 'scope_id' => $team->id, 'permission' => 'team:invite']);
$team->users()->attach($member);
$this->actingAs($manager)->patch(route('teams.permissions.users.update', [$team, $member]), ['permissions' => []])
->assertRedirect(route('teams.index'))->assertSessionHasNoErrors();
$this->assertDatabaseHas('team_user', ['team_id' => $team->id, 'user_id' => $member->id]);
$this->assertDatabaseMissing('permission_assignments', ['assignee_id' => $member->id, 'scope_id' => $team->id, 'scope_type' => $team->getMorphClass()]);
}
public function test_invalid_permissions_preserve_existing_assignments(): void
{
$admin = User::factory()->admin()->create();
$member = User::factory()->create();
$team = Team::factory()->create();
$this->syncTeamPermissions($team, $member, ['team:invite']);
foreach ([['global:admin'], ['team:manage', 'team:manage'], ['unknown']] as $permissions) {
$this->actingAs($admin)->patch(route('teams.permissions.users.update', [$team, $member]), ['permissions' => $permissions])
->assertSessionHasErrors('permissions.0');
$this->assertDatabaseHas('permission_assignments', ['assignee_id' => $member->id, 'scope_id' => $team->id, 'permission' => 'team:invite']);
$this->assertDatabaseMissing('permission_assignments', ['assignee_id' => $member->id, 'permission' => 'global:admin']);
}
}
}
@@ -0,0 +1,19 @@
<?php
namespace Tests\Feature\Teams;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class TeamPresentationTest extends TestCase
{
public function test_team_overview_respects_permissions_empty_states_and_localization(): void
{
$process = new Process(['node', 'tests/Fixtures/teams.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Teams presentation checks passed.', $process->getOutput());
}
}
+105
View File
@@ -0,0 +1,105 @@
import assert from 'node:assert/strict';
import process from 'node:process';
import { createServer } from 'vite';
const { chromium } = await import(
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
);
const server = await createServer({
configFile: false,
optimizeDeps: { noDiscovery: true, include: [] },
server: { host: '127.0.0.1', port: 0, hmr: false },
appType: 'custom',
});
server.middlewares.use((request, response, next) => {
if (request.url !== '/') {
return next();
}
response.setHeader('Content-Type', 'text/html');
response.end(`<!doctype html><html><body>
<div role="dialog"><input id="draft" value="unsaved draft"><button id="copy">Copy</button></div>
<script type="module">
import { copyText } from '/resources/js/lib/clipboard.ts';
window.originalClipboard = navigator.clipboard;
window.copyText = copyText;
document.querySelector('#copy').onclick = async () => {
window.result = 'pending';
const input = document.querySelector('#draft');
input.focus(); input.setSelectionRange(1, 5);
try { await copyText('kan55 selected text'); window.result = 'copied'; }
catch { window.result = 'failed'; }
};
</script></body></html>`);
});
await server.listen();
const browser = await chromium.launch({ channel: 'chrome', headless: true });
const context = await browser.newContext({
permissions: ['clipboard-read', 'clipboard-write'],
});
const page = await context.newPage();
try {
await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/`);
await page.waitForFunction(() => typeof window.copyText === 'function');
for (const mode of ['modern', 'absent', 'denied']) {
await page.evaluate(async (mode) => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value:
mode === 'modern'
? window.originalClipboard
: mode === 'absent'
? undefined
: {
writeText: () =>
Promise.reject(
new DOMException(
'Denied',
'NotAllowedError',
),
),
},
});
await window.originalClipboard.writeText('previous');
// Simulate the modal's focus boundary used by the two-factor setup dialog.
document.onfocusin = (event) => {
if (!event.target.closest('[role="dialog"]')) {
document.querySelector('#draft').focus();
}
};
}, mode);
await page.getByRole('button', { name: 'Copy', exact: true }).click();
await page.waitForFunction(() => window.result === 'copied');
assert.equal(
await page.evaluate(() => window.originalClipboard.readText()),
'kan55 selected text',
mode,
);
assert.deepEqual(
await page.evaluate(() => {
const input = document.querySelector('#draft');
return [
document.activeElement === input,
input.value,
input.selectionStart,
input.selectionEnd,
document.querySelectorAll('textarea').length,
];
}),
[true, 'unsaved draft', 1, 5, 0],
mode,
);
}
await page.evaluate(() => {
document.execCommand = () => false;
});
await page.getByRole('button', { name: 'Copy', exact: true }).click();
await page.waitForFunction(() => window.result === 'failed');
assert.equal(await page.locator('textarea').count(), 0);
assert.equal(await page.locator('#draft').inputValue(), 'unsaved draft');
console.log(
'Clipboard browser checks passed: async API, unavailable/denied API fallback, modal focus, input selection and failure cleanup.',
);
} finally {
await browser.close();
await server.close();
}
+189
View File
@@ -0,0 +1,189 @@
import assert from 'node:assert/strict';
import process from 'node:process';
const { chromium } = await import(
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
);
const browser = await chromium.launch({ channel: 'chrome', headless: true });
const context = await browser.newContext({ reducedMotion: 'reduce' });
const page = await context.newPage();
page.setDefaultTimeout(10000);
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const base = 'http://127.0.0.1:4198';
const result = (value) =>
context.addCookies([{ name: 'access_result', value, url: base }]);
const visit = async (query = '') => {
await result('success');
await page.goto(base + '/projects/1/access?reset' + query);
await page
.getByRole('heading', { name: 'Project access', exact: true })
.waitFor();
};
const button = (name) => page.getByRole('button', { name, exact: true });
const dialog = () => page.getByRole('dialog');
const checkbox = (name) =>
dialog().getByRole('checkbox', { name, exact: true });
const state = (value) =>
dialog()
.locator('[data-form-state="' + value + '"]')
.waitFor({ state: 'visible' });
const noOverflow = async () =>
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
true,
);
const save = async () => {
const request = page.waitForRequest((r) => r.method() === 'PATCH');
await dialog()
.getByRole('button', { name: 'Save permissions', exact: true })
.click();
return await request;
};
try {
for (const width of [1440, 390, 320]) {
await page.setViewportSize({ width, height: 1000 });
for (const theme of ['light', 'dark']) {
await page.emulateMedia({ colorScheme: theme });
await visit();
await noOverflow();
const trigger = button('Edit access for Sam Taylor');
await trigger.focus();
await page.keyboard.press('Enter');
await dialog().waitFor();
await noOverflow();
assert.equal(await checkbox('Read').isChecked(), true);
assert.equal(
await checkbox('Write').isChecked(),
false,
'Inherited write must not be editable as a direct grant',
);
await checkbox('Write').focus();
await page.keyboard.press('Space');
assert.equal(await checkbox('Write').isChecked(), true);
await page.keyboard.press('Space');
await page.keyboard.press('Tab');
assert.equal(
await dialog().evaluate((el) =>
el.contains(document.activeElement),
),
true,
);
await page.screenshot({
path:
'/private/tmp/kan48-dialog-' + width + '-' + theme + '.png',
});
await page.keyboard.press('Escape');
await dialog().waitFor({ state: 'hidden' });
assert.equal(
await trigger.evaluate((el) => el === document.activeElement),
true,
);
await page.screenshot({
path: '/private/tmp/kan48-' + width + '-' + theme + '.png',
fullPage: true,
});
}
}
await page.locator('#access-search').fill('SAM@EXAMPLE');
assert.equal(await page.locator('[data-user-id]').count(), 1);
await page.getByText('No matching teams', { exact: true }).waitFor();
await page.locator('#access-search').fill('unmatched');
await page.getByText('No matching people', { exact: true }).waitFor();
await visit('&empty&noUsers');
await page.getByText('No teams yet', { exact: true }).waitFor();
await page.getByText('No people available', { exact: true }).waitFor();
await visit();
await button('Edit access for Alex Morgan').click();
await dialog()
.getByText('This person is a global administrator.', { exact: false })
.waitFor();
await page.keyboard.press('Escape');
await button('Edit access for Sam Taylor').click();
await checkbox('Read').uncheck();
const request = await save();
assert.equal(
new URL(request.url()).pathname,
'/projects/1/permissions/users/2',
);
assert.deepEqual(request.postDataJSON(), { permissions: [] });
await dialog().waitFor({ state: 'hidden' });
await page
.locator('[data-user-id="2"]')
.getByText('Write', { exact: true })
.first()
.waitFor();
await button('Edit access for Sam Taylor').click();
assert.equal(await checkbox('Read').isChecked(), false);
await page.keyboard.press('Escape');
await button('Edit access for Platform team').click();
await checkbox('Write').uncheck();
await save();
await dialog().waitFor({ state: 'hidden' });
await page
.locator('[data-user-id="2"]')
.getByText('No project access', { exact: true })
.waitFor();
await button('Edit access for Sam Taylor').click();
await checkbox('Administrator').check();
await result('validation');
await save();
await state('validation');
await dialog()
.getByText('This permission is unavailable.', { exact: true })
.waitFor();
assert.equal(await checkbox('Administrator').isChecked(), true);
assert.equal(
await dialog().locator('fieldset').getAttribute('aria-invalid'),
'true',
);
await result('server');
await save();
await state('server');
await result('success');
await page.route('**/permissions/users/2', (route) =>
route.abort('failed'),
);
await save();
await state('network');
await page.unroute('**/permissions/users/2');
await dialog()
.getByRole('button', { name: 'Try again', exact: true })
.click();
await dialog().waitFor({ state: 'hidden' });
await page
.locator('[data-user-id="2"]')
.getByText('Administrator', { exact: true })
.first()
.waitFor();
await button('Edit access for Sam Taylor').click();
assert.equal(await checkbox('Administrator').isChecked(), true);
await result('forbidden');
await save();
await state('server');
await dialog()
.getByText('Project access can no longer be changed', { exact: true })
.waitFor();
assert.equal(
await dialog().getByRole('button', { name: 'Try again' }).count(),
0,
);
await page.keyboard.press('Escape');
await visit();
await button('Edit access for Sam Taylor').click();
await checkbox('Write').check();
page.once('dialog', (d) => d.dismiss());
await page.keyboard.press('Escape');
assert.equal(await dialog().isVisible(), true);
page.once('dialog', (d) => d.accept());
await page.keyboard.press('Escape');
await dialog().waitFor({ state: 'hidden' });
assert.deepEqual(errors, []);
console.log(
'Project access browser checks passed: direct/team updates and clearing, effective access refresh, errors/retry, unsaved changes, keyboard, 320/390/1440px, both themes.',
);
} finally {
await browser.close();
}
+15
View File
@@ -0,0 +1,15 @@
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import '../../resources/css/app.css';
import Access from '../../resources/js/pages/projects/Access.vue';
import { createAppearance } from '../../resources/js/composables/useAppearance';
import { createLocalization } from '../../resources/js/lib/localization';
createInertiaApp({
resolve: () => Access,
setup: ({ el, App, props, plugin }) =>
createApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization())
.use(createAppearance())
.mount(el),
});
+229
View File
@@ -0,0 +1,229 @@
import assert from 'node:assert/strict';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import vue from '@vitejs/plugin-vue';
import tailwind from '@tailwindcss/vite';
import { createServer } from 'vite';
import { createSSRApp, h } from 'vue';
import { renderToString } from 'vue/server-renderer';
import { createInertiaApp } from '@inertiajs/vue3';
const serve = process.argv.includes('--serve');
const server = await createServer({
configFile: false,
cacheDir: 'node_modules/.vite/project-access-fixture',
plugins: [vue(), ...(serve ? [tailwind()] : [])],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: serve
? { host: '127.0.0.1', port: 4198, strictPort: true, hmr: false }
: { middlewareMode: true, hmr: false, watch: null },
appType: 'custom',
});
const users = [
{ id: 1, name: 'Alex Morgan', email: 'alex@example.test', avatar: null },
{ id: 2, name: 'Sam Taylor', email: 'sam@example.test', avatar: null },
{
id: 3,
name: 'Jordan Longname With Several Names',
email: 'jordan.long.email.address@example.test',
avatar: null,
},
];
function initial() {
return {
project: { id: 1, name: 'Core platform', key: 'CORE' },
users,
status: null,
globalAdminUserIds: [1],
directPermissions: { 2: ['project:read'] },
teamPermissions: { 1: ['project:write'] },
effectivePermissions: {
1: ['project:read', 'project:write', 'project:admin'],
2: ['project:read', 'project:write'],
3: [],
},
teams: [
{ id: 1, name: 'Platform team', user_ids: [2], users: [users[1]] },
{ id: 2, name: 'Delivery team', user_ids: [], users: [] },
],
permissionOptions: ['read', 'write', 'admin'].map((name) => ({
value: 'project:' + name,
label: name,
})),
};
}
const makePage = (extra = {}) => ({
component: 'Access',
url: '/projects/1/access',
version: null,
clearHistory: false,
encryptHistory: false,
props: {
name: 'KanbanGit',
errors: {},
sidebarOpen: true,
appearance: 'system',
auth: {
user: users[0],
permissions: {
globalAdmin: false,
projectCreate: false,
canManageTeams: true,
},
},
navigation: { project: null },
...initial(),
...extra,
},
});
if (serve) {
let data = initial();
server.middlewares.use(async (request, response, next) => {
if (!request.url.startsWith('/projects/1/')) {
return next();
}
const url = new URL(request.url, 'http://localhost');
if (url.searchParams.has('reset')) {
data = initial();
}
if (url.searchParams.has('noUsers')) {
data.users = [];
}
if (url.searchParams.has('empty')) {
data.teams = [];
}
let errors = {};
if (request.method !== 'GET') {
const result =
request.headers.cookie?.match(/access_result=(\w+)/)?.[1] ??
'success';
if (['server', 'forbidden'].includes(result)) {
response.statusCode = result === 'forbidden' ? 403 : 503;
response.end('Simulated failure');
return;
}
let body = '';
for await (const chunk of request) {
body += chunk;
}
const submitted = JSON.parse(body || '{}');
if (result === 'validation') {
errors = { 'permissions.0': 'This permission is unavailable.' };
} else {
const parts = url.pathname.split('/');
const kind = parts[4];
const id = parts[5];
data[
kind === 'users' ? 'directPermissions' : 'teamPermissions'
][id] = submitted.permissions;
for (const user of users) {
const grants = [
...(data.directPermissions[user.id] ?? []),
...data.teams
.filter((team) => team.user_ids.includes(user.id))
.flatMap(
(team) => data.teamPermissions[team.id] ?? [],
),
];
data.effectivePermissions[user.id] =
data.globalAdminUserIds.includes(user.id) ||
grants.includes('project:admin')
? ['project:read', 'project:write', 'project:admin']
: grants.includes('project:write')
? ['project:read', 'project:write']
: grants.includes('project:read')
? ['project:read']
: [];
}
}
if (Object.keys(errors).length === 0) {
data.status = 'Saved';
}
}
const page = makePage({ ...data, errors });
response.setHeader('Vary', 'X-Inertia');
if (request.headers['x-inertia']) {
response.setHeader('X-Inertia', 'true');
response.setHeader('Content-Type', 'application/json');
response.end(JSON.stringify(page));
return;
}
const escaped = JSON.stringify(page)
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;');
response.setHeader('Content-Type', 'text/html');
response.end(
await server.transformIndexHtml(
request.url,
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
escaped +
'"></div><script type="module" src="/tests/Fixtures/project-access-preview.mjs"></script></body></html>',
),
);
});
await server.listen();
console.log('Project access fixture ready.');
} else {
try {
const { createLocalization } = await server.ssrLoadModule(
'/resources/js/lib/localization.ts',
);
const { createAppearance } = await server.ssrLoadModule(
'/resources/js/composables/useAppearance.ts',
);
const { en } = await server.ssrLoadModule(
'/resources/js/locales/en.ts',
);
const component = (
await server.ssrLoadModule(
'/resources/js/pages/projects/Access.vue',
)
).default;
const render = async (extra = {}, locale = createLocalization()) =>
(
await createInertiaApp({
page: makePage(extra),
resolve: () => component,
render: renderToString,
setup: ({ App, props, plugin }) =>
createSSRApp({ render: () => h(App, props) })
.use(plugin)
.use(locale)
.use(createAppearance()),
})
).body;
const html = await render();
assert.match(html, /People and effective access/);
assert.match(html, /Global administrator · all project permissions/);
assert.match(html, /Via Platform team:/);
assert.match(html, /No project access/);
assert.match(html, /Edit access for Sam Taylor/);
assert.match(html, /No project permissions assigned/);
assert.doesNotMatch(html, /<table|type="checkbox"/);
assert.doesNotMatch(html, /href="[^"]*settings\/general/);
assert.match(await render({ teams: [] }), /No teams yet/);
assert.match(await render({ users: [] }), /No people available/);
const translated = await render(
{},
createLocalization(
{ locale: 'de' },
{
de: {
...en,
'projectAccess.title': 'Projektzugriff',
'projectAccess.read': 'Lesen',
},
},
),
);
assert.match(translated, /Projektzugriff/);
assert.match(translated, /Lesen/);
console.log('Project access presentation checks passed.');
} finally {
await server.close();
}
}
+19
View File
@@ -82,6 +82,25 @@ try {
),
true,
);
await page.evaluate(() => {
window.savedClipboard = navigator.clipboard;
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: undefined,
});
});
await button('Copy key').click();
assert.equal(
await page.evaluate(() => window.savedClipboard.readText()),
'MANUALSETUPTESTKEY',
);
assert.equal(await dialog().locator('textarea').count(), 0);
await page.evaluate(() => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: window.savedClipboard,
});
});
await button('Continue').click();
const code = page.getByLabel('Authentication code', {
exact: true,
+200
View File
@@ -0,0 +1,200 @@
import assert from 'node:assert/strict';
import process from 'node:process';
const { chromium } = await import(
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
);
const browser = await chromium.launch({ channel: 'chrome', headless: true });
const context = await browser.newContext({ reducedMotion: 'reduce' });
const page = await context.newPage();
page.setDefaultTimeout(10000);
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const base = 'http://127.0.0.1:4197';
const result = (value) =>
context.addCookies([{ name: 'teams_result', value, url: base }]);
const visit = async (query = '') => {
await result('success');
await page.goto(base + '/teams?reset' + query);
await page.getByRole('heading', { name: 'Teams', exact: true }).waitFor();
};
const button = (name) => page.getByRole('button', { name, exact: true });
const dialog = () => page.getByRole('dialog');
const state = (value) =>
dialog()
.locator('[data-form-state="' + value + '"]')
.waitFor({ state: 'visible' });
const noOverflow = async () =>
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
true,
);
const save = async (label) => {
const request = page.waitForRequest((r) =>
['POST', 'PATCH'].includes(r.method()),
);
await dialog().getByRole('button', { name: label, exact: true }).click();
return (await request).postDataJSON();
};
try {
for (const width of [1440, 390, 320]) {
await page.setViewportSize({ width, height: 900 });
for (const theme of ['light', 'dark']) {
await page.emulateMedia({ colorScheme: theme });
await visit();
await noOverflow();
await page.screenshot({
path: '/private/tmp/kan47-' + width + '-' + theme + '.png',
fullPage: true,
});
const trigger = button('Manage members of Platform team');
await trigger.focus();
await page.keyboard.press('Enter');
await dialog().waitFor();
await page.keyboard.press('Tab');
assert.equal(
await dialog().evaluate((el) =>
el.contains(document.activeElement),
),
true,
);
await noOverflow();
await page.screenshot({
path:
'/private/tmp/kan47-members-' +
width +
'-' +
theme +
'.png',
fullPage: true,
});
await page.keyboard.press('Escape');
await dialog().waitFor({ state: 'hidden' });
assert.equal(
await trigger.evaluate((el) => document.activeElement === el),
true,
);
}
}
await visit('&manager');
assert.equal(await button('Create team').count(), 0);
await visit('&empty');
await page.getByText('No teams to manage', { exact: true }).waitFor();
await visit();
await page.getByLabel('Search teams', { exact: true }).fill('missing');
await page.getByText('No matching teams', { exact: true }).waitFor();
await button('Clear search').click();
await button('Create team').click();
await page.getByLabel('Team name', { exact: true }).fill('New team');
await result('validation');
assert.deepEqual(await save('Create team'), { name: 'New team' });
await state('validation');
assert.equal(
await page.getByLabel('Team name', { exact: true }).inputValue(),
'New team',
);
await page
.getByText('This team name is already taken.', { exact: true })
.waitFor();
await result('success');
await save('Create team');
await dialog().waitFor({ state: 'hidden' });
await page
.getByRole('heading', { name: 'New team', exact: true })
.waitFor();
await button('Manage members of Platform team').click();
const person = (name) =>
dialog().getByRole('checkbox', { name: new RegExp(name) });
await page.getByLabel('Find people').fill('sam@');
await person('Sam Taylor').check();
await page.getByLabel('Find people').fill('');
assert.equal(await person('Alex Morgan').isChecked(), true);
let dismissed = false;
page.once('dialog', async (d) => {
dismissed = true;
await d.dismiss();
});
await dialog().getByRole('button', { name: 'Cancel', exact: true }).click();
assert.equal(dismissed, true);
assert.equal(await dialog().isVisible(), true);
await result('validation');
assert.deepEqual(await save('Save members'), { user_ids: [1, 2] });
await state('validation');
await page
.getByText('This user is unavailable.', { exact: true })
.waitFor();
assert.equal(await person('Sam Taylor').isChecked(), true);
await page.route('**/teams/1/members', (route) => route.abort('failed'));
await save('Save members');
await state('network');
await page.unroute('**/teams/1/members');
await result('server');
await save('Save members');
await state('server');
await result('success');
await dialog()
.getByRole('button', { name: 'Try again', exact: true })
.click();
await dialog().waitFor({ state: 'hidden' });
await button('Manage members of Platform team').click();
await person('Alex Morgan').uncheck();
await person('Sam Taylor').uncheck();
assert.deepEqual(await save('Save members'), { user_ids: [] });
await dialog().waitFor({ state: 'hidden' });
await button('Manage permissions for Platform team').click();
assert.equal(
await dialog()
.getByRole('button', { name: 'Save permissions', exact: true })
.isDisabled(),
true,
);
await page.getByLabel('User', { exact: true }).selectOption('2');
assert.equal(await person('Team invite').isChecked(), true);
await person('Team manage').check();
page.once('dialog', (d) => d.dismiss());
await page.getByLabel('User', { exact: true }).selectOption('3');
assert.equal(
await page.getByLabel('User', { exact: true }).inputValue(),
'2',
);
await result('validation');
assert.deepEqual(await save('Save permissions'), {
permissions: ['team:invite', 'team:manage'],
});
await state('validation');
await page
.getByText('This permission is unavailable.', { exact: true })
.waitFor();
await result('forbidden');
await save('Save permissions');
await state('server');
await page
.getByText('You no longer have permission', { exact: true })
.waitFor();
assert.equal(
await dialog()
.getByRole('button', { name: 'Try again', exact: true })
.count(),
0,
);
assert.equal(await person('Team manage').isChecked(), true);
await result('success');
await save('Save permissions');
await dialog().waitFor({ state: 'hidden' });
await button('Manage permissions for Platform team').click();
await page.getByLabel('User', { exact: true }).selectOption('2');
assert.equal(await person('Team manage').isChecked(), true);
await person('Team invite').uncheck();
await person('Team manage').uncheck();
assert.deepEqual(await save('Save permissions'), { permissions: [] });
await dialog().waitFor({ state: 'hidden' });
assert.deepEqual(errors, []);
console.log(
'Teams browser checks passed: creation, membership, permissions, empty selections, validation, network/server/403, draft protection, focus, keyboard, themes and mobile.',
);
} finally {
await browser.close();
}
+15
View File
@@ -0,0 +1,15 @@
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import '../../resources/css/app.css';
import Teams from '../../resources/js/pages/teams/Index.vue';
import { createAppearance } from '../../resources/js/composables/useAppearance';
import { createLocalization } from '../../resources/js/lib/localization';
createInertiaApp({
resolve: () => Teams,
setup: ({ el, App, props, plugin }) =>
createApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization())
.use(createAppearance())
.mount(el),
});
+220
View File
@@ -0,0 +1,220 @@
import assert from 'node:assert/strict';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import vue from '@vitejs/plugin-vue';
import tailwind from '@tailwindcss/vite';
import { createServer } from 'vite';
import { createSSRApp, h } from 'vue';
import { renderToString } from 'vue/server-renderer';
import { createInertiaApp } from '@inertiajs/vue3';
const serve = process.argv.includes('--serve');
const server = await createServer({
configFile: false,
cacheDir: 'node_modules/.vite/teams-fixture',
plugins: [vue(), ...(serve ? [tailwind()] : [])],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: serve
? { host: '127.0.0.1', port: 4197, strictPort: true, hmr: false }
: { middlewareMode: true, hmr: false, watch: null },
appType: 'custom',
});
const users = [
{ id: 1, name: 'Alex Morgan', email: 'alex@example.test', avatar: null },
{ id: 2, name: 'Sam Taylor', email: 'sam@example.test', avatar: null },
{
id: 3,
name: 'Jordan Longname With Several Names',
email: 'jordan.long.email.address@example.test',
avatar: null,
},
];
function initial() {
return {
canCreateTeams: true,
users,
status: null,
teams: [
{ id: 1, name: 'Platform team', user_ids: [1], users: [users[0]] },
{ id: 2, name: 'Delivery team', user_ids: [], users: [] },
],
teamPermissions: { 1: { 2: ['team:invite'] } },
permissionOptions: ['invite', 'manage', 'admin'].map((name) => ({
value: 'team:' + name,
label: 'Team ' + name,
})),
};
}
const makePage = (extra = {}) => ({
component: 'Teams',
url: '/teams',
version: null,
clearHistory: false,
encryptHistory: false,
props: {
name: 'KanbanGit',
errors: {},
sidebarOpen: true,
appearance: 'system',
auth: {
user: users[0],
permissions: {
globalAdmin: false,
projectCreate: false,
canManageTeams: true,
},
},
navigation: { project: null },
...initial(),
...extra,
},
});
if (serve) {
let data = initial();
server.middlewares.use(async (request, response, next) => {
if (!request.url.startsWith('/teams')) {
return next();
}
const url = new URL(request.url, 'http://localhost');
if (url.searchParams.has('reset')) {
data = initial();
}
if (url.searchParams.has('manager')) {
data.canCreateTeams = false;
}
if (url.searchParams.has('empty')) {
data.teams = [];
}
let errors = {};
if (request.method !== 'GET') {
const result =
request.headers.cookie?.match(/teams_result=(\w+)/)?.[1] ??
'success';
if (['server', 'forbidden'].includes(result)) {
response.statusCode = result === 'forbidden' ? 403 : 503;
response.end('Simulated failure');
return;
}
let body = '';
for await (const chunk of request) {
body += chunk;
}
const submitted = JSON.parse(body || '{}');
if (result === 'validation') {
errors = url.pathname.endsWith('/members')
? { 'user_ids.0': 'This user is unavailable.' }
: url.pathname.includes('/permissions/')
? { 'permissions.0': 'This permission is unavailable.' }
: { name: 'This team name is already taken.' };
} else if (request.method === 'POST') {
data.teams.push({
id: 3,
name: submitted.name,
user_ids: [],
users: [],
});
} else if (url.pathname.endsWith('/members')) {
const team = data.teams.find(
(team) => team.id === Number(url.pathname.split('/')[2]),
);
team.user_ids = submitted.user_ids;
team.users = users.filter((user) =>
team.user_ids.includes(user.id),
);
} else {
const [, , teamId, , , userId] = url.pathname.split('/');
data.teamPermissions[teamId] ??= {};
data.teamPermissions[teamId][userId] = submitted.permissions;
}
if (Object.keys(errors).length === 0) {
data.status = 'Saved';
}
}
const page = makePage({ ...data, errors });
response.setHeader('Vary', 'X-Inertia');
if (request.headers['x-inertia']) {
response.setHeader('X-Inertia', 'true');
response.setHeader('Content-Type', 'application/json');
response.end(JSON.stringify(page));
return;
}
const escaped = JSON.stringify(page)
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;');
response.setHeader('Content-Type', 'text/html');
response.end(
await server.transformIndexHtml(
request.url,
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
escaped +
'"></div><script type="module" src="/tests/Fixtures/teams-preview.mjs"></script></body></html>',
),
);
});
await server.listen();
console.log('Teams fixture ready.');
} else {
try {
const { createLocalization } = await server.ssrLoadModule(
'/resources/js/lib/localization.ts',
);
const { createAppearance } = await server.ssrLoadModule(
'/resources/js/composables/useAppearance.ts',
);
const { en } = await server.ssrLoadModule(
'/resources/js/locales/en.ts',
);
const component = (
await server.ssrLoadModule('/resources/js/pages/teams/Index.vue')
).default;
const render = async (extra = {}, locale = createLocalization()) =>
(
await createInertiaApp({
page: makePage(extra),
resolve: () => component,
render: renderToString,
setup: ({ App, props, plugin }) =>
createSSRApp({ render: () => h(App, props) })
.use(plugin)
.use(locale)
.use(createAppearance()),
})
).body;
const html = await render();
assert.match(html, /Platform team/);
assert.match(html, /1 member/);
assert.match(html, /0 members/);
assert.match(html, /Manage members of Platform team/);
assert.match(html, /Manage permissions for Platform team/);
assert.match(html, /No members yet/);
assert.match(html, /Create team/);
assert.doesNotMatch(html, /Global admin|Hold Cmd|<select/);
assert.doesNotMatch(
await render({ canCreateTeams: false }),
/Create team/,
);
assert.match(await render({ teams: [] }), /No teams to manage/);
const translated = await render(
{},
createLocalization(
{ locale: 'de' },
{
de: {
...en,
'navigation.teams': 'Arbeitsgruppen',
'teams.create': 'Team anlegen',
},
},
),
);
assert.match(translated, /Arbeitsgruppen/);
assert.match(translated, /Team anlegen/);
console.log('Teams presentation checks passed.');
} finally {
await server.close();
}
}