Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d07b17ebdb | ||
|
|
859229fa21 | ||
|
|
32dac91620 | ||
|
|
39d0887180 | ||
|
|
340e6bfff1 | ||
|
|
f3f1ffdc52 | ||
|
|
2a56d03dbf | ||
|
|
7e19364757 | ||
|
|
de0c19a86b | ||
|
|
ce250d8be5 | ||
|
|
5c2b62de2a | ||
|
|
b5030e6acc | ||
|
|
6224880219 | ||
|
|
19d102d140 | ||
|
|
e70b6b4de0 | ||
|
|
d9a1288d73 | ||
|
|
1d9df6897d | ||
|
|
0a2807a28c | ||
|
|
8a9113a7af | ||
|
|
0a974cfd49 | ||
|
|
538b17b9b8 | ||
|
|
b2789b4670 | ||
|
|
7c6981dc0a | ||
|
|
b8409a702c | ||
|
|
2acbdb4760 | ||
|
|
9faf02e2c4 | ||
|
|
0941745883 | ||
|
|
5a10c85e61 | ||
|
|
fb4faa6070 | ||
|
|
b226ee8d48 | ||
|
|
ac553f0087 | ||
|
|
28d20882ac | ||
|
|
208989d5fc | ||
|
|
d2f659de8f | ||
|
|
f461439ce6 | ||
|
|
3eea473336 | ||
|
|
bfec4f464e | ||
|
|
2edee702d4 | ||
|
|
9b90c24224 | ||
|
|
3869e5b8a6 | ||
|
|
196623bb67 | ||
|
|
bc6a113acf | ||
|
|
1377ee8510 | ||
|
|
2409c3b18f | ||
|
|
f61298fda4 | ||
|
|
7a4c6f3865 | ||
|
|
79c78b3434 | ||
|
|
38438b4700 | ||
|
|
fed0b657c0 | ||
|
|
1fe0c0b2d9 | ||
|
|
463cb7fe36 | ||
|
|
3bf5a0c508 | ||
|
|
32de1f425a | ||
|
|
1af98cd56e | ||
|
|
aadf159ac5 | ||
|
|
5c47453d4d |
@@ -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
|
||||
|
||||
@@ -33,9 +33,6 @@
|
||||
vendor
|
||||
**/.DS_Store
|
||||
.editorconfig
|
||||
.env.example
|
||||
.env.mariadb
|
||||
.env.mariadb.example
|
||||
.env.production
|
||||
LICENSE
|
||||
README.md
|
||||
@@ -46,3 +43,7 @@ storage/framework/sessions/*
|
||||
storage/framework/testing/*
|
||||
storage/framework/views/*
|
||||
storage/logs/*
|
||||
|
||||
# Native build artifacts are independent of the Laravel image.
|
||||
native/src-tauri/target
|
||||
native/src-tauri/gen
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
MARIADB_DATABASE=kanbangit
|
||||
MARIADB_USER=kanbangit
|
||||
MARIADB_PASSWORD=
|
||||
MARIADB_ROOT_PASSWORD=
|
||||
@@ -1,5 +1,5 @@
|
||||
name: "Lint and Test API"
|
||||
description: "Run Pint and the Pest test suite for the Laravel API"
|
||||
name: "Lint and Test APP"
|
||||
description: "Run Pint and the Pest test suite"
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
@@ -15,7 +15,7 @@ runs:
|
||||
- name: Verify Composer lockfile
|
||||
run: |
|
||||
if [ ! -f composer.lock ]; then
|
||||
echo "::error::api/composer.lock not found"
|
||||
echo "::error::composer.lock not found"
|
||||
exit 1
|
||||
fi
|
||||
shell: bash
|
||||
@@ -29,7 +29,7 @@ runs:
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: ${{ steps.composer_cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-php-8.5-${{ hashFiles('api/composer.lock') }}
|
||||
key: ${{ runner.os }}-php-8.5-${{ hashFiles('composer.lock') }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v11.7.0
|
||||
@@ -63,10 +63,16 @@ runs:
|
||||
run: php artisan wayfinder:generate --with-form
|
||||
shell: bash
|
||||
|
||||
- name: Build Vite app
|
||||
run: pnpm run build
|
||||
- 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 ci check
|
||||
run: composer run ci:check
|
||||
shell: bash
|
||||
- name: Run Laravel tests
|
||||
run: composer run ci:test
|
||||
shell: bash
|
||||
|
||||
@@ -28,3 +28,16 @@ yarn-error.log
|
||||
/.vscode
|
||||
/.zed
|
||||
.codex/config.toml
|
||||
|
||||
/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/
|
||||
/native/src-tauri/gen/apple/assets/
|
||||
/native/src-tauri/gen/apple/Pods/
|
||||
/native/src-tauri/gen/apple/ExportOptions.plist
|
||||
/native/src-tauri/gen/apple/**/*.xcuserstate
|
||||
/native/src-tauri/gen/apple/**/xcuserdata/
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -61,7 +61,8 @@ RUN composer install --prefer-dist --no-interaction --no-progress \
|
||||
|
||||
FROM build AS test
|
||||
|
||||
RUN touch .env \
|
||||
RUN cp .env.example .env \
|
||||
&& php artisan key:generate\
|
||||
&& composer audit --locked --no-interaction \
|
||||
&& COMPOSER_ALLOW_SUPERUSER=1 composer ci:check
|
||||
|
||||
|
||||
@@ -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>>
|
||||
*/
|
||||
|
||||
@@ -34,8 +34,8 @@ class BuildProjectTicketMetadataSettingsPayload
|
||||
DuplicateProjectConfiguration::COPY_TICKET_FIELDS,
|
||||
DuplicateProjectConfiguration::COPY_TICKET_LINK_TYPES,
|
||||
]),
|
||||
'ticketFieldDefinitions' => TicketFieldDefinitionResource::collection($project->ticketFieldDefinitions()->get())->resolve(),
|
||||
'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->get())->resolve(),
|
||||
'ticketFieldDefinitions' => TicketFieldDefinitionResource::collection($project->ticketFieldDefinitions()->withCount('ticketFieldValues')->get())->resolve(),
|
||||
'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->withCount('ticketLinks')->get())->resolve(),
|
||||
'ticketPriorities' => TicketPriorityResource::collection($project->ticketPriorities()->get())->resolve(),
|
||||
'ticketTypes' => TicketTypeResource::collection($project->ticketTypes()->withCount('tickets')->get())->resolve(),
|
||||
];
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ class UpdateProjectTemplateRequest extends FormRequest
|
||||
'copy_areas' => ['required', 'array', 'min:1'],
|
||||
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
|
||||
'confirm_linked_projects_update' => [
|
||||
Rule::excludeIf(! $requiresImpactConfirmation),
|
||||
Rule::requiredIf($requiresImpactConfirmation),
|
||||
'accepted',
|
||||
],
|
||||
|
||||
@@ -20,6 +20,7 @@ class TicketFieldDefinitionResource extends JsonResource
|
||||
'label' => $this->label,
|
||||
'position' => $this->position,
|
||||
'project_id' => $this->project_id,
|
||||
'ticket_field_values_count' => $this->when(isset($this->ticket_field_values_count), (int) $this->ticket_field_values_count),
|
||||
'created_at' => $this->created_at?->toISOString(),
|
||||
'updated_at' => $this->updated_at?->toISOString(),
|
||||
];
|
||||
|
||||
@@ -20,6 +20,7 @@ class TicketLinkTypeResource extends JsonResource
|
||||
'counterpart_label' => $this->counterpart_label ?: $this->label,
|
||||
'position' => $this->position,
|
||||
'project_id' => $this->project_id,
|
||||
'ticket_links_count' => $this->when(isset($this->ticket_links_count), (int) $this->ticket_links_count),
|
||||
'created_at' => $this->created_at?->toISOString(),
|
||||
'updated_at' => $this->updated_at?->toISOString(),
|
||||
];
|
||||
|
||||
@@ -10,8 +10,6 @@
|
||||
"fortify-development",
|
||||
"laravel-best-practices",
|
||||
"mcp-development",
|
||||
"wayfinder-development",
|
||||
"inertia-vue-development",
|
||||
"tailwindcss-development"
|
||||
"wayfinder-development"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -33,5 +33,5 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
$exceptions->dontFlash(['api_token']);
|
||||
})->create();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -27,7 +27,9 @@ const eslintConfig = [
|
||||
ignores: [
|
||||
'bootstrap/ssr/**',
|
||||
'**/dist/**',
|
||||
'node_modules/**',
|
||||
'**/node_modules/**',
|
||||
'native/src-tauri/target/**',
|
||||
'native/src-tauri/gen/**',
|
||||
'.github/**',
|
||||
'**/*.yml',
|
||||
'**/*.yaml',
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "kanbangit-native",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tauri dev",
|
||||
"build": "tauri build",
|
||||
"build:macos": "tauri build --debug --bundles app",
|
||||
"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",
|
||||
"open:ios": "tauri ios dev --open --no-dev-server",
|
||||
"prepare:ios": "xcodegen generate --spec src-tauri/gen/apple/project.yml",
|
||||
"test:ios": "xcodebuild -project src-tauri/gen/apple/kanbangit-native.xcodeproj -scheme kanbangit-native_iOS -configuration debug -destination \"platform=iOS Simulator,id=${IOS_SIMULATOR_ID:?Set IOS_SIMULATOR_ID to an installed simulator UUID}\" -parallel-testing-enabled NO CODE_SIGNING_ALLOWED=NO test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "2.11.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
devDependencies:
|
||||
'@tauri-apps/cli':
|
||||
specifier: 2.11.4
|
||||
version: 2.11.4
|
||||
|
||||
packages:
|
||||
|
||||
'@tauri-apps/cli-darwin-arm64@2.11.4':
|
||||
resolution: {integrity: sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@tauri-apps/cli-darwin-x64@2.11.4':
|
||||
resolution: {integrity: sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@tauri-apps/cli-linux-arm-gnueabihf@2.11.4':
|
||||
resolution: {integrity: sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@tauri-apps/cli-linux-arm64-gnu@2.11.4':
|
||||
resolution: {integrity: sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tauri-apps/cli-linux-arm64-musl@2.11.4':
|
||||
resolution: {integrity: sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tauri-apps/cli-linux-riscv64-gnu@2.11.4':
|
||||
resolution: {integrity: sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tauri-apps/cli-linux-x64-gnu@2.11.4':
|
||||
resolution: {integrity: sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tauri-apps/cli-linux-x64-musl@2.11.4':
|
||||
resolution: {integrity: sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tauri-apps/cli-win32-arm64-msvc@2.11.4':
|
||||
resolution: {integrity: sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@tauri-apps/cli-win32-ia32-msvc@2.11.4':
|
||||
resolution: {integrity: sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@tauri-apps/cli-win32-x64-msvc@2.11.4':
|
||||
resolution: {integrity: sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@tauri-apps/cli@2.11.4':
|
||||
resolution: {integrity: sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==}
|
||||
engines: {node: '>= 10'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@tauri-apps/cli-darwin-arm64@2.11.4':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-darwin-x64@2.11.4':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-linux-arm-gnueabihf@2.11.4':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-linux-arm64-gnu@2.11.4':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-linux-arm64-musl@2.11.4':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-linux-riscv64-gnu@2.11.4':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-linux-x64-gnu@2.11.4':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-linux-x64-musl@2.11.4':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-win32-arm64-msvc@2.11.4':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-win32-ia32-msvc@2.11.4':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-win32-x64-msvc@2.11.4':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli@2.11.4':
|
||||
optionalDependencies:
|
||||
'@tauri-apps/cli-darwin-arm64': 2.11.4
|
||||
'@tauri-apps/cli-darwin-x64': 2.11.4
|
||||
'@tauri-apps/cli-linux-arm-gnueabihf': 2.11.4
|
||||
'@tauri-apps/cli-linux-arm64-gnu': 2.11.4
|
||||
'@tauri-apps/cli-linux-arm64-musl': 2.11.4
|
||||
'@tauri-apps/cli-linux-riscv64-gnu': 2.11.4
|
||||
'@tauri-apps/cli-linux-x64-gnu': 2.11.4
|
||||
'@tauri-apps/cli-linux-x64-musl': 2.11.4
|
||||
'@tauri-apps/cli-win32-arm64-msvc': 2.11.4
|
||||
'@tauri-apps/cli-win32-ia32-msvc': 2.11.4
|
||||
'@tauri-apps/cli-win32-x64-msvc': 2.11.4
|
||||
@@ -0,0 +1 @@
|
||||
minimumReleaseAge: 10080
|
||||
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "kanbangit-native"
|
||||
version = "0.1.0"
|
||||
description = "KanbanGit native application"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "kanbangit_native"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
|
||||
[dev-dependencies]
|
||||
tauri = { version = "2", features = ["test"] }
|
||||
serde_json = "1"
|
||||
|
||||
[features]
|
||||
smoke-test = []
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
||||
[[test]]
|
||||
name = "macos_smoke"
|
||||
harness = false
|
||||
required-features = ["smoke-test"]
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build();
|
||||
}
|
||||
@@ -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,116 @@
|
||||
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.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 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,306 @@
|
||||
package de.kanbangit.desktop
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
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.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.SslErrorHandler
|
||||
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 androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
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
|
||||
|
||||
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)
|
||||
server = saved?.let(NavigationPolicy::installation) ?: NavigationPolicy.DEFAULT_SERVER
|
||||
updateLauncher()
|
||||
if (saved != null) openWorkspace(server)
|
||||
}
|
||||
"/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
|
||||
}
|
||||
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 = false
|
||||
mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
|
||||
javaScriptCanOpenWindowsAutomatically = false
|
||||
setSupportMultipleWindows(true)
|
||||
}
|
||||
CookieManager.getInstance().setAcceptThirdPartyCookies(remote, false)
|
||||
remote.webChromeClient = WebChromeClient()
|
||||
remote.webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean =
|
||||
!NavigationPolicy.allows(server, request.url.toString())
|
||||
|
||||
override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
|
||||
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()
|
||||
updateLauncher()
|
||||
if (firstLoad) showWorkspace()
|
||||
}
|
||||
|
||||
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(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)
|
||||
workspace?.destroy()
|
||||
workspace = null
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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 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 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,6 @@
|
||||
<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>
|
||||
</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,33 @@
|
||||
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 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 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'
|
||||
@@ -0,0 +1,3 @@
|
||||
xcuserdata/
|
||||
build/
|
||||
Externals/
|
||||
|
After Width: | Height: | Size: 422 B |
|
After Width: | Height: | Size: 548 B |
|
After Width: | Height: | Size: 548 B |
|
After Width: | Height: | Size: 800 B |
|
After Width: | Height: | Size: 415 B |
|
After Width: | Height: | Size: 845 B |
|
After Width: | Height: | Size: 845 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 548 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 955 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "AppIcon-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "AppIcon-20x20@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "AppIcon-29x29@2x-1.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "AppIcon-29x29@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "AppIcon-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "AppIcon-40x40@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "AppIcon-60x60@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "AppIcon-60x60@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "AppIcon-20x20@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "AppIcon-20x20@2x-1.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "AppIcon-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "AppIcon-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "AppIcon-40x40@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "AppIcon-40x40@2x-1.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "AppIcon-76x76@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "AppIcon-76x76@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "83.5x83.5",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "AppIcon-83.5x83.5@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "1024x1024",
|
||||
"idiom" : "ios-marketing",
|
||||
"filename" : "AppIcon-512@2x.png",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17150" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="Y6W-OH-hqX">
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17122"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="s0d-6b-0kx">
|
||||
<objects>
|
||||
<viewController id="Y6W-OH-hqX" sceneMemberID="viewController">
|
||||
<view key="view" contentMode="scaleToFill" id="5EZ-qb-Rvc">
|
||||
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<viewLayoutGuide key="safeArea" id="vDu-zF-Fre"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="Ief-a0-LHa" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<systemColor name="systemBackgroundColor">
|
||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</systemColor>
|
||||
</resources>
|
||||
</document>
|
||||
@@ -0,0 +1,14 @@
|
||||
platform :ios, '17.0'
|
||||
|
||||
target 'kanbangit-native_iOS' do
|
||||
# Pods for kanbangit-native_iOS
|
||||
end
|
||||
|
||||
# Inherit the deployment target from the Podfile.
|
||||
post_install do |installer|
|
||||
installer.pods_project.targets.each do |target|
|
||||
target.build_configurations.each do |config|
|
||||
config.build_settings.delete 'IPHONEOS_DEPLOYMENT_TARGET'
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,58 @@
|
||||
import Foundation
|
||||
|
||||
struct WorkspaceServer: Codable, Equatable {
|
||||
let url: URL
|
||||
let profile: UUID
|
||||
|
||||
static func normalize(_ input: String) -> URL? {
|
||||
let input = input.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard input.lowercased().hasPrefix("https://"),
|
||||
!input.contains("\\"),
|
||||
input.rangeOfCharacter(from: .controlCharacters) == nil,
|
||||
var components = URLComponents(string: input),
|
||||
components.scheme?.lowercased() == "https",
|
||||
let host = components.host, !host.isEmpty,
|
||||
components.user == nil, components.password == nil,
|
||||
components.query == nil, components.fragment == nil,
|
||||
components.port.map({ (1...65535).contains($0) }) ?? true else { return nil }
|
||||
components.scheme = "https"
|
||||
components.host = host.lowercased()
|
||||
if components.port == 443 { components.port = nil }
|
||||
if !components.path.hasSuffix("/") { components.path += "/" }
|
||||
return components.url
|
||||
}
|
||||
|
||||
func allows(_ destination: URL) -> Bool {
|
||||
destination.scheme?.lowercased() == "https"
|
||||
&& destination.host?.lowercased() == url.host?.lowercased()
|
||||
&& (destination.port ?? 443) == (url.port ?? 443)
|
||||
&& destination.user == nil && destination.password == nil
|
||||
}
|
||||
|
||||
static func selecting(_ url: URL, current: WorkspaceServer?) -> WorkspaceServer {
|
||||
current?.url == url ? current! : WorkspaceServer(url: url, profile: UUID())
|
||||
}
|
||||
}
|
||||
|
||||
struct WorkspaceConnection {
|
||||
private(set) var revision = 0
|
||||
private(set) var ready = false
|
||||
private(set) var connecting = false
|
||||
|
||||
mutating func begin() -> Int {
|
||||
revision += 1
|
||||
connecting = true
|
||||
return revision
|
||||
}
|
||||
|
||||
mutating func finish() {
|
||||
connecting = false
|
||||
ready = true
|
||||
}
|
||||
|
||||
mutating func fail(_ expectedRevision: Int) -> Bool {
|
||||
guard expectedRevision == revision, connecting else { return false }
|
||||
connecting = false
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
@_cdecl("install_ios_workspace")
|
||||
func installIOSWorkspace() {
|
||||
DispatchQueue.main.async {
|
||||
guard let root = UIApplication.shared.connectedScenes
|
||||
.compactMap({ $0 as? UIWindowScene }).flatMap(\.windows)
|
||||
.first(where: { $0.isKeyWindow })?.rootViewController
|
||||
?? UIApplication.shared.windows.first?.rootViewController else { return }
|
||||
guard !root.children.contains(where: { $0 is WorkspaceViewController }) else { return }
|
||||
let controller = WorkspaceViewController()
|
||||
root.view.subviews.forEach { $0.isHidden = true }
|
||||
root.addChild(controller)
|
||||
root.view.addSubview(controller.view)
|
||||
controller.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
controller.view.topAnchor.constraint(equalTo: root.view.topAnchor),
|
||||
controller.view.bottomAnchor.constraint(equalTo: root.view.bottomAnchor),
|
||||
controller.view.leadingAnchor.constraint(equalTo: root.view.leadingAnchor),
|
||||
controller.view.trailingAnchor.constraint(equalTo: root.view.trailingAnchor),
|
||||
])
|
||||
controller.didMove(toParent: root)
|
||||
}
|
||||
}
|
||||
|
||||
final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKUIDelegate, UITextFieldDelegate {
|
||||
private let defaults: UserDefaults
|
||||
private let serverKey = "ios.activeServer"
|
||||
private(set) var server: WorkspaceServer?
|
||||
private(set) var workspace: WKWebView?
|
||||
private var connection = WorkspaceConnection()
|
||||
private var pendingNavigation: WKNavigation?
|
||||
private var timeout: DispatchWorkItem?
|
||||
private let launcher = UIScrollView()
|
||||
private let stack = UIStackView()
|
||||
private let address = UITextField()
|
||||
private let status = UILabel()
|
||||
private let connectButton = UIButton(type: .system)
|
||||
private let resumeButton = UIButton(type: .system)
|
||||
private let toolbar = UIToolbar()
|
||||
private var backButton: UIBarButtonItem!
|
||||
private var appearance: UISegmentedControl!
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
private func text(_ key: String) -> String {
|
||||
NSLocalizedString(key, comment: "KanbanGit iOS workspace")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemBackground
|
||||
view.tintColor = UIColor(red: 0, green: 0.43, blue: 0.18, alpha: 1)
|
||||
configureLauncher()
|
||||
configureToolbar()
|
||||
if let data = defaults.data(forKey: serverKey) {
|
||||
if let saved = try? JSONDecoder().decode(WorkspaceServer.self, from: data),
|
||||
WorkspaceServer.normalize(saved.url.absoluteString) == saved.url {
|
||||
server = saved
|
||||
} else {
|
||||
status.text = text("storageError")
|
||||
}
|
||||
}
|
||||
address.text = server?.url.absoluteString ?? "https://kanbangit.de/"
|
||||
showSettings()
|
||||
if let server { open(server.url) }
|
||||
}
|
||||
|
||||
private func label(_ key: String, style: UIFont.TextStyle = .body) -> UILabel {
|
||||
let label = UILabel()
|
||||
label.text = text(key)
|
||||
label.font = .preferredFont(forTextStyle: style)
|
||||
label.adjustsFontForContentSizeCategory = true
|
||||
label.numberOfLines = 0
|
||||
return label
|
||||
}
|
||||
|
||||
private func configureLauncher() {
|
||||
launcher.accessibilityIdentifier = "serverSettings"
|
||||
launcher.keyboardDismissMode = .interactive
|
||||
launcher.alwaysBounceVertical = true
|
||||
view.addSubview(launcher)
|
||||
launcher.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
launcher.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||
launcher.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
|
||||
launcher.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
||||
launcher.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
||||
])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 18
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
launcher.addSubview(stack)
|
||||
let width = stack.widthAnchor.constraint(equalTo: launcher.frameLayoutGuide.widthAnchor, constant: -40)
|
||||
width.priority = .defaultHigh
|
||||
NSLayoutConstraint.activate([
|
||||
stack.topAnchor.constraint(equalTo: launcher.contentLayoutGuide.topAnchor, constant: 32),
|
||||
stack.bottomAnchor.constraint(equalTo: launcher.contentLayoutGuide.bottomAnchor, constant: -32),
|
||||
stack.centerXAnchor.constraint(equalTo: launcher.frameLayoutGuide.centerXAnchor),
|
||||
stack.leadingAnchor.constraint(greaterThanOrEqualTo: launcher.contentLayoutGuide.leadingAnchor, constant: 20),
|
||||
stack.trailingAnchor.constraint(lessThanOrEqualTo: launcher.contentLayoutGuide.trailingAnchor, constant: -20),
|
||||
stack.widthAnchor.constraint(lessThanOrEqualToConstant: 480), width,
|
||||
])
|
||||
stack.addArrangedSubview(label("appName", style: .headline))
|
||||
stack.addArrangedSubview(label("title", style: .largeTitle))
|
||||
stack.addArrangedSubview(label("description"))
|
||||
stack.addArrangedSubview(label("serverLabel", style: .headline))
|
||||
address.borderStyle = .roundedRect
|
||||
address.keyboardType = .URL
|
||||
address.textContentType = .URL
|
||||
address.autocorrectionType = .no
|
||||
address.autocapitalizationType = .none
|
||||
address.clearButtonMode = .whileEditing
|
||||
address.returnKeyType = .go
|
||||
address.delegate = self
|
||||
address.accessibilityLabel = text("serverLabel")
|
||||
address.accessibilityIdentifier = "serverAddress"
|
||||
address.heightAnchor.constraint(greaterThanOrEqualToConstant: 48).isActive = true
|
||||
stack.addArrangedSubview(address)
|
||||
stack.addArrangedSubview(label("serverHelp", style: .footnote))
|
||||
connectButton.configuration = .filled()
|
||||
connectButton.setTitle(text("openWorkspace"), for: .normal)
|
||||
connectButton.accessibilityIdentifier = "openWorkspace"
|
||||
connectButton.addTarget(self, action: #selector(connect), for: .touchUpInside)
|
||||
stack.addArrangedSubview(connectButton)
|
||||
status.numberOfLines = 0
|
||||
status.accessibilityIdentifier = "connectionStatus"
|
||||
status.font = .preferredFont(forTextStyle: .body)
|
||||
status.adjustsFontForContentSizeCategory = true
|
||||
stack.addArrangedSubview(status)
|
||||
resumeButton.setTitle(text("resume"), for: .normal)
|
||||
resumeButton.accessibilityIdentifier = "resumeWorkspace"
|
||||
resumeButton.addTarget(self, action: #selector(showWorkspace), for: .touchUpInside)
|
||||
stack.addArrangedSubview(resumeButton)
|
||||
stack.addArrangedSubview(label("appearance", style: .headline))
|
||||
appearance = UISegmentedControl(items: [text("system"), text("light"), text("dark")])
|
||||
appearance.selectedSegmentIndex = min(2, max(0, defaults.integer(forKey: "ios.appearance")))
|
||||
appearance.accessibilityIdentifier = "appearance"
|
||||
appearance.addTarget(self, action: #selector(changeAppearance), for: .valueChanged)
|
||||
stack.addArrangedSubview(appearance)
|
||||
[connectButton, resumeButton, appearance].forEach {
|
||||
$0.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).isActive = true
|
||||
}
|
||||
changeAppearance()
|
||||
}
|
||||
|
||||
private func configureToolbar() {
|
||||
toolbar.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(toolbar)
|
||||
backButton = UIBarButtonItem(title: text("back"), style: .plain, target: self, action: #selector(goBack))
|
||||
backButton.accessibilityIdentifier = "workspaceBack"
|
||||
let settings = UIBarButtonItem(title: text("serverSettings"), style: .plain, target: self, action: #selector(showSettings))
|
||||
settings.accessibilityIdentifier = "showServerSettings"
|
||||
toolbar.items = [backButton, UIBarButtonItem(systemItem: .flexibleSpace), settings]
|
||||
NSLayoutConstraint.activate([
|
||||
toolbar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||
toolbar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
||||
toolbar.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
@objc private func changeAppearance() {
|
||||
defaults.set(appearance.selectedSegmentIndex, forKey: "ios.appearance")
|
||||
overrideUserInterfaceStyle = [.unspecified, .light, .dark][appearance.selectedSegmentIndex]
|
||||
}
|
||||
|
||||
func textFieldShouldReturn(_ textField: UITextField) -> Bool { connect(); return true }
|
||||
|
||||
@objc private func connect() {
|
||||
guard let url = WorkspaceServer.normalize(address.text ?? "") else {
|
||||
status.text = text("invalidServer")
|
||||
UIAccessibility.post(notification: .announcement, argument: status.text)
|
||||
return
|
||||
}
|
||||
if let server, server.url != url {
|
||||
let alert = UIAlertController(title: text("confirmSwitch"), message: text("switchWarning"), preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: text("cancel"), style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: text("confirmSwitch"), style: .destructive) { [weak self] _ in self?.open(url) })
|
||||
present(alert, animated: true)
|
||||
} else { open(url) }
|
||||
}
|
||||
|
||||
func open(_ url: URL) {
|
||||
if server?.url == url, connection.ready { showWorkspace(); return }
|
||||
let selected = WorkspaceServer.selecting(url, current: server)
|
||||
guard let data = try? JSONEncoder().encode(selected) else { status.text = text("storageError"); return }
|
||||
defaults.set(data, forKey: serverKey)
|
||||
timeout?.cancel()
|
||||
workspace?.stopLoading()
|
||||
workspace?.navigationDelegate = nil
|
||||
workspace?.removeFromSuperview()
|
||||
connection = WorkspaceConnection()
|
||||
server = selected
|
||||
let configuration = WKWebViewConfiguration()
|
||||
configuration.websiteDataStore = WKWebsiteDataStore(forIdentifier: selected.profile)
|
||||
// Remote content receives a plain WKWebView, with no native message handlers or Tauri bridge.
|
||||
let webview = WKWebView(frame: .zero, configuration: configuration)
|
||||
webview.navigationDelegate = self
|
||||
webview.uiDelegate = self
|
||||
webview.allowsBackForwardNavigationGestures = true
|
||||
webview.accessibilityIdentifier = "workspace"
|
||||
webview.translatesAutoresizingMaskIntoConstraints = false
|
||||
webview.isHidden = true
|
||||
view.insertSubview(webview, belowSubview: launcher)
|
||||
NSLayoutConstraint.activate([
|
||||
webview.topAnchor.constraint(equalTo: toolbar.bottomAnchor),
|
||||
webview.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
|
||||
webview.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
||||
webview.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
||||
])
|
||||
workspace = webview
|
||||
view.endEditing(true)
|
||||
address.text = url.absoluteString
|
||||
beginNavigation()
|
||||
pendingNavigation = webview.load(URLRequest(url: url))
|
||||
}
|
||||
|
||||
private func beginNavigation() {
|
||||
timeout?.cancel()
|
||||
let revision = connection.begin()
|
||||
status.text = text("connecting")
|
||||
connectButton.isEnabled = false
|
||||
resumeButton.isHidden = !connection.ready
|
||||
let work = DispatchWorkItem { [weak self] in self?.fail(revision) }
|
||||
timeout = work
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 15, execute: work)
|
||||
}
|
||||
|
||||
private func fail(_ revision: Int) {
|
||||
guard connection.fail(revision) else { return }
|
||||
timeout?.cancel()
|
||||
status.text = text("connectionError")
|
||||
connectButton.isEnabled = true
|
||||
connectButton.setTitle(text("retry"), for: .normal)
|
||||
showSettings()
|
||||
}
|
||||
|
||||
@objc private func showSettings() {
|
||||
view.endEditing(true)
|
||||
launcher.isHidden = false
|
||||
toolbar.isHidden = true
|
||||
workspace?.isHidden = true
|
||||
resumeButton.isHidden = !connection.ready
|
||||
}
|
||||
|
||||
@objc private func showWorkspace() {
|
||||
guard connection.ready else { return }
|
||||
view.endEditing(true)
|
||||
launcher.isHidden = true
|
||||
toolbar.isHidden = false
|
||||
workspace?.isHidden = false
|
||||
backButton.isEnabled = workspace?.canGoBack == true
|
||||
}
|
||||
|
||||
@objc private func goBack() { if workspace?.canGoBack == true { workspace?.goBack() } }
|
||||
|
||||
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
guard webView === workspace, let url = action.request.url,
|
||||
server?.allows(url) == true else { decisionHandler(.cancel); return }
|
||||
if action.targetFrame == nil {
|
||||
decisionHandler(.cancel)
|
||||
webView.load(action.request)
|
||||
} else { decisionHandler(.allow) }
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, decidePolicyFor response: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
|
||||
guard webView === workspace, let url = response.response.url,
|
||||
server?.allows(url) == true else { decisionHandler(.cancel); return }
|
||||
decisionHandler(.allow)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||
guard webView === workspace else { return }
|
||||
pendingNavigation = navigation
|
||||
beginNavigation()
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
guard webView === workspace, navigation === pendingNavigation else { return }
|
||||
let firstLoad = !connection.ready
|
||||
timeout?.cancel()
|
||||
connection.finish()
|
||||
connectButton.isEnabled = true
|
||||
connectButton.setTitle(text("openWorkspace"), for: .normal)
|
||||
status.text = text("connected")
|
||||
resumeButton.isHidden = false
|
||||
backButton.isEnabled = webView.canGoBack
|
||||
if firstLoad { showWorkspace() }
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||
navigationFailed(webView, navigation, error)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
navigationFailed(webView, navigation, error)
|
||||
}
|
||||
|
||||
private func navigationFailed(_ webView: WKWebView, _ navigation: WKNavigation?, _ error: Error) {
|
||||
guard webView === workspace, navigation === pendingNavigation,
|
||||
(error as NSError).code != NSURLErrorCancelled else { return }
|
||||
fail(connection.revision)
|
||||
}
|
||||
|
||||
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
||||
guard webView === workspace else { return }
|
||||
connection = WorkspaceConnection()
|
||||
status.text = text("processTerminated")
|
||||
connectButton.isEnabled = true
|
||||
showSettings()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
namespace ffi {
|
||||
extern "C" {
|
||||
void start_app();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"appName" = "KanbanGit";
|
||||
"title" = "Choose your workspace.";
|
||||
"description" = "Connect to your KanbanGit installation over HTTPS.";
|
||||
"serverLabel" = "Server address";
|
||||
"serverHelp" = "Use the installation address without login details, a query or a fragment.";
|
||||
"openWorkspace" = "Open workspace";
|
||||
"retry" = "Try again";
|
||||
"resume" = "Return to workspace";
|
||||
"switchWarning" = "Switching servers closes the current workspace and discards unsaved inputs. You will sign in separately on the new server.";
|
||||
"confirmSwitch" = "Switch server";
|
||||
"cancel" = "Cancel";
|
||||
"connecting" = "Connecting to your server…";
|
||||
"connected" = "Your workspace is open. Use Server settings to change servers.";
|
||||
"connectionError" = "The server did not finish loading. Check your connection, server address and HTTPS certificate, then try again. An already open workspace and its inputs are kept.";
|
||||
"invalidServer" = "Enter a valid HTTPS installation address without login details, a query or a fragment.";
|
||||
"storageError" = "The saved server setting could not be read. Enter your server address again.";
|
||||
"processTerminated" = "iOS closed the workspace to free memory. Open it again to continue. Unsaved inputs may have been lost.";
|
||||
"appearance" = "Appearance";
|
||||
"system" = "System";
|
||||
"light" = "Light";
|
||||
"dark" = "Dark";
|
||||
"back" = "Back";
|
||||
"serverSettings" = "Server settings";
|
||||
@@ -0,0 +1,36 @@
|
||||
#include "bindings/bindings.h"
|
||||
#import <UIKit/UIKit.h>
|
||||
#include <cstdlib>
|
||||
#include <unistd.h>
|
||||
|
||||
extern "C" void install_ios_workspace(void);
|
||||
|
||||
int main(int argc, char * argv[]) {
|
||||
@autoreleasepool {
|
||||
// Xcode mirrors os_log to stderr. Tauri 2.11 redirects stderr back to os_log,
|
||||
// which can fill its pipe and block UIKit/XCTest. Keep Xcode's original streams.
|
||||
const bool debuggerLogging = getenv("OS_ACTIVITY_DT_MODE") != nullptr
|
||||
|| getenv("XCTestConfigurationFilePath") != nullptr;
|
||||
__block int originalOut = debuggerLogging ? dup(STDOUT_FILENO) : -1;
|
||||
__block int originalError = debuggerLogging ? dup(STDERR_FILENO) : -1;
|
||||
[[NSNotificationCenter defaultCenter]
|
||||
addObserverForName:UIApplicationDidBecomeActiveNotification
|
||||
object:nil
|
||||
queue:NSOperationQueue.mainQueue
|
||||
usingBlock:^(NSNotification *notification) {
|
||||
if (originalOut >= 0) {
|
||||
dup2(originalOut, STDOUT_FILENO);
|
||||
close(originalOut);
|
||||
originalOut = -1;
|
||||
}
|
||||
if (originalError >= 0) {
|
||||
dup2(originalError, STDERR_FILENO);
|
||||
close(originalError);
|
||||
originalError = -1;
|
||||
}
|
||||
install_ios_workspace();
|
||||
}];
|
||||
ffi::start_app();
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import XCTest
|
||||
import WebKit
|
||||
|
||||
final class WorkspaceTests: XCTestCase {
|
||||
func testServerValidationAndOriginBoundary() throws {
|
||||
let url = try XCTUnwrap(WorkspaceServer.normalize(" HTTPS://Example.com:443/kanban "))
|
||||
XCTAssertEqual(url.absoluteString, "https://example.com/kanban/")
|
||||
let server = WorkspaceServer(url: url, profile: UUID())
|
||||
for value in ["http://example.com/", "https://user:secret@example.com/", "https://example.com/?token=1", "https://example.com/#x", "https://exa\nmple.com", "https://example.com\\evil", "file:///etc/passwd", "javascript:alert(1)", "https://example.com:99999/"] {
|
||||
XCTAssertNil(WorkspaceServer.normalize(value), value)
|
||||
}
|
||||
for value in ["https://example.com/login", "https://example.com/two-factor-challenge", "https://example.com/projects/1?ticket=54#details"] {
|
||||
XCTAssertTrue(server.allows(try XCTUnwrap(URL(string: value))), value)
|
||||
}
|
||||
for value in ["http://example.com/", "https://example.com:8443/", "https://evil.example.com/", "https://user@example.com/", "file:///tmp/index.html", "tauri://localhost/action/open"] {
|
||||
XCTAssertFalse(server.allows(try XCTUnwrap(URL(string: value))), value)
|
||||
}
|
||||
}
|
||||
|
||||
func testServerChangesNeverReusePreviousProfile() throws {
|
||||
let firstURL = try XCTUnwrap(WorkspaceServer.normalize("https://example.com/"))
|
||||
let otherURL = try XCTUnwrap(WorkspaceServer.normalize("https://example.com/other/"))
|
||||
let first = WorkspaceServer.selecting(firstURL, current: nil)
|
||||
XCTAssertEqual(WorkspaceServer.selecting(firstURL, current: first), first)
|
||||
let second = WorkspaceServer.selecting(otherURL, current: first)
|
||||
XCTAssertNotEqual(second.profile, first.profile)
|
||||
XCTAssertNotEqual(WorkspaceServer.selecting(firstURL, current: second).profile, first.profile)
|
||||
XCTAssertEqual(try JSONDecoder().decode(WorkspaceServer.self, from: JSONEncoder().encode(first)), first)
|
||||
}
|
||||
|
||||
func testTimeoutDoesNotDiscardLoadedWorkspaceOrAffectLaterNavigation() {
|
||||
var state = WorkspaceConnection()
|
||||
let first = state.begin()
|
||||
let second = state.begin()
|
||||
XCTAssertFalse(state.fail(first))
|
||||
state.finish()
|
||||
XCTAssertFalse(state.fail(second))
|
||||
let third = state.begin()
|
||||
XCTAssertTrue(state.fail(third))
|
||||
XCTAssertTrue(state.ready)
|
||||
XCTAssertFalse(state.fail(third))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testWKWebViewPersistsCookiesAcrossViewsAndIsolatesServerChanges() async throws {
|
||||
let profile = UUID()
|
||||
let first = WKWebViewConfiguration()
|
||||
first.websiteDataStore = WKWebsiteDataStore(forIdentifier: profile)
|
||||
let cookie = try XCTUnwrap(HTTPCookie(properties: [
|
||||
.domain: "example.com", .path: "/", .name: "laravel_session", .value: "test-session",
|
||||
.secure: "TRUE", .expires: Date().addingTimeInterval(3600),
|
||||
]))
|
||||
await first.websiteDataStore.httpCookieStore.setCookie(cookie)
|
||||
let restored = WKWebViewConfiguration()
|
||||
restored.websiteDataStore = WKWebsiteDataStore(forIdentifier: profile)
|
||||
let restoredCookies = await restored.websiteDataStore.httpCookieStore.allCookies()
|
||||
XCTAssertTrue(restoredCookies.contains { $0.name == "laravel_session" && $0.value == "test-session" })
|
||||
let other = WKWebsiteDataStore(forIdentifier: UUID())
|
||||
let otherCookies = await other.httpCookieStore.allCookies()
|
||||
XCTAssertTrue(otherCookies.isEmpty)
|
||||
await restored.websiteDataStore.httpCookieStore.deleteCookie(cookie)
|
||||
let loggedOut = await first.websiteDataStore.httpCookieStore.allCookies()
|
||||
XCTAssertFalse(loggedOut.contains { $0.name == "laravel_session" })
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testControllerRestoresServerAndCreatesIsolatedUnprivilegedWebView() throws {
|
||||
let name = "KAN54-tests-\(UUID())"
|
||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
||||
defer { defaults.removePersistentDomain(forName: name) }
|
||||
let controller = WorkspaceViewController(defaults: defaults)
|
||||
controller.loadViewIfNeeded()
|
||||
let url = try XCTUnwrap(WorkspaceServer.normalize("https://example.invalid/"))
|
||||
controller.open(url)
|
||||
let server = try XCTUnwrap(controller.server)
|
||||
XCTAssertEqual(controller.workspace?.configuration.websiteDataStore.identifier, server.profile)
|
||||
XCTAssertTrue(controller.workspace?.configuration.userContentController.userScripts.isEmpty == true)
|
||||
let restored = WorkspaceViewController(defaults: defaults)
|
||||
restored.loadViewIfNeeded()
|
||||
XCTAssertEqual(restored.server, server)
|
||||
let other = try XCTUnwrap(WorkspaceServer.normalize("https://other.invalid/"))
|
||||
restored.open(other)
|
||||
XCTAssertNotEqual(restored.server?.profile, server.profile)
|
||||
}
|
||||
@MainActor
|
||||
func testConnectionFailureKeepsLoadedFormAndAllowsResume() async throws {
|
||||
let name = "KAN54-form-\(UUID())"
|
||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
||||
defer { defaults.removePersistentDomain(forName: name) }
|
||||
let controller = WorkspaceViewController(defaults: defaults)
|
||||
controller.loadViewIfNeeded()
|
||||
let url = try XCTUnwrap(WorkspaceServer.normalize("https://example.invalid/"))
|
||||
controller.open(url)
|
||||
let webview = try XCTUnwrap(controller.workspace)
|
||||
webview.stopLoading()
|
||||
let navigation = try XCTUnwrap(webview.loadHTMLString("<html><body><input id='draft' value='unsaved ticket'></body></html>", baseURL: url))
|
||||
var loaded = false
|
||||
for _ in 0..<50 {
|
||||
if let value = try? await webview.evaluateJavaScript("document.getElementById('draft')?.value"),
|
||||
value as? String == "unsaved ticket" { loaded = true; break }
|
||||
try await Task.sleep(for: .milliseconds(100))
|
||||
}
|
||||
XCTAssertTrue(loaded)
|
||||
controller.webView(webview, didFinish: navigation)
|
||||
controller.webView(webview, didStartProvisionalNavigation: navigation)
|
||||
controller.webView(webview, didFailProvisionalNavigation: navigation,
|
||||
withError: NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet))
|
||||
XCTAssertTrue(webview.isHidden)
|
||||
controller.open(url)
|
||||
XCTAssertTrue(controller.workspace === webview)
|
||||
XCTAssertFalse(webview.isHidden)
|
||||
let value = try await webview.evaluateJavaScript("document.getElementById('draft').value")
|
||||
XCTAssertEqual(value as? String, "unsaved ticket")
|
||||
let bridge = try await webview.evaluateJavaScript("typeof window.__TAURI_INTERNALS__")
|
||||
XCTAssertEqual(bridge as? String, "undefined")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import XCTest
|
||||
|
||||
final class WorkspaceUITests: XCTestCase {
|
||||
@MainActor
|
||||
private func reveal(_ element: XCUIElement, in scrollView: XCUIElement) {
|
||||
for _ in 0..<8 {
|
||||
if scrollView.frame.contains(CGPoint(x: element.frame.midX, y: element.frame.midY)), element.isHittable { return }
|
||||
let start = scrollView.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
|
||||
let direction: CGFloat = element.frame.midY > scrollView.frame.midY ? 0.2 : 0.8
|
||||
let end = scrollView.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: direction))
|
||||
start.press(forDuration: 0.05, thenDragTo: end)
|
||||
}
|
||||
XCTAssertTrue(element.isHittable)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testLauncherKeyboardValidationRotationAndAppearance() {
|
||||
continueAfterFailure = false
|
||||
XCUIDevice.shared.orientation = .portrait
|
||||
let app = XCUIApplication()
|
||||
app.launch()
|
||||
let settings = app.buttons["showServerSettings"]
|
||||
if settings.waitForExistence(timeout: 3) { settings.tap() }
|
||||
let address = app.textFields["serverAddress"]
|
||||
XCTAssertTrue(address.waitForExistence(timeout: 15))
|
||||
address.tap()
|
||||
let clear = address.buttons.firstMatch
|
||||
if clear.exists { clear.tap() }
|
||||
address.typeText("http://invalid.example")
|
||||
app.buttons["openWorkspace"].tap()
|
||||
XCTAssertTrue(app.staticTexts["connectionStatus"].label.contains("valid HTTPS"))
|
||||
XCUIDevice.shared.orientation = .landscapeLeft
|
||||
let launcher = app.scrollViews["serverSettings"]
|
||||
reveal(address, in: launcher)
|
||||
let open = app.buttons["openWorkspace"]
|
||||
reveal(open, in: launcher)
|
||||
let dark = app.segmentedControls["appearance"].buttons["Dark"]
|
||||
reveal(dark, in: launcher)
|
||||
dark.tap()
|
||||
XCTAssertTrue(dark.isSelected)
|
||||
app.segmentedControls["appearance"].buttons["System"].tap()
|
||||
XCUIDevice.shared.orientation = .portrait
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,687 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
009F8FEC2765B2F19DC12CA8 /* WorkspaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */; };
|
||||
0FAB3CC7BB466BFF871C0C30 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 31630170C358EA8657E34854 /* Assets.xcassets */; };
|
||||
205D0CBA33F7D3C59AAD5300 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 63685B1189795CAEA85F7AB7 /* QuartzCore.framework */; };
|
||||
333E7761EA87AE29CE04F1AC /* main.mm in Sources */ = {isa = PBXBuildFile; fileRef = F46963153D20DB21D97843E9 /* main.mm */; };
|
||||
34C189B72A1B9EBE33D700D1 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F63BC1BDE65F5AEBE5B13519 /* LaunchScreen.storyboard */; };
|
||||
5E764E27CA85A3746845F417 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8243D0C3821DAC731FECF105 /* UIKit.framework */; };
|
||||
6514B0DB3A5CCCDF7385AC18 /* WorkspaceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE63F98563975EF4FF2A10FB /* WorkspaceViewController.swift */; };
|
||||
70EBA05ACBA5402617A3371C /* WorkspacePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 554AB059B4228085924F4959 /* WorkspacePolicy.swift */; };
|
||||
7245AD23F39C4620BAA3AA92 /* WorkspaceUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED199829CC1B1598F58EDC9B /* WorkspaceUITests.swift */; };
|
||||
73954B7AADE8C37663BEF288 /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CA65E67046326EA008175045 /* Metal.framework */; };
|
||||
98D5B23C9BE2F62C23DF397A /* WorkspacePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 554AB059B4228085924F4959 /* WorkspacePolicy.swift */; };
|
||||
A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C8C02BC0E757897278EB954A /* Localizable.strings */; };
|
||||
B939982700E800053A9C7663 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE3B71153677F722CAC6DCF7 /* Security.framework */; };
|
||||
C362C102D4950A81D89BB8C8 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */; };
|
||||
C44BDCDEA454DBD106D51C9C /* WorkspaceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE63F98563975EF4FF2A10FB /* WorkspaceViewController.swift */; };
|
||||
CD64D99EBD87E6FFC0C9001C /* MetalKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D581BB90601D7A89F8F188E2 /* MetalKit.framework */; };
|
||||
D8772A5E508092D6F831058A /* libapp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 91991DFDB0C064B06DF854ED /* libapp.a */; };
|
||||
EB37EAF992C1A156795CAEC0 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
8665A25D07C5F1A295164797 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = AA6AD2A91A9E86D319D86D59 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 721CF221CEDDC28FF2C5EBD5;
|
||||
remoteInfo = "kanbangit-native_iOS";
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
01E10FB645809173AF1D4C2D /* bindings.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = bindings.h; sourceTree = "<group>"; };
|
||||
05A753935B6CB0316DA56D96 /* desktop.rs */ = {isa = PBXFileReference; path = desktop.rs; sourceTree = "<group>"; };
|
||||
08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTests.swift; sourceTree = "<group>"; };
|
||||
0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; };
|
||||
23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = "kanbangit-native_iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
2B96972F58CC158AB3DFF94A /* lib.rs */ = {isa = PBXFileReference; path = lib.rs; sourceTree = "<group>"; };
|
||||
31630170C358EA8657E34854 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
4C76059D31C50B198779A5F7 /* en.lproj */ = {isa = PBXFileReference; path = en.lproj; sourceTree = "<group>"; };
|
||||
554AB059B4228085924F4959 /* WorkspacePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacePolicy.swift; sourceTree = "<group>"; };
|
||||
63685B1189795CAEA85F7AB7 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; };
|
||||
7440C7F0426E6B3977DD1D94 /* main.rs */ = {isa = PBXFileReference; path = main.rs; sourceTree = "<group>"; };
|
||||
8243D0C3821DAC731FECF105 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; };
|
||||
91991DFDB0C064B06DF854ED /* libapp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libapp.a; sourceTree = "<group>"; };
|
||||
92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; };
|
||||
946BA5CC3FD90AD90D101A36 /* kanbangit-native_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "kanbangit-native_iOS.entitlements"; sourceTree = "<group>"; };
|
||||
9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
AEBAD9ABE758897C50BC1F2E /* locale.rs */ = {isa = PBXFileReference; path = locale.rs; sourceTree = "<group>"; };
|
||||
CA65E67046326EA008175045 /* Metal.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Metal.framework; path = System/Library/Frameworks/Metal.framework; sourceTree = SDKROOT; };
|
||||
CAF08AD60D2F29A879CDAEBB /* ios.rs */ = {isa = PBXFileReference; path = ios.rs; sourceTree = "<group>"; };
|
||||
D13B8D854BEB7757AB95231B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
D581BB90601D7A89F8F188E2 /* MetalKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MetalKit.framework; path = System/Library/Frameworks/MetalKit.framework; sourceTree = SDKROOT; };
|
||||
DE3B71153677F722CAC6DCF7 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; };
|
||||
E8BA104C81071623E1FF367D /* server.rs */ = {isa = PBXFileReference; path = server.rs; sourceTree = "<group>"; };
|
||||
ED199829CC1B1598F58EDC9B /* WorkspaceUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceUITests.swift; sourceTree = "<group>"; };
|
||||
ED4469441C3E0F37B1123572 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
EE63F98563975EF4FF2A10FB /* WorkspaceViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceViewController.swift; sourceTree = "<group>"; };
|
||||
F46963153D20DB21D97843E9 /* main.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = main.mm; sourceTree = "<group>"; };
|
||||
F63BC1BDE65F5AEBE5B13519 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
32E70C778E91CB2E8CD86715 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
D8772A5E508092D6F831058A /* libapp.a in Frameworks */,
|
||||
EB37EAF992C1A156795CAEC0 /* CoreGraphics.framework in Frameworks */,
|
||||
73954B7AADE8C37663BEF288 /* Metal.framework in Frameworks */,
|
||||
CD64D99EBD87E6FFC0C9001C /* MetalKit.framework in Frameworks */,
|
||||
205D0CBA33F7D3C59AAD5300 /* QuartzCore.framework in Frameworks */,
|
||||
B939982700E800053A9C7663 /* Security.framework in Frameworks */,
|
||||
5E764E27CA85A3746845F417 /* UIKit.framework in Frameworks */,
|
||||
C362C102D4950A81D89BB8C8 /* WebKit.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
140A7DB7E2461E64B1FBFA70 /* Tests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */,
|
||||
);
|
||||
path = Tests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
157AF88201BDE4D99AA2E14C = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
31630170C358EA8657E34854 /* Assets.xcassets */,
|
||||
F63BC1BDE65F5AEBE5B13519 /* LaunchScreen.storyboard */,
|
||||
4058655E6F37D054E25D1F37 /* kanbangit-native_iOS */,
|
||||
8606F3EE5A736385061692EF /* Sources */,
|
||||
335B1262A9019C70C7F887A5 /* src */,
|
||||
140A7DB7E2461E64B1FBFA70 /* Tests */,
|
||||
3C9F31BB065F210267ECA390 /* UITests */,
|
||||
3028C3786B437115B6486F1C /* Frameworks */,
|
||||
59D2DE9FD94E6D2D64CB0117 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2A11A26F0082A5075CD710C6 /* bindings */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
01E10FB645809173AF1D4C2D /* bindings.h */,
|
||||
);
|
||||
path = bindings;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
3028C3786B437115B6486F1C /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */,
|
||||
91991DFDB0C064B06DF854ED /* libapp.a */,
|
||||
CA65E67046326EA008175045 /* Metal.framework */,
|
||||
D581BB90601D7A89F8F188E2 /* MetalKit.framework */,
|
||||
63685B1189795CAEA85F7AB7 /* QuartzCore.framework */,
|
||||
DE3B71153677F722CAC6DCF7 /* Security.framework */,
|
||||
8243D0C3821DAC731FECF105 /* UIKit.framework */,
|
||||
92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
335B1262A9019C70C7F887A5 /* src */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
05A753935B6CB0316DA56D96 /* desktop.rs */,
|
||||
CAF08AD60D2F29A879CDAEBB /* ios.rs */,
|
||||
2B96972F58CC158AB3DFF94A /* lib.rs */,
|
||||
AEBAD9ABE758897C50BC1F2E /* locale.rs */,
|
||||
7440C7F0426E6B3977DD1D94 /* main.rs */,
|
||||
E8BA104C81071623E1FF367D /* server.rs */,
|
||||
);
|
||||
name = src;
|
||||
path = ../../src;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
3C9F31BB065F210267ECA390 /* UITests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
ED199829CC1B1598F58EDC9B /* WorkspaceUITests.swift */,
|
||||
);
|
||||
path = UITests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4058655E6F37D054E25D1F37 /* kanbangit-native_iOS */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D13B8D854BEB7757AB95231B /* Info.plist */,
|
||||
946BA5CC3FD90AD90D101A36 /* kanbangit-native_iOS.entitlements */,
|
||||
);
|
||||
path = "kanbangit-native_iOS";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
59D2DE9FD94E6D2D64CB0117 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */,
|
||||
A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */,
|
||||
9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
8606F3EE5A736385061692EF /* Sources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
89AA023B8C28CB860513C685 /* kanbangit-native */,
|
||||
);
|
||||
path = Sources;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
89AA023B8C28CB860513C685 /* kanbangit-native */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4C76059D31C50B198779A5F7 /* en.lproj */,
|
||||
F46963153D20DB21D97843E9 /* main.mm */,
|
||||
554AB059B4228085924F4959 /* WorkspacePolicy.swift */,
|
||||
EE63F98563975EF4FF2A10FB /* WorkspaceViewController.swift */,
|
||||
2A11A26F0082A5075CD710C6 /* bindings */,
|
||||
C8C02BC0E757897278EB954A /* Localizable.strings */,
|
||||
);
|
||||
path = "kanbangit-native";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = F0C7EA9610FB69A5EA27B950 /* Build configuration list for PBXNativeTarget "kanbangit-native_iOS" */;
|
||||
buildPhases = (
|
||||
C74F06A7ECCC2C6C79C74A2D /* Build Rust Code */,
|
||||
F39C45598E4345705F3820FA /* Sources */,
|
||||
4A4C7FC1B57785DDD6B301FB /* Resources */,
|
||||
32E70C778E91CB2E8CD86715 /* Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = "kanbangit-native_iOS";
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = "kanbangit-native_iOS";
|
||||
productReference = 23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
988CFF304CB81C8F3A4D6612 /* WorkspaceUITests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = E6F418AEF58E5F3D739C68F1 /* Build configuration list for PBXNativeTarget "WorkspaceUITests" */;
|
||||
buildPhases = (
|
||||
0258B26407CD2D8C61079F84 /* Sources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
5658F3B2915DC3A46531EDCE /* PBXTargetDependency */,
|
||||
);
|
||||
name = WorkspaceUITests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = WorkspaceUITests;
|
||||
productReference = 9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.ui-testing";
|
||||
};
|
||||
CBC4561F8018D37DD49A0598 /* WorkspaceTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 26F675D59EF454AE0D263F35 /* Build configuration list for PBXNativeTarget "WorkspaceTests" */;
|
||||
buildPhases = (
|
||||
2A0467365B852D44D1A9FD5B /* Sources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = WorkspaceTests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = WorkspaceTests;
|
||||
productReference = A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
AA6AD2A91A9E86D319D86D59 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 1430;
|
||||
TargetAttributes = {
|
||||
721CF221CEDDC28FF2C5EBD5 = {
|
||||
DevelopmentTeam = "";
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
988CFF304CB81C8F3A4D6612 = {
|
||||
TestTargetID = 721CF221CEDDC28FF2C5EBD5;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = A9CA551DD3F3EBCCF8C41CC1 /* Build configuration list for PBXProject "kanbangit-native" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
Base,
|
||||
en,
|
||||
);
|
||||
mainGroup = 157AF88201BDE4D99AA2E14C;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 59D2DE9FD94E6D2D64CB0117 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */,
|
||||
CBC4561F8018D37DD49A0598 /* WorkspaceTests */,
|
||||
988CFF304CB81C8F3A4D6612 /* WorkspaceUITests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
4A4C7FC1B57785DDD6B301FB /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
0FAB3CC7BB466BFF871C0C30 /* Assets.xcassets in Resources */,
|
||||
34C189B72A1B9EBE33D700D1 /* LaunchScreen.storyboard in Resources */,
|
||||
A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
C74F06A7ECCC2C6C79C74A2D /* Build Rust Code */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "Build Rust Code";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(SRCROOT)/Externals/x86_64/${CONFIGURATION}/libapp.a",
|
||||
"$(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "export PATH=\"$(dirname \"$(rustup which cargo)\"):$PATH\"\nexport RUSTC=\"$(rustup which rustc)\"\ncd \"$SRCROOT/../../..\"\n./node_modules/.bin/tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths \"${FRAMEWORK_SEARCH_PATHS:?}\" --header-search-paths \"${HEADER_SEARCH_PATHS:?}\" --gcc-preprocessor-definitions \"${GCC_PREPROCESSOR_DEFINITIONS:-}\" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}\n";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
0258B26407CD2D8C61079F84 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
7245AD23F39C4620BAA3AA92 /* WorkspaceUITests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
2A0467365B852D44D1A9FD5B /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
98D5B23C9BE2F62C23DF397A /* WorkspacePolicy.swift in Sources */,
|
||||
009F8FEC2765B2F19DC12CA8 /* WorkspaceTests.swift in Sources */,
|
||||
6514B0DB3A5CCCDF7385AC18 /* WorkspaceViewController.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
F39C45598E4345705F3820FA /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
70EBA05ACBA5402617A3371C /* WorkspacePolicy.swift in Sources */,
|
||||
C44BDCDEA454DBD106D51C9C /* WorkspaceViewController.swift in Sources */,
|
||||
333E7761EA87AE29CE04F1AC /* main.mm in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
5658F3B2915DC3A46531EDCE /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */;
|
||||
targetProxy = 8665A25D07C5F1A295164797 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
C8C02BC0E757897278EB954A /* Localizable.strings */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
ED4469441C3E0F37B1123572 /* en */,
|
||||
);
|
||||
name = Localizable.strings;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
0C46162024E99BEFF872CABF /* release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
ARCHS = (
|
||||
arm64,
|
||||
);
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
|
||||
FRAMEWORK_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"\".\"",
|
||||
);
|
||||
INFOPLIST_FILE = "kanbangit-native_iOS/Info.plist";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.desktop;
|
||||
PRODUCT_MODULE_NAME = KanbanGitNative;
|
||||
PRODUCT_NAME = "KanbanGit";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALID_ARCHS = arm64;
|
||||
};
|
||||
name = release;
|
||||
};
|
||||
390A27695F28BF3AA9A049C9 /* debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"$(inherited)",
|
||||
"DEBUG=1",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = debug;
|
||||
};
|
||||
5110A47C3B60906E18EE09D2 /* debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.uitests;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_TARGET_NAME = "kanbangit-native_iOS";
|
||||
};
|
||||
name = debug;
|
||||
};
|
||||
C81211E40BBB158A79AEBE3D /* release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = release;
|
||||
};
|
||||
D41150B2CAE4512D04C26759 /* debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
ARCHS = (
|
||||
arm64,
|
||||
);
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
|
||||
FRAMEWORK_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"\".\"",
|
||||
);
|
||||
INFOPLIST_FILE = "kanbangit-native_iOS/Info.plist";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.desktop;
|
||||
PRODUCT_MODULE_NAME = KanbanGitNative;
|
||||
PRODUCT_NAME = "KanbanGit";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALID_ARCHS = arm64;
|
||||
};
|
||||
name = debug;
|
||||
};
|
||||
EF104E75BD04AFEF735BAA52 /* release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.uitests;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_TARGET_NAME = "kanbangit-native_iOS";
|
||||
};
|
||||
name = release;
|
||||
};
|
||||
FB6E824120F9ED3CFE30E713 /* debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.tests;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = debug;
|
||||
};
|
||||
FF1553DD6765EEA42FF50CDD /* release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.tests;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
26F675D59EF454AE0D263F35 /* Build configuration list for PBXNativeTarget "WorkspaceTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
FB6E824120F9ED3CFE30E713 /* debug */,
|
||||
FF1553DD6765EEA42FF50CDD /* release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = debug;
|
||||
};
|
||||
A9CA551DD3F3EBCCF8C41CC1 /* Build configuration list for PBXProject "kanbangit-native" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
390A27695F28BF3AA9A049C9 /* debug */,
|
||||
C81211E40BBB158A79AEBE3D /* release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = debug;
|
||||
};
|
||||
E6F418AEF58E5F3D739C68F1 /* Build configuration list for PBXNativeTarget "WorkspaceUITests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
5110A47C3B60906E18EE09D2 /* debug */,
|
||||
EF104E75BD04AFEF735BAA52 /* release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = debug;
|
||||
};
|
||||
F0C7EA9610FB69A5EA27B950 /* Build configuration list for PBXNativeTarget "kanbangit-native_iOS" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
D41150B2CAE4512D04C26759 /* debug */,
|
||||
0C46162024E99BEFF872CABF /* release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = debug;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = AA6AD2A91A9E86D319D86D59 /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BuildSystemType</key>
|
||||
<string>Original</string>
|
||||
<key>DisableBuildSystemDeprecationDiagnostic</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,144 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1430"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "721CF221CEDDC28FF2C5EBD5"
|
||||
BuildableName = "KanbanGit.app"
|
||||
BlueprintName = "kanbangit-native_iOS"
|
||||
ReferencedContainer = "container:kanbangit-native.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "NO">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "721CF221CEDDC28FF2C5EBD5"
|
||||
BuildableName = "KanbanGit.app"
|
||||
BlueprintName = "kanbangit-native_iOS"
|
||||
ReferencedContainer = "container:kanbangit-native.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "RUST_BACKTRACE"
|
||||
value = "full"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
<EnvironmentVariable
|
||||
key = "RUST_LOG"
|
||||
value = "info"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "CBC4561F8018D37DD49A0598"
|
||||
BuildableName = "WorkspaceTests.xctest"
|
||||
BlueprintName = "WorkspaceTests"
|
||||
ReferencedContainer = "container:kanbangit-native.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "988CFF304CB81C8F3A4D6612"
|
||||
BuildableName = "WorkspaceUITests.xctest"
|
||||
BlueprintName = "WorkspaceUITests"
|
||||
ReferencedContainer = "container:kanbangit-native.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "721CF221CEDDC28FF2C5EBD5"
|
||||
BuildableName = "KanbanGit.app"
|
||||
BlueprintName = "kanbangit-native_iOS"
|
||||
ReferencedContainer = "container:kanbangit-native.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "RUST_BACKTRACE"
|
||||
value = "full"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
<EnvironmentVariable
|
||||
key = "RUST_LOG"
|
||||
value = "info"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "release"
|
||||
shouldUseLaunchSchemeArgsEnv = "NO"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "721CF221CEDDC28FF2C5EBD5"
|
||||
BuildableName = "KanbanGit.app"
|
||||
BlueprintName = "kanbangit-native_iOS"
|
||||
ReferencedContainer = "container:kanbangit-native.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "RUST_BACKTRACE"
|
||||
value = "full"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
<EnvironmentVariable
|
||||
key = "RUST_LOG"
|
||||
value = "info"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.1.0</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
<string>metal</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict/>
|
||||
</plist>
|
||||
@@ -0,0 +1,122 @@
|
||||
name: kanbangit-native
|
||||
options:
|
||||
bundleIdPrefix: de.kanbangit.desktop
|
||||
deploymentTarget:
|
||||
iOS: 17.0
|
||||
fileGroups: [../../src]
|
||||
configs:
|
||||
debug: debug
|
||||
release: release
|
||||
settingGroups:
|
||||
app:
|
||||
base:
|
||||
PRODUCT_NAME: KanbanGit
|
||||
SWIFT_VERSION: "5.0"
|
||||
PRODUCT_MODULE_NAME: KanbanGitNative
|
||||
TARGETED_DEVICE_FAMILY: "1,2"
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
DEVELOPMENT_TEAM: ""
|
||||
ENABLE_USER_SCRIPT_SANDBOXING: false
|
||||
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.desktop
|
||||
targetTemplates:
|
||||
app:
|
||||
type: application
|
||||
sources:
|
||||
- path: Sources
|
||||
scheme:
|
||||
environmentVariables:
|
||||
RUST_BACKTRACE: full
|
||||
RUST_LOG: info
|
||||
settings:
|
||||
groups: [app]
|
||||
targets:
|
||||
kanbangit-native_iOS:
|
||||
type: application
|
||||
platform: iOS
|
||||
sources:
|
||||
- path: Sources
|
||||
- path: Assets.xcassets
|
||||
- path: kanbangit-native_iOS
|
||||
- path: LaunchScreen.storyboard
|
||||
info:
|
||||
path: kanbangit-native_iOS/Info.plist
|
||||
properties:
|
||||
LSRequiresIPhoneOS: true
|
||||
UILaunchStoryboardName: LaunchScreen
|
||||
UIRequiredDeviceCapabilities: [arm64, metal]
|
||||
UISupportedInterfaceOrientations:
|
||||
- UIInterfaceOrientationPortrait
|
||||
- UIInterfaceOrientationLandscapeLeft
|
||||
- UIInterfaceOrientationLandscapeRight
|
||||
UISupportedInterfaceOrientations~ipad:
|
||||
- UIInterfaceOrientationPortrait
|
||||
- UIInterfaceOrientationPortraitUpsideDown
|
||||
- UIInterfaceOrientationLandscapeLeft
|
||||
- UIInterfaceOrientationLandscapeRight
|
||||
CFBundleShortVersionString: 0.1.0
|
||||
CFBundleVersion: "0.1.0"
|
||||
entitlements:
|
||||
path: kanbangit-native_iOS/kanbangit-native_iOS.entitlements
|
||||
scheme:
|
||||
environmentVariables:
|
||||
RUST_BACKTRACE: full
|
||||
RUST_LOG: info
|
||||
testTargets:
|
||||
- WorkspaceTests
|
||||
- WorkspaceUITests
|
||||
settings:
|
||||
base:
|
||||
ENABLE_BITCODE: false
|
||||
ARCHS: [arm64]
|
||||
VALID_ARCHS: arm64
|
||||
LIBRARY_SEARCH_PATHS[arch=x86_64]: $(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)
|
||||
LIBRARY_SEARCH_PATHS[arch=arm64]: $(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES: true
|
||||
EXCLUDED_ARCHS[sdk=iphoneos*]: x86_64
|
||||
groups: [app]
|
||||
dependencies:
|
||||
- framework: libapp.a
|
||||
embed: false
|
||||
- sdk: CoreGraphics.framework
|
||||
- sdk: Metal.framework
|
||||
- sdk: MetalKit.framework
|
||||
- sdk: QuartzCore.framework
|
||||
- sdk: Security.framework
|
||||
- sdk: UIKit.framework
|
||||
- sdk: WebKit.framework
|
||||
preBuildScripts:
|
||||
- script: |
|
||||
export PATH="$(dirname "$(rustup which cargo)"):$PATH"
|
||||
export RUSTC="$(rustup which rustc)"
|
||||
cd "$SRCROOT/../../.."
|
||||
./node_modules/.bin/tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths "${FRAMEWORK_SEARCH_PATHS:?}" --header-search-paths "${HEADER_SEARCH_PATHS:?}" --gcc-preprocessor-definitions "${GCC_PREPROCESSOR_DEFINITIONS:-}" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}
|
||||
name: Build Rust Code
|
||||
basedOnDependencyAnalysis: false
|
||||
outputFiles:
|
||||
- $(SRCROOT)/Externals/x86_64/${CONFIGURATION}/libapp.a
|
||||
- $(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a
|
||||
WorkspaceTests:
|
||||
type: bundle.unit-test
|
||||
platform: iOS
|
||||
sources:
|
||||
- path: Tests
|
||||
- path: Sources/kanbangit-native/WorkspacePolicy.swift
|
||||
- path: Sources/kanbangit-native/WorkspaceViewController.swift
|
||||
- path: Sources/kanbangit-native/en.lproj
|
||||
settings:
|
||||
base:
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.tests
|
||||
SWIFT_VERSION: "5.0"
|
||||
WorkspaceUITests:
|
||||
type: bundle.ui-testing
|
||||
platform: iOS
|
||||
sources: [UITests]
|
||||
dependencies:
|
||||
- target: kanbangit-native_iOS
|
||||
settings:
|
||||
base:
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.uitests
|
||||
SWIFT_VERSION: "5.0"
|
||||
TEST_TARGET_NAME: kanbangit-native_iOS
|
||||
|
After Width: | Height: | Size: 5.9 KiB |
@@ -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(())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
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},
|
||||
webview::{NewWindowResponse, PageLoadEvent},
|
||||
AppHandle, Manager, Url, WebviewUrl, WebviewWindowBuilder, WindowEvent,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Connection {
|
||||
server: Option<Server>,
|
||||
label: Option<String>,
|
||||
revision: u64,
|
||||
ready: bool,
|
||||
phase: &'static str,
|
||||
}
|
||||
|
||||
impl Connection {
|
||||
fn begin_navigation(&mut self) -> u64 {
|
||||
self.revision += 1;
|
||||
self.phase = "connecting";
|
||||
self.revision
|
||||
}
|
||||
|
||||
fn timeout(&mut self, revision: u64) -> bool {
|
||||
if self.revision != revision || self.phase != "connecting" {
|
||||
return false;
|
||||
}
|
||||
self.phase = "connectionError";
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn allows_server_navigation(server: &Url, destination: &Url) -> bool {
|
||||
destination.scheme() == "https"
|
||||
&& destination.origin() == server.origin()
|
||||
&& destination.username().is_empty()
|
||||
&& destination.password().is_none()
|
||||
}
|
||||
|
||||
fn allows_launcher_navigation(destination: &Url) -> bool {
|
||||
destination.scheme() == "tauri"
|
||||
&& destination.host_str() == Some("localhost")
|
||||
&& destination.port().is_none()
|
||||
&& destination.username().is_empty()
|
||||
&& destination.password().is_none()
|
||||
}
|
||||
|
||||
fn configuration_path(app: &AppHandle) -> tauri::Result<PathBuf> {
|
||||
Ok(app.path().app_config_dir()?.join("active-server"))
|
||||
}
|
||||
|
||||
fn update_launcher(app: &AppHandle) {
|
||||
let state = app.state::<Mutex<Connection>>();
|
||||
let state = state.lock().unwrap();
|
||||
let mut message = Url::parse("tauri://localhost/").unwrap();
|
||||
message
|
||||
.query_pairs_mut()
|
||||
.append_pair(
|
||||
"server",
|
||||
state
|
||||
.server
|
||||
.as_ref()
|
||||
.map_or(DEFAULT_SERVER, |server| server.url.as_str()),
|
||||
)
|
||||
.append_pair("phase", state.phase)
|
||||
.append_pair("ready", if state.ready { "true" } else { "false" });
|
||||
if let Some(window) = app.get_webview_window("launcher") {
|
||||
let _ = window.eval(format!(
|
||||
"window.updateLauncher?.('{}')",
|
||||
message.query().unwrap()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn show_launcher(app: &AppHandle) {
|
||||
update_launcher(app);
|
||||
if let Some(window) = app.get_webview_window("launcher") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
fn begin_navigation(app: &AppHandle, label: &str) {
|
||||
let state = app.state::<Mutex<Connection>>();
|
||||
let revision = {
|
||||
let mut state = state.lock().unwrap();
|
||||
if state.label.as_deref() != Some(label) {
|
||||
return;
|
||||
}
|
||||
state.begin_navigation()
|
||||
};
|
||||
update_launcher(app);
|
||||
let handle = app.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_secs(15));
|
||||
let state = handle.state::<Mutex<Connection>>();
|
||||
let timed_out = state.lock().unwrap().timeout(revision);
|
||||
if timed_out {
|
||||
show_launcher(&handle);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
|
||||
let url = validate_url(input)?;
|
||||
let state = app.state::<Mutex<Connection>>();
|
||||
let (server, old_label, label) = {
|
||||
let mut state = state.lock().unwrap();
|
||||
let same_server = state
|
||||
.server
|
||||
.as_ref()
|
||||
.is_some_and(|server| server.url == url);
|
||||
if same_server && state.ready {
|
||||
if let Some(window) = state
|
||||
.label
|
||||
.as_ref()
|
||||
.and_then(|label| app.get_webview_window(label))
|
||||
{
|
||||
window.show().map_err(|_| "connectionError")?;
|
||||
window.set_focus().map_err(|_| "connectionError")?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
let server = if same_server {
|
||||
state.server.clone().unwrap()
|
||||
} else {
|
||||
Server::new(url).map_err(|_| "storageError")?
|
||||
};
|
||||
server
|
||||
.save(&configuration_path(app).map_err(|_| "storageError")?)
|
||||
.map_err(|_| "storageError")?;
|
||||
let old_label = state.label.take();
|
||||
state.server = Some(server.clone());
|
||||
state.ready = false;
|
||||
state.revision += 1;
|
||||
let label = format!("workspace-{}", state.revision);
|
||||
state.label = Some(label.clone());
|
||||
(server, old_label, label)
|
||||
};
|
||||
if let Some(window) = old_label.and_then(|label| app.get_webview_window(&label)) {
|
||||
window.close().map_err(|_| "connectionError")?;
|
||||
}
|
||||
begin_navigation(app, &label);
|
||||
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))
|
||||
.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)
|
||||
.on_navigation(move |destination| {
|
||||
if !allows_server_navigation(&server_url, destination) {
|
||||
return false;
|
||||
}
|
||||
begin_navigation(&handle, &navigation_label);
|
||||
true
|
||||
})
|
||||
.on_new_window(|_, _| NewWindowResponse::Deny)
|
||||
.on_page_load(|window, payload| {
|
||||
if payload.event() != PageLoadEvent::Finished {
|
||||
return;
|
||||
}
|
||||
let app = window.app_handle();
|
||||
let state = app.state::<Mutex<Connection>>();
|
||||
let should_show = {
|
||||
let mut state = state.lock().unwrap();
|
||||
if state.label.as_deref() != Some(window.label()) {
|
||||
return;
|
||||
}
|
||||
let first_load = !state.ready;
|
||||
state.ready = true;
|
||||
state.phase = "connected";
|
||||
first_load
|
||||
};
|
||||
update_launcher(app);
|
||||
if should_show {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
if let Some(launcher) = app.get_webview_window("launcher") {
|
||||
let _ = launcher.hide();
|
||||
}
|
||||
}
|
||||
})
|
||||
.build()
|
||||
.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 current {
|
||||
show_launcher(&handle);
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_launcher_action(app: &AppHandle, destination: &Url) {
|
||||
let result = match destination.path() {
|
||||
"/action/open" => {
|
||||
let input = destination
|
||||
.query_pairs()
|
||||
.find(|(key, _)| key == "server")
|
||||
.map(|(_, value)| value.into_owned())
|
||||
.unwrap_or_default();
|
||||
open_workspace(app, &input)
|
||||
}
|
||||
"/action/resume" => {
|
||||
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.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Ok(()),
|
||||
};
|
||||
if let Err(error) = result {
|
||||
app.state::<Mutex<Connection>>().lock().unwrap().phase = error;
|
||||
show_launcher(app);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn context<R: tauri::Runtime>() -> tauri::Context<R> {
|
||||
tauri::generate_context!()
|
||||
}
|
||||
|
||||
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"),
|
||||
Err(_) => (None, "storageError"),
|
||||
};
|
||||
app.manage(Mutex::new(Connection {
|
||||
server,
|
||||
phase,
|
||||
..Default::default()
|
||||
}));
|
||||
let menu = MenuBuilder::new(app)
|
||||
.item(
|
||||
&SubmenuBuilder::new(app, en::APP_NAME)
|
||||
.text("server-settings", en::SERVER_SETTINGS)
|
||||
.separator()
|
||||
.quit()
|
||||
.build()?,
|
||||
)
|
||||
.item(
|
||||
&SubmenuBuilder::new(app, en::EDIT)
|
||||
.undo()
|
||||
.redo()
|
||||
.separator()
|
||||
.cut()
|
||||
.copy()
|
||||
.paste()
|
||||
.select_all()
|
||||
.build()?,
|
||||
)
|
||||
.build()?;
|
||||
app.set_menu(menu)?;
|
||||
app.on_menu_event(|app, event| {
|
||||
if event.id().as_ref() == "server-settings" {
|
||||
show_launcher(app);
|
||||
}
|
||||
});
|
||||
let handle = app.handle().clone();
|
||||
let launcher =
|
||||
WebviewWindowBuilder::new(app, "launcher", WebviewUrl::App("index.html".into()))
|
||||
.title(en::APP_NAME)
|
||||
.inner_size(560.0, 720.0)
|
||||
.min_inner_size(375.0, 640.0)
|
||||
.center()
|
||||
.on_navigation(move |destination| {
|
||||
if !allows_launcher_navigation(destination) {
|
||||
return false;
|
||||
}
|
||||
if destination.path().starts_with("/action/") {
|
||||
let handle = handle.clone();
|
||||
let destination = destination.clone();
|
||||
let _ = handle.clone().run_on_main_thread(move || {
|
||||
handle_launcher_action(&handle, &destination)
|
||||
});
|
||||
return false;
|
||||
}
|
||||
matches!(destination.path(), "" | "/" | "/index.html")
|
||||
})
|
||||
.on_new_window(|_, _| NewWindowResponse::Deny)
|
||||
.on_page_load(|window, payload| {
|
||||
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
|
||||
.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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.build()?;
|
||||
let window = launcher.clone();
|
||||
launcher.on_window_event(move |event| {
|
||||
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||
api.prevent_close();
|
||||
let _ = window.hide();
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn navigation_is_limited_to_the_selected_https_origin() {
|
||||
let server = validate_url("https://example.com:8443/app/").unwrap();
|
||||
for path in [
|
||||
"/login",
|
||||
"/two-factor-challenge",
|
||||
"/projects/1?ticket=52#details",
|
||||
] {
|
||||
assert!(allows_server_navigation(
|
||||
&server,
|
||||
&server.join(path).unwrap()
|
||||
));
|
||||
}
|
||||
for destination in [
|
||||
"http://example.com:8443/",
|
||||
"https://example.com/",
|
||||
"https://other.example.com:8443/",
|
||||
"https://user:secret@example.com:8443/",
|
||||
"tauri://localhost/action/open?server=https://evil.test",
|
||||
"file:///etc/passwd",
|
||||
"data:text/html,hello",
|
||||
"javascript:alert(1)",
|
||||
] {
|
||||
assert!(!allows_server_navigation(
|
||||
&server,
|
||||
&Url::parse(destination).unwrap()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launcher_actions_are_restricted_to_the_bundled_origin() {
|
||||
assert!(allows_launcher_navigation(
|
||||
&Url::parse("tauri://localhost/action/open").unwrap()
|
||||
));
|
||||
for input in [
|
||||
DEFAULT_SERVER,
|
||||
"https://localhost/",
|
||||
"tauri://other/index.html",
|
||||
"tauri://user@localhost/index.html",
|
||||
] {
|
||||
assert!(!allows_launcher_navigation(&Url::parse(input).unwrap()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_timeouts_do_not_replace_newer_connections_or_loaded_forms() {
|
||||
let mut state = Connection::default();
|
||||
let first = state.begin_navigation();
|
||||
let second = state.begin_navigation();
|
||||
assert!(!state.timeout(first));
|
||||
state.ready = true;
|
||||
assert!(state.timeout(second));
|
||||
assert!(state.ready);
|
||||
assert!(!state.timeout(second));
|
||||
let third = state.begin_navigation();
|
||||
state.phase = "connected";
|
||||
assert!(!state.timeout(third));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_pages_cannot_invoke_native_commands() {
|
||||
let app = tauri::test::mock_builder().build(context()).unwrap();
|
||||
let window = WebviewWindowBuilder::new(
|
||||
&app,
|
||||
"workspace-1",
|
||||
WebviewUrl::External(Url::parse(DEFAULT_SERVER).unwrap()),
|
||||
)
|
||||
.build()
|
||||
.unwrap();
|
||||
for command in [
|
||||
"plugin:window|close",
|
||||
"plugin:webview|create_webview_window",
|
||||
"plugin:path|resolve_directory",
|
||||
] {
|
||||
let response = tauri::test::get_ipc_response(
|
||||
&window,
|
||||
tauri::webview::InvokeRequest {
|
||||
cmd: command.into(),
|
||||
callback: tauri::ipc::CallbackFn(0),
|
||||
error: tauri::ipc::CallbackFn(1),
|
||||
url: Url::parse(DEFAULT_SERVER).unwrap(),
|
||||
body: tauri::ipc::InvokeBody::default(),
|
||||
headers: Default::default(),
|
||||
invoke_key: tauri::test::INVOKE_KEY.into(),
|
||||
},
|
||||
);
|
||||
assert!(response
|
||||
.unwrap_err()
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("not allowed"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packaged_app_exposes_no_native_capabilities_or_global_api() {
|
||||
let config: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
|
||||
assert_eq!(config["bundle"]["macOS"]["minimumSystemVersion"], "14.0");
|
||||
assert_eq!(config["app"]["withGlobalTauri"], false);
|
||||
assert_eq!(
|
||||
config["app"]["security"]["capabilities"],
|
||||
serde_json::json!([])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
use tauri::{WebviewUrl, WebviewWindowBuilder};
|
||||
|
||||
pub fn context<R: tauri::Runtime>() -> tauri::Context<R> {
|
||||
tauri::generate_context!()
|
||||
}
|
||||
|
||||
pub fn builder() -> tauri::Builder<tauri::Wry> {
|
||||
tauri::Builder::default().setup(|app| {
|
||||
WebviewWindowBuilder::new(app, "host", WebviewUrl::App("index.html".into()))
|
||||
.on_navigation(|_| false)
|
||||
.build()?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
#[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(any(target_os = "android", target_os = "ios")))]
|
||||
mod locale;
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
mod server;
|
||||
|
||||
#[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_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
builder().run(context()).expect("Could not start KanbanGit");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod en {
|
||||
pub const APP_NAME: &str = "KanbanGit";
|
||||
pub const SERVER_SETTINGS: &str = "Server settings…";
|
||||
pub const EDIT: &str = "Edit";
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
kanbangit_native::run();
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
use std::{fs, io, io::Read, path::Path};
|
||||
use tauri::Url;
|
||||
|
||||
pub const DEFAULT_SERVER: &str = "https://kanbangit.de/";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Server {
|
||||
pub url: Url,
|
||||
pub profile: [u8; 16],
|
||||
}
|
||||
|
||||
pub fn validate_url(input: &str) -> Result<Url, &'static str> {
|
||||
let input = input.trim();
|
||||
if !input
|
||||
.get(..8)
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("https://"))
|
||||
|| input
|
||||
.chars()
|
||||
.any(|character| character.is_control() || character == '\\')
|
||||
{
|
||||
return Err("invalidServer");
|
||||
}
|
||||
let mut url = Url::parse(input).map_err(|_| "invalidServer")?;
|
||||
if url.scheme() != "https"
|
||||
|| url.host_str().is_none()
|
||||
|| !url.username().is_empty()
|
||||
|| url.password().is_some()
|
||||
|| url.query().is_some()
|
||||
|| url.fragment().is_some()
|
||||
{
|
||||
return Err("invalidServer");
|
||||
}
|
||||
if !url.path().ends_with('/') {
|
||||
url.set_path(&format!("{}/", url.path()));
|
||||
}
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub fn new(url: Url) -> io::Result<Self> {
|
||||
let mut profile = [0; 16];
|
||||
fs::File::open("/dev/urandom")?.read_exact(&mut profile)?;
|
||||
Ok(Self { url, profile })
|
||||
}
|
||||
|
||||
pub fn load(path: &Path) -> io::Result<Option<Self>> {
|
||||
let contents = match fs::read_to_string(path) {
|
||||
Ok(contents) => contents,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let invalid = || io::Error::new(io::ErrorKind::InvalidData, "Invalid server configuration");
|
||||
let (url, profile) = contents.split_once('\n').ok_or_else(invalid)?;
|
||||
let url = validate_url(url).map_err(|_| invalid())?;
|
||||
if profile.len() != 32 || !profile.is_ascii() {
|
||||
return Err(invalid());
|
||||
}
|
||||
let mut bytes = [0; 16];
|
||||
for (index, byte) in bytes.iter_mut().enumerate() {
|
||||
*byte = u8::from_str_radix(&profile[index * 2..index * 2 + 2], 16)
|
||||
.map_err(|_| invalid())?;
|
||||
}
|
||||
Ok(Some(Self {
|
||||
url,
|
||||
profile: bytes,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn save(&self, path: &Path) -> io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let profile: String = self
|
||||
.profile
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect();
|
||||
let temporary = path.with_extension("tmp");
|
||||
fs::write(&temporary, format!("{}\n{profile}", self.url))?;
|
||||
fs::rename(temporary, path)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn validates_and_normalizes_installation_addresses() {
|
||||
assert_eq!(
|
||||
validate_url(" HTTPS://Example.com:443/kanban ")
|
||||
.unwrap()
|
||||
.as_str(),
|
||||
"https://example.com/kanban/"
|
||||
);
|
||||
assert!(validate_url("https://[::1]:8443/").is_ok());
|
||||
for input in [
|
||||
"http://example.com",
|
||||
"https:example.com",
|
||||
"https://user:secret@example.com",
|
||||
"https://user@example.com",
|
||||
"https://example.com/?token=secret",
|
||||
"https://example.com/#secret",
|
||||
"https://exa\nmple.com",
|
||||
"https://example.com\\other",
|
||||
"file:///tmp/test",
|
||||
"javascript:alert(1)",
|
||||
"",
|
||||
] {
|
||||
assert!(validate_url(input).is_err(), "{input}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_round_trip_preserves_only_the_address_and_profile() {
|
||||
let server = Server::new(validate_url(DEFAULT_SERVER).unwrap()).unwrap();
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("kanbangit-server-{:x?}/active", server.profile));
|
||||
assert_eq!(Server::load(&path).unwrap(), None);
|
||||
server.save(&path).unwrap();
|
||||
assert_eq!(Server::load(&path).unwrap(), Some(server.clone()));
|
||||
let other = Server::new(validate_url("https://other.example.com:444/").unwrap()).unwrap();
|
||||
assert_ne!(server.profile, other.profile);
|
||||
other.save(&path).unwrap();
|
||||
assert_eq!(Server::load(&path).unwrap(), Some(other));
|
||||
for contents in [
|
||||
"http://example.com/\n00000000000000000000000000000000",
|
||||
"invalid",
|
||||
"https://example.com/\nshort",
|
||||
"https://example.com/\nzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
|
||||
] {
|
||||
fs::write(&path, contents).unwrap();
|
||||
assert!(Server::load(&path).is_err());
|
||||
}
|
||||
fs::remove_dir_all(path.parent().unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "KanbanGit",
|
||||
"version": "0.1.0",
|
||||
"identifier": "de.kanbangit.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../ui"
|
||||
},
|
||||
"app": {
|
||||
"windows": [],
|
||||
"withGlobalTauri": false,
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-src 'none'",
|
||||
"capabilities": []
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["app"],
|
||||
"icon": ["icons/icon.png", "icons/icon.icns"],
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "14.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"bundle": {
|
||||
"iOS": { "minimumSystemVersion": "17.0" }
|
||||
}
|
||||
}
|
||||