Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
386b25858b | ||
|
|
aa14068c66 | ||
|
|
4afaa79834 | ||
|
|
0662a6970e | ||
|
|
6820e0b25e | ||
|
|
b124a77cd1 | ||
|
|
2725816273 | ||
|
|
32a6756419 | ||
|
|
2a637affdf | ||
|
|
188aa07059 | ||
|
|
fa8a0cc512 | ||
|
|
d18fffe665 | ||
|
|
aece653970 | ||
|
|
2b1cf6ec13 | ||
|
|
38355c9507 | ||
|
|
d0c5d65f0e | ||
|
|
4f9bf2dec4 | ||
|
|
4ee047311f | ||
|
|
41b6dc8f10 | ||
|
|
3d5ee223e8 | ||
|
|
9dbb491dc9 | ||
|
|
40b66bf7ce | ||
|
|
dc9f400f34 | ||
|
|
48f5fc410b | ||
|
|
f6725ac025 | ||
|
|
8aa7bf6860 | ||
|
|
ba9c4fd2e8 | ||
|
|
74bcf5d30c | ||
|
|
20f9314659 | ||
|
|
3e5c316b8f | ||
|
|
4dd73ec583 | ||
|
|
d8ca4975e5 | ||
|
|
f2e1bd6a0d | ||
|
|
b0cc9dad14 | ||
|
|
52304c7eb2 | ||
|
|
b4cd6a5808 | ||
|
|
f52cffeaeb | ||
|
|
1c620654a3 | ||
|
|
fcba7673c2 | ||
|
|
2f99b8e65c | ||
|
|
2b85517965 | ||
|
|
7001776c48 | ||
|
|
9d472b5091 | ||
|
|
8bed02a484 | ||
|
|
d07b17ebdb | ||
|
|
859229fa21 | ||
|
|
32dac91620 | ||
|
|
39d0887180 | ||
|
|
340e6bfff1 | ||
|
|
f3f1ffdc52 | ||
|
|
2a56d03dbf | ||
|
|
7e19364757 | ||
|
|
de0c19a86b | ||
|
|
ce250d8be5 | ||
|
|
5c2b62de2a | ||
|
|
b5030e6acc | ||
|
|
6224880219 | ||
|
|
19d102d140 | ||
|
|
e70b6b4de0 | ||
|
|
d9a1288d73 | ||
|
|
1d9df6897d |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,228 @@
|
||||
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:
|
||||
# Partial release files may be replaced until the feed is promoted.
|
||||
# Keep every publisher in this group to prevent overlapping rebuilds.
|
||||
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 }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
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 TAURI_SIGNING_PRIVATE_KEY 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:
|
||||
# The DMG bundler reads these variables separately from --ci.
|
||||
CI: "true"
|
||||
TAURI_BUNDLER_DMG_IGNORE_CI: "false"
|
||||
APPLE_ID: dev@petsch-studios.de
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
with:
|
||||
projectPath: native
|
||||
tauriScript: pnpm exec tauri
|
||||
args: >-
|
||||
--ci --target ${{ env.MACOS_RELEASE_TARGET }} --bundles app,dmg
|
||||
--config '{"bundle":{"createUpdaterArtifacts":true,"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: Upload release files and promote the verified update feed
|
||||
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
|
||||
@@ -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
|
||||
@@ -32,6 +32,7 @@ yarn-error.log
|
||||
/native/node_modules
|
||||
/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/
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
# Working Agreements
|
||||
|
||||
## Missing programs and SDKs
|
||||
|
||||
If implementation or verification requires a missing program, SDK, runtime, toolchain, or platform component, do not download or install a temporary copy in `/tmp` or another temporary directory. Do not use an ad hoc toolchain as a workaround. Tell the user which prerequisites are missing, ask them to install them permanently and confirm when installation is complete, and wait for that confirmation before continuing dependent work. This rule applies to every implementation task in this project.
|
||||
|
||||
<laravel-boost-guidelines>
|
||||
=== foundation rules ===
|
||||
|
||||
@@ -9,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
|
||||
@@ -21,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
|
||||
|
||||
@@ -36,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
|
||||
|
||||
@@ -56,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
|
||||
|
||||
@@ -130,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
|
||||
|
||||
@@ -166,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
|
||||
|
||||
@@ -203,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>>
|
||||
*/
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,6 @@
|
||||
"fortify-development",
|
||||
"laravel-best-practices",
|
||||
"mcp-development",
|
||||
"wayfinder-development",
|
||||
"inertia-vue-development",
|
||||
"tailwindcss-development"
|
||||
"wayfinder-development"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, isAbsolute, join, relative } 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.ios';
|
||||
|
||||
const plistReader = `import base64, datetime, json, plistlib, sys
|
||||
def encode(value):
|
||||
if isinstance(value, bytes): return base64.b64encode(value).decode()
|
||||
if isinstance(value, datetime.datetime): return value.isoformat() + 'Z'
|
||||
raise TypeError(type(value).__name__)
|
||||
print(json.dumps(plistlib.loads(sys.stdin.buffer.read()), default=encode))`;
|
||||
|
||||
function execute(command, args, options) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
input: options.input,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
stdio: options.inherit ? 'inherit' : 'pipe',
|
||||
});
|
||||
if (result.error || result.status !== 0) {
|
||||
throw new Error(
|
||||
`${options.step} failed: ${result.error?.message || result.stderr?.trim() || `exit ${result.status ?? result.signal}`}`,
|
||||
);
|
||||
}
|
||||
return result.stdout?.trim() || '';
|
||||
}
|
||||
|
||||
export function validateProfile(profile, mode, now = new Date()) {
|
||||
const entitlements = profile.Entitlements ?? {};
|
||||
if (
|
||||
profile.TeamIdentifier?.length !== 1 ||
|
||||
profile.TeamIdentifier[0] !== appleTeamId ||
|
||||
entitlements['com.apple.developer.team-identifier'] !== appleTeamId ||
|
||||
entitlements['application-identifier'] !==
|
||||
`${appleTeamId}.${bundleIdentifier}`
|
||||
) {
|
||||
throw new Error(
|
||||
`The profile must explicitly match ${bundleIdentifier} in team ${appleTeamId}.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
!/^[a-f\d]{8}-(?:[a-f\d]{4}-){3}[a-f\d]{12}$/i.test(profile.UUID) ||
|
||||
!(new Date(profile.ExpirationDate) > now) ||
|
||||
!profile.DeveloperCertificates?.length ||
|
||||
!profile.Platform?.includes('iOS')
|
||||
) {
|
||||
throw new Error(
|
||||
'The iOS profile is invalid or expired. Download a current profile from your Apple account.',
|
||||
);
|
||||
}
|
||||
if (mode === 'testflight') {
|
||||
if (
|
||||
entitlements['get-task-allow'] !== false ||
|
||||
entitlements['beta-reports-active'] !== true ||
|
||||
'ProvisionedDevices' in profile ||
|
||||
profile.ProvisionsAllDevices
|
||||
) {
|
||||
throw new Error(
|
||||
'TestFlight requires an App Store Connect profile, not a development, Ad Hoc or enterprise profile.',
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
entitlements['get-task-allow'] !== true ||
|
||||
!profile.ProvisionedDevices?.length ||
|
||||
profile.ProvisionsAllDevices
|
||||
) {
|
||||
throw new Error(
|
||||
'Device development requires an iOS App Development profile containing your registered device.',
|
||||
);
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
export function selectIdentity(profile, identities, mode) {
|
||||
const fingerprints = profile.DeveloperCertificates.map((certificate) =>
|
||||
createHash('sha1')
|
||||
.update(Buffer.from(certificate, 'base64'))
|
||||
.digest('hex')
|
||||
.toUpperCase(),
|
||||
);
|
||||
const prefix =
|
||||
mode === 'testflight' ? 'Apple Distribution: ' : 'Apple Development: ';
|
||||
const matches = [
|
||||
...identities.matchAll(/\) ([A-Fa-f\d]{40}) "([^"]+)"/g),
|
||||
].filter(
|
||||
([, fingerprint, name]) =>
|
||||
fingerprints.includes(fingerprint.toUpperCase()) &&
|
||||
name.startsWith(prefix),
|
||||
);
|
||||
if (matches.length !== 1) {
|
||||
throw new Error(
|
||||
`The profile must match exactly one usable ${prefix.replace(': ', '')} certificate with its private key in your local keychain. Select a profile containing the intended certificate.`,
|
||||
);
|
||||
}
|
||||
return matches[0][1].toUpperCase();
|
||||
}
|
||||
|
||||
export function buildIos({
|
||||
mode = 'testflight',
|
||||
env = process.env,
|
||||
platform = process.platform,
|
||||
directory = dirname(fileURLToPath(import.meta.url)),
|
||||
home = homedir(),
|
||||
run = execute,
|
||||
log = console.log,
|
||||
now = new Date(),
|
||||
} = {}) {
|
||||
if (!['testflight', 'development', 'simulator'].includes(mode)) {
|
||||
throw new Error('Choose --mode testflight, development or simulator.');
|
||||
}
|
||||
if (platform !== 'darwin') {
|
||||
throw new Error(
|
||||
'iOS builds require a Mac with Xcode, XcodeGen and the matching Rust target installed permanently.',
|
||||
);
|
||||
}
|
||||
if (
|
||||
env.APPLE_DEVELOPMENT_TEAM &&
|
||||
env.APPLE_DEVELOPMENT_TEAM !== appleTeamId
|
||||
) {
|
||||
throw new Error(`APPLE_DEVELOPMENT_TEAM must be ${appleTeamId}.`);
|
||||
}
|
||||
if (env.TAURI_CONFIG) {
|
||||
throw new Error(
|
||||
'Remove TAURI_CONFIG overrides before building the configured iOS app.',
|
||||
);
|
||||
}
|
||||
const config = JSON.parse(
|
||||
readFileSync(join(directory, 'src-tauri/tauri.conf.json'), 'utf8'),
|
||||
);
|
||||
const ios = JSON.parse(
|
||||
readFileSync(join(directory, 'src-tauri/tauri.ios.conf.json'), 'utf8'),
|
||||
);
|
||||
const version = config.version;
|
||||
const buildNumber = ios.bundle?.iOS?.bundleVersion;
|
||||
if (
|
||||
ios.identifier !== bundleIdentifier ||
|
||||
ios.bundle?.iOS?.developmentTeam !== appleTeamId ||
|
||||
config.identifier !== 'de.kanbangit.desktop' ||
|
||||
config.productName !== 'KanbanGit' ||
|
||||
!/^\d+\.\d+\.\d+$/.test(version) ||
|
||||
!/^[1-9]\d*$/.test(buildNumber)
|
||||
) {
|
||||
throw new Error(
|
||||
'Check the iOS team, bundle identifier, version and positive integer bundleVersion in the Tauri configuration.',
|
||||
);
|
||||
}
|
||||
const simulator = mode === 'simulator';
|
||||
const configuration = mode === 'testflight' ? 'release' : 'debug';
|
||||
const target = simulator ? 'aarch64-apple-ios-sim' : 'aarch64-apple-ios';
|
||||
const sdk = simulator ? 'iphonesimulator' : 'iphoneos';
|
||||
const project = join(directory, 'src-tauri/gen/apple');
|
||||
const targetDirectory = join(directory, 'src-tauri/target');
|
||||
const outputRoot = join(targetDirectory, `ios-${mode}`);
|
||||
const destination = join(outputRoot, `${version}-${buildNumber}`);
|
||||
if (mode === 'testflight' && existsSync(destination)) {
|
||||
throw new Error(
|
||||
`Build output already exists: ${destination}. Move it aside, or increment bundle.iOS.bundleVersion for a new TestFlight upload.`,
|
||||
);
|
||||
}
|
||||
const buildEnv = Object.fromEntries(
|
||||
Object.entries(env).filter(
|
||||
([key]) =>
|
||||
!key.startsWith('APPLE_') &&
|
||||
!key.startsWith('IOS_') &&
|
||||
!key.startsWith('TAURI_') &&
|
||||
![
|
||||
'SDKROOT',
|
||||
'CARGO_ENCODED_RUSTFLAGS',
|
||||
'RUSTFLAGS',
|
||||
'RUSTC_WRAPPER',
|
||||
'RUSTC_WORKSPACE_WRAPPER',
|
||||
'XCODE_XCCONFIG_FILE',
|
||||
].includes(key),
|
||||
),
|
||||
);
|
||||
function step(label, command, args, options = {}) {
|
||||
log(label);
|
||||
return run(command, args, {
|
||||
cwd: directory,
|
||||
env: buildEnv,
|
||||
step: label,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
function parsePlist(xml) {
|
||||
return JSON.parse(
|
||||
step(
|
||||
'Reading local plist metadata',
|
||||
'python3',
|
||||
['-c', plistReader],
|
||||
{ input: xml },
|
||||
),
|
||||
);
|
||||
}
|
||||
function readProfile(path) {
|
||||
return validateProfile(
|
||||
parsePlist(
|
||||
step('Reading provisioning profile', 'security', [
|
||||
'cms',
|
||||
'-D',
|
||||
'-i',
|
||||
path,
|
||||
]),
|
||||
),
|
||||
mode,
|
||||
now,
|
||||
);
|
||||
}
|
||||
step(
|
||||
'Checking XcodeGen (install permanently with brew install xcodegen if missing)',
|
||||
'xcodegen',
|
||||
['--version'],
|
||||
);
|
||||
step('Checking Xcode', 'xcodebuild', ['-version']);
|
||||
const installedTargets = step('Checking installed Rust targets', 'rustup', [
|
||||
'target',
|
||||
'list',
|
||||
'--installed',
|
||||
]);
|
||||
if (!installedTargets.split(/\s+/).includes(target)) {
|
||||
throw new Error(
|
||||
`Install the missing target permanently with rustup target add ${target}, then retry.`,
|
||||
);
|
||||
}
|
||||
const cargo = step('Selecting the rustup Cargo toolchain', 'rustup', [
|
||||
'which',
|
||||
'cargo',
|
||||
]);
|
||||
buildEnv.RUSTC = step('Selecting the matching Rust compiler', 'rustup', [
|
||||
'which',
|
||||
'rustc',
|
||||
]);
|
||||
buildEnv.PATH = `${dirname(cargo)}:${buildEnv.PATH ?? ''}`;
|
||||
buildEnv.SDKROOT = step('Checking the iOS SDK', 'xcrun', [
|
||||
'--sdk',
|
||||
sdk,
|
||||
'--show-sdk-path',
|
||||
]);
|
||||
buildEnv.IPHONEOS_DEPLOYMENT_TARGET = ios.bundle.iOS.minimumSystemVersion;
|
||||
buildEnv.CARGO_TARGET_DIR = targetDirectory;
|
||||
let profile;
|
||||
let profilePath;
|
||||
let identity;
|
||||
if (!simulator) {
|
||||
if (
|
||||
!env.IOS_PROVISIONING_PROFILE ||
|
||||
!existsSync(env.IOS_PROVISIONING_PROFILE)
|
||||
) {
|
||||
throw new Error(
|
||||
'Set IOS_PROVISIONING_PROFILE to your downloaded .mobileprovision file outside the repository. Apple account setup is manual; this command never creates or downloads profiles.',
|
||||
);
|
||||
}
|
||||
profilePath = realpathSync(env.IOS_PROVISIONING_PROFILE);
|
||||
const withinProject = relative(
|
||||
realpathSync(join(directory, '..')),
|
||||
profilePath,
|
||||
);
|
||||
if (
|
||||
!withinProject.startsWith(
|
||||
`..${process.platform === 'win32' ? '\\' : '/'}`,
|
||||
) &&
|
||||
!isAbsolute(withinProject)
|
||||
) {
|
||||
throw new Error(
|
||||
'Keep provisioning profiles outside the repository.',
|
||||
);
|
||||
}
|
||||
profile = readProfile(profilePath);
|
||||
identity = selectIdentity(
|
||||
profile,
|
||||
step('Checking local signing identities', 'security', [
|
||||
'find-identity',
|
||||
'-v',
|
||||
'-p',
|
||||
'codesigning',
|
||||
]),
|
||||
mode,
|
||||
);
|
||||
}
|
||||
mkdirSync(outputRoot, { recursive: true });
|
||||
const working = mkdtempSync(join(outputRoot, '.building-'));
|
||||
let installedProfile;
|
||||
try {
|
||||
if (profile) {
|
||||
const installedPath = join(
|
||||
home,
|
||||
'Library/Developer/Xcode/UserData/Provisioning Profiles',
|
||||
`${profile.UUID}.mobileprovision`,
|
||||
);
|
||||
mkdirSync(dirname(installedPath), { recursive: true });
|
||||
if (existsSync(installedPath)) {
|
||||
if (
|
||||
!readFileSync(installedPath).equals(
|
||||
readFileSync(profilePath),
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'A different provisioning profile already exists with the same UUID. Resolve it in Xcode before retrying.',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
copyFileSync(profilePath, installedPath);
|
||||
installedProfile = installedPath;
|
||||
}
|
||||
}
|
||||
step('Generating the Xcode project', 'xcodegen', [
|
||||
'generate',
|
||||
'--spec',
|
||||
join(project, 'project.yml'),
|
||||
]);
|
||||
step(
|
||||
'Building the iOS Rust library',
|
||||
cargo,
|
||||
[
|
||||
'build',
|
||||
'--manifest-path',
|
||||
join(directory, 'src-tauri/Cargo.toml'),
|
||||
'--lib',
|
||||
'--locked',
|
||||
'--target',
|
||||
target,
|
||||
'--features',
|
||||
'custom-protocol',
|
||||
...(configuration === 'release' ? ['--release'] : []),
|
||||
],
|
||||
{ inherit: true },
|
||||
);
|
||||
const library = join(
|
||||
targetDirectory,
|
||||
target,
|
||||
configuration,
|
||||
'libkanbangit_native.a',
|
||||
);
|
||||
if (!existsSync(library)) {
|
||||
throw new Error(
|
||||
`The iOS Rust library was not produced: ${library}`,
|
||||
);
|
||||
}
|
||||
buildEnv.KANBANGIT_IOS_LIBRARY = library;
|
||||
const archive = join(working, 'KanbanGit.xcarchive');
|
||||
step(
|
||||
'Archiving locally without Apple provisioning updates',
|
||||
'xcodebuild',
|
||||
[
|
||||
'-project',
|
||||
join(project, 'kanbangit-native.xcodeproj'),
|
||||
'-scheme',
|
||||
'kanbangit-native_iOS',
|
||||
'-configuration',
|
||||
configuration,
|
||||
'-sdk',
|
||||
sdk,
|
||||
'-destination',
|
||||
`generic/platform=${simulator ? 'iOS Simulator' : 'iOS'}`,
|
||||
'-archivePath',
|
||||
archive,
|
||||
'-derivedDataPath',
|
||||
join(targetDirectory, `ios-derived-${mode}`),
|
||||
'archive',
|
||||
`MARKETING_VERSION=${version}`,
|
||||
`CURRENT_PROJECT_VERSION=${buildNumber}`,
|
||||
`DEVELOPMENT_TEAM=${appleTeamId}`,
|
||||
`PRODUCT_BUNDLE_IDENTIFIER=${bundleIdentifier}`,
|
||||
'CODE_SIGN_STYLE=Manual',
|
||||
'ARCHS=arm64',
|
||||
...(simulator
|
||||
? ['CODE_SIGNING_ALLOWED=NO', 'CODE_SIGNING_REQUIRED=NO']
|
||||
: [
|
||||
`CODE_SIGN_IDENTITY=${identity}`,
|
||||
`PROVISIONING_PROFILE_SPECIFIER=${profile.UUID}`,
|
||||
]),
|
||||
],
|
||||
{ inherit: true },
|
||||
);
|
||||
function verifyApp(app) {
|
||||
const info = parsePlist(readFileSync(join(app, 'Info.plist')));
|
||||
if (
|
||||
info.CFBundleIdentifier !== bundleIdentifier ||
|
||||
info.CFBundleShortVersionString !== version ||
|
||||
info.CFBundleVersion !== buildNumber
|
||||
) {
|
||||
throw new Error(
|
||||
'The built app has an unexpected identifier, version or build number.',
|
||||
);
|
||||
}
|
||||
if (!simulator) {
|
||||
step('Verifying the Apple signature and team', 'codesign', [
|
||||
'--verify',
|
||||
'--deep',
|
||||
'--strict',
|
||||
'-R',
|
||||
`=anchor apple generic and identifier "${bundleIdentifier}" and certificate leaf[subject.OU] = "${appleTeamId}" and certificate leaf = H"${identity}" and entitlement["application-identifier"] = "${appleTeamId}.${bundleIdentifier}" and entitlement["com.apple.developer.team-identifier"] = "${appleTeamId}" and ${mode === 'testflight' ? '! ' : ''}entitlement["get-task-allow"] exists${mode === 'testflight' ? ' and entitlement["beta-reports-active"] exists' : ''}`,
|
||||
app,
|
||||
]);
|
||||
if (
|
||||
readProfile(join(app, 'embedded.mobileprovision')).UUID !==
|
||||
profile.UUID
|
||||
) {
|
||||
throw new Error(
|
||||
'The app was signed with an unexpected provisioning profile.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
verifyApp(join(archive, 'Products/Applications/KanbanGit.app'));
|
||||
if (!simulator) {
|
||||
const exportOptions = join(working, 'ExportOptions.plist');
|
||||
writeFileSync(
|
||||
exportOptions,
|
||||
JSON.stringify({
|
||||
method:
|
||||
mode === 'testflight'
|
||||
? 'app-store-connect'
|
||||
: 'debugging',
|
||||
destination: 'export',
|
||||
signingStyle: 'manual',
|
||||
teamID: appleTeamId,
|
||||
signingCertificate: identity,
|
||||
provisioningProfiles: { [bundleIdentifier]: profile.UUID },
|
||||
manageAppVersionAndBuildNumber: false,
|
||||
}),
|
||||
);
|
||||
step('Preparing local export options', 'plutil', [
|
||||
'-convert',
|
||||
'xml1',
|
||||
exportOptions,
|
||||
]);
|
||||
step(
|
||||
'Exporting the signed IPA locally (no upload)',
|
||||
'xcodebuild',
|
||||
[
|
||||
'-exportArchive',
|
||||
'-archivePath',
|
||||
archive,
|
||||
'-exportPath',
|
||||
join(working, 'export'),
|
||||
'-exportOptionsPlist',
|
||||
exportOptions,
|
||||
],
|
||||
{ inherit: true },
|
||||
);
|
||||
const ipa = join(working, 'export/KanbanGit.ipa');
|
||||
if (!existsSync(ipa)) {
|
||||
throw new Error('Xcode did not produce the signed IPA.');
|
||||
}
|
||||
const extracted = join(working, 'verify');
|
||||
step('Verifying the exported IPA contents', 'ditto', [
|
||||
'-x',
|
||||
'-k',
|
||||
ipa,
|
||||
extracted,
|
||||
]);
|
||||
verifyApp(join(extracted, 'Payload/KanbanGit.app'));
|
||||
rmSync(extracted, { recursive: true, force: true });
|
||||
writeFileSync(
|
||||
`${ipa}.sha256`,
|
||||
`${createHash('sha256').update(readFileSync(ipa)).digest('hex')} KanbanGit.ipa\n`,
|
||||
);
|
||||
}
|
||||
if (mode !== 'testflight') {
|
||||
rmSync(destination, { recursive: true, force: true });
|
||||
}
|
||||
renameSync(working, destination);
|
||||
log(`Verified local ${mode} build: ${destination}`);
|
||||
return destination;
|
||||
} catch (error) {
|
||||
log(`Build did not complete. Diagnostic output remains in ${working}`);
|
||||
throw error;
|
||||
} finally {
|
||||
if (installedProfile) {
|
||||
rmSync(installedProfile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
) {
|
||||
try {
|
||||
const { values } = parseArgs({
|
||||
options: { mode: { type: 'string', default: 'testflight' } },
|
||||
});
|
||||
buildIos({ mode: values.mode });
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
appendFileSync,
|
||||
copyFileSync,
|
||||
readlinkSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { updateSource } from './update-manifest.mjs';
|
||||
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 nativePackage = JSON.parse(
|
||||
readFileSync(join(directory, 'package.json'), 'utf8'),
|
||||
);
|
||||
const packageVersion = nativePackage.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.',
|
||||
);
|
||||
}
|
||||
if (config.plugins?.updater?.pubkey !== updateSource.publicKey) {
|
||||
throw new Error(
|
||||
'The app updater key must match the permanent release key.',
|
||||
);
|
||||
}
|
||||
if (
|
||||
!/^\d+(?:\.\d+){0,2}$/.test(config.bundle?.macOS?.bundleVersion || '')
|
||||
) {
|
||||
throw new Error(
|
||||
'Set a numeric macOS bundleVersion for the About build number.',
|
||||
);
|
||||
}
|
||||
if (!nativePackage.releaseNotes?.trim()) {
|
||||
throw new Error(
|
||||
'Add releaseNotes to native/package.json before publishing.',
|
||||
);
|
||||
}
|
||||
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,
|
||||
notes: nativePackage.releaseNotes,
|
||||
target,
|
||||
arch,
|
||||
filename: `KanbanGit-${config.version}-${target}.dmg`,
|
||||
updaterFilename: `KanbanGit-${config.version}-${target}.app.tar.gz`,
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
const bundle = join(root, metadata.target, 'release/bundle');
|
||||
copyFileSync(
|
||||
join(
|
||||
bundle,
|
||||
'dmg',
|
||||
`KanbanGit_${metadata.version}_${metadata.arch === 'arm64' ? 'aarch64' : 'x64'}.dmg`,
|
||||
),
|
||||
archive,
|
||||
);
|
||||
const mounted = join(staging, 'mounted');
|
||||
mkdirSync(mounted);
|
||||
run('hdiutil', ['verify', archive]);
|
||||
run('hdiutil', [
|
||||
'attach',
|
||||
archive,
|
||||
'-readonly',
|
||||
'-nobrowse',
|
||||
'-mountpoint',
|
||||
mounted,
|
||||
]);
|
||||
try {
|
||||
if (
|
||||
readlinkSync(join(mounted, 'Applications')) !== '/Applications'
|
||||
) {
|
||||
throw new Error(
|
||||
'DMG must contain the Applications installation link.',
|
||||
);
|
||||
}
|
||||
verifyNotarizedApp(join(mounted, 'KanbanGit.app'), metadata, run);
|
||||
} finally {
|
||||
run('hdiutil', ['detach', mounted]);
|
||||
}
|
||||
rmSync(mounted, { recursive: true, force: true });
|
||||
const updateArchive = join(staging, metadata.updaterFilename);
|
||||
copyFileSync(join(bundle, 'macos/KanbanGit.app.tar.gz'), updateArchive);
|
||||
copyFileSync(
|
||||
join(bundle, 'macos/KanbanGit.app.tar.gz.sig'),
|
||||
`${updateArchive}.sig`,
|
||||
);
|
||||
if (!readFileSync(`${updateArchive}.sig`, 'utf8').trim()) {
|
||||
throw new Error('The updater signature is missing.');
|
||||
}
|
||||
const extracted = join(staging, 'extracted');
|
||||
mkdirSync(extracted);
|
||||
run('tar', ['-xzf', updateArchive, '-C', extracted]);
|
||||
verifyNotarizedApp(join(extracted, 'KanbanGit.app'), metadata, run);
|
||||
rmSync(extracted, { recursive: true, force: true });
|
||||
const checksum = createHash('sha256')
|
||||
.update(readFileSync(archive))
|
||||
.digest('hex');
|
||||
for (const name of [metadata.filename, metadata.updaterFilename]) {
|
||||
const hash = createHash('sha256')
|
||||
.update(readFileSync(join(staging, name)))
|
||||
.digest('hex');
|
||||
writeFileSync(
|
||||
join(staging, `${name}.sha256`),
|
||||
`${hash} ${name}\n`,
|
||||
);
|
||||
}
|
||||
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)
|
||||
.filter(([key]) => key !== 'notes')
|
||||
.map(([key, value]) => `${key}=${value}\n`)
|
||||
.join(''),
|
||||
);
|
||||
}
|
||||
console.log(JSON.stringify(output));
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { mkdirSync, rmSync, symlinkSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
export function createDiskImage({ app, archive, identity, run }) {
|
||||
const contents = join(dirname(archive), 'dmg-contents');
|
||||
mkdirSync(contents);
|
||||
try {
|
||||
run('ditto', [app, join(contents, 'KanbanGit.app')]);
|
||||
symlinkSync('/Applications', join(contents, 'Applications'));
|
||||
run('hdiutil', [
|
||||
'create',
|
||||
'-volname',
|
||||
'KanbanGit',
|
||||
'-srcfolder',
|
||||
contents,
|
||||
'-format',
|
||||
'UDZO',
|
||||
archive,
|
||||
]);
|
||||
run('codesign', ['--sign', identity, '--timestamp', archive]);
|
||||
run('hdiutil', ['verify', archive]);
|
||||
} finally {
|
||||
rmSync(contents, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,29 @@
|
||||
{
|
||||
"name": "kanbangit-native",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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",
|
||||
"dev:android": "tauri android dev",
|
||||
"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",
|
||||
"build:ios:simulator": "node build-ios.mjs --mode simulator",
|
||||
"build:ios:device": "node build-ios.mjs --mode development",
|
||||
"release:ios": "node build-ios.mjs --mode testflight",
|
||||
"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"
|
||||
"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",
|
||||
"smoke:updater": "cargo test --manifest-path src-tauri/Cargo.toml --locked --features smoke-test,custom-protocol --test updater_smoke"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "2.11.4"
|
||||
}
|
||||
},
|
||||
"releaseNotes": "Correct iOS and macOS navigation gestures, preserve single-step page history, and constrain vertical overscroll."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
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 { setTimeout } from 'node:timers/promises';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { execute, releaseMetadata } from './macos-ci.mjs';
|
||||
import {
|
||||
compareVersions,
|
||||
updateManifest,
|
||||
updateSource,
|
||||
verifyUpdateSignature,
|
||||
} from './update-manifest.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 function conditionalEtag(etag) {
|
||||
const value =
|
||||
typeof etag === 'string' && etag.startsWith('"') && etag.endsWith('"')
|
||||
? etag.slice(1, -1)
|
||||
: etag;
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!/^[a-f\d]{32}(?:-[1-9]\d*)?$/i.test(value)
|
||||
) {
|
||||
throw new Error(
|
||||
'Cannot atomically replace an object without a valid S3 ETag.',
|
||||
);
|
||||
}
|
||||
// Hetzner returns quoted ETags on GET but compares bare values on conditional PUT.
|
||||
return value;
|
||||
}
|
||||
|
||||
function requiresExistingObjectCheck(error) {
|
||||
// AWS CLI can crash while formatting Hetzner's empty error Message.
|
||||
// This only permits a readback; content checks and conditional writes still apply.
|
||||
return /\(PreconditionFailed\)|\(412\)|argument of type 'NoneType' is not (?:a container or )?iterable/.test(
|
||||
error.message,
|
||||
);
|
||||
}
|
||||
|
||||
export async function publishMacos({
|
||||
env = process.env,
|
||||
directory,
|
||||
metadata,
|
||||
run = execute,
|
||||
fetchPublic = fetch,
|
||||
wait = setTimeout,
|
||||
publicKey = updateSource.publicKey,
|
||||
feedBase = updateSource.baseUrl,
|
||||
} = {}) {
|
||||
const storage = storageConfiguration(env);
|
||||
if (`${storage.publicBase}/${storage.prefix}` !== feedBase) {
|
||||
throw new Error(
|
||||
'Release storage does not match the update source bundled in the app.',
|
||||
);
|
||||
}
|
||||
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 updater = readFileSync(join(directory, metadata.updaterFilename));
|
||||
const signature = readFileSync(
|
||||
join(directory, `${metadata.updaterFilename}.sig`),
|
||||
'utf8',
|
||||
);
|
||||
verifyUpdateSignature(updater, signature, publicKey);
|
||||
const updateChecksum = readFileSync(
|
||||
join(directory, `${metadata.updaterFilename}.sha256`),
|
||||
);
|
||||
if (
|
||||
updateChecksum.toString() !==
|
||||
`${sha256(updater)} ${metadata.updaterFilename}\n`
|
||||
) {
|
||||
throw new Error('Updater 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) {
|
||||
try {
|
||||
return run('aws', [
|
||||
...common,
|
||||
operation,
|
||||
'--bucket',
|
||||
storage.bucket,
|
||||
...args,
|
||||
]);
|
||||
} catch (error) {
|
||||
const keyIndex = args.indexOf('--key');
|
||||
const key = keyIndex >= 0 ? ` ${args[keyIndex + 1]}` : '';
|
||||
throw new Error(`S3 ${operation}${key}: ${error.message}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
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}`;
|
||||
const latestPrefix = `${storage.prefix}/${metadata.target}`;
|
||||
const latestKey = `${latestPrefix}/latest.json`;
|
||||
async function verifyPublic(name, contents, prefix = publicPrefix) {
|
||||
for (let attempt = 1; attempt <= 5; attempt++) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetchPublic(`${prefix}/${name}`, {
|
||||
redirect: 'error',
|
||||
cache: 'no-store',
|
||||
signal: AbortSignal.timeout(60000),
|
||||
});
|
||||
} catch (error) {
|
||||
if (attempt === 5) {
|
||||
throw new Error(
|
||||
`Public download request failed for ${name}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
await wait(2000);
|
||||
continue;
|
||||
}
|
||||
if (response.ok) {
|
||||
if (
|
||||
sha256(Buffer.from(await response.arrayBuffer())) !==
|
||||
sha256(contents)
|
||||
) {
|
||||
throw new Error(
|
||||
`Public download verification failed for ${name}: SHA-256 mismatch.`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await response.body?.cancel();
|
||||
const retryable = [403, 404, 408, 429, 500, 502, 503, 504].includes(
|
||||
response.status,
|
||||
);
|
||||
if (!retryable || attempt === 5) {
|
||||
throw new Error(
|
||||
`Public download verification failed for ${name}: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
await wait(2000);
|
||||
}
|
||||
}
|
||||
function assertUnpublishedRelease(allowSameVersion = false) {
|
||||
const file = join(staging, 'published-latest.json');
|
||||
try {
|
||||
s3('get-object', ['--key', latestKey, file]);
|
||||
} catch (error) {
|
||||
if (/\(NoSuchKey\)/.test(error.message)) {
|
||||
return;
|
||||
}
|
||||
if (requiresExistingObjectCheck(error)) {
|
||||
// A broken CLI error message cannot distinguish missing from denied.
|
||||
// Only an authenticated, explicitly empty listing proves absence.
|
||||
const listing = JSON.parse(
|
||||
s3('list-objects-v2', [
|
||||
'--prefix',
|
||||
latestKey,
|
||||
'--max-keys',
|
||||
'1',
|
||||
'--output',
|
||||
'json',
|
||||
]),
|
||||
);
|
||||
if (
|
||||
listing?.KeyCount === 0 &&
|
||||
listing.IsTruncated === false &&
|
||||
Array.isArray(listing.Contents ?? []) &&
|
||||
(listing.Contents ?? []).length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const published = JSON.parse(readFileSync(file, 'utf8'));
|
||||
const comparison = compareVersions(metadata.version, published.version);
|
||||
if (allowSameVersion && comparison < 0) {
|
||||
throw new Error('Refusing to downgrade the stable download.');
|
||||
}
|
||||
if (comparison < 0 || (!allowSameVersion && comparison === 0)) {
|
||||
throw new Error(
|
||||
'This version has already been released through the update feed; bump the app version. Published objects were not overwritten.',
|
||||
);
|
||||
}
|
||||
}
|
||||
// The macos-release concurrency group serializes the entire publication.
|
||||
// Never run independent publishers against the same release destination.
|
||||
function putReleaseFile(name, contents, contentType) {
|
||||
const file = join(staging, name);
|
||||
writeFileSync(file, contents);
|
||||
function put(condition) {
|
||||
s3('put-object', [
|
||||
'--key',
|
||||
`${keyPrefix}/${name}`,
|
||||
'--body',
|
||||
file,
|
||||
...condition,
|
||||
'--content-type',
|
||||
contentType,
|
||||
'--cache-control',
|
||||
'no-cache, max-age=0, must-revalidate',
|
||||
'--content-md5',
|
||||
createHash('md5').update(contents).digest('base64'),
|
||||
'--metadata',
|
||||
`sha256=${sha256(contents)}`,
|
||||
]);
|
||||
}
|
||||
try {
|
||||
put(['--if-none-match', '*']);
|
||||
} catch (error) {
|
||||
if (!requiresExistingObjectCheck(error)) {
|
||||
throw error;
|
||||
}
|
||||
const existing = join(staging, `existing-${name}`);
|
||||
const response = JSON.parse(
|
||||
s3('get-object', [
|
||||
'--key',
|
||||
`${keyPrefix}/${name}`,
|
||||
'--output',
|
||||
'json',
|
||||
existing,
|
||||
]),
|
||||
);
|
||||
if (sha256(readFileSync(existing)) !== sha256(contents)) {
|
||||
assertUnpublishedRelease();
|
||||
put(['--if-match', conditionalEtag(response.ETag)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
const files = [
|
||||
[metadata.filename, archive, 'application/x-apple-diskimage'],
|
||||
[metadata.updaterFilename, updater, 'application/gzip'],
|
||||
[
|
||||
`${metadata.updaterFilename}.sig`,
|
||||
Buffer.from(signature),
|
||||
'text/plain; charset=utf-8',
|
||||
],
|
||||
[
|
||||
`${metadata.updaterFilename}.sha256`,
|
||||
updateChecksum,
|
||||
'text/plain; charset=utf-8',
|
||||
],
|
||||
[
|
||||
`${metadata.filename}.sha256`,
|
||||
checksumFile,
|
||||
'text/plain; charset=utf-8',
|
||||
],
|
||||
];
|
||||
for (const [name, contents, type] of files) {
|
||||
putReleaseFile(name, contents, type);
|
||||
await verifyPublic(name, contents);
|
||||
}
|
||||
// Completion metadata is written only after every public artifact matches.
|
||||
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',
|
||||
);
|
||||
putReleaseFile('release.json', manifest, 'application/json');
|
||||
await verifyPublic('release.json', manifest);
|
||||
const latest = Buffer.from(
|
||||
JSON.stringify(
|
||||
updateManifest(
|
||||
metadata,
|
||||
publicPrefix,
|
||||
signature,
|
||||
metadata.notes,
|
||||
),
|
||||
null,
|
||||
2,
|
||||
) + '\n',
|
||||
);
|
||||
putReleaseFile('update.json', latest, 'application/json');
|
||||
await verifyPublic('update.json', latest);
|
||||
// An older release must not populate an absent download alias either.
|
||||
assertUnpublishedRelease(true);
|
||||
const downloadName = 'KanbanGit.dmg';
|
||||
const downloadKey = `${latestPrefix}/${downloadName}`;
|
||||
const downloadFile = join(staging, downloadName);
|
||||
writeFileSync(downloadFile, archive);
|
||||
function publishDownload(condition) {
|
||||
s3('put-object', [
|
||||
'--key',
|
||||
downloadKey,
|
||||
'--body',
|
||||
downloadFile,
|
||||
...condition,
|
||||
'--content-type',
|
||||
'application/x-apple-diskimage',
|
||||
'--cache-control',
|
||||
'no-cache, max-age=0, must-revalidate',
|
||||
'--content-md5',
|
||||
createHash('md5').update(archive).digest('base64'),
|
||||
'--metadata',
|
||||
`sha256=${checksum}`,
|
||||
]);
|
||||
}
|
||||
try {
|
||||
publishDownload(['--if-none-match', '*']);
|
||||
} catch (error) {
|
||||
if (!requiresExistingObjectCheck(error)) {
|
||||
throw error;
|
||||
}
|
||||
const previousFile = join(staging, 'previous-download.dmg');
|
||||
const response = JSON.parse(
|
||||
s3('get-object', [
|
||||
'--key',
|
||||
downloadKey,
|
||||
'--output',
|
||||
'json',
|
||||
previousFile,
|
||||
]),
|
||||
);
|
||||
if (sha256(readFileSync(previousFile)) !== checksum) {
|
||||
assertUnpublishedRelease();
|
||||
publishDownload(['--if-match', conditionalEtag(response.ETag)]);
|
||||
}
|
||||
}
|
||||
await verifyPublic(
|
||||
downloadName,
|
||||
archive,
|
||||
`${storage.publicBase}/${latestPrefix}`,
|
||||
);
|
||||
const latestFile = join(staging, 'latest.json');
|
||||
writeFileSync(latestFile, latest);
|
||||
function publishLatest(condition) {
|
||||
s3('put-object', [
|
||||
'--key',
|
||||
latestKey,
|
||||
'--body',
|
||||
latestFile,
|
||||
...condition,
|
||||
'--content-type',
|
||||
'application/json',
|
||||
'--cache-control',
|
||||
'no-cache, max-age=0, must-revalidate',
|
||||
'--content-md5',
|
||||
createHash('md5').update(latest).digest('base64'),
|
||||
]);
|
||||
}
|
||||
try {
|
||||
publishLatest(['--if-none-match', '*']);
|
||||
} catch (error) {
|
||||
if (!requiresExistingObjectCheck(error)) {
|
||||
throw error;
|
||||
}
|
||||
const previousFile = join(staging, 'previous-latest.json');
|
||||
const response = JSON.parse(
|
||||
s3('get-object', [
|
||||
'--key',
|
||||
latestKey,
|
||||
'--output',
|
||||
'json',
|
||||
previousFile,
|
||||
]),
|
||||
);
|
||||
const previousBytes = readFileSync(previousFile);
|
||||
const previous = JSON.parse(previousBytes);
|
||||
const comparison = compareVersions(
|
||||
metadata.version,
|
||||
previous.version,
|
||||
);
|
||||
if (comparison < 0) {
|
||||
throw new Error('Refusing to downgrade the update feed.');
|
||||
}
|
||||
if (comparison === 0) {
|
||||
if (!previousBytes.equals(latest)) {
|
||||
throw new Error(
|
||||
'Published update metadata differs; bump the app version.',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
publishLatest(['--if-match', conditionalEtag(response.ETag)]);
|
||||
}
|
||||
}
|
||||
await verifyPublic(
|
||||
'latest.json',
|
||||
latest,
|
||||
`${storage.publicBase}/${latestPrefix}`,
|
||||
);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
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';
|
||||
import { createDiskImage } from './macos-dmg.mjs';
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
if (!env.TAURI_SIGNING_PRIVATE_KEY?.trim()) {
|
||||
throw new Error(
|
||||
'Set TAURI_SIGNING_PRIVATE_KEY to the permanent updater signing key.',
|
||||
);
|
||||
}
|
||||
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}.dmg`;
|
||||
const archive = join(staging, name);
|
||||
createDiskImage({
|
||||
app,
|
||||
archive,
|
||||
identity,
|
||||
run: (command, args) =>
|
||||
step('Packaging notarized download', command, args),
|
||||
});
|
||||
const checksum = createHash('sha256')
|
||||
.update(readFileSync(archive))
|
||||
.digest('hex');
|
||||
writeFileSync(`${archive}.sha256`, `${checksum} ${name}\n`);
|
||||
const updaterName = `KanbanGit-${config.version}-${target}.app.tar.gz`;
|
||||
const updaterArchive = join(staging, updaterName);
|
||||
step('Packaging updater archive', 'tar', [
|
||||
'-czf',
|
||||
updaterArchive,
|
||||
'-C',
|
||||
dirname(app),
|
||||
'KanbanGit.app',
|
||||
]);
|
||||
step('Signing updater archive', 'pnpm', [
|
||||
'exec',
|
||||
'tauri',
|
||||
'signer',
|
||||
'sign',
|
||||
updaterArchive,
|
||||
]);
|
||||
const updaterChecksum = createHash('sha256')
|
||||
.update(readFileSync(updaterArchive))
|
||||
.digest('hex');
|
||||
writeFileSync(
|
||||
`${updaterArchive}.sha256`,
|
||||
`${updaterChecksum} ${updaterName}\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,6 @@
|
||||
[package]
|
||||
name = "kanbangit-native"
|
||||
version = "0.1.0"
|
||||
version = "0.2.4"
|
||||
description = "KanbanGit native application"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
@@ -13,6 +13,9 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
semver = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri = { version = "2", features = [] }
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -27,3 +30,11 @@ custom-protocol = ["tauri/custom-protocol"]
|
||||
name = "macos_smoke"
|
||||
harness = false
|
||||
required-features = ["smoke-test"]
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
tauri-plugin-updater = "=2.12.0"
|
||||
|
||||
[[test]]
|
||||
name = "updater_smoke"
|
||||
harness = false
|
||||
required-features = ["smoke-test"]
|
||||
|
||||
@@ -1,3 +1,51 @@
|
||||
fn main() {
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") {
|
||||
build_navigation();
|
||||
}
|
||||
tauri_build::build();
|
||||
}
|
||||
|
||||
fn build_navigation() {
|
||||
use std::{env, path::PathBuf, process::Command};
|
||||
|
||||
println!("cargo:rerun-if-changed=src/navigation.m");
|
||||
let output = PathBuf::from(env::var_os("OUT_DIR").unwrap());
|
||||
let object = output.join("navigation.o");
|
||||
let archive = output.join("libkanban_navigation.a");
|
||||
let arch = match env::var("CARGO_CFG_TARGET_ARCH").unwrap().as_str() {
|
||||
"aarch64" => "arm64",
|
||||
"x86_64" => "x86_64",
|
||||
_ => panic!("Unsupported macOS architecture"),
|
||||
};
|
||||
assert!(Command::new("xcrun")
|
||||
.args([
|
||||
"--sdk",
|
||||
"macosx",
|
||||
"clang",
|
||||
"-fobjc-arc",
|
||||
"-fblocks",
|
||||
"-Wall",
|
||||
"-Werror",
|
||||
"-mmacosx-version-min=14.0",
|
||||
"-arch",
|
||||
arch,
|
||||
"-c",
|
||||
"src/navigation.m",
|
||||
"-o"
|
||||
])
|
||||
.arg(&object)
|
||||
.status()
|
||||
.expect("Xcode clang is required")
|
||||
.success());
|
||||
assert!(Command::new("xcrun")
|
||||
.args(["ar", "crs"])
|
||||
.arg(&archive)
|
||||
.arg(&object)
|
||||
.status()
|
||||
.expect("Xcode ar is required")
|
||||
.success());
|
||||
println!("cargo:rustc-link-search=native={}", output.display());
|
||||
println!("cargo:rustc-link-lib=static=kanban_navigation");
|
||||
println!("cargo:rustc-link-lib=framework=AppKit");
|
||||
println!("cargo:rustc-link-lib=framework=WebKit");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# EditorConfig is awesome: https://EditorConfig.org
|
||||
|
||||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = false
|
||||
insert_final_newline = false
|
||||
@@ -0,0 +1,26 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/caches
|
||||
/.idea/libraries
|
||||
/.idea/modules.xml
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
.DS_Store
|
||||
build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
key.properties
|
||||
keystore.properties
|
||||
|
||||
/.tauri
|
||||
/tauri.settings.gradle
|
||||
/app/tauri.build.gradle.kts
|
||||
/app/src/main/jniLibs
|
||||
/app/src/main/java/de/kanbangit/desktop/generated
|
||||
|
||||
.kotlin
|
||||
/app/tauri.properties
|
||||
@@ -0,0 +1,6 @@
|
||||
/src/main/**/generated
|
||||
/src/main/jniLibs/**/*.so
|
||||
/src/main/assets/tauri.conf.json
|
||||
/tauri.build.gradle.kts
|
||||
/proguard-tauri.pro
|
||||
/tauri.properties
|
||||
@@ -0,0 +1,76 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("rust")
|
||||
}
|
||||
|
||||
val tauriProperties = Properties().apply {
|
||||
val propFile = file("tauri.properties")
|
||||
if (propFile.exists()) {
|
||||
propFile.inputStream().use { load(it) }
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = 36
|
||||
buildToolsVersion = "36.0.0"
|
||||
namespace = "de.kanbangit.desktop"
|
||||
defaultConfig {
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||
applicationId = "de.kanbangit.desktop"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
// Tauri exits the process when its last activity is destroyed; report results first.
|
||||
testInstrumentationRunnerArguments["waitForActivitiesToComplete"] = "false"
|
||||
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
|
||||
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
|
||||
}
|
||||
buildTypes {
|
||||
getByName("debug") {
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||
isDebuggable = true
|
||||
isJniDebuggable = true
|
||||
isMinifyEnabled = false
|
||||
packaging {
|
||||
jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so")
|
||||
jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so")
|
||||
jniLibs.keepDebugSymbols.add("*/x86/*.so")
|
||||
jniLibs.keepDebugSymbols.add("*/x86_64/*.so")
|
||||
}
|
||||
}
|
||||
getByName("release") {
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(
|
||||
*fileTree(".") { include("**/*.pro") }
|
||||
.plus(getDefaultProguardFile("proguard-android-optimize.txt"))
|
||||
.toList().toTypedArray()
|
||||
)
|
||||
}
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
rust {
|
||||
rootDirRel = "../../../"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.webkit:webkit:1.14.0")
|
||||
implementation("androidx.appcompat:appcompat:1.7.1")
|
||||
implementation("androidx.activity:activity-ktx:1.10.1")
|
||||
implementation("com.google.android.material:material:1.12.0")
|
||||
implementation("androidx.lifecycle:lifecycle-process:2.10.0")
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.4")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
|
||||
}
|
||||
|
||||
apply(from = "tauri.build.gradle.kts")
|
||||
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,170 @@
|
||||
package de.kanbangit.desktop
|
||||
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import android.view.InputDevice
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PlatformTest {
|
||||
private val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
|
||||
private fun evaluate(view: WebView, script: String): String {
|
||||
val result = AtomicReference<String>()
|
||||
val latch = CountDownLatch(1)
|
||||
instrumentation.runOnMainSync { view.evaluateJavascript(script) { result.set(it); latch.countDown() } }
|
||||
assertTrue("JavaScript callback timed out", latch.await(10, TimeUnit.SECONDS))
|
||||
return result.get()
|
||||
}
|
||||
|
||||
private fun waitFor(condition: () -> Boolean) {
|
||||
val end = System.currentTimeMillis() + 25000
|
||||
while (System.currentTimeMillis() < end) {
|
||||
if (condition()) return
|
||||
Thread.sleep(100)
|
||||
}
|
||||
fail("Platform condition timed out")
|
||||
}
|
||||
|
||||
private fun tapCenter(view: View) {
|
||||
val position = IntArray(2)
|
||||
instrumentation.runOnMainSync {
|
||||
view.getLocationOnScreen(position)
|
||||
position[0] += view.width / 2
|
||||
position[1] += view.height / 2
|
||||
}
|
||||
val downTime = SystemClock.uptimeMillis()
|
||||
for (action in listOf(MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP)) {
|
||||
val event = MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), action, position[0].toFloat(), position[1].toFloat(), 0)
|
||||
event.source = InputDevice.SOURCE_TOUCHSCREEN
|
||||
try {
|
||||
instrumentation.sendPointerSync(event)
|
||||
} finally {
|
||||
event.recycle()
|
||||
}
|
||||
}
|
||||
instrumentation.waitForIdleSync()
|
||||
}
|
||||
|
||||
@Test fun coldStartRestoresWorkspaceWithoutSelectionOrLauncherHistory() {
|
||||
val mode = InstrumentationRegistry.getArguments().getString("startupConfig", "saved")
|
||||
val preferences = instrumentation.targetContext.getSharedPreferences("workspace", Context.MODE_PRIVATE)
|
||||
preferences.edit().clear().apply {
|
||||
if (mode == "saved") putString("server", "https://127.0.0.1:9/app/")
|
||||
if (mode == "invalid") putString("server", "http://invalid.example/")
|
||||
}.commit()
|
||||
val scenario = ActivityScenario.launch(MainActivity::class.java)
|
||||
val launcher = AtomicReference<WebView>()
|
||||
fun findLauncher(view: View): WebView? {
|
||||
if (view is WebView && view.url?.startsWith(NavigationPolicy.LAUNCHER_ORIGIN) == true) return view
|
||||
if (view is ViewGroup) for (index in 0 until view.childCount) {
|
||||
findLauncher(view.getChildAt(index))?.let { return it }
|
||||
}
|
||||
return null
|
||||
}
|
||||
waitFor {
|
||||
scenario.onActivity { launcher.set(findLauncher(it.window.decorView)) }
|
||||
launcher.get() != null && evaluate(launcher.get(), "typeof window.updateLauncher === 'function'") == "true"
|
||||
}
|
||||
if (mode == "saved") {
|
||||
waitFor {
|
||||
val state = evaluate(launcher.get(), "document.querySelector('#connection-status').dataset.state")
|
||||
if (state == "\"connecting\"") {
|
||||
assertEquals("true", evaluate(launcher.get(), "document.querySelector('#workspace-settings').hidden"))
|
||||
}
|
||||
state == "\"connectionError\""
|
||||
}
|
||||
assertEquals("https://127.0.0.1:9/app/", preferences.getString("server", null))
|
||||
assertEquals("\"Try again\"", evaluate(launcher.get(), "document.querySelector('.open-workspace').textContent"))
|
||||
scenario.onActivity {
|
||||
assertEquals("https://127.0.0.1:9/app/dashboard", it.workspace?.originalUrl)
|
||||
assertFalse(it.workspace!!.canGoBack())
|
||||
it.openWorkspace("https://127.0.0.1:9/app/")
|
||||
}
|
||||
waitFor { evaluate(launcher.get(), "document.querySelector('#connection-status').dataset.state") == "\"connectionError\"" }
|
||||
} else {
|
||||
waitFor { evaluate(launcher.get(), "document.querySelector('#workspace-settings').hidden") == "false" }
|
||||
scenario.onActivity { assertNull(it.workspace) }
|
||||
}
|
||||
scenario.onActivity { it.openWorkspace("https://kanbangit.de/") }
|
||||
val remote = AtomicReference<WebView>()
|
||||
waitFor {
|
||||
var visible = false
|
||||
scenario.onActivity { remote.set(it.workspace); visible = it.workspace?.visibility == View.VISIBLE }
|
||||
visible && remote.get() != null
|
||||
}
|
||||
assertEquals("\"/login\"", evaluate(remote.get(), "location.pathname"))
|
||||
assertEquals("1", evaluate(remote.get(), "history.length"))
|
||||
assertEquals("https://kanbangit.de/", preferences.getString("server", null))
|
||||
scenario.moveToState(Lifecycle.State.CREATED)
|
||||
}
|
||||
|
||||
@Test fun workspacePreservesFormsAndIsolatesServers() {
|
||||
instrumentation.targetContext.getSharedPreferences("workspace", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
val scenario = ActivityScenario.launch(MainActivity::class.java)
|
||||
Thread.sleep(1500)
|
||||
scenario.onActivity { it.openWorkspace("https://kanbangit.de/") }
|
||||
val remote = AtomicReference<WebView>()
|
||||
waitFor {
|
||||
var visible = false
|
||||
scenario.onActivity { remote.set(it.workspace); visible = it.workspace?.visibility == View.VISIBLE }
|
||||
visible && remote.get() != null && evaluate(remote.get(), "document.readyState === 'complete' && document.querySelector('#app')?.innerText.trim().length > 100") == "true"
|
||||
}
|
||||
val view = remote.get()
|
||||
assertEquals("true", evaluate(view, "typeof window.nativeLauncher === 'undefined' && typeof window.__TAURI_INTERNALS__ === 'undefined'"))
|
||||
assertEquals("true", evaluate(view, """
|
||||
(() => {
|
||||
const input = document.createElement('input');
|
||||
input.id = 'kan53-input';
|
||||
input.value = 'retained form';
|
||||
document.body.append(input);
|
||||
const button = document.createElement('button');
|
||||
button.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;z-index:2147483647';
|
||||
button.textContent = 'Navigate';
|
||||
button.onclick = () => {
|
||||
history.pushState({}, '', '/kan53-history');
|
||||
button.remove();
|
||||
};
|
||||
document.body.append(button);
|
||||
return true;
|
||||
})()
|
||||
""".trimIndent()))
|
||||
tapCenter(view)
|
||||
waitFor { evaluate(view, "location.pathname === '/kan53-history'") == "true" }
|
||||
waitFor {
|
||||
var canGoBack = false
|
||||
scenario.onActivity { canGoBack = view.canGoBack() }
|
||||
canGoBack
|
||||
}
|
||||
scenario.moveToState(Lifecycle.State.CREATED)
|
||||
scenario.moveToState(Lifecycle.State.RESUMED)
|
||||
assertEquals("\"retained form\"", evaluate(view, "document.querySelector('#kan53-input').value"))
|
||||
scenario.onActivity { it.onBackPressedDispatcher.onBackPressed() }
|
||||
waitFor { evaluate(view, "location.pathname !== '/kan53-history'") == "true" }
|
||||
assertEquals("\"retained form\"", evaluate(view, "document.querySelector('#kan53-input').value"))
|
||||
evaluate(view, "document.cookie = 'kan53_fixture=secret; Path=/; Secure; SameSite=Lax'; localStorage.setItem('kan53_fixture', 'secret')")
|
||||
scenario.onActivity { it.openWorkspace("https://127.0.0.1:9/") }
|
||||
waitFor {
|
||||
val cookie = AtomicReference<String>()
|
||||
instrumentation.runOnMainSync { cookie.set(CookieManager.getInstance().getCookie("https://kanbangit.de/")) }
|
||||
cookie.get()?.contains("kan53_fixture") != true
|
||||
}
|
||||
scenario.onActivity { assertNotSame(view, it.workspace) }
|
||||
// Let instrumentation finish the process after reporting, rather than exiting via Tauri.
|
||||
scenario.moveToState(Lifecycle.State.CREATED)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:icon="@drawable/kanbangit"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.kanbangit_native"
|
||||
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
||||
android:launchMode="singleTask"
|
||||
android:label="@string/main_activity_title"
|
||||
android:name=".MainActivity"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,406 @@
|
||||
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
|
||||
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
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebStorage
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
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
|
||||
import androidx.webkit.WebViewCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
import org.json.JSONObject
|
||||
|
||||
class MainActivity : TauriActivity() {
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val preferences by lazy { getSharedPreferences("workspace", MODE_PRIVATE) }
|
||||
private lateinit var launcher: WebView
|
||||
private lateinit var root: LinearLayout
|
||||
private lateinit var content: FrameLayout
|
||||
private lateinit var settings: Button
|
||||
internal var workspace: WebView? = null
|
||||
private set
|
||||
private var server = NavigationPolicy.DEFAULT_SERVER
|
||||
private var phase = "idle"
|
||||
private var ready = false
|
||||
private var revision = 0
|
||||
private var launcherVisible = true
|
||||
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)
|
||||
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
when (NavigationPolicy.backAction(keyboardVisible, launcherVisible, ready, workspace?.canGoBack() == true)) {
|
||||
"keyboard" -> WindowCompat.getInsetsController(window, window.decorView).hide(WindowInsetsCompat.Type.ime())
|
||||
"resume" -> showWorkspace()
|
||||
"history" -> workspace?.goBack()
|
||||
"settings" -> showLauncher()
|
||||
else -> moveTaskToBack(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun onWebViewCreate(webView: WebView) {
|
||||
launcher = webView
|
||||
if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
|
||||
WebViewCompat.addWebMessageListener(launcher, "nativeLauncher", setOf(NavigationPolicy.LAUNCHER_ORIGIN)) {
|
||||
_, message, origin, mainFrame, _ ->
|
||||
if (mainFrame && origin.toString().trimEnd('/') == NavigationPolicy.LAUNCHER_ORIGIN) {
|
||||
launcher.post { handleAction(message.data.orEmpty()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
launcher.post { attachShell() }
|
||||
}
|
||||
|
||||
private fun attachShell() {
|
||||
if (::root.isInitialized) return
|
||||
(launcher.parent as? ViewGroup)?.removeView(launcher)
|
||||
root = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
|
||||
settings = Button(this).apply {
|
||||
text = getString(R.string.server_settings)
|
||||
isAllCaps = false
|
||||
visibility = View.GONE
|
||||
setOnClickListener { showLauncher() }
|
||||
}
|
||||
root.addView(settings, LinearLayout.LayoutParams(-1, -2))
|
||||
content = FrameLayout(this)
|
||||
content.addView(launcher, FrameLayout.LayoutParams(-1, -1))
|
||||
root.addView(content, LinearLayout.LayoutParams(-1, 0, 1f))
|
||||
setContentView(root)
|
||||
ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets ->
|
||||
val safe = insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
|
||||
val keyboard = insets.getInsets(WindowInsetsCompat.Type.ime())
|
||||
keyboardVisible = insets.isVisible(WindowInsetsCompat.Type.ime())
|
||||
view.setPadding(safe.left, safe.top, safe.right, maxOf(safe.bottom, keyboard.bottom))
|
||||
WindowInsetsCompat.CONSUMED
|
||||
}
|
||||
ViewCompat.requestApplyInsets(root)
|
||||
if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
|
||||
content.removeAllViews()
|
||||
content.addView(TextView(this).apply { text = getString(R.string.update_webview) })
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleAction(value: String) {
|
||||
attachShell()
|
||||
val url = Uri.parse(value)
|
||||
if (url.scheme != "https" || url.host != "tauri.localhost" || url.port != -1 || url.userInfo != null) return
|
||||
when (url.path) {
|
||||
"/action/ready" -> {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
val saved = preferences.getString("server", null)
|
||||
val restored = saved?.let(NavigationPolicy::installation)
|
||||
server = restored ?: NavigationPolicy.DEFAULT_SERVER
|
||||
if (restored != null) openWorkspace(restored) else updateLauncher()
|
||||
}
|
||||
"/action/open" -> openWorkspace(url.getQueryParameter("server").orEmpty())
|
||||
"/action/resume" -> if (ready) showWorkspace()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun openWorkspace(input: String) {
|
||||
val address = NavigationPolicy.installation(input)
|
||||
if (address == null) {
|
||||
phase = "invalidServer"
|
||||
showLauncher()
|
||||
return
|
||||
}
|
||||
if (phase == "connecting") return
|
||||
if (address == server && ready) {
|
||||
if (phase == "connectionError") {
|
||||
phase = "connecting"
|
||||
workspace?.let { startTimeout(it); it.reload() }
|
||||
updateLauncher()
|
||||
return
|
||||
}
|
||||
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()) {
|
||||
phase = "storageError"
|
||||
showLauncher()
|
||||
return
|
||||
}
|
||||
server = address
|
||||
ready = false
|
||||
phase = "connecting"
|
||||
revision++
|
||||
workspace?.let { old ->
|
||||
content.removeView(old)
|
||||
old.stopLoading()
|
||||
if (clearSession) old.clearCache(true)
|
||||
old.destroy()
|
||||
}
|
||||
workspace = null
|
||||
updateLauncher()
|
||||
if (clearSession) {
|
||||
WebStorage.getInstance().deleteAllData()
|
||||
CookieManager.getInstance().removeAllCookies {
|
||||
CookieManager.getInstance().flush()
|
||||
if (!preferences.edit().putBoolean("clearing", false).commit()) {
|
||||
phase = "storageError"
|
||||
showLauncher()
|
||||
} else if (!isDestroyed) {
|
||||
createWorkspace()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
createWorkspace()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
private fun createWorkspace() {
|
||||
val remote = WebView(this)
|
||||
workspace = remote
|
||||
remote.settings.apply {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
allowFileAccess = false
|
||||
allowContentAccess = true
|
||||
mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
|
||||
javaScriptCanOpenWindowsAutomatically = false
|
||||
setSupportMultipleWindows(true)
|
||||
}
|
||||
CookieManager.getInstance().setAcceptThirdPartyCookies(remote, false)
|
||||
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 {
|
||||
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)
|
||||
return
|
||||
}
|
||||
phase = "connecting"
|
||||
startTimeout(view)
|
||||
updateLauncher()
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView, url: String) {
|
||||
if (workspace !== view || phase != "connecting" || !NavigationPolicy.allows(server, url)) return
|
||||
revision++
|
||||
phase = "connected"
|
||||
val firstLoad = !ready
|
||||
ready = true
|
||||
CookieManager.getInstance().flush()
|
||||
if (firstLoad) showWorkspace()
|
||||
updateLauncher()
|
||||
}
|
||||
|
||||
override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) {
|
||||
if (request.isForMainFrame) fail(view)
|
||||
}
|
||||
|
||||
override fun onReceivedHttpError(view: WebView, request: WebResourceRequest, response: WebResourceResponse) {
|
||||
if (request.isForMainFrame && response.statusCode >= 500) fail(view)
|
||||
}
|
||||
|
||||
override fun onReceivedSslError(view: WebView, sslHandler: SslErrorHandler, error: SslError) {
|
||||
sslHandler.cancel()
|
||||
fail(view)
|
||||
}
|
||||
}
|
||||
remote.visibility = View.GONE
|
||||
content.addView(remote, 0, FrameLayout.LayoutParams(-1, -1))
|
||||
startTimeout(remote)
|
||||
remote.loadUrl(NavigationPolicy.dashboard(server))
|
||||
}
|
||||
|
||||
private fun startTimeout(view: WebView) {
|
||||
val current = ++revision
|
||||
handler.postDelayed({
|
||||
if (current == revision && phase == "connecting" && workspace === view) {
|
||||
view.stopLoading()
|
||||
fail(view)
|
||||
}
|
||||
}, 15000)
|
||||
}
|
||||
|
||||
private fun fail(view: WebView) {
|
||||
if (workspace !== view) return
|
||||
revision++
|
||||
phase = "connectionError"
|
||||
showLauncher()
|
||||
}
|
||||
|
||||
private fun updateLauncher() {
|
||||
val query = Uri.Builder().appendQueryParameter("server", server)
|
||||
.appendQueryParameter("phase", phase).appendQueryParameter("ready", ready.toString())
|
||||
.appendQueryParameter("platform", "android").build().encodedQuery.orEmpty()
|
||||
launcher.evaluateJavascript("window.updateLauncher?.(${JSONObject.quote(query)})", null)
|
||||
}
|
||||
|
||||
private fun showLauncher() {
|
||||
updateLauncher()
|
||||
launcherVisible = true
|
||||
launcher.visibility = View.VISIBLE
|
||||
workspace?.visibility = View.GONE
|
||||
settings.visibility = View.GONE
|
||||
launcher.requestFocus()
|
||||
}
|
||||
|
||||
private fun showWorkspace() {
|
||||
launcherVisible = false
|
||||
launcher.visibility = View.GONE
|
||||
workspace?.visibility = View.VISIBLE
|
||||
settings.visibility = View.VISIBLE
|
||||
workspace?.requestFocus()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
workspace?.onPause()
|
||||
CookieManager.getInstance().flush()
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
workspace?.onResume()
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
enableEdgeToEdge()
|
||||
if (::root.isInitialized) ViewCompat.requestApplyInsets(root)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
cancelFileSelection()
|
||||
popups.forEach { it.destroy() }
|
||||
popups.clear()
|
||||
workspace?.destroy()
|
||||
workspace = null
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package de.kanbangit.desktop
|
||||
|
||||
import java.net.URI
|
||||
import java.util.Locale
|
||||
|
||||
object NavigationPolicy {
|
||||
const val DEFAULT_SERVER = "https://kanbangit.de/"
|
||||
const val LAUNCHER_ORIGIN = "https://tauri.localhost"
|
||||
|
||||
fun installation(input: String): String? = runCatching {
|
||||
val trimmed = input.trim()
|
||||
require(trimmed.none { it.isISOControl() || it == '\\' })
|
||||
val uri = URI(trimmed)
|
||||
require(uri.scheme.equals("https", ignoreCase = true))
|
||||
require(!uri.host.isNullOrBlank() && uri.rawUserInfo == null)
|
||||
require(uri.rawQuery == null && uri.rawFragment == null)
|
||||
require(uri.port == -1 || uri.port in 1..65535)
|
||||
require(!uri.host.equals("tauri.localhost", ignoreCase = true))
|
||||
val port = if (uri.port == -1 || uri.port == 443) "" else ":${uri.port}"
|
||||
val path = uri.rawPath.orEmpty().let { if (it.endsWith('/')) it else "$it/" }
|
||||
"https://${uri.host.lowercase(Locale.ROOT)}$port$path"
|
||||
}.getOrNull()
|
||||
|
||||
fun dashboard(server: String): String = URI(requireNotNull(installation(server))).resolve("dashboard").toString()
|
||||
|
||||
fun allows(server: String, destination: String): Boolean = runCatching {
|
||||
val base = URI(server)
|
||||
val target = URI(destination)
|
||||
target.scheme.equals("https", ignoreCase = true) &&
|
||||
target.host.equals(base.host, ignoreCase = true) &&
|
||||
(if (target.port == -1) 443 else target.port) ==
|
||||
(if (base.port == -1) 443 else base.port) &&
|
||||
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"
|
||||
!launcher && history -> "history"
|
||||
!launcher -> "settings"
|
||||
else -> "background"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp" android:height="48dp"
|
||||
android:viewportWidth="64" android:viewportHeight="64">
|
||||
<path android:fillColor="#22c55e" android:pathData="M12,0 H52 Q64,0 64,12 V52 Q64,64 52,64 H12 Q0,64 0,52 V12 Q0,0 12,0 Z" />
|
||||
<path android:fillColor="@android:color/transparent" android:strokeColor="#004b1e"
|
||||
android:strokeWidth="3" android:strokeLineCap="round" android:strokeLineJoin="round"
|
||||
android:pathData="M19,16 H45 Q48,16 48,19 V45 Q48,48 45,48 H19 Q16,48 16,45 V19 Q16,16 19,16 Z M27,16 V48 M37,16 V48" />
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.kanbangit_native" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,8 @@
|
||||
<resources>
|
||||
<string name="app_name">"KanbanGit"</string>
|
||||
<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>
|
||||
@@ -0,0 +1,6 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.kanbangit_native" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
<exclude domain="external" path="." />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
<exclude domain="external" path="." />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-path name="my_images" path="." />
|
||||
<cache-path name="my_cache_images" path="." />
|
||||
</paths>
|
||||
@@ -0,0 +1,56 @@
|
||||
package de.kanbangit.desktop
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class NavigationPolicyTest {
|
||||
@Test fun normalizesAddressesAndRejectsCredentialsAndUnsafeSchemes() {
|
||||
assertEquals("https://example.com/kanban/", NavigationPolicy.installation(" HTTPS://Example.com:443/kanban "))
|
||||
assertEquals("https://[::1]:8443/", NavigationPolicy.installation("https://[::1]:8443"))
|
||||
for (value in listOf("http://example.com", "https:example.com", "https://user:pass@example.com", "https://example.com/?token=x", "https://example.com/#x", "https://exa\nmple.com", "https://example.com\\other", "https://example.com:99999", "https://tauri.localhost/", "file:///etc/passwd", "")) {
|
||||
assertNull(value, NavigationPolicy.installation(value))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun startupTargetsDashboardWithinTheInstallation() {
|
||||
assertEquals("https://example.com/dashboard", NavigationPolicy.dashboard("https://example.com/"))
|
||||
assertEquals("https://example.com:8443/kanban/dashboard", NavigationPolicy.dashboard("https://example.com:8443/kanban/"))
|
||||
}
|
||||
|
||||
@Test fun allowsLoginTwoFactorAndHistoryOnlyOnTheSelectedOrigin() {
|
||||
val server = "https://example.com:8443/app/"
|
||||
for (path in listOf("/login", "/two-factor-challenge", "/projects/1?ticket=53#details")) {
|
||||
assertTrue(NavigationPolicy.allows(server, "https://example.com:8443$path"))
|
||||
}
|
||||
for (value in listOf("https://example.com/", "http://example.com:8443/", "https://other.example.com:8443/", "https://user@example.com:8443/", "https://tauri.localhost/action/open", "javascript:alert(1)", "intent://example.com", "file:///etc/passwd")) {
|
||||
assertFalse(value, NavigationPolicy.allows(server, value))
|
||||
}
|
||||
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))
|
||||
assertEquals("history", NavigationPolicy.backAction(false, false, true, true))
|
||||
assertEquals("settings", NavigationPolicy.backAction(false, false, true, false))
|
||||
assertEquals("background", NavigationPolicy.backAction(false, true, false, false))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath("com.android.tools.build:gradle:8.11.0")
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25")
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("clean").configure {
|
||||
delete("build")
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
plugins {
|
||||
`kotlin-dsl`
|
||||
}
|
||||
|
||||
gradlePlugin {
|
||||
plugins {
|
||||
create("pluginsForCoolKids") {
|
||||
id = "rust"
|
||||
implementationClass = "RustPlugin"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly(gradleApi())
|
||||
implementation("com.android.tools.build:gradle:8.11.0")
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import java.io.File
|
||||
import org.apache.tools.ant.taskdefs.condition.Os
|
||||
import org.gradle.api.DefaultTask
|
||||
import org.gradle.api.GradleException
|
||||
import org.gradle.api.logging.LogLevel
|
||||
import org.gradle.api.tasks.Input
|
||||
import org.gradle.api.tasks.TaskAction
|
||||
|
||||
open class BuildTask : DefaultTask() {
|
||||
@Input
|
||||
var rootDirRel: String? = null
|
||||
@Input
|
||||
var target: String? = null
|
||||
@Input
|
||||
var release: Boolean? = null
|
||||
|
||||
@TaskAction
|
||||
fun assemble() {
|
||||
val executable = """pnpm""";
|
||||
try {
|
||||
runTauriCli(executable)
|
||||
} catch (e: Exception) {
|
||||
if (Os.isFamily(Os.FAMILY_WINDOWS)) {
|
||||
// Try different Windows-specific extensions
|
||||
val fallbacks = listOf(
|
||||
"$executable.exe",
|
||||
"$executable.cmd",
|
||||
"$executable.bat",
|
||||
)
|
||||
|
||||
var lastException: Exception = e
|
||||
for (fallback in fallbacks) {
|
||||
try {
|
||||
runTauriCli(fallback)
|
||||
return
|
||||
} catch (fallbackException: Exception) {
|
||||
lastException = fallbackException
|
||||
}
|
||||
}
|
||||
throw lastException
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun runTauriCli(executable: String) {
|
||||
val rootDirRel = rootDirRel ?: throw GradleException("rootDirRel cannot be null")
|
||||
val target = target ?: throw GradleException("target cannot be null")
|
||||
val release = release ?: throw GradleException("release cannot be null")
|
||||
val args = listOf("tauri", "android", "android-studio-script");
|
||||
|
||||
project.exec {
|
||||
workingDir(File(project.projectDir, rootDirRel))
|
||||
executable(executable)
|
||||
args(args)
|
||||
if (project.logger.isEnabled(LogLevel.DEBUG)) {
|
||||
args("-vv")
|
||||
} else if (project.logger.isEnabled(LogLevel.INFO)) {
|
||||
args("-v")
|
||||
}
|
||||
if (release) {
|
||||
args("--release")
|
||||
}
|
||||
args(listOf("--target", target))
|
||||
}.assertNormalExitValue()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import com.android.build.api.dsl.ApplicationExtension
|
||||
import org.gradle.api.DefaultTask
|
||||
import org.gradle.api.Plugin
|
||||
import org.gradle.api.Project
|
||||
import org.gradle.kotlin.dsl.configure
|
||||
import org.gradle.kotlin.dsl.get
|
||||
|
||||
const val TASK_GROUP = "rust"
|
||||
|
||||
open class Config {
|
||||
lateinit var rootDirRel: String
|
||||
}
|
||||
|
||||
open class RustPlugin : Plugin<Project> {
|
||||
private lateinit var config: Config
|
||||
|
||||
override fun apply(project: Project) = with(project) {
|
||||
config = extensions.create("rust", Config::class.java)
|
||||
|
||||
val defaultAbiList = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64");
|
||||
val abiList = (findProperty("abiList") as? String)?.split(',') ?: defaultAbiList
|
||||
|
||||
val defaultArchList = listOf("arm64", "arm", "x86", "x86_64");
|
||||
val archList = (findProperty("archList") as? String)?.split(',') ?: defaultArchList
|
||||
|
||||
val targetsList = (findProperty("targetList") as? String)?.split(',') ?: listOf("aarch64", "armv7", "i686", "x86_64")
|
||||
|
||||
extensions.configure<ApplicationExtension> {
|
||||
@Suppress("UnstableApiUsage")
|
||||
flavorDimensions.add("abi")
|
||||
productFlavors {
|
||||
create("universal") {
|
||||
dimension = "abi"
|
||||
ndk {
|
||||
abiFilters += abiList
|
||||
}
|
||||
}
|
||||
defaultArchList.forEachIndexed { index, arch ->
|
||||
create(arch) {
|
||||
dimension = "abi"
|
||||
ndk {
|
||||
abiFilters.add(defaultAbiList[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
for (profile in listOf("debug", "release")) {
|
||||
val profileCapitalized = profile.replaceFirstChar { it.uppercase() }
|
||||
val buildTask = tasks.maybeCreate(
|
||||
"rustBuildUniversal$profileCapitalized",
|
||||
DefaultTask::class.java
|
||||
).apply {
|
||||
group = TASK_GROUP
|
||||
description = "Build dynamic library in $profile mode for all targets"
|
||||
}
|
||||
|
||||
tasks["mergeUniversal${profileCapitalized}JniLibFolders"].dependsOn(buildTask)
|
||||
|
||||
for (targetPair in targetsList.withIndex()) {
|
||||
val targetName = targetPair.value
|
||||
val targetArch = archList[targetPair.index]
|
||||
val targetArchCapitalized = targetArch.replaceFirstChar { it.uppercase() }
|
||||
val targetBuildTask = project.tasks.maybeCreate(
|
||||
"rustBuild$targetArchCapitalized$profileCapitalized",
|
||||
BuildTask::class.java
|
||||
).apply {
|
||||
group = TASK_GROUP
|
||||
description = "Build dynamic library in $profile mode for $targetArch"
|
||||
rootDirRel = config.rootDirRel
|
||||
target = targetName
|
||||
release = profile == "release"
|
||||
}
|
||||
|
||||
buildTask.dependsOn(targetBuildTask)
|
||||
tasks["merge$targetArchCapitalized${profileCapitalized}JniLibFolders"].dependsOn(
|
||||
targetBuildTask
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
# Project-wide Gradle settings.
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
# org.gradle.parallel=true
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app"s APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
# Kotlin code style for this project: "official" or "obsolete":
|
||||
kotlin.code.style=official
|
||||
# Enables namespacing of each library's R class so that its R class includes only the
|
||||
# resources declared in the library itself and none from the library's dependencies,
|
||||
# thereby reducing the size of the R class for that library
|
||||
android.nonTransitiveRClass=true
|
||||
android.nonFinalResIds=false
|
||||
android.builder.sdkDownload=false
|
||||
@@ -0,0 +1,6 @@
|
||||
#Tue May 10 19:22:52 CST 2022
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
||||
distributionPath=wrapper/dists
|
||||
zipStorePath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
#
|
||||
# Copyright 2015 the original author or authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
NONSTOP* )
|
||||
nonstop=true
|
||||
;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD="java"
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=`expr $i + 1`
|
||||
done
|
||||
case $i in
|
||||
0) set -- ;;
|
||||
1) set -- "$args0" ;;
|
||||
2) set -- "$args0" "$args1" ;;
|
||||
3) set -- "$args0" "$args1" "$args2" ;;
|
||||
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Escape application args
|
||||
save () {
|
||||
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||
echo " "
|
||||
}
|
||||
APP_ARGS=`save "$@"`
|
||||
|
||||
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
@@ -0,0 +1,89 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%" == "" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,3 @@
|
||||
include ':app'
|
||||
|
||||
apply from: 'tauri.settings.gradle'
|
||||
|
Before Width: | Height: | Size: 422 B After Width: | Height: | Size: 638 B |
|
Before Width: | Height: | Size: 548 B After Width: | Height: | Size: 774 B |
|
Before Width: | Height: | Size: 548 B After Width: | Height: | Size: 774 B |
|
Before Width: | Height: | Size: 800 B After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 415 B After Width: | Height: | Size: 658 B |
|
Before Width: | Height: | Size: 845 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 845 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 548 B After Width: | Height: | Size: 774 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 955 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.4 KiB |
@@ -22,6 +22,8 @@ struct WorkspaceServer: Codable, Equatable {
|
||||
return components.url
|
||||
}
|
||||
|
||||
var dashboardURL: URL { url.appendingPathComponent("dashboard") }
|
||||
|
||||
func allows(_ destination: URL) -> Bool {
|
||||
destination.scheme?.lowercased() == "https"
|
||||
&& destination.host?.lowercased() == url.host?.lowercased()
|
||||
@@ -29,6 +31,13 @@ struct WorkspaceServer: Codable, Equatable {
|
||||
&& 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())
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ func installIOSWorkspace() {
|
||||
}
|
||||
}
|
||||
|
||||
final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKUIDelegate, UITextFieldDelegate {
|
||||
final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKUIDelegate, UITextFieldDelegate, UIGestureRecognizerDelegate {
|
||||
private let defaults: UserDefaults
|
||||
private let serverKey = "ios.activeServer"
|
||||
private(set) var server: WorkspaceServer?
|
||||
@@ -32,6 +32,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
private var connection = WorkspaceConnection()
|
||||
private var pendingNavigation: WKNavigation?
|
||||
private var timeout: DispatchWorkItem?
|
||||
private let loading = UIActivityIndicatorView(style: .large)
|
||||
private let launcher = UIScrollView()
|
||||
private let stack = UIStackView()
|
||||
private let address = UITextField()
|
||||
@@ -39,7 +40,9 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
private let connectButton = UIButton(type: .system)
|
||||
private let resumeButton = UIButton(type: .system)
|
||||
private let toolbar = UIToolbar()
|
||||
private var backButton: UIBarButtonItem!
|
||||
private var gestureStart = CGPoint.zero
|
||||
private var gestureAllowed = false
|
||||
private var gestureRevision = 0
|
||||
private var appearance: UISegmentedControl!
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
@@ -57,6 +60,14 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemBackground
|
||||
view.tintColor = UIColor(red: 0, green: 0.43, blue: 0.18, alpha: 1)
|
||||
loading.accessibilityIdentifier = "workspaceLoading"
|
||||
loading.accessibilityLabel = text("connecting")
|
||||
loading.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(loading)
|
||||
NSLayoutConstraint.activate([
|
||||
loading.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
loading.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||
])
|
||||
configureLauncher()
|
||||
configureToolbar()
|
||||
if let data = defaults.data(forKey: serverKey) {
|
||||
@@ -68,8 +79,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
}
|
||||
}
|
||||
address.text = server?.url.absoluteString ?? "https://kanbangit.de/"
|
||||
showSettings()
|
||||
if let server { open(server.url) }
|
||||
if let server { open(server.url) } else { showSettings() }
|
||||
}
|
||||
|
||||
private func label(_ key: String, style: UIFont.TextStyle = .body) -> UILabel {
|
||||
@@ -82,9 +92,10 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
}
|
||||
|
||||
private func configureLauncher() {
|
||||
launcher.isHidden = true
|
||||
launcher.accessibilityIdentifier = "serverSettings"
|
||||
launcher.keyboardDismissMode = .interactive
|
||||
launcher.alwaysBounceVertical = true
|
||||
launcher.bounces = false
|
||||
view.addSubview(launcher)
|
||||
launcher.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
@@ -151,13 +162,12 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
}
|
||||
|
||||
private func configureToolbar() {
|
||||
toolbar.isHidden = true
|
||||
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]
|
||||
toolbar.items = [UIBarButtonItem(systemItem: .flexibleSpace), settings]
|
||||
NSLayoutConstraint.activate([
|
||||
toolbar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||
toolbar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
||||
@@ -199,11 +209,24 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
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 scriptURL = Bundle(for: WorkspaceViewController.self).url(forResource: "workspace-navigation", withExtension: "js")!
|
||||
let script = try! String(contentsOf: scriptURL, encoding: .utf8)
|
||||
configuration.userContentController.addUserScript(WKUserScript(source: script, injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
||||
// Remote content receives no native message handlers or Tauri bridge.
|
||||
let webview = WKWebView(frame: .zero, configuration: configuration)
|
||||
webview.navigationDelegate = self
|
||||
webview.uiDelegate = self
|
||||
webview.allowsBackForwardNavigationGestures = true
|
||||
// Native WebKit swipes can skip async pushState entries. One edge recognizer
|
||||
// owns each gesture and traverses the same history that Inertia restores.
|
||||
webview.allowsBackForwardNavigationGestures = false
|
||||
webview.scrollView.bounces = false
|
||||
for edge: UIRectEdge in [.left, .right] {
|
||||
let gesture = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(navigateGesture(_:)))
|
||||
gesture.edges = edge
|
||||
gesture.delegate = self
|
||||
webview.addGestureRecognizer(gesture)
|
||||
webview.scrollView.panGestureRecognizer.require(toFail: gesture)
|
||||
}
|
||||
webview.accessibilityIdentifier = "workspace"
|
||||
webview.translatesAutoresizingMaskIntoConstraints = false
|
||||
webview.isHidden = true
|
||||
@@ -217,8 +240,11 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
workspace = webview
|
||||
view.endEditing(true)
|
||||
address.text = url.absoluteString
|
||||
launcher.isHidden = true
|
||||
toolbar.isHidden = true
|
||||
loading.startAnimating()
|
||||
beginNavigation()
|
||||
pendingNavigation = webview.load(URLRequest(url: url))
|
||||
pendingNavigation = webview.load(URLRequest(url: selected.dashboardURL))
|
||||
}
|
||||
|
||||
private func beginNavigation() {
|
||||
@@ -242,6 +268,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
}
|
||||
|
||||
@objc private func showSettings() {
|
||||
loading.stopAnimating()
|
||||
view.endEditing(true)
|
||||
launcher.isHidden = false
|
||||
toolbar.isHidden = true
|
||||
@@ -251,18 +278,64 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
|
||||
@objc private func showWorkspace() {
|
||||
guard connection.ready else { return }
|
||||
loading.stopAnimating()
|
||||
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 gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
||||
guard gestureRecognizer is UIScreenEdgePanGestureRecognizer, let webview = workspace else { return false }
|
||||
gestureStart = touch.location(in: webview)
|
||||
gestureAllowed = false
|
||||
gestureRevision += 1
|
||||
let revision = gestureRevision
|
||||
webview.evaluateJavaScript("window.__kanbanNavigation?.canSwipe(\(gestureStart.x), \(gestureStart.y)) === true") { [weak self, weak gestureRecognizer] result, _ in
|
||||
guard let self, self.gestureRevision == revision, self.workspace === webview else { return }
|
||||
self.gestureAllowed = result as? Bool == true
|
||||
// Release the scroll view immediately when a board, field or overlay owns the touch.
|
||||
if !self.gestureAllowed, gestureRecognizer?.state == .possible { gestureRecognizer?.state = .failed }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
guard let edge = gestureRecognizer as? UIScreenEdgePanGestureRecognizer,
|
||||
let webview = workspace, !webview.isHidden, gestureAllowed else { return false }
|
||||
let velocity = edge.velocity(in: webview)
|
||||
return abs(velocity.x) > abs(velocity.y)
|
||||
&& (edge.edges == .left ? webview.canGoBack && velocity.x > 0 : webview.canGoForward && velocity.x < 0)
|
||||
}
|
||||
|
||||
@objc private func navigateGesture(_ gesture: UIScreenEdgePanGestureRecognizer) {
|
||||
guard gesture.state == .ended, let webview = workspace else { return }
|
||||
let direction = gesture.edges == .left ? -1 : 1
|
||||
let distance = gesture.translation(in: webview).x * CGFloat(-direction)
|
||||
let velocity = gesture.velocity(in: webview).x * CGFloat(-direction)
|
||||
guard distance > webview.bounds.width * 0.25 || (distance > 30 && velocity > 500) else { return }
|
||||
navigateHistory(direction, from: gestureStart)
|
||||
}
|
||||
|
||||
func navigateHistory(_ direction: Int, from point: CGPoint) {
|
||||
guard let webview = workspace,
|
||||
direction == -1 ? webview.canGoBack : webview.canGoForward else { return }
|
||||
let script = "if (window.__kanbanNavigation?.canSwipe(\(point.x), \(point.y))) window.__kanbanNavigation.step(\(direction))"
|
||||
webview.evaluateJavaScript(script, completionHandler: nil)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
guard webView === workspace, let url = action.request.url,
|
||||
server?.allows(url) == true else { decisionHandler(.cancel); return }
|
||||
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)
|
||||
@@ -282,7 +355,8 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
guard webView === workspace, navigation === pendingNavigation else { return }
|
||||
guard webView === workspace, navigation === pendingNavigation, connection.connecting,
|
||||
let url = webView.url, server?.allows(url) == true else { return }
|
||||
let firstLoad = !connection.ready
|
||||
timeout?.cancel()
|
||||
connection.finish()
|
||||
@@ -290,7 +364,6 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
connectButton.setTitle(text("openWorkspace"), for: .normal)
|
||||
status.text = text("connected")
|
||||
resumeButton.isHidden = false
|
||||
backButton.isEnabled = webView.canGoBack
|
||||
if firstLoad { showWorkspace() }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"NSCameraUsageDescription" = "Choose a profile picture using your camera.";
|
||||
"NSPhotoLibraryUsageDescription" = "Choose a profile picture from your photo library.";
|
||||
@@ -17,6 +17,162 @@ final class WorkspaceTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
func testDashboardAddressPreservesTheInstallationPath() throws {
|
||||
let url = try XCTUnwrap(WorkspaceServer.normalize("https://example.com:8443/kanban/"))
|
||||
XCTAssertEqual(WorkspaceServer(url: url, profile: UUID()).dashboardURL.absoluteString,
|
||||
"https://example.com:8443/kanban/dashboard")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testColdStartHidesSelectionUntilFailureAndPreservesSavedWorkspace() throws {
|
||||
let name = "KAN69-startup-\(UUID())"
|
||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
||||
defer { defaults.removePersistentDomain(forName: name) }
|
||||
let saved = WorkspaceServer(url: URL(string: "https://example.invalid/kanban/")!, profile: UUID())
|
||||
let data = try JSONEncoder().encode(saved)
|
||||
defaults.set(data, forKey: "ios.activeServer")
|
||||
let controller = WorkspaceViewController(defaults: defaults)
|
||||
controller.loadViewIfNeeded()
|
||||
let launcher = try XCTUnwrap(controller.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" })
|
||||
let loading = try XCTUnwrap(controller.view.subviews.first { $0.accessibilityIdentifier == "workspaceLoading" } as? UIActivityIndicatorView)
|
||||
XCTAssertTrue(launcher.isHidden)
|
||||
XCTAssertTrue(loading.isAnimating)
|
||||
XCTAssertEqual(controller.server, saved)
|
||||
let webview = try XCTUnwrap(controller.workspace)
|
||||
XCTAssertFalse(webview.canGoBack)
|
||||
XCTAssertTrue(webview.backForwardList.backList.isEmpty)
|
||||
webview.stopLoading()
|
||||
let navigation = try XCTUnwrap(webview.loadSimulatedRequest(URLRequest(url: saved.dashboardURL), responseHTML: "<html></html>"))
|
||||
controller.webView(webview, didStartProvisionalNavigation: navigation)
|
||||
XCTAssertTrue(launcher.isHidden)
|
||||
controller.webView(webview, didFailProvisionalNavigation: navigation,
|
||||
withError: NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet))
|
||||
XCTAssertFalse(launcher.isHidden)
|
||||
XCTAssertFalse(loading.isAnimating)
|
||||
XCTAssertEqual(try JSONDecoder().decode(WorkspaceServer.self, from: XCTUnwrap(defaults.data(forKey: "ios.activeServer"))), saved)
|
||||
controller.webView(webview, didFinish: navigation)
|
||||
XCTAssertFalse(launcher.isHidden, "A late finish must not hide recovery after a failure")
|
||||
controller.open(saved.url)
|
||||
XCTAssertTrue(launcher.isHidden)
|
||||
XCTAssertTrue(loading.isAnimating)
|
||||
XCTAssertEqual(controller.server?.profile, saved.profile)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testMissingOrInvalidConfigurationShowsSetupAndServerChangesAreRestored() throws {
|
||||
let name = "KAN69-setup-\(UUID())"
|
||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
||||
defer { defaults.removePersistentDomain(forName: name) }
|
||||
for data in [nil, Data("invalid".utf8), try JSONEncoder().encode(WorkspaceServer(url: URL(string: "http://example.com/")!, profile: UUID()))] {
|
||||
defaults.set(data, forKey: "ios.activeServer")
|
||||
let controller = WorkspaceViewController(defaults: defaults)
|
||||
controller.loadViewIfNeeded()
|
||||
XCTAssertNil(controller.workspace)
|
||||
let launcher = try XCTUnwrap(controller.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" })
|
||||
XCTAssertFalse(launcher.isHidden)
|
||||
}
|
||||
let controller = WorkspaceViewController(defaults: defaults)
|
||||
controller.loadViewIfNeeded()
|
||||
controller.open(URL(string: "https://first.invalid/")!)
|
||||
controller.open(URL(string: "https://second.invalid/app/")!)
|
||||
let restored = WorkspaceViewController(defaults: defaults)
|
||||
restored.loadViewIfNeeded()
|
||||
XCTAssertEqual(restored.server, controller.server)
|
||||
XCTAssertEqual(restored.server?.dashboardURL.absoluteString, "https://second.invalid/app/dashboard")
|
||||
XCTAssertTrue(restored.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" }?.isHidden == true)
|
||||
}
|
||||
|
||||
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 testGesturesTraverseEverySPAEntryOnceAndPreserveForwardHistory() async throws {
|
||||
let name = "KAN71-history-\(UUID())"
|
||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
||||
defer { defaults.removePersistentDomain(forName: name) }
|
||||
let controller = WorkspaceViewController(defaults: defaults)
|
||||
controller.loadViewIfNeeded()
|
||||
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||
let url = URL(string: "https://example.invalid/dashboard")!
|
||||
controller.open(URL(string: "https://example.invalid/")!)
|
||||
let webview = try XCTUnwrap(controller.workspace)
|
||||
webview.stopLoading()
|
||||
webview.frame = controller.view.bounds
|
||||
webview.loadSimulatedRequest(URLRequest(url: url), responseHTML: """
|
||||
<html><head><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
||||
<body style="margin:0"><p>Workspace</p><input id="draft" value="unsaved"></body></html>
|
||||
""")
|
||||
try await waitFor(webview, "document.querySelector('#draft')?.value === 'unsaved'")
|
||||
XCTAssertFalse(webview.canGoBack)
|
||||
controller.navigateHistory(-1, from: CGPoint(x: 1, y: 1))
|
||||
try await waitFor(webview, "location.pathname === '/dashboard'")
|
||||
for index in 1...3 {
|
||||
_ = try await webview.evaluateJavaScript("history.pushState({page: {url: '/page\(index)'}}, '', '/page\(index)')")
|
||||
}
|
||||
_ = try await webview.evaluateJavaScript("window.steps = 0; addEventListener('popstate', () => window.steps++)")
|
||||
let edge = TestEdgeGesture()
|
||||
edge.edges = .left
|
||||
edge.testVelocity = CGPoint(x: 800, y: 0)
|
||||
XCTAssertTrue(controller.gestureRecognizer(edge, shouldReceive: TestTouch()))
|
||||
var allowed = false
|
||||
for _ in 0..<50 {
|
||||
if controller.gestureRecognizerShouldBegin(edge) { allowed = true; break }
|
||||
try await Task.sleep(for: .milliseconds(20))
|
||||
}
|
||||
XCTAssertTrue(allowed)
|
||||
edge.testState = .cancelled
|
||||
edge.testTranslation = CGPoint(x: 200, y: 0)
|
||||
controller.perform(NSSelectorFromString("navigateGesture:"), with: edge)
|
||||
try await Task.sleep(for: .milliseconds(100))
|
||||
try await waitFor(webview, "location.pathname === '/page3' && window.steps === 0")
|
||||
edge.testState = .ended
|
||||
edge.testTranslation = CGPoint(x: 10, y: 0)
|
||||
controller.perform(NSSelectorFromString("navigateGesture:"), with: edge)
|
||||
try await Task.sleep(for: .milliseconds(100))
|
||||
try await waitFor(webview, "location.pathname === '/page3' && window.steps === 0")
|
||||
edge.testTranslation = CGPoint(x: 200, y: 0)
|
||||
controller.perform(NSSelectorFromString("navigateGesture:"), with: edge)
|
||||
try await waitFor(webview, "location.pathname === '/page2' && window.steps === 1")
|
||||
controller.navigateHistory(-1, from: CGPoint(x: 1, y: 1))
|
||||
try await waitFor(webview, "location.pathname === '/page1' && window.steps === 2")
|
||||
controller.navigateHistory(1, from: CGPoint(x: 1, y: 1))
|
||||
try await waitFor(webview, "location.pathname === '/page2' && window.steps === 3")
|
||||
let draft = try await webview.evaluateJavaScript("document.querySelector('#draft').value")
|
||||
XCTAssertEqual(draft as? String, "unsaved")
|
||||
XCTAssertFalse(webview.scrollView.bounces)
|
||||
XCTAssertFalse(webview.allowsBackForwardNavigationGestures)
|
||||
XCTAssertEqual(webview.gestureRecognizers?.compactMap { $0 as? UIScreenEdgePanGestureRecognizer }.count, 2)
|
||||
let toolbar = try XCTUnwrap(controller.view.subviews.compactMap { $0 as? UIToolbar }.first)
|
||||
XCTAssertFalse(toolbar.items!.contains { $0.accessibilityIdentifier == "workspaceBack" })
|
||||
XCTAssertTrue(toolbar.items!.contains { $0.accessibilityIdentifier == "showServerSettings" })
|
||||
let scroll = try await webview.evaluateJavaScript("getComputedStyle(document.documentElement).overscrollBehaviorY")
|
||||
XCTAssertEqual(scroll as? String, "none")
|
||||
let horizontal = try await webview.evaluateJavaScript("getComputedStyle(document.documentElement).overscrollBehaviorX")
|
||||
XCTAssertEqual(horizontal as? String, "auto")
|
||||
_ = try await webview.evaluateJavaScript("document.body.insertAdjacentHTML('beforeend', '<div role=dialog style=\"position:fixed;inset:0\">Dialog</div>')")
|
||||
controller.navigateHistory(-1, from: CGPoint(x: 1, y: 1))
|
||||
try await Task.sleep(for: .milliseconds(200))
|
||||
try await waitFor(webview, "location.pathname === '/page2' && window.steps === 3")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func waitFor(_ webview: WKWebView, _ expression: String) async throws {
|
||||
for _ in 0..<100 {
|
||||
if let result = try? await webview.evaluateJavaScript(expression), result as? Bool == true { return }
|
||||
try await Task.sleep(for: .milliseconds(50))
|
||||
}
|
||||
XCTFail("WebView condition timed out: \(expression)")
|
||||
}
|
||||
|
||||
func testServerChangesNeverReusePreviousProfile() throws {
|
||||
let firstURL = try XCTUnwrap(WorkspaceServer.normalize("https://example.com/"))
|
||||
let otherURL = try XCTUnwrap(WorkspaceServer.normalize("https://example.com/other/"))
|
||||
@@ -74,7 +230,7 @@ final class WorkspaceTests: XCTestCase {
|
||||
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)
|
||||
XCTAssertEqual(controller.workspace?.configuration.userContentController.userScripts.count, 1)
|
||||
let restored = WorkspaceViewController(defaults: defaults)
|
||||
restored.loadViewIfNeeded()
|
||||
XCTAssertEqual(restored.server, server)
|
||||
@@ -116,3 +272,19 @@ final class WorkspaceTests: XCTestCase {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private final class TestTouch: UITouch {
|
||||
override func location(in view: UIView?) -> CGPoint { CGPoint(x: 1, y: 1) }
|
||||
}
|
||||
|
||||
private final class TestEdgeGesture: UIScreenEdgePanGestureRecognizer {
|
||||
var testState: UIGestureRecognizer.State = .possible
|
||||
var testVelocity = CGPoint.zero
|
||||
var testTranslation = CGPoint.zero
|
||||
override var state: UIGestureRecognizer.State {
|
||||
get { testState }
|
||||
set { testState = newValue }
|
||||
}
|
||||
override func velocity(in view: UIView?) -> CGPoint { testVelocity }
|
||||
override func translation(in view: UIView?) -> CGPoint { testTranslation }
|
||||
}
|
||||
|
||||
@@ -13,11 +13,50 @@ final class WorkspaceUITests: XCTestCase {
|
||||
XCTAssertTrue(element.isHittable)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testSavedWorkspaceColdStartNeverShowsSelectionBeforeRecovery() {
|
||||
continueAfterFailure = false
|
||||
XCUIDevice.shared.orientation = .portrait
|
||||
defer { XCUIDevice.shared.orientation = .portrait }
|
||||
let app = XCUIApplication()
|
||||
app.launchEnvironment["OS_ACTIVITY_DT_MODE"] = "NO"
|
||||
app.launchEnvironment["OS_ACTIVITY_MODE"] = "disable"
|
||||
app.launch()
|
||||
let settings = app.buttons["showServerSettings"]
|
||||
if settings.waitForExistence(timeout: 3) { settings.tap() }
|
||||
let address = app.textFields["serverAddress"]
|
||||
XCTAssertTrue(address.waitForExistence(timeout: 20))
|
||||
address.tap()
|
||||
if address.buttons.firstMatch.exists { address.buttons.firstMatch.tap() }
|
||||
address.typeText("https://127.0.0.1:9/")
|
||||
app.buttons["openWorkspace"].tap()
|
||||
let confirmation = app.alerts.buttons["Switch server"]
|
||||
if confirmation.waitForExistence(timeout: 1) { confirmation.tap() }
|
||||
XCTAssertTrue(app.staticTexts["connectionStatus"].waitForExistence(timeout: 20))
|
||||
app.terminate()
|
||||
app.launch()
|
||||
// An unreachable saved server may fail immediately; only recovery may reveal setup.
|
||||
for _ in 0..<30 {
|
||||
if address.exists {
|
||||
XCTAssertTrue(app.staticTexts["connectionStatus"].label.contains("server did not finish loading"))
|
||||
break
|
||||
}
|
||||
XCTAssertFalse(app.staticTexts["Choose your workspace."].exists)
|
||||
Thread.sleep(forTimeInterval: 0.1)
|
||||
}
|
||||
XCTAssertTrue(address.waitForExistence(timeout: 20))
|
||||
XCTAssertEqual(address.value as? String, "https://127.0.0.1:9/")
|
||||
XCTAssertTrue(app.buttons["openWorkspace"].label.contains("Try again"))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testLauncherKeyboardValidationRotationAndAppearance() {
|
||||
continueAfterFailure = false
|
||||
XCUIDevice.shared.orientation = .portrait
|
||||
defer { XCUIDevice.shared.orientation = .portrait }
|
||||
let app = XCUIApplication()
|
||||
app.launchEnvironment["OS_ACTIVITY_DT_MODE"] = "NO"
|
||||
app.launchEnvironment["OS_ACTIVITY_MODE"] = "disable"
|
||||
app.launch()
|
||||
let settings = app.buttons["showServerSettings"]
|
||||
if settings.waitForExistence(timeout: 3) { settings.tap() }
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
009F8FEC2765B2F19DC12CA8 /* WorkspaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */; };
|
||||
0EC5CB2D7F774DBDCA69BCD8 /* workspace-navigation.js in Resources */ = {isa = PBXBuildFile; fileRef = 7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */; };
|
||||
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 */; };
|
||||
@@ -18,10 +19,12 @@
|
||||
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 */; };
|
||||
99291BB0C1DE7714FB32F554 /* workspace-navigation.js in Resources */ = {isa = PBXBuildFile; fileRef = 7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */; };
|
||||
A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C8C02BC0E757897278EB954A /* Localizable.strings */; };
|
||||
B939982700E800053A9C7663 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE3B71153677F722CAC6DCF7 /* Security.framework */; };
|
||||
C362C102D4950A81D89BB8C8 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */; };
|
||||
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 */; };
|
||||
@@ -45,14 +48,20 @@
|
||||
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>"; };
|
||||
5F71F06AB639140AB9714425 /* metadata.rs */ = {isa = PBXFileReference; path = metadata.rs; sourceTree = "<group>"; };
|
||||
63685B1189795CAEA85F7AB7 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; };
|
||||
6610D376CF901AC1733D394E /* updates.rs */ = {isa = PBXFileReference; path = updates.rs; sourceTree = "<group>"; };
|
||||
7440C7F0426E6B3977DD1D94 /* main.rs */ = {isa = PBXFileReference; path = main.rs; sourceTree = "<group>"; };
|
||||
7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "workspace-navigation.js"; sourceTree = "<group>"; };
|
||||
8243D0C3821DAC731FECF105 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; };
|
||||
8C0F963909020429B3D257D5 /* navigation.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = navigation.m; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
@@ -105,6 +114,7 @@
|
||||
8606F3EE5A736385061692EF /* Sources */,
|
||||
335B1262A9019C70C7F887A5 /* src */,
|
||||
140A7DB7E2461E64B1FBFA70 /* Tests */,
|
||||
709BE6DF191B953724A55555 /* ui */,
|
||||
3C9F31BB065F210267ECA390 /* UITests */,
|
||||
3028C3786B437115B6486F1C /* Frameworks */,
|
||||
59D2DE9FD94E6D2D64CB0117 /* Products */,
|
||||
@@ -137,12 +147,16 @@
|
||||
335B1262A9019C70C7F887A5 /* src */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9C56BE6890F0452F69D64795 /* android.rs */,
|
||||
05A753935B6CB0316DA56D96 /* desktop.rs */,
|
||||
CAF08AD60D2F29A879CDAEBB /* ios.rs */,
|
||||
2B96972F58CC158AB3DFF94A /* lib.rs */,
|
||||
AEBAD9ABE758897C50BC1F2E /* locale.rs */,
|
||||
7440C7F0426E6B3977DD1D94 /* main.rs */,
|
||||
5F71F06AB639140AB9714425 /* metadata.rs */,
|
||||
8C0F963909020429B3D257D5 /* navigation.m */,
|
||||
E8BA104C81071623E1FF367D /* server.rs */,
|
||||
6610D376CF901AC1733D394E /* updates.rs */,
|
||||
);
|
||||
name = src;
|
||||
path = ../../src;
|
||||
@@ -175,6 +189,15 @@
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
709BE6DF191B953724A55555 /* ui */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */,
|
||||
);
|
||||
name = ui;
|
||||
path = ../../../ui;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
8606F3EE5A736385061692EF /* Sources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -191,6 +214,7 @@
|
||||
554AB059B4228085924F4959 /* WorkspacePolicy.swift */,
|
||||
EE63F98563975EF4FF2A10FB /* WorkspaceViewController.swift */,
|
||||
2A11A26F0082A5075CD710C6 /* bindings */,
|
||||
2AB6F9398EA3CB62A68287D2 /* InfoPlist.strings */,
|
||||
C8C02BC0E757897278EB954A /* Localizable.strings */,
|
||||
);
|
||||
path = "kanbangit-native";
|
||||
@@ -242,6 +266,7 @@
|
||||
buildConfigurationList = 26F675D59EF454AE0D263F35 /* Build configuration list for PBXNativeTarget "WorkspaceTests" */;
|
||||
buildPhases = (
|
||||
2A0467365B852D44D1A9FD5B /* Sources */,
|
||||
DC3295DE0893151786FEA37B /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -264,12 +289,18 @@
|
||||
LastUpgradeCheck = 1430;
|
||||
TargetAttributes = {
|
||||
721CF221CEDDC28FF2C5EBD5 = {
|
||||
DevelopmentTeam = "";
|
||||
ProvisioningStyle = Automatic;
|
||||
DevelopmentTeam = ZZX2KZGTHU;
|
||||
ProvisioningStyle = Manual;
|
||||
};
|
||||
988CFF304CB81C8F3A4D6612 = {
|
||||
DevelopmentTeam = ZZX2KZGTHU;
|
||||
ProvisioningStyle = Manual;
|
||||
TestTargetID = 721CF221CEDDC28FF2C5EBD5;
|
||||
};
|
||||
CBC4561F8018D37DD49A0598 = {
|
||||
DevelopmentTeam = ZZX2KZGTHU;
|
||||
ProvisioningStyle = Manual;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = A9CA551DD3F3EBCCF8C41CC1 /* Build configuration list for PBXProject "kanbangit-native" */;
|
||||
@@ -299,8 +330,18 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
0FAB3CC7BB466BFF871C0C30 /* Assets.xcassets in Resources */,
|
||||
C53E7EC983C008DF98244C74 /* InfoPlist.strings in Resources */,
|
||||
34C189B72A1B9EBE33D700D1 /* LaunchScreen.storyboard in Resources */,
|
||||
A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */,
|
||||
99291BB0C1DE7714FB32F554 /* workspace-navigation.js in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
DC3295DE0893151786FEA37B /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
0EC5CB2D7F774DBDCA69BCD8 /* workspace-navigation.js in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -326,7 +367,7 @@
|
||||
);
|
||||
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";
|
||||
shellScript = "set -eu\nif [ -n \"${KANBANGIT_IOS_LIBRARY:-}\" ]; then\n mkdir -p \"$SRCROOT/Externals/arm64/$CONFIGURATION\"\n cp \"$KANBANGIT_IOS_LIBRARY\" \"$SRCROOT/Externals/arm64/$CONFIGURATION/libapp.a\"\n exit 0\nfi\nexport 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 */
|
||||
|
||||
@@ -370,6 +411,14 @@
|
||||
/* 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 = (
|
||||
@@ -390,9 +439,10 @@
|
||||
);
|
||||
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 = "";
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
|
||||
@@ -407,9 +457,10 @@
|
||||
);
|
||||
"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;
|
||||
MARKETING_VERSION = 0.2.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
|
||||
PRODUCT_MODULE_NAME = KanbanGitNative;
|
||||
PRODUCT_NAME = "KanbanGit";
|
||||
PRODUCT_NAME = KanbanGit;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
@@ -484,13 +535,15 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.uitests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios.uitests;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
@@ -563,9 +616,10 @@
|
||||
);
|
||||
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 = "";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
|
||||
@@ -580,9 +634,10 @@
|
||||
);
|
||||
"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;
|
||||
MARKETING_VERSION = 0.2.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
|
||||
PRODUCT_MODULE_NAME = KanbanGitNative;
|
||||
PRODUCT_NAME = "KanbanGit";
|
||||
PRODUCT_NAME = KanbanGit;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
@@ -594,13 +649,15 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.uitests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios.uitests;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
@@ -612,13 +669,15 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.tests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios.tests;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
@@ -629,13 +688,15 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.tests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios.tests;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
|
||||
@@ -15,11 +15,15 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.1.0</string>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.1.0</string>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: kanbangit-native
|
||||
options:
|
||||
bundleIdPrefix: de.kanbangit.desktop
|
||||
bundleIdPrefix: de.kanbangit.ios
|
||||
deploymentTarget:
|
||||
iOS: 17.0
|
||||
fileGroups: [../../src]
|
||||
@@ -14,10 +14,17 @@ settingGroups:
|
||||
SWIFT_VERSION: "5.0"
|
||||
PRODUCT_MODULE_NAME: KanbanGitNative
|
||||
TARGETED_DEVICE_FAMILY: "1,2"
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
DEVELOPMENT_TEAM: ""
|
||||
CODE_SIGN_STYLE: Manual
|
||||
DEVELOPMENT_TEAM: ZZX2KZGTHU
|
||||
ENABLE_USER_SCRIPT_SANDBOXING: false
|
||||
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.desktop
|
||||
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios
|
||||
MARKETING_VERSION: "0.2.4"
|
||||
CURRENT_PROJECT_VERSION: "4"
|
||||
configs:
|
||||
debug:
|
||||
CODE_SIGN_IDENTITY: Apple Development
|
||||
release:
|
||||
CODE_SIGN_IDENTITY: Apple Distribution
|
||||
targetTemplates:
|
||||
app:
|
||||
type: application
|
||||
@@ -35,12 +42,15 @@ targets:
|
||||
platform: iOS
|
||||
sources:
|
||||
- path: Sources
|
||||
- path: ../../../ui/workspace-navigation.js
|
||||
- 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]
|
||||
@@ -53,8 +63,8 @@ targets:
|
||||
- UIInterfaceOrientationPortraitUpsideDown
|
||||
- UIInterfaceOrientationLandscapeLeft
|
||||
- UIInterfaceOrientationLandscapeRight
|
||||
CFBundleShortVersionString: 0.1.0
|
||||
CFBundleVersion: "0.1.0"
|
||||
CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||
entitlements:
|
||||
path: kanbangit-native_iOS/kanbangit-native_iOS.entitlements
|
||||
scheme:
|
||||
@@ -86,10 +96,16 @@ targets:
|
||||
- sdk: WebKit.framework
|
||||
preBuildScripts:
|
||||
- script: |
|
||||
set -eu
|
||||
if [ -n "${KANBANGIT_IOS_LIBRARY:-}" ]; then
|
||||
mkdir -p "$SRCROOT/Externals/arm64/$CONFIGURATION"
|
||||
cp "$KANBANGIT_IOS_LIBRARY" "$SRCROOT/Externals/arm64/$CONFIGURATION/libapp.a"
|
||||
exit 0
|
||||
fi
|
||||
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:?}
|
||||
./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:
|
||||
@@ -99,6 +115,7 @@ targets:
|
||||
type: bundle.unit-test
|
||||
platform: iOS
|
||||
sources:
|
||||
- path: ../../../ui/workspace-navigation.js
|
||||
- path: Tests
|
||||
- path: Sources/kanbangit-native/WorkspacePolicy.swift
|
||||
- path: Sources/kanbangit-native/WorkspaceViewController.swift
|
||||
@@ -106,7 +123,9 @@ targets:
|
||||
settings:
|
||||
base:
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.tests
|
||||
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios.tests
|
||||
DEVELOPMENT_TEAM: ZZX2KZGTHU
|
||||
CODE_SIGN_STYLE: Manual
|
||||
SWIFT_VERSION: "5.0"
|
||||
WorkspaceUITests:
|
||||
type: bundle.ui-testing
|
||||
@@ -117,6 +136,8 @@ targets:
|
||||
settings:
|
||||
base:
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.uitests
|
||||
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios.uitests
|
||||
DEVELOPMENT_TEAM: ZZX2KZGTHU
|
||||
CODE_SIGN_STYLE: Manual
|
||||
SWIFT_VERSION: "5.0"
|
||||
TEST_TARGET_NAME: kanbangit-native_iOS
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
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, "launcher", WebviewUrl::App("index.html".into()))
|
||||
.use_https_scheme(true)
|
||||
.on_navigation(|url| {
|
||||
url.scheme() == "https"
|
||||
&& url.host_str() == Some("tauri.localhost")
|
||||
&& url.port().is_none()
|
||||
&& url.username().is_empty()
|
||||
&& url.password().is_none()
|
||||
&& matches!(url.path(), "/" | "/index.html")
|
||||
})
|
||||
.build()?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -2,7 +2,7 @@ use crate::locale::en;
|
||||
use crate::server::{validate_url, Server, DEFAULT_SERVER};
|
||||
use std::{path::PathBuf, sync::Mutex, time::Duration};
|
||||
use tauri::{
|
||||
menu::{MenuBuilder, SubmenuBuilder},
|
||||
menu::{AboutMetadata, MenuBuilder, MenuItem, SubmenuBuilder},
|
||||
webview::{NewWindowResponse, PageLoadEvent},
|
||||
AppHandle, Manager, Url, WebviewUrl, WebviewWindowBuilder, WindowEvent,
|
||||
};
|
||||
@@ -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,21 +166,36 @@ 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 window = WebviewWindowBuilder::new(app, &label, WebviewUrl::External(server.url))
|
||||
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.dashboard_url()))
|
||||
.title(en::APP_NAME)
|
||||
.inner_size(1280.0, 850.0)
|
||||
.min_inner_size(375.0, 600.0)
|
||||
.center()
|
||||
.visible(false)
|
||||
.data_store_identifier(server.profile)
|
||||
.initialization_script(include_str!("../../ui/workspace-navigation.js"))
|
||||
.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;
|
||||
@@ -169,7 +204,12 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
|
||||
let state = app.state::<Mutex<Connection>>();
|
||||
let should_show = {
|
||||
let mut state = state.lock().unwrap();
|
||||
if state.label.as_deref() != Some(window.label()) {
|
||||
if state.label.as_deref() != Some(window.label())
|
||||
|| state.phase != "connecting"
|
||||
|| !state.server.as_ref().is_some_and(|server| {
|
||||
allows_server_navigation(&server.url, payload.url())
|
||||
})
|
||||
{
|
||||
return;
|
||||
}
|
||||
let first_load = !state.ready;
|
||||
@@ -177,7 +217,6 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
|
||||
state.phase = "connected";
|
||||
first_load
|
||||
};
|
||||
update_launcher(app);
|
||||
if should_show {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
@@ -185,27 +224,32 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
|
||||
let _ = launcher.hide();
|
||||
}
|
||||
}
|
||||
update_launcher(app);
|
||||
})
|
||||
.build()
|
||||
.map_err(|_| "connectionError")?;
|
||||
#[cfg(target_os = "macos")]
|
||||
window
|
||||
.with_webview(|webview| unsafe {
|
||||
unsafe extern "C" {
|
||||
fn install_workspace_navigation(webview: *mut std::ffi::c_void);
|
||||
}
|
||||
install_workspace_navigation(webview.inner().cast());
|
||||
})
|
||||
.map_err(|_| "connectionError")?;
|
||||
let handle = app.clone();
|
||||
window.on_window_event(move |event| {
|
||||
if matches!(event, WindowEvent::Destroyed) {
|
||||
let state = handle.state::<Mutex<Connection>>();
|
||||
let current = {
|
||||
let mut state = state.lock().unwrap();
|
||||
if state.label.as_deref() == Some(&label) {
|
||||
state.label = None;
|
||||
state.ready = false;
|
||||
state.revision += 1;
|
||||
state.phase = "idle";
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||
let current = handle
|
||||
.state::<Mutex<Connection>>()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.label
|
||||
.as_deref()
|
||||
== Some(&label);
|
||||
if current {
|
||||
show_launcher(&handle);
|
||||
api.prevent_close();
|
||||
handle.exit(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -214,6 +258,11 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
|
||||
|
||||
fn handle_launcher_action(app: &AppHandle, destination: &Url) {
|
||||
let result = match destination.path() {
|
||||
"/action/updates" => {
|
||||
#[cfg(target_os = "macos")]
|
||||
crate::updates::show(app);
|
||||
Ok(())
|
||||
}
|
||||
"/action/open" => {
|
||||
let input = destination
|
||||
.query_pairs()
|
||||
@@ -247,12 +296,24 @@ pub fn context<R: tauri::Runtime>() -> tauri::Context<R> {
|
||||
tauri::generate_context!()
|
||||
}
|
||||
|
||||
fn about_metadata(version: String) -> AboutMetadata<'static> {
|
||||
AboutMetadata {
|
||||
name: Some(en::APP_NAME.into()),
|
||||
version: Some(version),
|
||||
#[cfg(target_os = "macos")]
|
||||
short_version: Some(crate::metadata::build_number().into()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn builder() -> tauri::Builder<tauri::Wry> {
|
||||
tauri::Builder::default().setup(|app| {
|
||||
let (server, phase) = match Server::load(&configuration_path(app.handle())?) {
|
||||
Ok(server) => (server, "idle"),
|
||||
Ok(Some(server)) => (Some(server), "connecting"),
|
||||
Ok(None) => (None, "idle"),
|
||||
Err(_) => (None, "storageError"),
|
||||
};
|
||||
let startup_server = server.clone();
|
||||
app.manage(Mutex::new(Connection {
|
||||
server,
|
||||
phase,
|
||||
@@ -260,7 +321,20 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
|
||||
}));
|
||||
let menu = MenuBuilder::new(app)
|
||||
.item(
|
||||
&SubmenuBuilder::new(app, en::APP_NAME)
|
||||
&SubmenuBuilder::with_id(app, "app-menu", en::APP_NAME)
|
||||
.about_with_text(
|
||||
en::ABOUT,
|
||||
Some(about_metadata(app.package_info().version.to_string())),
|
||||
)
|
||||
.text("check-updates", en::CHECK_UPDATES)
|
||||
.item(&MenuItem::with_id(
|
||||
app,
|
||||
"install-update",
|
||||
en::INSTALL_UPDATE,
|
||||
false,
|
||||
None::<&str>,
|
||||
)?)
|
||||
.separator()
|
||||
.text("server-settings", en::SERVER_SETTINGS)
|
||||
.separator()
|
||||
.quit()
|
||||
@@ -277,11 +351,45 @@ 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)?;
|
||||
#[cfg(target_os = "macos")]
|
||||
crate::updates::setup(app.handle())?;
|
||||
app.on_menu_event(|app, event| {
|
||||
#[cfg(target_os = "macos")]
|
||||
match event.id().as_ref() {
|
||||
"check-updates" => {
|
||||
crate::updates::check(app, true);
|
||||
return;
|
||||
}
|
||||
"install-update" => {
|
||||
crate::updates::show(app);
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
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();
|
||||
@@ -290,6 +398,7 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
|
||||
.title(en::APP_NAME)
|
||||
.inner_size(560.0, 720.0)
|
||||
.min_inner_size(375.0, 640.0)
|
||||
.visible(false)
|
||||
.center()
|
||||
.on_navigation(move |destination| {
|
||||
if !allows_launcher_navigation(destination) {
|
||||
@@ -310,35 +419,43 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
|
||||
if payload.event() == PageLoadEvent::Finished {
|
||||
let app = window.app_handle().clone();
|
||||
update_launcher(&app);
|
||||
std::thread::spawn(move || {
|
||||
let handle = app.clone();
|
||||
let _ = app.run_on_main_thread(move || {
|
||||
let server = handle
|
||||
#[cfg(target_os = "macos")]
|
||||
crate::updates::refresh(&app);
|
||||
let needs_setup = app
|
||||
.state::<Mutex<Connection>>()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.server
|
||||
.clone();
|
||||
if let Some(server) = server {
|
||||
if let Err(error) = open_workspace(&handle, server.url.as_str())
|
||||
{
|
||||
handle.state::<Mutex<Connection>>().lock().unwrap().phase =
|
||||
error;
|
||||
show_launcher(&handle);
|
||||
.is_none();
|
||||
if needs_setup {
|
||||
show_launcher(&app);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.build()?;
|
||||
let window = launcher.clone();
|
||||
launcher.on_window_event(move |event| {
|
||||
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||
api.prevent_close();
|
||||
let has_workspace = window
|
||||
.app_handle()
|
||||
.state::<Mutex<Connection>>()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.ready;
|
||||
if has_workspace {
|
||||
let _ = window.hide();
|
||||
} else {
|
||||
window.app_handle().exit(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(server) = startup_server {
|
||||
if let Err(error) = open_workspace(app.handle(), server.url.as_str()) {
|
||||
app.state::<Mutex<Connection>>().lock().unwrap().phase = error;
|
||||
show_launcher(app.handle());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -347,6 +464,38 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn about_shows_app_version_followed_by_the_actual_build_number() {
|
||||
let metadata = about_metadata("9.8.7".into());
|
||||
assert_eq!(metadata.version.as_deref(), Some("9.8.7"));
|
||||
assert_eq!(
|
||||
metadata.short_version.as_deref(),
|
||||
Some(crate::metadata::build_number())
|
||||
);
|
||||
assert_ne!(metadata.version, metadata.short_version);
|
||||
}
|
||||
|
||||
#[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();
|
||||
@@ -409,7 +558,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn remote_pages_cannot_invoke_native_commands() {
|
||||
let app = tauri::test::mock_builder().build(context()).unwrap();
|
||||
let builder = tauri::test::mock_builder();
|
||||
#[cfg(target_os = "macos")]
|
||||
let builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
|
||||
let app = builder.build(context()).unwrap();
|
||||
let window = WebviewWindowBuilder::new(
|
||||
&app,
|
||||
"workspace-1",
|
||||
@@ -418,6 +570,8 @@ mod tests {
|
||||
.build()
|
||||
.unwrap();
|
||||
for command in [
|
||||
"plugin:updater|check",
|
||||
"plugin:updater|download_and_install",
|
||||
"plugin:window|close",
|
||||
"plugin:webview|create_webview_window",
|
||||
"plugin:path|resolve_directory",
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
#[cfg(target_os = "android")]
|
||||
mod android;
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
mod desktop;
|
||||
#[cfg(target_os = "ios")]
|
||||
mod ios;
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
mod locale;
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
#[cfg(target_os = "macos")]
|
||||
mod metadata;
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
mod server;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod updates;
|
||||
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
#[cfg(target_os = "android")]
|
||||
pub use android::{builder, context};
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub use desktop::{builder, context};
|
||||
#[cfg(target_os = "ios")]
|
||||
pub use ios::{builder, context};
|
||||
|
||||
#[cfg(all(target_os = "macos", feature = "smoke-test"))]
|
||||
pub use updates::refresh_menu as refresh_update_menu_for_smoke_test;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
builder().run(context()).expect("Could not start KanbanGit");
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
pub mod en {
|
||||
pub const ABOUT: &str = "About KanbanGit";
|
||||
pub const CHECK_UPDATES: &str = "Check For Updates…";
|
||||
pub const INSTALL_UPDATE: &str = "Install Update…";
|
||||
pub const UPDATES: &str = "KanbanGit Updates";
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
use std::{path::Path, sync::LazyLock};
|
||||
|
||||
fn build_from_executable(executable: &Path) -> Option<String> {
|
||||
let macos = executable.parent()?;
|
||||
let contents = macos.parent()?;
|
||||
if macos.file_name()? != "MacOS" || contents.file_name()? != "Contents" {
|
||||
return None;
|
||||
}
|
||||
let output = std::process::Command::new("/usr/bin/plutil")
|
||||
.args(["-extract", "CFBundleVersion", "raw", "-o", "-"])
|
||||
.arg(contents.join("Info.plist"))
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return Some("unknown".into());
|
||||
}
|
||||
let build = String::from_utf8(output.stdout).ok()?;
|
||||
Some(build.trim().to_owned())
|
||||
}
|
||||
|
||||
pub fn build_number() -> &'static str {
|
||||
static BUILD: LazyLock<String> = LazyLock::new(|| {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|executable| build_from_executable(&executable))
|
||||
.unwrap_or_else(|| {
|
||||
// Tauri omits macOS bundle settings from its generated runtime config.
|
||||
let config: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
|
||||
config["bundle"]["macOS"]["bundleVersion"]
|
||||
.as_str()
|
||||
.unwrap_or("unknown")
|
||||
.to_owned()
|
||||
})
|
||||
});
|
||||
&BUILD
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn installed_build_comes_from_the_app_plist_instead_of_the_app_version() {
|
||||
let root = std::env::temp_dir().join(format!("kanbangit-build-{}", std::process::id()));
|
||||
let contents = root.join("KanbanGit.app/Contents");
|
||||
std::fs::create_dir_all(contents.join("MacOS")).unwrap();
|
||||
let executable = contents.join("MacOS/kanbangit-native");
|
||||
std::fs::write(contents.join("Info.plist"), r#"<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict><key>CFBundleShortVersionString</key><string>0.1.3</string><key>CFBundleVersion</key><string>42</string></dict></plist>"#).unwrap();
|
||||
assert_eq!(build_from_executable(&executable).as_deref(), Some("42"));
|
||||
std::fs::remove_file(contents.join("Info.plist")).unwrap();
|
||||
assert_eq!(
|
||||
build_from_executable(&executable).as_deref(),
|
||||
Some("unknown")
|
||||
);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbundled_execution_uses_the_configured_build() {
|
||||
assert!(build_from_executable(Path::new("/tmp/target/debug/kanbangit-native")).is_none());
|
||||
let config: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
|
||||
assert_eq!(
|
||||
build_number(),
|
||||
config["bundle"]["macOS"]["bundleVersion"].as_str().unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
#import <AppKit/AppKit.h>
|
||||
#import <WebKit/WebKit.h>
|
||||
#import <objc/runtime.h>
|
||||
|
||||
// Keep AppKit's system swipe preferences and cancellation thresholds, but use
|
||||
// script history traversal so asynchronously created Inertia entries aren't skipped.
|
||||
@interface KanbanNavigation : NSObject
|
||||
@property(nonatomic, weak) WKWebView *webview;
|
||||
@property(nonatomic, strong) id monitor;
|
||||
@property(nonatomic) BOOL pending;
|
||||
@property(nonatomic) BOOL tracking;
|
||||
@property(nonatomic) BOOL blocked;
|
||||
@property(nonatomic) NSUInteger sequence;
|
||||
@end
|
||||
|
||||
@implementation KanbanNavigation
|
||||
- (BOOL)swipeTrackingEnabled {
|
||||
return NSEvent.isSwipeTrackingFromScrollEventsEnabled;
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
if (_monitor) [NSEvent removeMonitor:_monitor];
|
||||
}
|
||||
|
||||
- (NSEvent *)handleEvent:(NSEvent *)event {
|
||||
WKWebView *webview = self.webview;
|
||||
if (!webview || event.window != webview.window || !webview.window.isKeyWindow ||
|
||||
webview.hidden || ![self swipeTrackingEnabled]) return event;
|
||||
if (event.phase == NSEventPhaseBegan) {
|
||||
self.sequence++;
|
||||
self.blocked = NO;
|
||||
self.pending = NO;
|
||||
self.tracking = NO;
|
||||
}
|
||||
if (self.tracking) return nil;
|
||||
if (event.phase == NSEventPhaseEnded || event.phase == NSEventPhaseCancelled) {
|
||||
self.sequence++;
|
||||
self.pending = NO;
|
||||
return event;
|
||||
}
|
||||
if (self.blocked || self.pending || event.momentumPhase != NSEventPhaseNone ||
|
||||
!(event.phase & (NSEventPhaseBegan | NSEventPhaseChanged)) || !event.hasPreciseScrollingDeltas) return event;
|
||||
if (fabs(event.scrollingDeltaX) <= fabs(event.scrollingDeltaY)) {
|
||||
if (fabs(event.scrollingDeltaY) > 0) self.blocked = YES;
|
||||
return event;
|
||||
}
|
||||
NSPoint point = [webview convertPoint:event.locationInWindow fromView:nil];
|
||||
if (!NSPointInRect(point, webview.bounds)) return event;
|
||||
if (!webview.isFlipped) point.y = webview.bounds.size.height - point.y;
|
||||
NSString *script = [NSString stringWithFormat:@"window.__kanbanNavigation?.canSwipe(%f, %f) === true", point.x, point.y];
|
||||
self.pending = YES;
|
||||
NSUInteger sequence = self.sequence;
|
||||
NSURL *page = webview.URL;
|
||||
__weak KanbanNavigation *weakSelf = self;
|
||||
[webview evaluateJavaScript:script completionHandler:^(id allowed, NSError *error) {
|
||||
KanbanNavigation *owner = weakSelf;
|
||||
if (!owner || owner.sequence != sequence) return;
|
||||
owner.pending = NO;
|
||||
owner.blocked = YES;
|
||||
if (error || ![allowed isEqual:@YES] || ![webview.URL isEqual:page] ||
|
||||
(!webview.canGoBack && !webview.canGoForward)) return;
|
||||
owner.tracking = YES;
|
||||
[event trackSwipeEventWithOptions:NSEventSwipeTrackingLockDirection | NSEventSwipeTrackingClampGestureAmount
|
||||
dampenAmountThresholdMin:webview.canGoForward ? -1 : 0
|
||||
max:webview.canGoBack ? 1 : 0
|
||||
usingHandler:^(CGFloat amount, NSEventPhase phase, BOOL complete, BOOL *stop) {
|
||||
KanbanNavigation *current = weakSelf;
|
||||
if (current.sequence != sequence) {
|
||||
*stop = YES;
|
||||
return;
|
||||
}
|
||||
if (!current || !webview.window.isKeyWindow || ![webview.URL isEqual:page]) {
|
||||
current.tracking = NO;
|
||||
*stop = YES;
|
||||
return;
|
||||
}
|
||||
if (!complete) return;
|
||||
current.tracking = NO;
|
||||
if (phase == NSEventPhaseCancelled) return;
|
||||
NSInteger direction = amount >= 1 ? -1 : amount <= -1 ? 1 : 0;
|
||||
if ((direction == -1 && webview.canGoBack) || (direction == 1 && webview.canGoForward)) {
|
||||
NSString *navigate = [NSString stringWithFormat:@"if (%@) window.__kanbanNavigation.step(%ld)", script, (long)direction];
|
||||
[webview evaluateJavaScript:navigate completionHandler:nil];
|
||||
}
|
||||
}];
|
||||
}];
|
||||
return event;
|
||||
}
|
||||
@end
|
||||
|
||||
void install_workspace_navigation(void *pointer) {
|
||||
WKWebView *webview = (__bridge WKWebView *)pointer;
|
||||
webview.allowsBackForwardNavigationGestures = NO;
|
||||
KanbanNavigation *navigation = [KanbanNavigation new];
|
||||
navigation.webview = webview;
|
||||
__weak KanbanNavigation *weakNavigation = navigation;
|
||||
navigation.monitor = [NSEvent addLocalMonitorForEventsMatchingMask:NSEventMaskScrollWheel handler:^NSEvent *(NSEvent *event) {
|
||||
KanbanNavigation *owner = weakNavigation;
|
||||
return owner ? [owner handleEvent:event] : event;
|
||||
}];
|
||||
objc_setAssociatedObject(webview, @selector(handleEvent:), navigation, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
||||
}
|
||||
@@ -37,6 +37,12 @@ pub fn validate_url(input: &str) -> Result<Url, &'static str> {
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub fn dashboard_url(&self) -> Url {
|
||||
self.url
|
||||
.join("dashboard")
|
||||
.expect("Validated installation URL")
|
||||
}
|
||||
|
||||
pub fn new(url: Url) -> io::Result<Self> {
|
||||
let mut profile = [0; 16];
|
||||
fs::File::open("/dev/urandom")?.read_exact(&mut profile)?;
|
||||
@@ -111,6 +117,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_targets_dashboard_within_the_installation() {
|
||||
for (base, expected) in [
|
||||
("https://example.com/", "https://example.com/dashboard"),
|
||||
(
|
||||
"https://example.com:8443/kanban/",
|
||||
"https://example.com:8443/kanban/dashboard",
|
||||
),
|
||||
] {
|
||||
let server = Server::new(validate_url(base).unwrap()).unwrap();
|
||||
assert_eq!(server.dashboard_url().as_str(), expected);
|
||||
assert_eq!(server.url.as_str(), base);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_round_trip_preserves_only_the_address_and_profile() {
|
||||
let server = Server::new(validate_url(DEFAULT_SERVER).unwrap()).unwrap();
|
||||
|
||||
@@ -0,0 +1,802 @@
|
||||
use semver::Version;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
fs,
|
||||
io::Read,
|
||||
path::Path,
|
||||
sync::Mutex,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tauri::{
|
||||
webview::{NewWindowResponse, PageLoadEvent},
|
||||
AppHandle, Manager, Url, WebviewUrl, WebviewWindowBuilder,
|
||||
};
|
||||
use tauri_plugin_updater::{Update, UpdaterExt};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Source {
|
||||
base_url: String,
|
||||
public_key: String,
|
||||
}
|
||||
fn source() -> &'static Source {
|
||||
static SOURCE: std::sync::LazyLock<Source> = std::sync::LazyLock::new(|| {
|
||||
serde_json::from_str(include_str!("../../update-source.json"))
|
||||
.expect("Bundled update source must be valid")
|
||||
});
|
||||
&SOURCE
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Preferences {
|
||||
automatic: bool,
|
||||
interval_hours: u64,
|
||||
}
|
||||
impl Default for Preferences {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
automatic: true,
|
||||
interval_hours: 6,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Preferences {
|
||||
fn valid(&self) -> bool {
|
||||
[1, 6, 24].contains(&self.interval_hours)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct View {
|
||||
phase: &'static str,
|
||||
current_version: String,
|
||||
build: String,
|
||||
version: Option<String>,
|
||||
notes: Option<String>,
|
||||
downloaded: u64,
|
||||
total: Option<u64>,
|
||||
error: Option<&'static str>,
|
||||
preferences: Preferences,
|
||||
}
|
||||
struct State {
|
||||
view: View,
|
||||
update: Option<Update>,
|
||||
last_check: Option<Instant>,
|
||||
}
|
||||
impl State {
|
||||
fn busy(&self) -> bool {
|
||||
matches!(
|
||||
self.view.phase,
|
||||
"checking" | "downloading" | "verifying" | "installing" | "restarting"
|
||||
)
|
||||
}
|
||||
fn begin_check(&mut self, manual: bool, now: Instant) -> bool {
|
||||
if self.busy()
|
||||
|| (!manual
|
||||
&& (!self.view.preferences.automatic
|
||||
|| self.last_check.is_some_and(|last| {
|
||||
now.duration_since(last)
|
||||
< Duration::from_secs(self.view.preferences.interval_hours * 3600)
|
||||
})))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.last_check = Some(now);
|
||||
self.view.phase = "checking";
|
||||
self.view.error = None;
|
||||
self.update = None;
|
||||
self.view.version = None;
|
||||
self.view.notes = None;
|
||||
true
|
||||
}
|
||||
}
|
||||
fn target() -> String {
|
||||
format!("{}-apple-darwin", std::env::consts::ARCH)
|
||||
}
|
||||
fn feed_url() -> Url {
|
||||
Url::parse(&format!("{}/{}/latest.json", source().base_url, target())).unwrap()
|
||||
}
|
||||
fn newer_stable(current: &str, candidate: &str) -> bool {
|
||||
match (Version::parse(current), Version::parse(candidate)) {
|
||||
(Ok(current), Ok(candidate)) => {
|
||||
candidate.pre.is_empty() && candidate.build.is_empty() && candidate > current
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
fn valid_download(current: &str, version: &str, url: &Url) -> bool {
|
||||
newer_stable(current, version)
|
||||
&& url.as_str()
|
||||
== format!(
|
||||
"{}/{}/{version}/KanbanGit-{version}-{}.app.tar.gz",
|
||||
source().base_url,
|
||||
target(),
|
||||
target()
|
||||
)
|
||||
}
|
||||
fn trusted_navigation(url: &Url) -> bool {
|
||||
url.scheme() == "tauri"
|
||||
&& url.host_str() == Some("localhost")
|
||||
&& url.port().is_none()
|
||||
&& url.username().is_empty()
|
||||
&& url.password().is_none()
|
||||
}
|
||||
pub fn refresh_menu(app: &AppHandle, available: bool, busy: bool) {
|
||||
if let Some(menu) = app
|
||||
.menu()
|
||||
.and_then(|menu| menu.get("app-menu"))
|
||||
.and_then(|item| {
|
||||
item.as_submenu()
|
||||
.and_then(|menu| menu.get("install-update"))
|
||||
})
|
||||
{
|
||||
if let Some(item) = menu.as_menuitem() {
|
||||
let _ = item.set_enabled(available && !busy);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn refresh(app: &AppHandle) {
|
||||
let (view, available, busy) = {
|
||||
let state = app.state::<Mutex<State>>();
|
||||
let state = state.lock().unwrap();
|
||||
(state.view.clone(), state.update.is_some(), state.busy())
|
||||
};
|
||||
refresh_menu(app, available, busy);
|
||||
if let Some(window) = app.get_webview_window("updates") {
|
||||
if let Ok(json) = serde_json::to_string(&view) {
|
||||
let _ = window.eval(format!("window.updateStatus?.({json})"));
|
||||
}
|
||||
}
|
||||
if let Some(window) = app.get_webview_window("launcher") {
|
||||
let _ = window.eval(format!("window.updateAvailable?.({available})"));
|
||||
}
|
||||
}
|
||||
pub fn show(app: &AppHandle) {
|
||||
if let Some(window) = app.get_webview_window("updates") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
refresh(app);
|
||||
}
|
||||
fn fail(app: &AppHandle, message: &'static str) {
|
||||
{
|
||||
let state = app.state::<Mutex<State>>();
|
||||
let mut state = state.lock().unwrap();
|
||||
state.view.phase = "error";
|
||||
state.view.error = Some(message);
|
||||
}
|
||||
refresh(app);
|
||||
}
|
||||
fn update_error(error: &tauri_plugin_updater::Error) -> &'static str {
|
||||
use tauri_plugin_updater::Error;
|
||||
match error {
|
||||
Error::Minisign(_)
|
||||
| Error::Base64(_)
|
||||
| Error::SignatureUtf8(_)
|
||||
| Error::SignedVersionMismatch { .. }
|
||||
| Error::MissingSignedVersion => "signatureError",
|
||||
Error::Serialization(_)
|
||||
| Error::Semver(_)
|
||||
| Error::TargetsNotFound(_)
|
||||
| Error::TargetNotFound(_) => "metadataError",
|
||||
_ => "networkError",
|
||||
}
|
||||
}
|
||||
pub fn check(app: &AppHandle, manual: bool) {
|
||||
if manual {
|
||||
show(app);
|
||||
}
|
||||
let begin = app
|
||||
.state::<Mutex<State>>()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.begin_check(manual, Instant::now());
|
||||
if !begin {
|
||||
return;
|
||||
}
|
||||
refresh(app);
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let result = async {
|
||||
app.updater_builder()
|
||||
.pubkey(&source().public_key)
|
||||
.endpoints(vec![feed_url()])?
|
||||
.timeout(Duration::from_secs(60))
|
||||
.configure_client(|client| client.https_only(true))
|
||||
.version_comparator(|current, release| {
|
||||
newer_stable(¤t.to_string(), &release.version.to_string())
|
||||
})
|
||||
.build()?
|
||||
.check()
|
||||
.await
|
||||
}
|
||||
.await;
|
||||
match result {
|
||||
Ok(Some(mut update)) => {
|
||||
if !valid_download(
|
||||
&update.current_version,
|
||||
&update.version,
|
||||
&update.download_url,
|
||||
) || update.signature.trim().is_empty()
|
||||
{
|
||||
fail(&app, "metadataError");
|
||||
return;
|
||||
}
|
||||
update.timeout = Some(Duration::from_secs(600));
|
||||
let state = app.state::<Mutex<State>>();
|
||||
let mut state = state.lock().unwrap();
|
||||
state.view.phase = "available";
|
||||
state.view.version = Some(update.version.clone());
|
||||
state.view.notes = update.body.clone();
|
||||
state.update = Some(update);
|
||||
}
|
||||
Ok(None) => app.state::<Mutex<State>>().lock().unwrap().view.phase = "current",
|
||||
Err(error) => {
|
||||
fail(&app, update_error(&error));
|
||||
return;
|
||||
}
|
||||
}
|
||||
refresh(&app);
|
||||
});
|
||||
}
|
||||
fn command(program: &str, args: &[&str]) -> Result<String, &'static str> {
|
||||
let output = std::process::Command::new(program)
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|_| "verificationError")?;
|
||||
if !output.status.success() {
|
||||
return Err("verificationError");
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
|
||||
}
|
||||
fn matches_architecture(header: &[u8]) -> bool {
|
||||
let expected_cpu: u32 = if cfg!(target_arch = "aarch64") {
|
||||
0x0100_000c
|
||||
} else {
|
||||
0x0100_0007
|
||||
};
|
||||
header.len() >= 8
|
||||
&& header[..4] == [0xcf, 0xfa, 0xed, 0xfe]
|
||||
&& header[4..8] == expected_cpu.to_le_bytes()
|
||||
}
|
||||
fn verify_bundle(archive: &Path, directory: &Path, version: &str) -> Result<(), &'static str> {
|
||||
command(
|
||||
"/usr/bin/tar",
|
||||
&[
|
||||
"-xzf",
|
||||
archive.to_str().ok_or("verificationError")?,
|
||||
"-C",
|
||||
directory.to_str().ok_or("verificationError")?,
|
||||
],
|
||||
)?;
|
||||
let app = directory.join("KanbanGit.app");
|
||||
let plist = app.join("Contents/Info.plist");
|
||||
let plist = plist.to_str().ok_or("verificationError")?;
|
||||
for (field, expected) in [
|
||||
("CFBundleShortVersionString", version),
|
||||
("CFBundleIdentifier", "de.kanbangit.desktop"),
|
||||
] {
|
||||
if command(
|
||||
"/usr/bin/plutil",
|
||||
&["-extract", field, "raw", "-o", "-", plist],
|
||||
)? != expected
|
||||
{
|
||||
return Err("verificationError");
|
||||
}
|
||||
}
|
||||
let executable = app.join("Contents/MacOS/kanbangit-native");
|
||||
let mut header = [0u8; 8];
|
||||
fs::File::open(executable)
|
||||
.and_then(|mut file| file.read_exact(&mut header))
|
||||
.map_err(|_| "verificationError")?;
|
||||
if !matches_architecture(&header) {
|
||||
return Err("verificationError");
|
||||
}
|
||||
command("/usr/bin/codesign", &["--verify", "--deep", "--strict", "-R", "=anchor apple generic and identifier \"de.kanbangit.desktop\" and certificate leaf[subject.OU] = \"ZZX2KZGTHU\" and certificate leaf[field.1.2.840.113635.100.6.1.13] exists", app.to_str().ok_or("verificationError")?])?;
|
||||
Ok(())
|
||||
}
|
||||
fn install(app: &AppHandle, confirmed_version: Option<&str>) {
|
||||
let update = {
|
||||
let state = app.state::<Mutex<State>>();
|
||||
let mut state = state.lock().unwrap();
|
||||
if state.busy() {
|
||||
return;
|
||||
}
|
||||
let Some(update) = state.update.clone() else {
|
||||
return;
|
||||
};
|
||||
if confirmed_version != Some(update.version.as_str()) {
|
||||
return;
|
||||
}
|
||||
state.view.phase = "downloading";
|
||||
state.view.error = None;
|
||||
state.view.downloaded = 0;
|
||||
state.view.total = None;
|
||||
update
|
||||
};
|
||||
refresh(app);
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let bytes = update
|
||||
.download(
|
||||
|chunk, total| {
|
||||
{
|
||||
let state = app.state::<Mutex<State>>();
|
||||
let mut state = state.lock().unwrap();
|
||||
state.view.downloaded += chunk as u64;
|
||||
state.view.total = total;
|
||||
}
|
||||
refresh(&app);
|
||||
},
|
||||
|| {
|
||||
app.state::<Mutex<State>>().lock().unwrap().view.phase = "verifying";
|
||||
refresh(&app);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let bytes = match bytes {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => {
|
||||
fail(&app, update_error(&error));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let handle = app.clone();
|
||||
let result = tauri::async_runtime::spawn_blocking(move || -> Result<(), &'static str> {
|
||||
let cache = handle
|
||||
.path()
|
||||
.app_cache_dir()
|
||||
.map_err(|_| "installationError")?
|
||||
.join("verified-update");
|
||||
if cache.exists() {
|
||||
fs::remove_dir_all(&cache).map_err(|_| "installationError")?;
|
||||
}
|
||||
fs::create_dir_all(&cache).map_err(|_| "installationError")?;
|
||||
let archive = cache.join("update.tar.gz");
|
||||
let result = (|| {
|
||||
fs::write(&archive, &bytes).map_err(|_| "installationError")?;
|
||||
verify_bundle(&archive, &cache, &update.version)?;
|
||||
handle.state::<Mutex<State>>().lock().unwrap().view.phase = "installing";
|
||||
refresh(&handle);
|
||||
update.install(&bytes).map_err(|_| "installationError")
|
||||
})();
|
||||
let _ = fs::remove_dir_all(cache);
|
||||
result
|
||||
})
|
||||
.await;
|
||||
match result {
|
||||
Ok(Ok(())) => {
|
||||
app.state::<Mutex<State>>().lock().unwrap().view.phase = "restarting";
|
||||
refresh(&app);
|
||||
app.restart();
|
||||
}
|
||||
Ok(Err(message)) => fail(&app, message),
|
||||
Err(_) => fail(&app, "installationError"),
|
||||
}
|
||||
});
|
||||
}
|
||||
fn action(app: &AppHandle, url: &Url) {
|
||||
match url.path() {
|
||||
"/updates/check" => check(app, true),
|
||||
"/updates/install" => install(
|
||||
app,
|
||||
url.query_pairs()
|
||||
.find(|(key, _)| key == "version")
|
||||
.as_ref()
|
||||
.map(|(_, value)| value.as_ref()),
|
||||
),
|
||||
"/updates/preferences" => {
|
||||
let params: std::collections::HashMap<_, _> = url.query_pairs().collect();
|
||||
let preferences = Preferences {
|
||||
automatic: params.get("automatic").is_some_and(|v| v == "true"),
|
||||
interval_hours: params
|
||||
.get("interval")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0),
|
||||
};
|
||||
if !preferences.valid() {
|
||||
fail(app, "preferencesError");
|
||||
return;
|
||||
}
|
||||
let saved = (|| -> Result<(), Box<dyn std::error::Error>> {
|
||||
let directory = app.path().app_config_dir()?;
|
||||
fs::create_dir_all(&directory)?;
|
||||
let pending = directory.join("update-preferences.pending");
|
||||
fs::write(&pending, serde_json::to_vec(&preferences)?)?;
|
||||
fs::rename(pending, directory.join("update-preferences.json"))?;
|
||||
Ok(())
|
||||
})();
|
||||
if saved.is_err() {
|
||||
fail(app, "preferencesError");
|
||||
return;
|
||||
}
|
||||
app.state::<Mutex<State>>().lock().unwrap().view.preferences = preferences;
|
||||
refresh(app);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
pub fn setup(app: &AppHandle) -> tauri::Result<()> {
|
||||
app.plugin(
|
||||
tauri_plugin_updater::Builder::new()
|
||||
.pubkey(&source().public_key)
|
||||
.build(),
|
||||
)?;
|
||||
let path = app.path().app_config_dir()?.join("update-preferences.json");
|
||||
let (preferences, error) = match fs::read(path) {
|
||||
Ok(bytes) => match serde_json::from_slice::<Preferences>(&bytes) {
|
||||
Ok(p) if p.valid() => (p, None),
|
||||
_ => (
|
||||
Preferences {
|
||||
automatic: false,
|
||||
..Default::default()
|
||||
},
|
||||
Some("preferencesError"),
|
||||
),
|
||||
},
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
(Preferences::default(), None)
|
||||
}
|
||||
Err(_) => (
|
||||
Preferences {
|
||||
automatic: false,
|
||||
..Default::default()
|
||||
},
|
||||
Some("preferencesError"),
|
||||
),
|
||||
};
|
||||
app.manage(Mutex::new(State {
|
||||
view: View {
|
||||
phase: if error.is_some() { "error" } else { "idle" },
|
||||
current_version: app.package_info().version.to_string(),
|
||||
build: crate::metadata::build_number().into(),
|
||||
version: None,
|
||||
notes: None,
|
||||
downloaded: 0,
|
||||
total: None,
|
||||
error,
|
||||
preferences,
|
||||
},
|
||||
update: None,
|
||||
last_check: None,
|
||||
}));
|
||||
let handle = app.clone();
|
||||
let window = WebviewWindowBuilder::new(app, "updates", WebviewUrl::App("updates.html".into()))
|
||||
.title(crate::locale::en::UPDATES)
|
||||
.inner_size(560.0, 690.0)
|
||||
.min_inner_size(420.0, 580.0)
|
||||
.center()
|
||||
.visible(false)
|
||||
.on_navigation(move |url| {
|
||||
if !trusted_navigation(url) {
|
||||
return false;
|
||||
}
|
||||
if url.path().starts_with("/updates/") {
|
||||
action(&handle, url);
|
||||
return false;
|
||||
}
|
||||
url.path() == "/updates.html"
|
||||
})
|
||||
.on_new_window(|_, _| NewWindowResponse::Deny)
|
||||
.on_page_load(|window, payload| {
|
||||
if payload.event() == PageLoadEvent::Finished {
|
||||
refresh(window.app_handle());
|
||||
}
|
||||
})
|
||||
.build()?;
|
||||
let hide = window.clone();
|
||||
window.on_window_event(move |event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
api.prevent_close();
|
||||
let _ = hide.hide();
|
||||
}
|
||||
});
|
||||
let handle = app.clone();
|
||||
std::thread::spawn(move || loop {
|
||||
check(&handle, false);
|
||||
std::thread::sleep(Duration::from_secs(30));
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn state() -> State {
|
||||
State {
|
||||
view: View {
|
||||
phase: "idle",
|
||||
current_version: "1.0.0".into(),
|
||||
build: "1".into(),
|
||||
version: None,
|
||||
notes: None,
|
||||
downloaded: 0,
|
||||
total: None,
|
||||
error: None,
|
||||
preferences: Preferences::default(),
|
||||
},
|
||||
update: None,
|
||||
last_check: None,
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn accepts_only_newer_stable_versions_and_exact_release_paths() {
|
||||
assert!(newer_stable("1.9.9", "1.10.0"));
|
||||
for version in ["0.9.0", "1.0.0", "1.2.0-beta", "1.2.0+build", "invalid"] {
|
||||
assert!(!newer_stable("1.0.0", version));
|
||||
}
|
||||
let good = format!(
|
||||
"{}/{}/1.1.0/KanbanGit-1.1.0-{}.app.tar.gz",
|
||||
source().base_url,
|
||||
target(),
|
||||
target()
|
||||
);
|
||||
assert!(valid_download(
|
||||
"1.0.0",
|
||||
"1.1.0",
|
||||
&Url::parse(&good).unwrap()
|
||||
));
|
||||
for input in [
|
||||
good.replace("https:", "http:"),
|
||||
good.replace(".app.tar.gz", ".dmg"),
|
||||
good.replace("1.1.0/", "0.9.0/"),
|
||||
good.replace(&target(), "foreign-apple-darwin"),
|
||||
format!("{good}?token=anything"),
|
||||
format!("{good}#fragment"),
|
||||
good.replace("https://", "https://user@"),
|
||||
"https://untrusted.example/update.app.tar.gz".into(),
|
||||
] {
|
||||
assert!(!valid_download(
|
||||
"1.0.0",
|
||||
"1.1.0",
|
||||
&Url::parse(&input).unwrap()
|
||||
));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn automatic_checks_start_once_then_respect_interval_and_opt_out() {
|
||||
let mut state = state();
|
||||
let now = Instant::now();
|
||||
assert!(state.begin_check(false, now));
|
||||
assert!(!state.begin_check(false, now));
|
||||
state.view.phase = "current";
|
||||
assert!(!state.begin_check(false, now + Duration::from_secs(6 * 3600 - 1)));
|
||||
assert!(state.begin_check(false, now + Duration::from_secs(6 * 3600)));
|
||||
state.view.phase = "current";
|
||||
state.view.preferences.automatic = false;
|
||||
assert!(!state.begin_check(false, now + Duration::from_secs(24 * 3600)));
|
||||
assert!(state.begin_check(true, now + Duration::from_secs(24 * 3600)));
|
||||
}
|
||||
#[test]
|
||||
fn manual_checks_retry_errors_but_never_overlap_downloads_or_installation() {
|
||||
let mut state = state();
|
||||
state.view.phase = "error";
|
||||
state.view.error = Some("networkError");
|
||||
assert!(state.begin_check(true, Instant::now()));
|
||||
assert!(state.view.error.is_none());
|
||||
for phase in [
|
||||
"checking",
|
||||
"downloading",
|
||||
"verifying",
|
||||
"installing",
|
||||
"restarting",
|
||||
] {
|
||||
state.view.phase = phase;
|
||||
assert!(!state.begin_check(true, Instant::now()));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn preferences_validate_and_round_trip_without_server_settings() {
|
||||
let p = Preferences {
|
||||
automatic: false,
|
||||
interval_hours: 24,
|
||||
};
|
||||
let json = serde_json::to_string(&p).unwrap();
|
||||
let loaded: Preferences = serde_json::from_str(&json).unwrap();
|
||||
assert!(!loaded.automatic);
|
||||
assert_eq!(loaded.interval_hours, 24);
|
||||
assert!(loaded.valid());
|
||||
for interval in [0, 2, 100, u64::MAX] {
|
||||
assert!(!Preferences {
|
||||
automatic: true,
|
||||
interval_hours: interval
|
||||
}
|
||||
.valid());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn update_actions_are_confined_to_the_local_trusted_window() {
|
||||
assert!(trusted_navigation(
|
||||
&Url::parse("tauri://localhost/updates/install").unwrap()
|
||||
));
|
||||
for address in [
|
||||
"https://kanbangit.de/updates/install",
|
||||
"https://localhost/updates/install",
|
||||
"tauri://user@localhost/updates/install",
|
||||
"tauri://localhost:443/updates/install",
|
||||
"tauri://other/updates/install",
|
||||
] {
|
||||
assert!(!trusted_navigation(&Url::parse(address).unwrap()));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn rejects_foreign_or_invalid_mach_o_headers_without_developer_tools() {
|
||||
let cpu: u32 = if cfg!(target_arch = "aarch64") {
|
||||
0x0100_000c
|
||||
} else {
|
||||
0x0100_0007
|
||||
};
|
||||
let mut header = vec![0xcf, 0xfa, 0xed, 0xfe];
|
||||
header.extend(cpu.to_le_bytes());
|
||||
assert!(matches_architecture(&header));
|
||||
header[4] ^= 1;
|
||||
assert!(!matches_architecture(&header));
|
||||
assert!(!matches_architecture(b"MZ"));
|
||||
assert!(!matches_architecture(&[0; 8]));
|
||||
}
|
||||
#[test]
|
||||
fn error_categories_offer_specific_retryable_messages() {
|
||||
use tauri_plugin_updater::Error;
|
||||
assert_eq!(
|
||||
update_error(&Error::Network("offline".into())),
|
||||
"networkError"
|
||||
);
|
||||
assert_eq!(
|
||||
update_error(&Error::TargetNotFound("darwin-other".into())),
|
||||
"metadataError"
|
||||
);
|
||||
assert_eq!(
|
||||
update_error(&Error::SignatureUtf8("invalid".into())),
|
||||
"signatureError"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod signed_upgrade_test {
|
||||
use super::*;
|
||||
use std::{io::Write, net::TcpListener};
|
||||
|
||||
#[test]
|
||||
#[ignore = "Requires KANBANGIT_UPGRADE_BASE and KANBANGIT_UPGRADE_ARCHIVE pointing to two Developer-ID-signed macOS versions"]
|
||||
fn installs_a_real_signed_upgrade_and_rejects_tampering() {
|
||||
let base = std::env::var("KANBANGIT_UPGRADE_BASE").expect("Set the signed base app path");
|
||||
let archive = std::env::var("KANBANGIT_UPGRADE_ARCHIVE")
|
||||
.expect("Set the signed updater archive path");
|
||||
let base_version = env!("CARGO_PKG_VERSION");
|
||||
let mut next_version = Version::parse(base_version).unwrap();
|
||||
next_version.patch += 1;
|
||||
let candidate_version =
|
||||
std::env::var("KANBANGIT_UPGRADE_VERSION").unwrap_or_else(|_| next_version.to_string());
|
||||
assert!(newer_stable(base_version, &candidate_version));
|
||||
let root = std::env::temp_dir().join(format!("kanbangit-upgrade-{}", std::process::id()));
|
||||
fs::create_dir(&root).unwrap();
|
||||
struct Cleanup(std::path::PathBuf);
|
||||
impl Drop for Cleanup {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
let _cleanup = Cleanup(root.clone());
|
||||
let installed = root.join("KanbanGit.app");
|
||||
command("/usr/bin/ditto", &[&base, installed.to_str().unwrap()]).unwrap();
|
||||
let data = root.join("user-data");
|
||||
fs::create_dir(&data).unwrap();
|
||||
fs::write(data.join("active-server"), "https://workspace.example/").unwrap();
|
||||
fs::write(data.join("session"), "existing-session").unwrap();
|
||||
let bytes = fs::read(&archive).unwrap();
|
||||
let signature = fs::read_to_string(format!("{archive}.sig")).unwrap();
|
||||
let verify_dir = root.join("verify");
|
||||
fs::create_dir(&verify_dir).unwrap();
|
||||
verify_bundle(Path::new(&archive), &verify_dir, &candidate_version).unwrap();
|
||||
assert!(verify_bundle(Path::new(&archive), &verify_dir, base_version).is_err());
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let download = bytes.clone();
|
||||
let announced_version = candidate_version.clone();
|
||||
let server = std::thread::spawn(move || {
|
||||
for index in 0..3 {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(10)))
|
||||
.unwrap();
|
||||
let mut request = [0; 4096];
|
||||
let _ = stream.read(&mut request).unwrap();
|
||||
let body = if index == 0 {
|
||||
serde_json::to_vec(&serde_json::json!({"version":announced_version, "platforms":{(format!("darwin-{}", std::env::consts::ARCH)): {"url":format!("http://{address}/update.tar.gz"), "signature":signature.trim()}}})).unwrap()
|
||||
} else if index == 1 {
|
||||
b"tampered update".to_vec()
|
||||
} else {
|
||||
download.clone()
|
||||
};
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
)
|
||||
.unwrap();
|
||||
stream.write_all(&body).unwrap();
|
||||
}
|
||||
});
|
||||
let mut context = crate::desktop::context();
|
||||
context.config_mut().plugins.0.insert("updater".into(), serde_json::json!({"pubkey":source().public_key, "dangerousInsecureTransportProtocol":true}));
|
||||
let app = tauri::test::mock_builder()
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.build(context)
|
||||
.unwrap();
|
||||
let update = tauri::async_runtime::block_on(async {
|
||||
app.updater_builder()
|
||||
.executable_path(installed.join("Contents/MacOS/kanbangit-native"))
|
||||
.endpoints(vec![
|
||||
Url::parse(&format!("http://{address}/latest.json")).unwrap()
|
||||
])
|
||||
.unwrap()
|
||||
.timeout(Duration::from_secs(20))
|
||||
.build()
|
||||
.unwrap()
|
||||
.check()
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
});
|
||||
assert_eq!(update.current_version, base_version);
|
||||
let rejected =
|
||||
tauri::async_runtime::block_on(update.download(|_, _| {}, || {})).unwrap_err();
|
||||
assert_eq!(update_error(&rejected), "signatureError");
|
||||
let plist = installed.join("Contents/Info.plist");
|
||||
assert_eq!(
|
||||
command(
|
||||
"/usr/bin/plutil",
|
||||
&[
|
||||
"-extract",
|
||||
"CFBundleShortVersionString",
|
||||
"raw",
|
||||
"-o",
|
||||
"-",
|
||||
plist.to_str().unwrap()
|
||||
]
|
||||
)
|
||||
.unwrap(),
|
||||
base_version
|
||||
);
|
||||
let verified = tauri::async_runtime::block_on(update.download(|_, _| {}, || {})).unwrap();
|
||||
assert_eq!(verified, bytes);
|
||||
update.install(verified).unwrap();
|
||||
assert_eq!(
|
||||
command(
|
||||
"/usr/bin/plutil",
|
||||
&[
|
||||
"-extract",
|
||||
"CFBundleShortVersionString",
|
||||
"raw",
|
||||
"-o",
|
||||
"-",
|
||||
plist.to_str().unwrap()
|
||||
]
|
||||
)
|
||||
.unwrap(),
|
||||
candidate_version
|
||||
);
|
||||
command(
|
||||
"/usr/bin/codesign",
|
||||
&[
|
||||
"--verify",
|
||||
"--deep",
|
||||
"--strict",
|
||||
installed.to_str().unwrap(),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
fs::read_to_string(data.join("active-server")).unwrap(),
|
||||
"https://workspace.example/"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(data.join("session")).unwrap(),
|
||||
"existing-session"
|
||||
);
|
||||
server.join().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "KanbanGit",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.4",
|
||||
"identifier": "de.kanbangit.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../ui"
|
||||
@@ -16,10 +16,38 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["app"],
|
||||
"icon": ["icons/icon.png", "icons/icon.icns"],
|
||||
"targets": [
|
||||
"app"
|
||||
],
|
||||
"icon": [
|
||||
"icons/icon.png",
|
||||
"icons/icon.icns"
|
||||
],
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "14.0"
|
||||
"minimumSystemVersion": "14.0",
|
||||
"bundleVersion": "9",
|
||||
"dmg": {
|
||||
"windowSize": {
|
||||
"width": 660,
|
||||
"height": 400
|
||||
},
|
||||
"appPosition": {
|
||||
"x": 180,
|
||||
"y": 170
|
||||
},
|
||||
"applicationFolderPosition": {
|
||||
"x": 480,
|
||||
"y": 170
|
||||
}
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"versionCode": 2004
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhBQzA0QjhENTI3OEE1QzAKUldUQXBYaFNqVXZBaXJVWmErWUZTNVp6QnllT25NWFMyemxqcFBnYnJHT3JBSWxrSkVYU1VVMFEK"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"identifier": "de.kanbangit.ios",
|
||||
"bundle": {
|
||||
"iOS": { "minimumSystemVersion": "17.0" }
|
||||
"iOS": {
|
||||
"minimumSystemVersion": "17.0",
|
||||
"developmentTeam": "ZZX2KZGTHU",
|
||||
"bundleVersion": "4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,18 +6,64 @@ fn main() {
|
||||
};
|
||||
use tauri::{webview::PageLoadEvent, Manager};
|
||||
|
||||
let startup = std::env::var("KANBANGIT_SMOKE_STARTUP").unwrap_or_default();
|
||||
let online = startup == "online";
|
||||
let needs_setup = matches!(startup.as_str(), "missing" | "invalid");
|
||||
let passed = Arc::new(AtomicBool::new(false));
|
||||
let stage = Arc::new(AtomicUsize::new(0));
|
||||
let page_stage = stage.clone();
|
||||
let result_passed = passed.clone();
|
||||
let setup_passed = passed.clone();
|
||||
let plugin = tauri::plugin::Builder::<tauri::Wry>::new("macos-smoke")
|
||||
.js_init_script("window.addEventListener('error', event => { location.href = 'tauri://localhost/__native_smoke_result?passed=false&error=' + encodeURIComponent(event.message || event.target.src); }, true);")
|
||||
.setup(move |app, _| {
|
||||
let directory = app.path().app_config_dir()?;
|
||||
std::fs::create_dir_all(&directory)?;
|
||||
if needs_setup {
|
||||
if startup == "invalid" {
|
||||
std::fs::write(directory.join("active-server"), "invalid configuration")?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let server = if online { "https://kanbangit.de/" } else { "https://127.0.0.1:9/" };
|
||||
std::fs::write(directory.join("active-server"), format!("{server}\n0123456789abcdef0123456789abcdef"))?;
|
||||
Ok(())
|
||||
})
|
||||
.js_init_script(r#"
|
||||
if (location.protocol === 'tauri:') {
|
||||
window.__selectionFlashed = false;
|
||||
new MutationObserver(() => {
|
||||
const selection = document.querySelector('#workspace-settings');
|
||||
const phase = document.querySelector('#connection-status')?.dataset.state;
|
||||
if (selection && !selection.hidden && phase !== 'connectionError') window.__selectionFlashed = true;
|
||||
}).observe(document, { subtree: true, attributes: true, childList: true });
|
||||
}
|
||||
"#)
|
||||
.js_init_script("window.addEventListener('error', event => { location.href = 'tauri://localhost/?smoke=result&passed=false&error=' + encodeURIComponent(event.message || event.target.src); }, true);")
|
||||
.on_page_load(move |webview, payload| {
|
||||
|
||||
eprintln!("Smoke load: {} {:?} {}", webview.label(), payload.event(), payload.url());
|
||||
if payload.event() != PageLoadEvent::Finished {
|
||||
return;
|
||||
}
|
||||
if webview.label() == "launcher" {
|
||||
if needs_setup {
|
||||
let app = webview.app_handle().clone();
|
||||
let passed = setup_passed.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
let handle = app.clone();
|
||||
app.run_on_main_thread(move || {
|
||||
let launcher = handle.get_webview_window("launcher").unwrap();
|
||||
assert!(launcher.is_visible().unwrap(), "First launch and invalid configuration must show setup");
|
||||
assert!(!handle.webview_windows().keys().any(|label| label.starts_with("workspace-")));
|
||||
passed.store(true, Ordering::SeqCst);
|
||||
launcher.close().unwrap();
|
||||
}).unwrap();
|
||||
});
|
||||
return;
|
||||
}
|
||||
assert!(!webview.app_handle().get_webview_window("launcher").unwrap().is_visible().unwrap(), "Saved startup must not show the native launcher window");
|
||||
if online { return; }
|
||||
webview.eval(r#"
|
||||
(async () => {
|
||||
const wait = async predicate => {
|
||||
@@ -28,7 +74,9 @@ fn main() {
|
||||
throw new Error('Timed out waiting for local state');
|
||||
};
|
||||
try {
|
||||
await wait(() => document.querySelector('.open-workspace')?.textContent === 'Open workspace');
|
||||
await wait(() => document.querySelector('#connection-status')?.dataset.state === 'connectionError');
|
||||
if (window.__selectionFlashed) throw new Error('Workspace selection flashed during saved startup');
|
||||
if (document.querySelector('#server-url').value !== 'https://127.0.0.1:9/') throw new Error('Saved workspace was discarded');
|
||||
const input = document.querySelector('#server-url');
|
||||
const form = document.querySelector('#server-form');
|
||||
const status = document.querySelector('#connection-status');
|
||||
@@ -43,6 +91,7 @@ fn main() {
|
||||
}
|
||||
input.value = 'https://127.0.0.1:9/';
|
||||
form.requestSubmit();
|
||||
await wait(() => status.dataset.state === 'connecting');
|
||||
await wait(() => status.dataset.state === 'connectionError');
|
||||
if (input.value !== 'https://127.0.0.1:9/' || document.querySelector('.open-workspace').disabled) throw new Error('Offline recovery failed');
|
||||
input.value = 'https://kanbangit.de/';
|
||||
@@ -53,7 +102,7 @@ fn main() {
|
||||
form.requestSubmit();
|
||||
document.querySelector('#confirm-switch').click();
|
||||
} catch (error) {
|
||||
location.href = 'tauri://localhost/__native_smoke_result?passed=false&reason=' + encodeURIComponent(error.message);
|
||||
location.href = 'tauri://localhost/?smoke=result&passed=false&reason=' + encodeURIComponent(error.message);
|
||||
}
|
||||
})();
|
||||
"#).unwrap();
|
||||
@@ -76,7 +125,27 @@ fn main() {
|
||||
input.id = 'kan52-unsaved'; input.value = 'unsaved input'; document.body.append(input);
|
||||
document.cookie = 'kan52_fixture=isolated; Path=/; Secure; SameSite=Lax';
|
||||
localStorage.setItem('kan52_fixture', 'isolated');
|
||||
const ok = rendered && denied && typeof window.__TAURI__ === 'undefined';
|
||||
const initialHistory = history.length;
|
||||
const initialPath = location.pathname;
|
||||
const navigation = window.__kanbanNavigation;
|
||||
if (!navigation || getComputedStyle(document.documentElement).overscrollBehaviorY !== 'none' || getComputedStyle(document.documentElement).overscrollBehaviorX !== 'auto') throw new Error('Scroll boundary policy missing');
|
||||
const traverse = direction => new Promise(resolve => {
|
||||
addEventListener('popstate', resolve, { once: true });
|
||||
navigation.step(direction);
|
||||
});
|
||||
for (const hash of ['#board', '#ticket', '#settings']) history.pushState({ page: { url: hash } }, '', hash);
|
||||
await traverse(-1);
|
||||
if (location.hash !== '#ticket') throw new Error('Back skipped an SPA entry');
|
||||
await traverse(-1);
|
||||
if (location.hash !== '#board') throw new Error('Back skipped a second SPA entry');
|
||||
await traverse(1);
|
||||
if (location.hash !== '#ticket' || history.length !== initialHistory + 3) throw new Error('Forward history was lost');
|
||||
const dialog = document.createElement('dialog');
|
||||
dialog.textContent = 'Navigation overlay'; document.body.append(dialog); dialog.showModal();
|
||||
if (navigation.canSwipe(1, 1)) throw new Error('Overlay must own navigation');
|
||||
dialog.close(); dialog.remove();
|
||||
if (input.value !== 'unsaved input') throw new Error('History discarded form state');
|
||||
const ok = rendered && denied && typeof window.__TAURI__ === 'undefined' && initialPath === '/login' && initialHistory === 1;
|
||||
location.href = '/__native_smoke_result?stage=loaded&passed=' + ok;
|
||||
})();
|
||||
"#).unwrap();
|
||||
@@ -90,7 +159,8 @@ fn main() {
|
||||
})
|
||||
.on_navigation(move |webview, url| {
|
||||
|
||||
if url.path() != "/__native_smoke_result" {
|
||||
eprintln!("Smoke navigation: {} {}", webview.label(), url);
|
||||
if url.path() != "/__native_smoke_result" && !url.query_pairs().any(|(key, value)| key == "smoke" && value == "result") {
|
||||
return true;
|
||||
}
|
||||
let success = url.query_pairs().any(|(key, value)| key == "passed" && value == "true");
|
||||
@@ -119,7 +189,17 @@ fn main() {
|
||||
});
|
||||
} else {
|
||||
result_passed.store(success, Ordering::SeqCst);
|
||||
webview.app_handle().exit(if success { 0 } else { 1 });
|
||||
if success {
|
||||
let app = webview.app_handle().clone();
|
||||
let label = webview.label().to_string();
|
||||
let handle = app.clone();
|
||||
app.run_on_main_thread(move || {
|
||||
assert!(!handle.get_webview_window("launcher").unwrap().is_visible().unwrap());
|
||||
handle.get_webview_window(&label).unwrap().close().unwrap();
|
||||
}).unwrap();
|
||||
} else {
|
||||
webview.app_handle().exit(1);
|
||||
}
|
||||
}
|
||||
false
|
||||
})
|
||||
@@ -130,6 +210,25 @@ fn main() {
|
||||
.plugin(plugin)
|
||||
.build(context)
|
||||
.expect("The macOS application must start");
|
||||
let visibility_handle = app.handle().clone();
|
||||
let visibility_stage = stage.clone();
|
||||
let visibility_check = std::thread::spawn(move || {
|
||||
if needs_setup {
|
||||
return;
|
||||
}
|
||||
for _ in 0..30 {
|
||||
if visibility_stage.load(Ordering::SeqCst) > 0 {
|
||||
break;
|
||||
}
|
||||
if let Some(launcher) = visibility_handle.get_webview_window("launcher") {
|
||||
assert!(
|
||||
!launcher.is_visible().unwrap(),
|
||||
"Launcher flashed while the workspace was loading"
|
||||
);
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
}
|
||||
});
|
||||
let config_dir = app.path().app_config_dir().unwrap();
|
||||
let handle = app.handle().clone();
|
||||
std::thread::spawn(move || {
|
||||
@@ -137,7 +236,28 @@ fn main() {
|
||||
eprintln!("macOS smoke test timed out");
|
||||
handle.exit(1);
|
||||
});
|
||||
app.run_return(|_, _| {});
|
||||
let closed = Arc::new(AtomicBool::new(false));
|
||||
let closed_result = closed.clone();
|
||||
let exit_code = app.run_return(move |app, event| {
|
||||
if let tauri::RunEvent::ExitRequested { code: Some(0), .. } = event {
|
||||
closed_result.store(true, Ordering::SeqCst);
|
||||
assert!(
|
||||
needs_setup
|
||||
|| !app
|
||||
.get_webview_window("launcher")
|
||||
.unwrap()
|
||||
.is_visible()
|
||||
.unwrap(),
|
||||
"Closing the workspace must not reopen settings"
|
||||
);
|
||||
}
|
||||
});
|
||||
visibility_check.join().unwrap();
|
||||
assert_eq!(exit_code, 0);
|
||||
assert!(
|
||||
closed.load(Ordering::SeqCst),
|
||||
"The close button must quit the application"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(config_dir);
|
||||
assert!(
|
||||
passed.load(Ordering::SeqCst),
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tauri::command]
|
||||
fn updater_smoke_menu(app: tauri::AppHandle) -> Result<(), String> {
|
||||
let submenu = app.menu().unwrap().get("app-menu").unwrap();
|
||||
let item = submenu.as_submenu().unwrap().get("install-update").unwrap();
|
||||
let item = item.as_menuitem().unwrap();
|
||||
for (available, busy, expected) in [
|
||||
(false, false, false), // No update.
|
||||
(true, false, true), // Available update, also after a retryable failure.
|
||||
(true, true, false), // Download or installation in progress.
|
||||
(true, false, true), // Retry after a failed download.
|
||||
(false, true, false), // A new check clears the previous update.
|
||||
(false, false, false), // Up to date.
|
||||
] {
|
||||
kanbangit_native::refresh_update_menu_for_smoke_test(&app, available, busy);
|
||||
if item.is_enabled().map_err(|error| error.to_string())? != expected {
|
||||
return Err(format!(
|
||||
"Native install menu has wrong state: available={available}, busy={busy}"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tauri::command]
|
||||
fn updater_smoke_report(app: tauri::AppHandle, passed: bool, reason: String) {
|
||||
use tauri::Manager;
|
||||
println!("Updater WebView test: passed={passed}, {reason}");
|
||||
app.state::<std::sync::Arc<std::sync::atomic::AtomicBool>>()
|
||||
.store(passed, std::sync::atomic::Ordering::SeqCst);
|
||||
app.exit(if passed { 0 } else { 1 });
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn main() {
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
};
|
||||
use tauri::webview::PageLoadEvent;
|
||||
let passed = Arc::new(AtomicBool::new(false));
|
||||
let config: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
|
||||
let expected_installed = format!(
|
||||
"Installed version {} (build {})",
|
||||
config["version"].as_str().unwrap(),
|
||||
config["bundle"]["macOS"]["bundleVersion"].as_str().unwrap()
|
||||
);
|
||||
|
||||
let plugin = tauri::plugin::Builder::<tauri::Wry>::new("updater-smoke")
|
||||
.js_init_script(format!("window.expectedInstalled = {};", serde_json::to_string(&expected_installed).unwrap()))
|
||||
.on_page_load(|window, payload| {
|
||||
if window.label() != "updates" || payload.event() != PageLoadEvent::Finished { return; }
|
||||
window.eval(r#"
|
||||
(async () => {
|
||||
try {
|
||||
for (let i = 0; i < 100 && !window.updateStatus; i++) await new Promise(r => setTimeout(r, 50));
|
||||
if (!window.updateStatus) throw new Error('Update UI did not load');
|
||||
const $ = id => document.getElementById(id);
|
||||
for (let i = 0; i < 100 && !$('installed').textContent; i++) await new Promise(r => setTimeout(r, 50));
|
||||
if ($('installed').textContent !== window.expectedInstalled) throw new Error('Actual installed version/build failed: ' + $('installed').textContent);
|
||||
await window.__TAURI_INTERNALS__.invoke('updater_smoke_menu');
|
||||
const state = { phase: 'current', currentVersion: '0.1.2', build: '2', version: null, notes: null, downloaded: 0, total: null, error: null, preferences: { automatic: true, intervalHours: 6 } };
|
||||
window.updateStatus(state);
|
||||
if (!$('installed').textContent.includes('0.1.2 (build 2)') || !$('install').hidden || $('check').disabled) throw new Error('Current version state failed');
|
||||
Object.assign(state, { phase: 'available', version: '0.1.3', notes: '<script>unsafe()</script>\nRelease notes' });
|
||||
window.updateStatus(state);
|
||||
if ($('install').hidden || $('release-notes').querySelector('script') || !$('release-notes').textContent.includes('<script>')) throw new Error('Available state or text escaping failed');
|
||||
$('install').click();
|
||||
if ($('install-confirmation').hidden || !$('install-confirmation').textContent.includes('Unsaved inputs')) throw new Error('Missing restart confirmation');
|
||||
$('cancel-install').click();
|
||||
if (!$('install-confirmation').hidden) throw new Error('Cancel failed');
|
||||
Object.assign(state, { phase: 'downloading', downloaded: 500, total: 1000 }); window.updateStatus(state);
|
||||
if (!$('check').disabled || !$('install').hidden || $('download-progress').value !== 500) throw new Error('Progress state failed');
|
||||
Object.assign(state, { phase: 'error', error: 'signatureError' }); window.updateStatus(state);
|
||||
if ($('install').hidden || !$('update-error').textContent.includes('Nothing was installed')) throw new Error('Retry failed');
|
||||
state.preferences.automatic = false; window.updateStatus(state);
|
||||
if ($('automatic').checked || !$('interval').disabled) throw new Error('Opt out failed');
|
||||
await window.__TAURI_INTERNALS__.invoke('updater_smoke_report', { passed: true, reason: '' });
|
||||
} catch (error) {
|
||||
await window.__TAURI_INTERNALS__.invoke('updater_smoke_report', { passed: false, reason: error.message });
|
||||
}
|
||||
})();
|
||||
"#).unwrap();
|
||||
})
|
||||
.build();
|
||||
let mut context = kanbangit_native::context();
|
||||
context.config_mut().identifier = format!("de.kanbangit.smoke.updates.{}", std::process::id());
|
||||
let app = kanbangit_native::builder()
|
||||
.manage(passed.clone())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
updater_smoke_report,
|
||||
updater_smoke_menu
|
||||
])
|
||||
.plugin(plugin)
|
||||
.build(context)
|
||||
.expect("App must start");
|
||||
let handle = app.handle().clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_secs(30));
|
||||
handle.exit(1);
|
||||
});
|
||||
app.run_return(|_, _| {});
|
||||
assert!(passed.load(Ordering::SeqCst), "Updater UI test must pass");
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn main() {
|
||||
panic!("Requires macOS");
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { 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';
|
||||
import { conditionalEtag } from '../publish-macos.mjs';
|
||||
|
||||
test(
|
||||
'Hetzner conditional writes accept a bare ETag and reject a stale ETag',
|
||||
{
|
||||
skip: process.env.KANBANGIT_S3_INTEGRATION !== '1',
|
||||
},
|
||||
() => {
|
||||
const bucket = process.env.AWS_BUCKET;
|
||||
assert(bucket, 'Set AWS_BUCKET to a test bucket');
|
||||
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-s3-etag-'));
|
||||
const key = `kanbangit-integration-tests/${randomUUID()}.txt`;
|
||||
const file = join(directory, 'object.txt');
|
||||
const downloaded = join(directory, 'downloaded.txt');
|
||||
const run = (operation, args) => {
|
||||
const result = spawnSync(
|
||||
'aws',
|
||||
[
|
||||
'--debug',
|
||||
'--endpoint-url',
|
||||
process.env.AWS_ENDPOINT,
|
||||
'--region',
|
||||
process.env.AWS_DEFAULT_REGION,
|
||||
'--no-cli-pager',
|
||||
's3api',
|
||||
operation,
|
||||
'--bucket',
|
||||
bucket,
|
||||
'--key',
|
||||
key,
|
||||
...args,
|
||||
],
|
||||
{ encoding: 'utf8', timeout: 30000 },
|
||||
);
|
||||
// Never expose debug headers: they contain the signed Authorization header.
|
||||
return {
|
||||
status: result.status,
|
||||
stdout: result.stdout,
|
||||
codes: [
|
||||
...(result.stderr || '').matchAll(/<Code>([^<]*)<\/Code>/g),
|
||||
].map((match) => match[1]),
|
||||
};
|
||||
};
|
||||
let created = false;
|
||||
try {
|
||||
writeFileSync(file, 'first');
|
||||
const initial = run('put-object', [
|
||||
'--body',
|
||||
file,
|
||||
'--if-none-match',
|
||||
'*',
|
||||
]);
|
||||
assert.equal(initial.status, 0, `Create failed: ${initial.codes}`);
|
||||
created = true;
|
||||
const read = run('get-object', ['--output', 'json', downloaded]);
|
||||
assert.equal(read.status, 0, `Read failed: ${read.codes}`);
|
||||
const etag = JSON.parse(read.stdout).ETag;
|
||||
const quoted = run('put-object', [
|
||||
'--body',
|
||||
file,
|
||||
'--if-match',
|
||||
etag,
|
||||
]);
|
||||
console.log(
|
||||
`Quoted ETag result: exit ${quoted.status}, S3 ${quoted.codes.join(', ')}`,
|
||||
);
|
||||
const bare = conditionalEtag(etag);
|
||||
writeFileSync(file, 'second');
|
||||
const updated = run('put-object', [
|
||||
'--body',
|
||||
file,
|
||||
'--if-match',
|
||||
bare,
|
||||
]);
|
||||
assert.equal(
|
||||
updated.status,
|
||||
0,
|
||||
`Bare ETag replacement failed: ${updated.codes}`,
|
||||
);
|
||||
writeFileSync(file, 'stale writer');
|
||||
const stale = run('put-object', [
|
||||
'--body',
|
||||
file,
|
||||
'--if-match',
|
||||
bare,
|
||||
]);
|
||||
assert.notEqual(stale.status, 0);
|
||||
assert(
|
||||
stale.codes.includes('PreconditionFailed'),
|
||||
`Unexpected stale error: ${stale.codes}`,
|
||||
);
|
||||
assert.equal(run('get-object', [downloaded]).status, 0);
|
||||
assert.equal(readFileSync(downloaded, 'utf8'), 'second');
|
||||
} finally {
|
||||
try {
|
||||
if (created) {
|
||||
const cleanup = run('delete-object', []);
|
||||
assert.equal(
|
||||
cleanup.status,
|
||||
0,
|
||||
`Cleanup failed: ${cleanup.codes}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,534 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
appleTeamId,
|
||||
buildIos,
|
||||
bundleIdentifier,
|
||||
selectIdentity,
|
||||
validateProfile,
|
||||
} from '../build-ios.mjs';
|
||||
|
||||
const certificate = Buffer.from('synthetic test certificate');
|
||||
const fingerprint = createHash('sha1')
|
||||
.update(certificate)
|
||||
.digest('hex')
|
||||
.toUpperCase();
|
||||
|
||||
function profileFor(mode = 'testflight') {
|
||||
return {
|
||||
UUID: '00000000-1111-2222-3333-444444444444',
|
||||
TeamIdentifier: [appleTeamId],
|
||||
Platform: ['iOS'],
|
||||
ExpirationDate: '2099-01-01T00:00:00Z',
|
||||
DeveloperCertificates: [certificate.toString('base64')],
|
||||
...(mode === 'development'
|
||||
? { ProvisionedDevices: ['test-device'] }
|
||||
: {}),
|
||||
Entitlements: {
|
||||
'application-identifier': `${appleTeamId}.${bundleIdentifier}`,
|
||||
'com.apple.developer.team-identifier': appleTeamId,
|
||||
'get-task-allow': mode === 'development',
|
||||
'beta-reports-active': mode === 'testflight',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fixture(t, mode = 'testflight', overrides = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'kanbangit-ios-test-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const directory = join(root, 'repo/native');
|
||||
const profilePath = join(root, 'profile.mobileprovision');
|
||||
const config = {
|
||||
productName: 'KanbanGit',
|
||||
identifier: 'de.kanbangit.desktop',
|
||||
version: '1.2.3',
|
||||
};
|
||||
const ios = {
|
||||
identifier: bundleIdentifier,
|
||||
bundle: {
|
||||
iOS: {
|
||||
developmentTeam: appleTeamId,
|
||||
bundleVersion: '7',
|
||||
minimumSystemVersion: '17.0',
|
||||
},
|
||||
},
|
||||
};
|
||||
mkdirSync(join(directory, 'src-tauri'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(directory, 'src-tauri/tauri.conf.json'),
|
||||
JSON.stringify(config),
|
||||
);
|
||||
writeFileSync(
|
||||
join(directory, 'src-tauri/tauri.ios.conf.json'),
|
||||
JSON.stringify(ios),
|
||||
);
|
||||
writeFileSync(profilePath, 'external provisioning profile');
|
||||
const profile = profileFor(mode);
|
||||
const calls = [];
|
||||
const destination = join(directory, `src-tauri/target/ios-${mode}/1.2.3-7`);
|
||||
const home = join(root, 'home');
|
||||
const installedProfile = join(
|
||||
home,
|
||||
`Library/Developer/Xcode/UserData/Provisioning Profiles/${profile.UUID}.mobileprovision`,
|
||||
);
|
||||
const info = {
|
||||
CFBundleIdentifier: bundleIdentifier,
|
||||
CFBundleShortVersionString: '1.2.3',
|
||||
CFBundleVersion: '7',
|
||||
};
|
||||
function app(path) {
|
||||
mkdirSync(path, { recursive: true });
|
||||
writeFileSync(
|
||||
join(path, 'Info.plist'),
|
||||
JSON.stringify({ ...info, ...overrides.info }),
|
||||
);
|
||||
}
|
||||
const options = {
|
||||
directory,
|
||||
home,
|
||||
mode,
|
||||
platform: 'darwin',
|
||||
log() {},
|
||||
env: { PATH: '/test/bin', IOS_PROVISIONING_PROFILE: profilePath },
|
||||
run(command, args, settings) {
|
||||
calls.push({ command, args, settings });
|
||||
if (overrides.failStep === settings.step) {
|
||||
throw new Error('Simulated build failure');
|
||||
}
|
||||
if (command === 'python3') {
|
||||
return settings.input.toString();
|
||||
}
|
||||
if (command === 'security') {
|
||||
if (args[0] === 'cms') {
|
||||
const embedded = args
|
||||
.at(-1)
|
||||
.endsWith('embedded.mobileprovision');
|
||||
return JSON.stringify(
|
||||
embedded && overrides.embeddedProfile
|
||||
? overrides.embeddedProfile
|
||||
: profile,
|
||||
);
|
||||
}
|
||||
return `1) ${fingerprint} "Apple ${mode === 'development' ? 'Development' : 'Distribution'}: Example (${appleTeamId})"`;
|
||||
}
|
||||
if (command === 'rustup') {
|
||||
if (args[0] === 'target') {
|
||||
return (
|
||||
overrides.targets ??
|
||||
'aarch64-apple-ios\naarch64-apple-ios-sim'
|
||||
);
|
||||
}
|
||||
return `/permanent/toolchain/bin/${args[1]}`;
|
||||
}
|
||||
if (command === '/permanent/toolchain/bin/cargo') {
|
||||
const library = join(
|
||||
directory,
|
||||
'src-tauri/target',
|
||||
args[args.indexOf('--target') + 1],
|
||||
args.includes('--release') ? 'release' : 'debug',
|
||||
'libkanbangit_native.a',
|
||||
);
|
||||
mkdirSync(dirname(library), { recursive: true });
|
||||
writeFileSync(library, 'compiled library');
|
||||
}
|
||||
if (command === 'xcodebuild' && args.includes('archive')) {
|
||||
assert.ok(mode === 'simulator' || existsSync(installedProfile));
|
||||
app(
|
||||
join(
|
||||
args[args.indexOf('-archivePath') + 1],
|
||||
'Products/Applications/KanbanGit.app',
|
||||
),
|
||||
);
|
||||
}
|
||||
if (command === 'xcodebuild' && args.includes('-exportArchive')) {
|
||||
const output = args[args.indexOf('-exportPath') + 1];
|
||||
mkdirSync(output, { recursive: true });
|
||||
if (!overrides.missingIpa) {
|
||||
writeFileSync(
|
||||
join(output, 'KanbanGit.ipa'),
|
||||
'signed ipa bytes',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (command === 'ditto') {
|
||||
app(join(args.at(-1), 'Payload/KanbanGit.app'));
|
||||
}
|
||||
if (command === 'xcrun') {
|
||||
return '/permanent/Xcode/SDK';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
};
|
||||
return {
|
||||
options,
|
||||
calls,
|
||||
profile,
|
||||
profilePath,
|
||||
destination,
|
||||
installedProfile,
|
||||
ios,
|
||||
};
|
||||
}
|
||||
|
||||
test('TestFlight produces a verified local IPA with manual signing and no Apple mutation or upload flags', (t) => {
|
||||
const f = fixture(t);
|
||||
f.options.env.APPLE_API_KEY = 'must-not-reach-build';
|
||||
f.options.env.IOS_CERTIFICATE = 'must-not-reach-build';
|
||||
assert.equal(buildIos(f.options), f.destination);
|
||||
const options = JSON.parse(
|
||||
readFileSync(join(f.destination, 'ExportOptions.plist'), 'utf8'),
|
||||
);
|
||||
assert.equal(options.method, 'app-store-connect');
|
||||
assert.equal(options.destination, 'export');
|
||||
assert.equal(options.signingStyle, 'manual');
|
||||
assert.equal(options.manageAppVersionAndBuildNumber, false);
|
||||
assert.equal(options.signingCertificate, fingerprint);
|
||||
assert.deepEqual(options.provisioningProfiles, {
|
||||
[bundleIdentifier]: f.profile.UUID,
|
||||
});
|
||||
assert.equal(
|
||||
readFileSync(
|
||||
join(f.destination, 'export/KanbanGit.ipa.sha256'),
|
||||
'utf8',
|
||||
),
|
||||
`${createHash('sha256').update('signed ipa bytes').digest('hex')} KanbanGit.ipa\n`,
|
||||
);
|
||||
assert.equal(
|
||||
f.calls.filter(
|
||||
(call) =>
|
||||
call.command === 'codesign' && call.args.includes('--verify'),
|
||||
).length,
|
||||
2,
|
||||
);
|
||||
assert.equal(existsSync(f.installedProfile), false);
|
||||
const verification = f.calls.find((call) => call.command === 'codesign');
|
||||
const requirement = verification.args[verification.args.indexOf('-R') + 1];
|
||||
assert.ok(requirement.includes('! entitlement["get-task-allow"] exists'));
|
||||
assert.ok(
|
||||
requirement.includes('entitlement["beta-reports-active"] exists'),
|
||||
);
|
||||
assert.ok(requirement.includes(`certificate leaf = H"${fingerprint}"`));
|
||||
for (const call of f.calls) {
|
||||
assert.equal(call.settings.env.APPLE_API_KEY, undefined);
|
||||
assert.equal(call.settings.env.IOS_CERTIFICATE, undefined);
|
||||
assert.ok(
|
||||
!call.args.some((arg) =>
|
||||
/allowProvisioning|authenticationKey|upload|downloadPlatform/.test(
|
||||
arg,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
const archive = f.calls.find((call) => call.args.includes('archive'));
|
||||
assert.ok(archive.args.includes('CODE_SIGN_STYLE=Manual'));
|
||||
assert.ok(
|
||||
archive.args.includes(
|
||||
`PROVISIONING_PROFILE_SPECIFIER=${f.profile.UUID}`,
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
archive.settings.env.KANBANGIT_IOS_LIBRARY.endsWith(
|
||||
'/aarch64-apple-ios/release/libkanbangit_native.a',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('the simulator needs no certificate or profile and disables signing explicitly', (t) => {
|
||||
const f = fixture(t, 'simulator');
|
||||
delete f.options.env.IOS_PROVISIONING_PROFILE;
|
||||
buildIos(f.options);
|
||||
assert.ok(
|
||||
f.calls.some((call) => call.args.includes('CODE_SIGNING_ALLOWED=NO')),
|
||||
);
|
||||
assert.ok(
|
||||
f.calls.some((call) => call.args.includes('aarch64-apple-ios-sim')),
|
||||
);
|
||||
assert.ok(
|
||||
!f.calls.some(
|
||||
(call) =>
|
||||
['security', 'codesign'].includes(call.command) ||
|
||||
call.args.includes('-exportArchive'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('local device development uses a registered-device development profile', (t) => {
|
||||
const f = fixture(t, 'development');
|
||||
buildIos(f.options);
|
||||
const options = JSON.parse(
|
||||
readFileSync(join(f.destination, 'ExportOptions.plist'), 'utf8'),
|
||||
);
|
||||
assert.equal(options.method, 'debugging');
|
||||
assert.equal(options.destination, 'export');
|
||||
});
|
||||
|
||||
for (const [name, change, message] of [
|
||||
[
|
||||
'different team',
|
||||
(p) => {
|
||||
p.TeamIdentifier = ['OLDTEAM'];
|
||||
},
|
||||
/explicitly match/,
|
||||
],
|
||||
[
|
||||
'wildcard identifier',
|
||||
(p) => {
|
||||
p.Entitlements['application-identifier'] = `${appleTeamId}.*`;
|
||||
},
|
||||
/explicitly match/,
|
||||
],
|
||||
[
|
||||
'macOS identifier',
|
||||
(p) => {
|
||||
p.Entitlements['application-identifier'] =
|
||||
`${appleTeamId}.de.kanbangit.desktop`;
|
||||
},
|
||||
/explicitly match/,
|
||||
],
|
||||
[
|
||||
'expired profile',
|
||||
(p) => {
|
||||
p.ExpirationDate = '2000-01-01';
|
||||
},
|
||||
/expired/,
|
||||
],
|
||||
[
|
||||
'invalid expiry',
|
||||
(p) => {
|
||||
p.ExpirationDate = 'invalid';
|
||||
},
|
||||
/expired/,
|
||||
],
|
||||
[
|
||||
'invalid UUID',
|
||||
(p) => {
|
||||
p.UUID = '../unexpected';
|
||||
},
|
||||
/invalid/,
|
||||
],
|
||||
[
|
||||
'missing certificate',
|
||||
(p) => {
|
||||
p.DeveloperCertificates = [];
|
||||
},
|
||||
/invalid/,
|
||||
],
|
||||
[
|
||||
'wrong platform',
|
||||
(p) => {
|
||||
p.Platform = ['OSX'];
|
||||
},
|
||||
/iOS profile/,
|
||||
],
|
||||
[
|
||||
'Ad Hoc profile',
|
||||
(p) => {
|
||||
p.ProvisionedDevices = ['device'];
|
||||
},
|
||||
/App Store Connect/,
|
||||
],
|
||||
[
|
||||
'enterprise profile',
|
||||
(p) => {
|
||||
p.ProvisionsAllDevices = true;
|
||||
},
|
||||
/App Store Connect/,
|
||||
],
|
||||
[
|
||||
'development entitlement',
|
||||
(p) => {
|
||||
p.Entitlements['get-task-allow'] = true;
|
||||
},
|
||||
/App Store Connect/,
|
||||
],
|
||||
[
|
||||
'missing beta entitlement',
|
||||
(p) => {
|
||||
delete p.Entitlements['beta-reports-active'];
|
||||
},
|
||||
/App Store Connect/,
|
||||
],
|
||||
]) {
|
||||
test(`rejects ${name} before signing`, () => {
|
||||
const profile = profileFor();
|
||||
change(profile);
|
||||
assert.throws(() => validateProfile(profile, 'testflight'), message);
|
||||
});
|
||||
}
|
||||
|
||||
test('a distribution profile cannot be used for device development', () => {
|
||||
assert.throws(
|
||||
() => validateProfile(profileFor(), 'development'),
|
||||
/App Development/,
|
||||
);
|
||||
});
|
||||
|
||||
test('identity selection requires the profile certificate and a usable private key of the correct kind', () => {
|
||||
const profile = profileFor();
|
||||
for (const identities of [
|
||||
'0 valid identities found',
|
||||
`1) ${'A'.repeat(40)} "Apple Distribution: Other"`,
|
||||
`1) ${fingerprint} "Developer ID Application: Example"`,
|
||||
`1) ${fingerprint} "Apple Development: Example"`,
|
||||
]) {
|
||||
assert.throws(
|
||||
() => selectIdentity(profile, identities, 'testflight'),
|
||||
/private key/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
for (const step of [
|
||||
'Generating the Xcode project',
|
||||
'Building the iOS Rust library',
|
||||
'Archiving locally without Apple provisioning updates',
|
||||
'Verifying the Apple signature and team',
|
||||
'Exporting the signed IPA locally (no upload)',
|
||||
]) {
|
||||
test(`failure at ${step} does not publish a successful build and cleans its profile`, (t) => {
|
||||
const f = fixture(t, 'testflight', { failStep: step });
|
||||
assert.throws(() => buildIos(f.options), /Simulated build failure/);
|
||||
assert.equal(existsSync(f.destination), false);
|
||||
assert.equal(existsSync(f.installedProfile), false);
|
||||
});
|
||||
}
|
||||
|
||||
test('an existing identical local profile is preserved after the build', (t) => {
|
||||
const f = fixture(t);
|
||||
mkdirSync(dirname(f.installedProfile), { recursive: true });
|
||||
writeFileSync(f.installedProfile, readFileSync(f.profilePath));
|
||||
buildIos(f.options);
|
||||
assert.equal(existsSync(f.installedProfile), true);
|
||||
});
|
||||
|
||||
test('a conflicting local profile is never overwritten or deleted', (t) => {
|
||||
const f = fixture(t);
|
||||
mkdirSync(dirname(f.installedProfile), { recursive: true });
|
||||
writeFileSync(f.installedProfile, 'user profile');
|
||||
assert.throws(() => buildIos(f.options), /same UUID/);
|
||||
assert.equal(readFileSync(f.installedProfile, 'utf8'), 'user profile');
|
||||
});
|
||||
|
||||
test('missing toolchain targets fail before compilation without installing anything', (t) => {
|
||||
const f = fixture(t, 'testflight', { targets: 'aarch64-apple-darwin' });
|
||||
assert.throws(
|
||||
() => buildIos(f.options),
|
||||
/rustup target add aarch64-apple-ios/,
|
||||
);
|
||||
assert.ok(
|
||||
!f.calls.some(
|
||||
(call) => call.args.includes('build') || call.args.includes('add'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('missing profile and ambient team overrides fail clearly', (t) => {
|
||||
const f = fixture(t);
|
||||
delete f.options.env.IOS_PROVISIONING_PROFILE;
|
||||
assert.throws(() => buildIos(f.options), /IOS_PROVISIONING_PROFILE/);
|
||||
f.options.env.APPLE_DEVELOPMENT_TEAM = 'OLDTEAM';
|
||||
assert.throws(() => buildIos(f.options), /must be ZZX2KZGTHU/);
|
||||
delete f.options.env.APPLE_DEVELOPMENT_TEAM;
|
||||
f.options.env.TAURI_CONFIG = '{}';
|
||||
assert.throws(() => buildIos(f.options), /Remove TAURI_CONFIG/);
|
||||
});
|
||||
|
||||
for (const [name, overrides, message] of [
|
||||
[
|
||||
'wrong app version',
|
||||
{ info: { CFBundleVersion: '8' } },
|
||||
/unexpected identifier, version/,
|
||||
],
|
||||
[
|
||||
'changed embedded profile',
|
||||
{
|
||||
embeddedProfile: {
|
||||
...profileFor(),
|
||||
UUID: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||
},
|
||||
},
|
||||
/unexpected provisioning profile/,
|
||||
],
|
||||
['missing IPA', { missingIpa: true }, /did not produce/],
|
||||
]) {
|
||||
test(`rejects ${name} in the build output`, (t) => {
|
||||
const f = fixture(t, 'testflight', overrides);
|
||||
assert.throws(() => buildIos(f.options), message);
|
||||
assert.equal(existsSync(f.destination), false);
|
||||
});
|
||||
}
|
||||
|
||||
test('completed artifacts are not silently overwritten', (t) => {
|
||||
const f = fixture(t);
|
||||
buildIos(f.options);
|
||||
assert.throws(() => buildIos(f.options), /already exists/);
|
||||
});
|
||||
|
||||
for (const mode of ['simulator', 'development']) {
|
||||
test(`${mode} can rebuild the same version and preserves the previous build on failure`, (t) => {
|
||||
const overrides = {};
|
||||
const f = fixture(t, mode, overrides);
|
||||
buildIos(f.options);
|
||||
const previousBuild = join(f.destination, 'previous-build');
|
||||
writeFileSync(previousBuild, 'verified artifact');
|
||||
overrides.failStep = 'Building the iOS Rust library';
|
||||
assert.throws(() => buildIos(f.options), /Simulated build failure/);
|
||||
assert.equal(readFileSync(previousBuild, 'utf8'), 'verified artifact');
|
||||
delete overrides.failStep;
|
||||
buildIos(f.options);
|
||||
assert.equal(existsSync(previousBuild), false);
|
||||
assert.ok(existsSync(join(f.destination, 'KanbanGit.xcarchive')));
|
||||
assert.equal(existsSync(f.installedProfile), false);
|
||||
});
|
||||
}
|
||||
|
||||
test('every iOS icon has its declared dimensions and contains no alpha channel or transparency chunk', () => {
|
||||
const assets = fileURLToPath(
|
||||
new URL(
|
||||
'../src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/',
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const catalog = JSON.parse(
|
||||
readFileSync(join(assets, 'Contents.json'), 'utf8'),
|
||||
);
|
||||
assert.ok(
|
||||
catalog.images.some(
|
||||
(icon) =>
|
||||
icon.idiom === 'ios-marketing' && icon.size === '1024x1024',
|
||||
),
|
||||
);
|
||||
for (const icon of catalog.images) {
|
||||
const png = readFileSync(join(assets, icon.filename));
|
||||
assert.equal(png.subarray(0, 8).toString('hex'), '89504e470d0a1a0a');
|
||||
const [width, height] = icon.size.split('x').map(Number);
|
||||
const scale = Number.parseInt(icon.scale, 10);
|
||||
assert.equal(png.readUInt32BE(16), width * scale, icon.filename);
|
||||
assert.equal(png.readUInt32BE(20), height * scale, icon.filename);
|
||||
assert.equal(png[25], 2, `${icon.filename} must use RGB without alpha`);
|
||||
for (
|
||||
let offset = 8;
|
||||
offset < png.length;
|
||||
offset += png.readUInt32BE(offset) + 12
|
||||
) {
|
||||
assert.notEqual(
|
||||
png.subarray(offset + 4, offset + 8).toString(),
|
||||
'tRNS',
|
||||
icon.filename,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -62,6 +62,12 @@ const latestAction = async (name) => {
|
||||
};
|
||||
try {
|
||||
await page.goto(`http://127.0.0.1:${server.address().port}/`);
|
||||
assert.equal(await page.locator('#workspace-settings').isVisible(), false);
|
||||
await state('connecting');
|
||||
await page.waitForTimeout(500);
|
||||
assert.equal(await page.locator('#workspace-settings').isVisible(), false);
|
||||
assert.equal(await page.locator('#startup-loading').isVisible(), true);
|
||||
await page.reload();
|
||||
await state('idle');
|
||||
for (const width of [320, 390, 1440]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
@@ -98,12 +104,7 @@ try {
|
||||
'https://first.example.com/',
|
||||
);
|
||||
await state('connecting', false, 'https://first.example.com/');
|
||||
assert.equal(
|
||||
await page
|
||||
.getByRole('button', { name: 'Open workspace', exact: true })
|
||||
.isDisabled(),
|
||||
true,
|
||||
);
|
||||
assert.equal(await page.locator('.open-workspace').isDisabled(), true);
|
||||
await state('connectionError', false, 'https://first.example.com/');
|
||||
assert.equal(
|
||||
await page.getByLabel('Server address').inputValue(),
|
||||
|
||||
@@ -6,6 +6,11 @@ import { normalizeServer, needsConfirmation } from '../ui/server.js';
|
||||
|
||||
const html = readFileSync(new URL('../ui/index.html', import.meta.url), 'utf8');
|
||||
|
||||
test('workspace selection is hidden before native configuration is available', () => {
|
||||
assert.match(html, /<main id="workspace-settings" hidden>/);
|
||||
assert.match(html, /id="startup-loading" role="status"/);
|
||||
});
|
||||
|
||||
test('every launcher text resolves to a nonempty English translation', () => {
|
||||
for (const [, key] of html.matchAll(/data-i18n="([^"]+)"/g)) {
|
||||
assert.equal(typeof en[key], 'string', key);
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
symlinkSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { updateSource } from '../update-manifest.mjs';
|
||||
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',
|
||||
plugins: { updater: { pubkey: updateSource.publicKey } },
|
||||
bundle: { macOS: { bundleVersion: '123' } },
|
||||
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', releaseNotes: 'Release notes' }),
|
||||
);
|
||||
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 bundle = join(
|
||||
directory,
|
||||
'src-tauri/target/aarch64-apple-darwin/release/bundle',
|
||||
);
|
||||
mkdirSync(join(bundle, 'dmg'), { recursive: true });
|
||||
mkdirSync(join(bundle, 'macos'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(bundle, 'dmg/KanbanGit_1.2.3_aarch64.dmg'),
|
||||
'notarized archive',
|
||||
);
|
||||
writeFileSync(
|
||||
join(bundle, 'macos/KanbanGit.app.tar.gz'),
|
||||
'notarized updater',
|
||||
);
|
||||
writeFileSync(join(bundle, 'macos/KanbanGit.app.tar.gz.sig'), 'signature');
|
||||
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 === 'hdiutil' && args[0] === 'attach') {
|
||||
symlinkSync('/Applications', join(args.at(-1), 'Applications'));
|
||||
}
|
||||
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',
|
||||
notes: 'Release notes',
|
||||
target: 'aarch64-apple-darwin',
|
||||
arch: 'arm64',
|
||||
filename: 'KanbanGit-1.2.3-aarch64-apple-darwin.dmg',
|
||||
updaterFilename: 'KanbanGit-1.2.3-aarch64-apple-darwin.app.tar.gz',
|
||||
});
|
||||
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, 3);
|
||||
assert.equal(f.calls.filter((call) => call.command === 'xcrun').length, 3);
|
||||
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',
|
||||
'hdiutil',
|
||||
'tar',
|
||||
]) {
|
||||
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,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,623 @@
|
||||
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: {
|
||||
TAURI_SIGNING_PRIVATE_KEY: 'test-private-key',
|
||||
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' &&
|
||||
args.includes('build') &&
|
||||
!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 === 'pnpm' && args.includes('signer')) {
|
||||
writeFileSync(`${args.at(-1)}.sig`, 'signature');
|
||||
}
|
||||
if (command === 'tar') {
|
||||
writeFileSync(args[1], 'updater with stapled ticket');
|
||||
}
|
||||
if (
|
||||
(command === 'hdiutil' && args[0] === 'create') ||
|
||||
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.dmg\n`,
|
||||
);
|
||||
assert.deepEqual(readdirSync(f.destination).sort(), [
|
||||
'KanbanGit-1.2.3-aarch64-apple-darwin.app.tar.gz',
|
||||
'KanbanGit-1.2.3-aarch64-apple-darwin.app.tar.gz.sha256',
|
||||
'KanbanGit-1.2.3-aarch64-apple-darwin.app.tar.gz.sig',
|
||||
'KanbanGit-1.2.3-aarch64-apple-darwin.dmg',
|
||||
'KanbanGit-1.2.3-aarch64-apple-darwin.dmg.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 updater signing key',
|
||||
{ TAURI_SIGNING_PRIVATE_KEY: '' },
|
||||
/TAURI_SIGNING_PRIVATE_KEY/,
|
||||
],
|
||||
[
|
||||
'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\.dmg$/,
|
||||
);
|
||||
});
|
||||
|
||||
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.includes('build')) ||
|
||||
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.includes('build')) ||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
test('release versions agree across native manifests', () => {
|
||||
const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8');
|
||||
const config = JSON.parse(read('../src-tauri/tauri.conf.json'));
|
||||
const ios = JSON.parse(read('../src-tauri/tauri.ios.conf.json'));
|
||||
const packageInfo = JSON.parse(read('../package.json'));
|
||||
assert.equal(packageInfo.version, config.version);
|
||||
assert.equal(
|
||||
read('../src-tauri/Cargo.toml').match(/^version = "([^"]+)"$/m)?.[1],
|
||||
config.version,
|
||||
);
|
||||
assert.equal(
|
||||
read('../src-tauri/Cargo.lock').match(
|
||||
/name = "kanbangit-native"\nversion = "([^"]+)"/,
|
||||
)?.[1],
|
||||
config.version,
|
||||
);
|
||||
assert.equal(
|
||||
read('../src-tauri/gen/apple/project.yml').match(
|
||||
/MARKETING_VERSION: "([^"]+)"/,
|
||||
)?.[1],
|
||||
config.version,
|
||||
);
|
||||
assert.equal(
|
||||
read('../src-tauri/gen/apple/project.yml').match(
|
||||
/CURRENT_PROJECT_VERSION: "([^"]+)"/,
|
||||
)?.[1],
|
||||
ios.bundle.iOS.bundleVersion,
|
||||
);
|
||||
const project = read(
|
||||
'../src-tauri/gen/apple/kanbangit-native.xcodeproj/project.pbxproj',
|
||||
);
|
||||
for (const [, version] of project.matchAll(
|
||||
/MARKETING_VERSION = ([^;]+);/g,
|
||||
)) {
|
||||
assert.equal(version, config.version);
|
||||
}
|
||||
for (const [, version] of project.matchAll(
|
||||
/CURRENT_PROJECT_VERSION = ([^;]+);/g,
|
||||
)) {
|
||||
assert.equal(version, ios.bundle.iOS.bundleVersion);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
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, remote, 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);
|
||||
assert.doesNotMatch(workflow, /inputs\.mode|RELEASE_MODE|\n {2}resume:/);
|
||||
assert.match(workflow, /--ci --target .* --bundles app,dmg/);
|
||||
assert.match(workflow, /node native\/macos-ci.mjs package/);
|
||||
assert.match(workflow, /node native\/publish-macos.mjs upload/);
|
||||
});
|
||||
|
||||
test('a moved macOS tag selects the new main commit for a full rebuild', (t) => {
|
||||
const f = fixture(t);
|
||||
f.git(
|
||||
f.remote,
|
||||
'-c',
|
||||
'commit.gpgsign=false',
|
||||
'commit',
|
||||
'--allow-empty',
|
||||
'-m',
|
||||
'Fix failed release',
|
||||
);
|
||||
const rebuilt = f.git(f.remote, 'rev-parse', 'HEAD');
|
||||
f.git(f.remote, 'tag', '--force', 'macos-v1.2.3');
|
||||
f.git(
|
||||
f.checkout,
|
||||
'fetch',
|
||||
'--force',
|
||||
'origin',
|
||||
'refs/tags/macos-v1.2.3:refs/tags/macos-v1.2.3',
|
||||
);
|
||||
const result = f.run();
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.output, `commit=${rebuilt}\ntag=macos-v1.2.3\n`);
|
||||
assert.notEqual(rebuilt, f.commit);
|
||||
assert.match(workflow, /tags:\n\s+- "macos-v\*"/);
|
||||
assert.match(workflow, /^ {2}group: macos-release$/m);
|
||||
assert.match(workflow, /^ {2}cancel-in-progress: false$/m);
|
||||
});
|
||||
|
||||
test('release DMG packaging disables Finder automation in the Tauri action environment', () => {
|
||||
const build = workflow
|
||||
.split(' - name: Build, sign and notarize with Tauri\n')[1]
|
||||
.split('\n - name:')[0];
|
||||
const environment = build
|
||||
.split(' env:\n')[1]
|
||||
.split(' with:\n')[0];
|
||||
assert.match(environment, /^ {10}CI: "true"$/m);
|
||||
assert.match(environment, /^ {10}TAURI_BUNDLER_DMG_IGNORE_CI: "false"$/m);
|
||||
assert.match(build, /--bundles app,dmg/);
|
||||
assert.match(build, /"createUpdaterArtifacts":true/);
|
||||
});
|
||||
|
||||
for (const [name, overrides] of [
|
||||
['wrong tag case', { GITHUB_REF: 'refs/tags/macOS-V1.2.3' }],
|
||||
['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, '');
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
class Element {
|
||||
constructor() {
|
||||
this.events = {};
|
||||
this.attributes = {};
|
||||
this.dataset = {};
|
||||
this.hidden = true;
|
||||
this.value = '';
|
||||
}
|
||||
addEventListener(name, callback) {
|
||||
this.events[name] = callback;
|
||||
}
|
||||
setAttribute(name, value) {
|
||||
this.attributes[name] = value;
|
||||
}
|
||||
removeAttribute(name) {
|
||||
delete this.attributes[name];
|
||||
}
|
||||
focus() {}
|
||||
fire(name) {
|
||||
this.events[name]({ preventDefault() {}, target: this });
|
||||
}
|
||||
}
|
||||
|
||||
test('Android launcher sends only explicit local actions and preserves switch confirmation', async () => {
|
||||
const elements = new Map();
|
||||
const element = (selector) => {
|
||||
if (!elements.has(selector)) {
|
||||
elements.set(selector, new Element());
|
||||
}
|
||||
return elements.get(selector);
|
||||
};
|
||||
const messages = [];
|
||||
globalThis.document = {
|
||||
querySelectorAll: () => [],
|
||||
querySelector: element,
|
||||
documentElement: new Element(),
|
||||
};
|
||||
globalThis.window = {
|
||||
nativeLauncher: {
|
||||
postMessage: (value) => messages.push(new URL(value)),
|
||||
},
|
||||
};
|
||||
globalThis.location = { href: 'https://tauri.localhost/index.html' };
|
||||
try {
|
||||
await import('../ui/main.js');
|
||||
assert.equal(messages.shift().pathname, '/action/ready');
|
||||
assert.equal(element('#workspace-settings').hidden, true);
|
||||
window.updateLauncher(
|
||||
'server=https%3A%2F%2Fexample.com%2F&phase=connecting&ready=false',
|
||||
);
|
||||
assert.equal(element('#workspace-settings').hidden, true);
|
||||
assert.equal(element('#startup-loading').hidden, false);
|
||||
window.updateLauncher(
|
||||
'server=https%3A%2F%2Fexample.com%2F&phase=connectionError&ready=false',
|
||||
);
|
||||
assert.equal(element('#workspace-settings').hidden, false);
|
||||
assert.equal(element('#startup-loading').hidden, true);
|
||||
assert.equal(element('.open-workspace').textContent, 'Try again');
|
||||
window.updateLauncher(
|
||||
'server=https%3A%2F%2Fexample.com%2F&phase=idle&ready=false&platform=android',
|
||||
);
|
||||
assert.equal(element('#server-url').value, 'https://example.com/');
|
||||
element('#server-url').value = 'http://example.com';
|
||||
element('#server-form').fire('submit');
|
||||
assert.equal(messages.length, 0);
|
||||
assert.equal(element('#server-url').attributes['aria-invalid'], 'true');
|
||||
element('#server-url').value = 'https://example.com/';
|
||||
element('#server-form').fire('submit');
|
||||
assert.equal(messages[0].pathname, '/action/open');
|
||||
assert.equal(
|
||||
messages.shift().searchParams.get('server'),
|
||||
'https://example.com/',
|
||||
);
|
||||
window.updateLauncher(
|
||||
'server=https%3A%2F%2Fexample.com%2F&phase=connected&ready=true&platform=android',
|
||||
);
|
||||
assert.match(
|
||||
element('#connection-status').textContent,
|
||||
/Use Server settings/,
|
||||
);
|
||||
element('#resume').fire('click');
|
||||
assert.equal(messages.shift().pathname, '/action/resume');
|
||||
element('#server-url').value = 'https://other.example.com/';
|
||||
element('#server-form').fire('submit');
|
||||
assert.equal(messages.length, 0);
|
||||
assert.equal(element('#switch-confirmation').hidden, false);
|
||||
element('#confirm-switch').fire('click');
|
||||
assert.equal(
|
||||
messages.shift().searchParams.get('server'),
|
||||
'https://other.example.com/',
|
||||
);
|
||||
assert.equal(location.href, 'https://tauri.localhost/index.html');
|
||||
} finally {
|
||||
delete globalThis.window;
|
||||
delete globalThis.document;
|
||||
delete globalThis.location;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { createServer } from 'node:http';
|
||||
import process from 'node:process';
|
||||
|
||||
const { chromium } = await import(
|
||||
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
|
||||
);
|
||||
const script = await readFile(
|
||||
new URL('../ui/workspace-navigation.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const server = createServer((request, response) => {
|
||||
response.setHeader('Content-Type', 'text/html');
|
||||
response.end(`<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><style>
|
||||
body { margin: 0; } #board { overflow-x: auto; width: 300px; height: 100px; }
|
||||
#board > div { width: 1500px; height: 100px; } dialog { max-height: 200px; overflow-y: auto; }
|
||||
</style></head><body><p id="page">${request.url}</p><input id="draft" value="retained"><div id="board"><div>Board columns</div></div>
|
||||
<dialog><button id="close">Close</button><div style="height:1000px">Long dialog</div></dialog><div id="long"></div></body></html>`);
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
await page.addInitScript(script);
|
||||
const root = `http://127.0.0.1:${server.address().port}`;
|
||||
try {
|
||||
await page.goto(root);
|
||||
const length = await page.evaluate(() => history.length);
|
||||
for (const path of ['/board', '/ticket', '/settings']) {
|
||||
await page.evaluate(
|
||||
(path) =>
|
||||
history.pushState(
|
||||
{
|
||||
page: { url: path },
|
||||
rememberedState: { draft: 'retained' },
|
||||
},
|
||||
'',
|
||||
path,
|
||||
),
|
||||
path,
|
||||
);
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
window.steps = 0;
|
||||
addEventListener('popstate', () => window.steps++);
|
||||
});
|
||||
await page.evaluate(() => window.__kanbanNavigation.step(-1));
|
||||
await page.waitForFunction(() => location.pathname === '/ticket');
|
||||
assert.equal(await page.evaluate(() => window.steps), 1);
|
||||
await page.evaluate(() => window.__kanbanNavigation.step(-1));
|
||||
await page.waitForFunction(() => location.pathname === '/board');
|
||||
await page.evaluate(() => window.__kanbanNavigation.step(1));
|
||||
await page.waitForFunction(() => location.pathname === '/ticket');
|
||||
assert.equal(await page.evaluate(() => window.steps), 3);
|
||||
assert.equal(await page.evaluate(() => history.length), length + 3);
|
||||
assert.equal(await page.locator('#draft').inputValue(), 'retained');
|
||||
assert.equal(
|
||||
await page.evaluate(() => history.state.rememberedState.draft),
|
||||
'retained',
|
||||
);
|
||||
await page.goto(`${root}/full-page`);
|
||||
await page.evaluate(() => window.__kanbanNavigation.step(-1));
|
||||
await page.waitForURL(`${root}/ticket`);
|
||||
await page.evaluate(() => window.__kanbanNavigation.step(1));
|
||||
await page.waitForURL(`${root}/full-page`);
|
||||
await page.locator('dialog').evaluate((dialog) => dialog.showModal());
|
||||
assert.equal(
|
||||
await page.evaluate(() => window.__kanbanNavigation.canSwipe(1, 1)),
|
||||
false,
|
||||
);
|
||||
await page.locator('dialog').evaluate((dialog) => dialog.close());
|
||||
for (const selector of ['#board', '#draft']) {
|
||||
const point = await page.locator(selector).boundingBox();
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
({ x, y }) => window.__kanbanNavigation.canSwipe(x + 5, y + 5),
|
||||
point,
|
||||
),
|
||||
false,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
await page.evaluate(() => window.__kanbanNavigation.canSwipe(1, 1)),
|
||||
true,
|
||||
);
|
||||
await page.dispatchEvent('#board', 'dragstart');
|
||||
assert.equal(
|
||||
await page.evaluate(() => window.__kanbanNavigation.canSwipe(1, 1)),
|
||||
false,
|
||||
);
|
||||
await page.dispatchEvent('#board', 'dragend');
|
||||
assert.equal(
|
||||
await page.evaluate(() => window.__kanbanNavigation.canSwipe(1, 1)),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
getComputedStyle(document.documentElement).overscrollBehaviorY,
|
||||
),
|
||||
'none',
|
||||
);
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
getComputedStyle(document.documentElement).overscrollBehaviorX,
|
||||
),
|
||||
'auto',
|
||||
);
|
||||
assert.equal(
|
||||
await page.evaluate(() => document.documentElement.scrollHeight),
|
||||
844,
|
||||
);
|
||||
await page.mouse.move(380, 400);
|
||||
await page.mouse.wheel(0, -1000);
|
||||
await page.waitForTimeout(200);
|
||||
assert.equal(await page.evaluate(() => scrollY), 0);
|
||||
await page
|
||||
.locator('#long')
|
||||
.evaluate((element) => (element.style.height = '3000px'));
|
||||
await page.mouse.wheel(0, 1000);
|
||||
await page.waitForFunction(() => scrollY > 0);
|
||||
await page.evaluate(() =>
|
||||
scrollTo(0, document.documentElement.scrollHeight),
|
||||
);
|
||||
await page.mouse.wheel(0, 5000);
|
||||
await page.waitForTimeout(200);
|
||||
assert.equal(
|
||||
await page.evaluate(() => scrollY + innerHeight),
|
||||
await page.evaluate(() => document.documentElement.scrollHeight),
|
||||
);
|
||||
await page.evaluate(() => scrollTo(0, 0));
|
||||
await page.locator('#board').hover();
|
||||
await page.mouse.wheel(500, 0);
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('#board').scrollLeft > 0,
|
||||
);
|
||||
assert.equal(new URL(page.url()).pathname, '/full-page');
|
||||
await page.locator('dialog').evaluate((dialog) => dialog.showModal());
|
||||
await page.locator('dialog').hover();
|
||||
await page.mouse.wheel(0, 500);
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('dialog').scrollTop > 0,
|
||||
);
|
||||
console.log(
|
||||
'PASS: SPA/full-page history, forward history, overlays, short/long scroll bounds, horizontal board, drag and input guards',
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
#import "../src-tauri/src/navigation.m"
|
||||
|
||||
// Exercise the AppKit event adapter deterministically; macos_smoke separately
|
||||
// runs the shared history script against a real WKWebView.
|
||||
@interface TestWindow : NSWindow
|
||||
@end
|
||||
@implementation TestWindow
|
||||
- (BOOL)isKeyWindow { return YES; }
|
||||
@end
|
||||
|
||||
@interface TestWebView : WKWebView
|
||||
@property(nonatomic, strong) NSWindow *testWindow;
|
||||
@property(nonatomic, strong) NSURL *testURL;
|
||||
@property(nonatomic) BOOL back;
|
||||
@property(nonatomic) BOOL forward;
|
||||
@property(nonatomic) BOOL allowsSwipe;
|
||||
@property(nonatomic) BOOL deferPermission;
|
||||
@property(nonatomic, copy) void (^permission)(id, NSError *);
|
||||
@property(nonatomic, strong) NSMutableArray<NSString *> *steps;
|
||||
@end
|
||||
@implementation TestWebView
|
||||
- (NSWindow *)window { return self.testWindow; }
|
||||
- (NSURL *)URL { return self.testURL; }
|
||||
- (BOOL)canGoBack { return self.back; }
|
||||
- (BOOL)canGoForward { return self.forward; }
|
||||
- (NSPoint)convertPoint:(NSPoint)point fromView:(NSView *)view { return point; }
|
||||
- (void)evaluateJavaScript:(NSString *)script completionHandler:(void (^)(id, NSError *))handler {
|
||||
if ([script containsString:@".step("]) [self.steps addObject:script];
|
||||
else if (self.deferPermission) self.permission = handler;
|
||||
else handler(@(self.allowsSwipe), nil);
|
||||
}
|
||||
@end
|
||||
|
||||
@interface TestEvent : NSEvent
|
||||
@property(nonatomic, strong) NSWindow *testWindow;
|
||||
@property(nonatomic) NSEventPhase testPhase;
|
||||
@property(nonatomic) NSEventPhase testMomentum;
|
||||
@property(nonatomic) CGFloat dx;
|
||||
@property(nonatomic) CGFloat dy;
|
||||
@property(nonatomic) BOOL precise;
|
||||
@property(nonatomic) NSInteger trackCount;
|
||||
@property(nonatomic, copy) void (^tracking)(CGFloat, NSEventPhase, BOOL, BOOL *);
|
||||
@end
|
||||
@implementation TestEvent
|
||||
- (NSWindow *)window { return self.testWindow; }
|
||||
- (NSEventPhase)phase { return self.testPhase; }
|
||||
- (NSEventPhase)momentumPhase { return self.testMomentum; }
|
||||
- (CGFloat)scrollingDeltaX { return self.dx; }
|
||||
- (CGFloat)scrollingDeltaY { return self.dy; }
|
||||
- (BOOL)hasPreciseScrollingDeltas { return self.precise; }
|
||||
- (NSPoint)locationInWindow { return NSMakePoint(20, 20); }
|
||||
- (void)trackSwipeEventWithOptions:(NSEventSwipeTrackingOptions)options dampenAmountThresholdMin:(CGFloat)minimum max:(CGFloat)maximum usingHandler:(void (^)(CGFloat, NSEventPhase, BOOL, BOOL *))handler {
|
||||
NSCAssert(options & NSEventSwipeTrackingLockDirection, @"Direction must be locked");
|
||||
self.trackCount++;
|
||||
self.tracking = handler;
|
||||
}
|
||||
@end
|
||||
|
||||
@interface TestNavigation : KanbanNavigation
|
||||
@property(nonatomic) BOOL enabled;
|
||||
@end
|
||||
@implementation TestNavigation
|
||||
- (BOOL)swipeTrackingEnabled { return self.enabled; }
|
||||
@end
|
||||
|
||||
int main(void) {
|
||||
@autoreleasepool {
|
||||
[NSApplication sharedApplication];
|
||||
TestWindow *window = [[TestWindow alloc] initWithContentRect:NSMakeRect(0, 0, 600, 400) styleMask:NSWindowStyleMaskBorderless backing:NSBackingStoreBuffered defer:NO];
|
||||
TestWebView *webview = [[TestWebView alloc] initWithFrame:NSMakeRect(0, 0, 600, 400)];
|
||||
webview.testWindow = window;
|
||||
webview.testURL = [NSURL URLWithString:@"https://example.com/board"];
|
||||
webview.back = YES;
|
||||
webview.forward = YES;
|
||||
webview.allowsSwipe = YES;
|
||||
webview.steps = [NSMutableArray new];
|
||||
TestNavigation *navigation = [TestNavigation new];
|
||||
navigation.webview = webview;
|
||||
navigation.enabled = YES;
|
||||
TestEvent *(^event)(void) = ^{
|
||||
TestEvent *value = [TestEvent new];
|
||||
value.testWindow = window;
|
||||
value.testPhase = NSEventPhaseBegan;
|
||||
value.precise = YES;
|
||||
value.dx = 15;
|
||||
return value;
|
||||
};
|
||||
BOOL stop = NO;
|
||||
TestEvent *back = event();
|
||||
[navigation handleEvent:back];
|
||||
NSCAssert(back.trackCount == 1, @"A horizontal gesture must use AppKit tracking");
|
||||
back.tracking(0.5, NSEventPhaseChanged, NO, &stop);
|
||||
NSCAssert(webview.steps.count == 0, @"A partial swipe must not navigate");
|
||||
back.tracking(1, NSEventPhaseNone, YES, &stop);
|
||||
NSCAssert(webview.steps.count == 1 && [webview.steps.lastObject containsString:@"step(-1)"], @"Back must request exactly one step");
|
||||
TestEvent *forward = event();
|
||||
forward.dx = -15;
|
||||
[navigation handleEvent:forward];
|
||||
forward.tracking(-1, NSEventPhaseNone, YES, &stop);
|
||||
NSCAssert(webview.steps.count == 2 && [webview.steps.lastObject containsString:@"step(1)"], @"Forward must request exactly one step");
|
||||
TestEvent *cancelled = event();
|
||||
[navigation handleEvent:cancelled];
|
||||
cancelled.tracking(0, NSEventPhaseCancelled, YES, &stop);
|
||||
NSCAssert(webview.steps.count == 2, @"Cancelled swipes must not navigate");
|
||||
TestEvent *interrupted = event();
|
||||
[navigation handleEvent:interrupted];
|
||||
webview.allowsSwipe = NO;
|
||||
[navigation handleEvent:event()];
|
||||
interrupted.tracking(1, NSEventPhaseNone, YES, &stop);
|
||||
NSCAssert(webview.steps.count == 2, @"A new gesture must cancel the previous gesture's animation callback");
|
||||
|
||||
for (NSInteger test = 0; test < 7; test++) {
|
||||
TestEvent *ignored = event();
|
||||
navigation.enabled = test != 0;
|
||||
webview.allowsSwipe = test != 1;
|
||||
webview.back = webview.forward = test != 2;
|
||||
if (test == 3) ignored.precise = NO;
|
||||
if (test == 4) ignored.dy = 30;
|
||||
if (test == 5) ignored.testMomentum = NSEventPhaseChanged;
|
||||
if (test == 6) ignored.testWindow = nil;
|
||||
NSCAssert([navigation handleEvent:ignored] == ignored && ignored.trackCount == 0, @"Non-navigation events must remain available for scrolling");
|
||||
}
|
||||
navigation.enabled = YES;
|
||||
webview.allowsSwipe = YES;
|
||||
webview.back = YES;
|
||||
webview.deferPermission = YES;
|
||||
TestEvent *stale = event();
|
||||
[navigation handleEvent:stale];
|
||||
TestEvent *ended = event();
|
||||
ended.testPhase = NSEventPhaseEnded;
|
||||
[navigation handleEvent:ended];
|
||||
webview.permission(@YES, nil);
|
||||
NSCAssert(stale.trackCount == 0, @"Late hit tests must not resurrect ended gestures");
|
||||
TestEvent *changedPage = event();
|
||||
[navigation handleEvent:changedPage];
|
||||
webview.testURL = [NSURL URLWithString:@"https://example.com/settings"];
|
||||
webview.permission(@YES, nil);
|
||||
NSCAssert(changedPage.trackCount == 0, @"Page changes must invalidate a pending gesture");
|
||||
puts("PASS: AppKit back/forward, cancelled/partial swipes, system preference, nested scroll ownership, empty history, mouse/vertical/momentum events, stale callbacks");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import process from 'node:process';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
test(
|
||||
'AppKit gestures preserve single steps, cancellation, system preferences and scroll ownership',
|
||||
{ skip: process.platform !== 'darwin' },
|
||||
() => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-navigation-'));
|
||||
const binary = join(directory, 'navigation-test');
|
||||
try {
|
||||
const compile = spawnSync(
|
||||
'xcrun',
|
||||
[
|
||||
'--sdk',
|
||||
'macosx',
|
||||
'clang',
|
||||
'-fobjc-arc',
|
||||
'-fblocks',
|
||||
'-Wall',
|
||||
'-Werror',
|
||||
'-framework',
|
||||
'AppKit',
|
||||
'-framework',
|
||||
'WebKit',
|
||||
fileURLToPath(
|
||||
new URL('./navigation-macos.m', import.meta.url),
|
||||
),
|
||||
'-o',
|
||||
binary,
|
||||
],
|
||||
{ encoding: 'utf8', timeout: 30000 },
|
||||
);
|
||||
assert.equal(
|
||||
compile.status,
|
||||
0,
|
||||
compile.stderr || compile.error?.message,
|
||||
);
|
||||
const result = spawnSync(binary, [], {
|
||||
encoding: 'utf8',
|
||||
timeout: 30000,
|
||||
});
|
||||
assert.equal(
|
||||
result.status,
|
||||
0,
|
||||
result.stderr || result.error?.message,
|
||||
);
|
||||
assert.match(result.stdout, /PASS: AppKit/);
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,888 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { 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';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
conditionalEtag,
|
||||
publishMacos,
|
||||
storageConfiguration,
|
||||
} from '../publish-macos.mjs';
|
||||
import { signedFixture } from './updater-fixture.mjs';
|
||||
|
||||
test('publisher rejects the removed resume command before accessing storage', () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
fileURLToPath(new URL('../publish-macos.mjs', import.meta.url)),
|
||||
'resume',
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(result.stdout, '');
|
||||
assert.match(
|
||||
result.stderr,
|
||||
/Use config or upload; upload requires RELEASE_DIRECTORY/,
|
||||
);
|
||||
});
|
||||
|
||||
test('conditional writes use bare S3 ETags without weakening the condition', () => {
|
||||
const etag = 'b88312b96169f4b2098a4cc43b0abb6a';
|
||||
for (const value of [etag, `"${etag}"`, `${etag}-2`, `"${etag}-2"`]) {
|
||||
assert.equal(conditionalEtag(value), value.replaceAll('"', ''));
|
||||
}
|
||||
for (const invalid of [
|
||||
undefined,
|
||||
null,
|
||||
'',
|
||||
'*',
|
||||
'"*"',
|
||||
`W/"${etag}"`,
|
||||
`"${etag}`,
|
||||
`${etag}"`,
|
||||
`${etag},${etag}`,
|
||||
`${etag}\r\n`,
|
||||
{},
|
||||
42,
|
||||
]) {
|
||||
assert.throws(() => conditionalEtag(invalid), /valid S3 ETag/);
|
||||
}
|
||||
});
|
||||
|
||||
function fixture(
|
||||
t,
|
||||
conflictMessage = 'aws failed: (PreconditionFailed) object exists',
|
||||
) {
|
||||
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.dmg',
|
||||
updaterFilename: 'KanbanGit-1.2.3-aarch64-apple-darwin.app.tar.gz',
|
||||
};
|
||||
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 updater = Buffer.from('signed update');
|
||||
const signing = signedFixture(updater);
|
||||
writeFileSync(join(directory, metadata.updaterFilename), updater);
|
||||
writeFileSync(
|
||||
join(directory, `${metadata.updaterFilename}.sig`),
|
||||
signing.signature,
|
||||
);
|
||||
writeFileSync(
|
||||
join(directory, `${metadata.updaterFilename}.sha256`),
|
||||
`${createHash('sha256').update(updater).digest('hex')} ${metadata.updaterFilename}\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, args });
|
||||
if (operation === 'get-bucket-versioning') {
|
||||
assert.equal(value('--output'), 'json');
|
||||
return '';
|
||||
}
|
||||
if (operation === 'put-object') {
|
||||
if (!args.includes('--if-match')) {
|
||||
assert.equal(value('--if-none-match'), '*');
|
||||
}
|
||||
if (objects.has(key)) {
|
||||
if (!args.includes('--if-match')) {
|
||||
throw new Error(conflictMessage);
|
||||
}
|
||||
assert.equal(
|
||||
value('--if-match'),
|
||||
'b88312b96169f4b2098a4cc43b0abb6a',
|
||||
);
|
||||
}
|
||||
objects.set(key, readFileSync(value('--body')));
|
||||
return '{}';
|
||||
}
|
||||
if (operation === 'get-object') {
|
||||
if (!objects.has(key)) {
|
||||
throw new Error('aws failed: (NoSuchKey) object missing');
|
||||
}
|
||||
writeFileSync(args.at(-1), objects.get(key));
|
||||
return JSON.stringify({
|
||||
ETag: '"b88312b96169f4b2098a4cc43b0abb6a"',
|
||||
});
|
||||
}
|
||||
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,
|
||||
publicKey: signing.publicKey,
|
||||
feedBase:
|
||||
'https://release-bucket.nbg1.your-objectstorage.com/releases/macos',
|
||||
metadata,
|
||||
env,
|
||||
objects,
|
||||
events,
|
||||
run,
|
||||
fetchPublic,
|
||||
wait: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
test('uploads revalidated 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.equal(
|
||||
f.events.filter((event) => event.operation === 'put-object').length,
|
||||
9,
|
||||
);
|
||||
assert.equal(f.objects.size, 9);
|
||||
for (const event of f.events.filter(
|
||||
(event) => event.operation === 'put-object',
|
||||
)) {
|
||||
assert.equal(
|
||||
event.args[event.args.indexOf('--cache-control') + 1],
|
||||
'no-cache, max-age=0, must-revalidate',
|
||||
);
|
||||
}
|
||||
assert(f.events.at(-1).key.endsWith('/latest.json'));
|
||||
assert.equal(
|
||||
f.events.at(-1).key,
|
||||
'releases/macos/aarch64-apple-darwin/latest.json',
|
||||
);
|
||||
const downloadKey = 'releases/macos/aarch64-apple-darwin/KanbanGit.dmg';
|
||||
assert.deepEqual(
|
||||
f.objects.get(downloadKey),
|
||||
readFileSync(join(f.directory, f.metadata.filename)),
|
||||
);
|
||||
const downloadPut = f.events.findIndex(
|
||||
(event) =>
|
||||
event.operation === 'put-object' && event.key === downloadKey,
|
||||
);
|
||||
const downloadVerified = f.events.findIndex(
|
||||
(event) =>
|
||||
event.operation === 'public-get' && event.key === downloadKey,
|
||||
);
|
||||
const feedPut = f.events.findIndex(
|
||||
(event) =>
|
||||
event.operation === 'put-object' &&
|
||||
event.key.endsWith('/latest.json'),
|
||||
);
|
||||
assert(downloadPut > 0 && downloadVerified > downloadPut);
|
||||
assert(feedPut > downloadVerified);
|
||||
assert.deepEqual(
|
||||
JSON.parse(
|
||||
f.objects.get(
|
||||
'releases/macos/aarch64-apple-darwin/1.2.3/release.json',
|
||||
),
|
||||
),
|
||||
{
|
||||
version: '1.2.3',
|
||||
target: f.metadata.target,
|
||||
url,
|
||||
sha256: createHash('sha256')
|
||||
.update('verified release')
|
||||
.digest('hex'),
|
||||
checksumUrl: `${url}.sha256`,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('replaces the stable download only for a newer verified release', async (t) => {
|
||||
const f = fixture(t);
|
||||
const downloadKey = `releases/macos/${f.metadata.target}/KanbanGit.dmg`;
|
||||
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
||||
f.objects.set(downloadKey, Buffer.from('previous download'));
|
||||
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.0.0' })));
|
||||
|
||||
await publishMacos(f);
|
||||
|
||||
assert.deepEqual(
|
||||
f.objects.get(downloadKey),
|
||||
readFileSync(join(f.directory, f.metadata.filename)),
|
||||
);
|
||||
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.2.3');
|
||||
const replacement = f.events.find(
|
||||
(event) =>
|
||||
event.operation === 'put-object' &&
|
||||
event.key === downloadKey &&
|
||||
event.args.includes('--if-match'),
|
||||
);
|
||||
assert(replacement);
|
||||
});
|
||||
|
||||
test('a newer published version protects the stable download even when its alias is missing', async (t) => {
|
||||
const f = fixture(t);
|
||||
const downloadKey = `releases/macos/${f.metadata.target}/KanbanGit.dmg`;
|
||||
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
||||
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '2.0.0' })));
|
||||
|
||||
await assert.rejects(publishMacos(f), /downgrade/);
|
||||
|
||||
assert.equal(f.objects.has(downloadKey), false);
|
||||
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '2.0.0');
|
||||
});
|
||||
|
||||
test('a published version cannot replace a different stable download', async (t) => {
|
||||
const f = fixture(t);
|
||||
const downloadKey = `releases/macos/${f.metadata.target}/KanbanGit.dmg`;
|
||||
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
||||
const original = Buffer.from('published download');
|
||||
f.objects.set(downloadKey, original);
|
||||
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.2.3' })));
|
||||
|
||||
await assert.rejects(publishMacos(f), /bump the app version/);
|
||||
|
||||
assert.deepEqual(f.objects.get(downloadKey), original);
|
||||
});
|
||||
|
||||
test('a failed public stable download blocks feed promotion', async (t) => {
|
||||
const f = fixture(t);
|
||||
const fetchPublic = f.fetchPublic;
|
||||
f.fetchPublic = (url, options) =>
|
||||
url.endsWith('/KanbanGit.dmg')
|
||||
? Promise.resolve({ ok: false, status: 403 })
|
||||
: fetchPublic(url, options);
|
||||
|
||||
await assert.rejects(publishMacos(f), /KanbanGit\.dmg: HTTP 403/);
|
||||
|
||||
assert.equal(
|
||||
f.objects.has(`releases/macos/${f.metadata.target}/latest.json`),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('concurrent stable download replacement preserves the winner', async (t) => {
|
||||
const f = fixture(t);
|
||||
const downloadKey = `releases/macos/${f.metadata.target}/KanbanGit.dmg`;
|
||||
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
||||
f.objects.set(downloadKey, Buffer.from('previous download'));
|
||||
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.0.0' })));
|
||||
const run = f.run;
|
||||
f.run = (command, args) => {
|
||||
if (args.includes('--if-match') && args.includes(downloadKey)) {
|
||||
f.objects.set(downloadKey, Buffer.from('concurrent winner'));
|
||||
throw new Error('(PreconditionFailed) concurrent replacement');
|
||||
}
|
||||
return run(command, args);
|
||||
};
|
||||
|
||||
await assert.rejects(publishMacos(f), /concurrent replacement/);
|
||||
|
||||
assert.equal(f.objects.get(downloadKey).toString(), 'concurrent winner');
|
||||
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.0.0');
|
||||
});
|
||||
|
||||
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, 9);
|
||||
assert.equal(f.events.at(-1).operation, 'public-get');
|
||||
assert(f.events.at(-1).key.endsWith('/latest.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,
|
||||
11,
|
||||
);
|
||||
});
|
||||
|
||||
for (const published of ['1.2.3', '1.2.4']) {
|
||||
test(`published release stays protected when the feed is at ${published}`, 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'));
|
||||
f.objects.set(
|
||||
`releases/macos/${f.metadata.target}/latest.json`,
|
||||
Buffer.from(JSON.stringify({ version: published })),
|
||||
);
|
||||
await assert.rejects(publishMacos(f), /bump the app version/);
|
||||
assert.equal(f.objects.get(key).toString(), 'previous version content');
|
||||
assert.equal(f.objects.size, 2);
|
||||
});
|
||||
}
|
||||
|
||||
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',
|
||||
status: failure === 'private' ? 403 : 200,
|
||||
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 be retried 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, 3);
|
||||
f.run = run;
|
||||
await publishMacos(f);
|
||||
assert.equal(f.objects.size, 9);
|
||||
});
|
||||
|
||||
function rebuild(f) {
|
||||
const updater = Buffer.from('rebuilt signed update');
|
||||
const signing = signedFixture(updater);
|
||||
f.publicKey = signing.publicKey;
|
||||
f.metadata.notes = 'Fixed build of the same version';
|
||||
for (const [name, contents] of [
|
||||
[f.metadata.filename, Buffer.from('rebuilt disk image')],
|
||||
[f.metadata.updaterFilename, updater],
|
||||
]) {
|
||||
writeFileSync(join(f.directory, name), contents);
|
||||
writeFileSync(
|
||||
join(f.directory, `${name}.sha256`),
|
||||
`${createHash('sha256').update(contents).digest('hex')} ${name}\n`,
|
||||
);
|
||||
}
|
||||
writeFileSync(
|
||||
join(f.directory, `${f.metadata.updaterFilename}.sig`),
|
||||
signing.signature,
|
||||
);
|
||||
}
|
||||
|
||||
for (const previous of [null, '1.0.0']) {
|
||||
for (const interruptedFile of ['.sha256', '/latest.json']) {
|
||||
test(`rebuild replaces unfinished ${interruptedFile} release with previous feed ${previous}`, async (t) => {
|
||||
const f = fixture(t);
|
||||
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
||||
const previousBytes =
|
||||
previous === null
|
||||
? undefined
|
||||
: Buffer.from(JSON.stringify({ version: previous }));
|
||||
if (previousBytes) {
|
||||
f.objects.set(latestKey, previousBytes);
|
||||
}
|
||||
const run = f.run;
|
||||
f.run = (command, args) => {
|
||||
if (
|
||||
args.includes('put-object') &&
|
||||
args[args.indexOf('--key') + 1].endsWith(interruptedFile)
|
||||
) {
|
||||
throw new Error('upload interrupted');
|
||||
}
|
||||
return run(command, args);
|
||||
};
|
||||
await assert.rejects(publishMacos(f), /upload interrupted/);
|
||||
assert.deepEqual(f.objects.get(latestKey), previousBytes);
|
||||
rebuild(f);
|
||||
f.events.length = 0;
|
||||
let promoted = false;
|
||||
f.run = (command, args) => {
|
||||
if (!promoted) {
|
||||
assert.deepEqual(f.objects.get(latestKey), previousBytes);
|
||||
}
|
||||
const result = run(command, args);
|
||||
if (args.includes('put-object') && args.includes(latestKey)) {
|
||||
promoted = true;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
await publishMacos(f);
|
||||
assert.equal(f.objects.size, 9);
|
||||
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.2.3');
|
||||
assert.equal(
|
||||
JSON.parse(f.objects.get(latestKey)).notes,
|
||||
f.metadata.notes,
|
||||
);
|
||||
for (const name of [
|
||||
f.metadata.filename,
|
||||
`${f.metadata.filename}.sha256`,
|
||||
f.metadata.updaterFilename,
|
||||
`${f.metadata.updaterFilename}.sig`,
|
||||
`${f.metadata.updaterFilename}.sha256`,
|
||||
]) {
|
||||
assert.deepEqual(
|
||||
f.objects.get(
|
||||
`releases/macos/${f.metadata.target}/1.2.3/${name}`,
|
||||
),
|
||||
readFileSync(join(f.directory, name)),
|
||||
);
|
||||
}
|
||||
const published = [...f.objects.entries()];
|
||||
rebuild(f);
|
||||
await assert.rejects(publishMacos(f), /already been released/);
|
||||
assert.deepEqual([...f.objects.entries()], published);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('failure after promoting the feed still protects that version from rebuilding', async (t) => {
|
||||
const f = fixture(t);
|
||||
const fetchPublic = f.fetchPublic;
|
||||
f.fetchPublic = async (url, options) =>
|
||||
url.endsWith('/latest.json')
|
||||
? { ok: false, status: 403 }
|
||||
: fetchPublic(url, options);
|
||||
await assert.rejects(publishMacos(f), /HTTP 403/);
|
||||
const published = [...f.objects.entries()];
|
||||
rebuild(f);
|
||||
await assert.rejects(publishMacos(f), /already been released/);
|
||||
assert.deepEqual([...f.objects.entries()], published);
|
||||
});
|
||||
|
||||
test('a concurrent change to a partial artifact is not 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('unfinished old build'));
|
||||
const run = f.run;
|
||||
f.run = (command, args) => {
|
||||
if (args.includes('--if-match')) {
|
||||
assert(args.includes(key));
|
||||
f.objects.set(key, Buffer.from('concurrent winner'));
|
||||
throw new Error('(PreconditionFailed) concurrent replacement');
|
||||
}
|
||||
return run(command, args);
|
||||
};
|
||||
await assert.rejects(publishMacos(f), /concurrent replacement/);
|
||||
assert.equal(f.objects.get(key).toString(), 'concurrent winner');
|
||||
assert.equal(f.objects.size, 1);
|
||||
});
|
||||
|
||||
for (const failure of [
|
||||
'AccessDenied',
|
||||
'network failure',
|
||||
'invalid JSON',
|
||||
'invalid version',
|
||||
]) {
|
||||
test(`cannot replace a partial artifact when the feed check has ${failure}`, async (t) => {
|
||||
const f = fixture(t);
|
||||
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
|
||||
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
||||
f.objects.set(key, Buffer.from('unfinished old build'));
|
||||
f.objects.set(
|
||||
latestKey,
|
||||
Buffer.from(
|
||||
failure === 'invalid version' ? '{"version":"invalid"}' : '{',
|
||||
),
|
||||
);
|
||||
const originals = [...f.objects.entries()];
|
||||
const run = f.run;
|
||||
f.run = (command, args) => {
|
||||
if (
|
||||
args.includes('get-object') &&
|
||||
args.includes(latestKey) &&
|
||||
!failure.startsWith('invalid')
|
||||
) {
|
||||
throw new Error(failure);
|
||||
}
|
||||
return run(command, args);
|
||||
};
|
||||
await assert.rejects(publishMacos(f));
|
||||
assert.deepEqual([...f.objects.entries()], originals);
|
||||
});
|
||||
}
|
||||
|
||||
for (const listing of [
|
||||
{ KeyCount: 0, IsTruncated: false },
|
||||
{ KeyCount: 1, IsTruncated: false, Contents: [{ Key: 'latest.json' }] },
|
||||
{},
|
||||
{ KeyCount: 0, IsTruncated: true },
|
||||
{ KeyCount: 0, IsTruncated: false, Contents: '' },
|
||||
'AccessDenied',
|
||||
]) {
|
||||
test(`ambiguous feed read error needs a proven empty listing: ${JSON.stringify(listing)}`, async (t) => {
|
||||
const f = fixture(t);
|
||||
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
|
||||
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
||||
f.objects.set(key, Buffer.from('unfinished old build'));
|
||||
const run = f.run;
|
||||
f.run = (command, args) => {
|
||||
if (args.includes('get-object') && args.includes(latestKey)) {
|
||||
throw new Error(
|
||||
"argument of type 'NoneType' is not a container or iterable",
|
||||
);
|
||||
}
|
||||
if (args.includes('list-objects-v2')) {
|
||||
assert.equal(args[args.indexOf('--prefix') + 1], latestKey);
|
||||
if (listing === 'AccessDenied') {
|
||||
throw new Error('AccessDenied');
|
||||
}
|
||||
return JSON.stringify(listing);
|
||||
}
|
||||
return run(command, args);
|
||||
};
|
||||
if (
|
||||
listing.KeyCount === 0 &&
|
||||
listing.IsTruncated === false &&
|
||||
!('Contents' in listing)
|
||||
) {
|
||||
await publishMacos(f);
|
||||
assert.equal(f.objects.size, 9);
|
||||
} else {
|
||||
await assert.rejects(publishMacos(f), /NoneType|AccessDenied/);
|
||||
assert.equal(f.objects.get(key).toString(), 'unfinished old build');
|
||||
assert.equal(f.objects.size, 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const status of [403, 404, 408, 429, 500, 502, 503, 504, 'network']) {
|
||||
test(`temporary public download failure ${status} recovers before completion`, async (t) => {
|
||||
const f = fixture(t);
|
||||
const fetchPublic = f.fetchPublic;
|
||||
let attempts = 0;
|
||||
let cancelled = 0;
|
||||
const waits = [];
|
||||
f.wait = async (ms) => waits.push(ms);
|
||||
f.fetchPublic = async (...args) => {
|
||||
if (++attempts <= 2) {
|
||||
if (status === 'network') {
|
||||
throw new Error('temporary network failure');
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
body: { cancel: async () => cancelled++ },
|
||||
};
|
||||
}
|
||||
return fetchPublic(...args);
|
||||
};
|
||||
await publishMacos(f);
|
||||
assert.deepEqual(waits, [2000, 2000]);
|
||||
assert.equal(cancelled, status === 'network' ? 0 : 2);
|
||||
assert.equal(f.objects.size, 9);
|
||||
assert(f.events.at(-1).key.endsWith('/latest.json'));
|
||||
});
|
||||
}
|
||||
|
||||
test('persistent public access denial stops after five attempts with HTTP status', async (t) => {
|
||||
const f = fixture(t);
|
||||
let attempts = 0;
|
||||
const waits = [];
|
||||
f.wait = async (ms) => waits.push(ms);
|
||||
f.fetchPublic = async () => {
|
||||
attempts++;
|
||||
return { ok: false, status: 403 };
|
||||
};
|
||||
await assert.rejects(publishMacos(f), /HTTP 403/);
|
||||
assert.equal(attempts, 5);
|
||||
assert.equal(waits.length, 4);
|
||||
assert.equal(f.objects.size, 1);
|
||||
});
|
||||
|
||||
test('incorrect public bytes fail immediately without retry or completion record', async (t) => {
|
||||
const f = fixture(t);
|
||||
f.wait = async () => assert.fail('Do not retry mismatched content');
|
||||
f.fetchPublic = async () => ({
|
||||
ok: true,
|
||||
arrayBuffer: async () => Buffer.from('incorrect'),
|
||||
});
|
||||
await assert.rejects(publishMacos(f), /SHA-256 mismatch/);
|
||||
assert.equal(f.objects.size, 1);
|
||||
});
|
||||
|
||||
test('checksum access retries finish before publishing the completion record', async (t) => {
|
||||
const f = fixture(t);
|
||||
const fetchPublic = f.fetchPublic;
|
||||
let failures = 0;
|
||||
f.fetchPublic = async (url, options) => {
|
||||
if (url.endsWith(`${f.metadata.filename}.sha256`) && ++failures <= 2) {
|
||||
assert.equal(f.objects.size, 5);
|
||||
return { ok: false, status: 403 };
|
||||
}
|
||||
return fetchPublic(url, options);
|
||||
};
|
||||
await publishMacos(f);
|
||||
assert.equal(failures, 3);
|
||||
assert.equal(f.objects.size, 9);
|
||||
});
|
||||
|
||||
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');
|
||||
}
|
||||
});
|
||||
|
||||
test('refuses a destination that differs from the built-in update source', async (t) => {
|
||||
const f = fixture(t);
|
||||
f.feedBase = 'https://another.example/releases/macos';
|
||||
await assert.rejects(publishMacos(f), /does not match/);
|
||||
assert.equal(f.objects.size, 0);
|
||||
});
|
||||
|
||||
test('rejects a corrupt updater archive before publishing any object', async (t) => {
|
||||
const f = fixture(t);
|
||||
writeFileSync(join(f.directory, f.metadata.updaterFilename), 'tampered');
|
||||
await assert.rejects(publishMacos(f), /signature/);
|
||||
assert.equal(f.objects.size, 0);
|
||||
});
|
||||
|
||||
test('atomically promotes a newer feed only after all release files are public', async (t) => {
|
||||
const f = fixture(t);
|
||||
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
||||
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.0.0' })));
|
||||
await publishMacos(f);
|
||||
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.2.3');
|
||||
const releaseVerified = f.events.findIndex(
|
||||
(event) =>
|
||||
event.operation === 'public-get' &&
|
||||
event.key.endsWith('/update.json'),
|
||||
);
|
||||
const latestPut = f.events.findIndex(
|
||||
(event) => event.operation === 'put-object' && event.key === latestKey,
|
||||
);
|
||||
assert(latestPut > releaseVerified);
|
||||
});
|
||||
|
||||
for (const previous of [
|
||||
{ version: '2.0.0' },
|
||||
{ version: '1.2.3', notes: 'different' },
|
||||
]) {
|
||||
test(`does not replace conflicting update feed ${previous.version}`, async (t) => {
|
||||
const f = fixture(t);
|
||||
const key = `releases/macos/${f.metadata.target}/latest.json`;
|
||||
const original = Buffer.from(JSON.stringify(previous));
|
||||
f.objects.set(key, original);
|
||||
await assert.rejects(publishMacos(f), /downgrade|differs/);
|
||||
assert.deepEqual(f.objects.get(key), original);
|
||||
});
|
||||
}
|
||||
|
||||
test('concurrent feed replacement fails without overwriting the winner', async (t) => {
|
||||
const f = fixture(t);
|
||||
const key = `releases/macos/${f.metadata.target}/latest.json`;
|
||||
f.objects.set(key, Buffer.from(JSON.stringify({ version: '1.0.0' })));
|
||||
const run = f.run;
|
||||
f.run = (command, args) => {
|
||||
if (args.includes('--if-match')) {
|
||||
f.objects.set(
|
||||
key,
|
||||
Buffer.from(JSON.stringify({ version: '2.0.0' })),
|
||||
);
|
||||
throw new Error('(PreconditionFailed) concurrent publication');
|
||||
}
|
||||
return run(command, args);
|
||||
};
|
||||
await assert.rejects(publishMacos(f), /concurrent publication/);
|
||||
assert.equal(JSON.parse(f.objects.get(key)).version, '2.0.0');
|
||||
});
|
||||
|
||||
for (const message of [
|
||||
"aws failed (exit 255). aws: [ERROR]: argument of type 'NoneType' is not a container or iterable",
|
||||
"aws failed (exit 255). argument of type 'NoneType' is not iterable",
|
||||
]) {
|
||||
test(`empty S3 error message permits verified promotion and identical retry: ${message}`, async (t) => {
|
||||
const f = fixture(t, message);
|
||||
const key = `releases/macos/${f.metadata.target}/latest.json`;
|
||||
f.objects.set(key, Buffer.from(JSON.stringify({ version: '1.0.0' })));
|
||||
await publishMacos(f);
|
||||
assert.equal(JSON.parse(f.objects.get(key)).version, '1.2.3');
|
||||
const published = [...f.objects.entries()];
|
||||
await publishMacos(f);
|
||||
assert.deepEqual([...f.objects.entries()], published);
|
||||
});
|
||||
|
||||
test(`empty S3 error cannot replace a published release: ${message}`, async (t) => {
|
||||
const f = fixture(t, message);
|
||||
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
|
||||
f.objects.set(key, Buffer.from('different existing release'));
|
||||
f.objects.set(
|
||||
`releases/macos/${f.metadata.target}/latest.json`,
|
||||
Buffer.from(JSON.stringify({ version: '1.2.3' })),
|
||||
);
|
||||
await assert.rejects(publishMacos(f), /bump the app version/);
|
||||
assert.equal(f.objects.size, 2);
|
||||
assert.equal(
|
||||
f.objects.get(key).toString(),
|
||||
'different existing release',
|
||||
);
|
||||
});
|
||||
|
||||
for (const previous of [
|
||||
{ version: '2.0.0' },
|
||||
{ version: '1.2.3', notes: 'different' },
|
||||
]) {
|
||||
test(`empty S3 error cannot bypass feed conflict checks ${previous.version}: ${message}`, async (t) => {
|
||||
const f = fixture(t, message);
|
||||
const key = `releases/macos/${f.metadata.target}/latest.json`;
|
||||
const original = Buffer.from(JSON.stringify(previous));
|
||||
f.objects.set(key, original);
|
||||
await assert.rejects(publishMacos(f), /downgrade|differs/);
|
||||
assert.deepEqual(f.objects.get(key), original);
|
||||
});
|
||||
}
|
||||
|
||||
test(`empty S3 error aborts when existing contents cannot be read: ${message}`, async (t) => {
|
||||
const f = fixture(t, message);
|
||||
const run = f.run;
|
||||
f.run = (command, args) => {
|
||||
if (args.includes('put-object')) {
|
||||
throw new Error(message);
|
||||
}
|
||||
if (args.includes('get-object')) {
|
||||
throw new Error('AccessDenied');
|
||||
}
|
||||
return run(command, args);
|
||||
};
|
||||
await assert.rejects(publishMacos(f), /S3 get-object .*AccessDenied/);
|
||||
assert.equal(f.objects.size, 0);
|
||||
});
|
||||
|
||||
test(`empty S3 error during conditional replacement preserves a concurrent winner: ${message}`, async (t) => {
|
||||
const f = fixture(t, message);
|
||||
const key = `releases/macos/${f.metadata.target}/latest.json`;
|
||||
f.objects.set(key, Buffer.from(JSON.stringify({ version: '1.0.0' })));
|
||||
const run = f.run;
|
||||
let attempts = 0;
|
||||
f.run = (command, args) => {
|
||||
if (args.includes('--if-match')) {
|
||||
attempts++;
|
||||
f.objects.set(
|
||||
key,
|
||||
Buffer.from(JSON.stringify({ version: '2.0.0' })),
|
||||
);
|
||||
throw new Error(message);
|
||||
}
|
||||
return run(command, args);
|
||||
};
|
||||
await assert.rejects(
|
||||
publishMacos(f),
|
||||
/S3 put-object .*latest.json: .*NoneType/,
|
||||
);
|
||||
assert.equal(attempts, 1);
|
||||
assert.equal(JSON.parse(f.objects.get(key)).version, '2.0.0');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { createHash, generateKeyPairSync, sign } from 'node:crypto';
|
||||
|
||||
export function signedFixture(bytes) {
|
||||
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
||||
const keyId = Buffer.alloc(8, 7);
|
||||
const key = Buffer.concat([
|
||||
Buffer.from('Ed'),
|
||||
keyId,
|
||||
publicKey.export({ type: 'spki', format: 'der' }).subarray(-32),
|
||||
]);
|
||||
const signature = sign(
|
||||
null,
|
||||
createHash('blake2b512').update(bytes).digest(),
|
||||
privateKey,
|
||||
);
|
||||
const trusted = 'timestamp:123456';
|
||||
const globalSignature = sign(
|
||||
null,
|
||||
Buffer.concat([signature, Buffer.from(trusted)]),
|
||||
privateKey,
|
||||
);
|
||||
return {
|
||||
publicKey: Buffer.from(
|
||||
`untrusted comment: test\n${key.toString('base64')}\n`,
|
||||
).toString('base64'),
|
||||
signature: Buffer.from(
|
||||
`untrusted comment: test\n${Buffer.concat([Buffer.from('ED'), keyId, signature]).toString('base64')}\ntrusted comment: ${trusted}\n${globalSignature.toString('base64')}\n`,
|
||||
).toString('base64'),
|
||||
};
|
||||
}
|
||||