Compare commits
177 Commits
derive-doc
...
v0.20.0
Author | SHA1 | Date | |
---|---|---|---|
00058f699a | |||
5a478fe0b3 | |||
723cf4f746 | |||
3950de0a4d | |||
901d474986 | |||
e7ab645267 | |||
cf830f9895 | |||
2c1f53f0f0 | |||
d39e2502d0 | |||
51fed9c541 | |||
b3a09abe01 | |||
cd3a2fea07 | |||
c29c4a8567 | |||
39ccd94884 | |||
d99ab22b56 | |||
20a8f2aa6a | |||
93266a9819 | |||
a9c7a7cb13 | |||
8dd9b8d192 | |||
23181d8144 | |||
834967df6a | |||
deacaac33a | |||
c55603853b | |||
93f652647e | |||
67cea620a6 | |||
ed0c7d038d | |||
d3aa789761 | |||
cd68f80b71 | |||
d341681c0d | |||
0578e9d2a1 | |||
b413538e9e | |||
c4e7754fc5 | |||
94515b5490 | |||
aa52407fda | |||
e45be831d0 | |||
005944f3a3 | |||
755ef8ce7f | |||
005d1f0ca7 | |||
e158f6f513 | |||
879d7ec4f4 | |||
f6838b9b14 | |||
cb75c47631 | |||
9b95ec1083 | |||
a3eeff65c8 | |||
fab3d2b130 | |||
0a96dc6fd2 | |||
e123a00d4b | |||
b950cc0583 | |||
c89780a489 | |||
1afed68dd7 | |||
dcbed4f06f | |||
379f154a5c | |||
60c4969322 | |||
cc6dee8ad4 | |||
2fc7c0d5fd | |||
bf2dcd808f | |||
ee21e486d4 | |||
b5a3eb9e9c | |||
c85645c9f2 | |||
cfa4dd2e33 | |||
c620f7269c | |||
2d8d29b345 | |||
00da062586 | |||
aafbaf6c50 | |||
2894c84a4e | |||
c01084feb0 | |||
c461db5f54 | |||
03fcb73aca | |||
8065e7e51a | |||
2d0ac249df | |||
3d73b82c23 | |||
0b235dc1cd | |||
0857415793 | |||
1da4fd03ef | |||
39d84c12ab | |||
537d86c8ff | |||
f08d955d40 | |||
7ea6722d2d | |||
39bd72fc83 | |||
88aec7e2c5 | |||
b936eafc26 | |||
6c11f0e456 | |||
06a7fcf6a7 | |||
6450622146 | |||
9dfe0c3d80 | |||
ba33b0da19 | |||
21756fe513 | |||
69d34c5318 | |||
f909ea7af5 | |||
38d9b5d4b4 | |||
ac140c054f | |||
215db38b44 | |||
0880199844 | |||
18ce254566 | |||
bc90840e7c | |||
3f8c4e7b5a | |||
168fed038d | |||
9544251b1a | |||
a490b4db8c | |||
410089549d | |||
05e27f354a | |||
6793555e86 | |||
6823c5eedd | |||
b13c1339aa | |||
624b1fc07d | |||
ed69213680 | |||
593b4e6f21 | |||
7eeaf96d18 | |||
6fa7698f42 | |||
4abb8fc267 | |||
ff482e5f9b | |||
dd51eecaed | |||
266450afbf | |||
e01b35d1e9 | |||
f0b9de2c1c | |||
35c3103186 | |||
08534a024c | |||
25fa3b48e1 | |||
db5abf0149 | |||
3634c96cf1 | |||
e9890aa22b | |||
36532c521e | |||
c75ecada03 | |||
21d64d7c29 | |||
2224c89909 | |||
9b0f9f321b | |||
f29573f3dc | |||
9a9c2223de | |||
6d12aa48f8 | |||
3fdf7bd45e | |||
fdadd059d6 | |||
b646504cfb | |||
ff8a994cb8 | |||
4f9a0be343 | |||
e8240ee896 | |||
70bc0accad | |||
9dedc56b7e | |||
72144052c0 | |||
82bad2cab1 | |||
63be31971f | |||
ba6b3d9a8d | |||
0b5802a0d2 | |||
e2d24edfee | |||
cc06825ec9 | |||
51f7addf54 | |||
226e4d2932 | |||
e7397ec564 | |||
af69856633 | |||
bce058ab52 | |||
75545ddff1 | |||
203fa7e454 | |||
76de64780c | |||
2d804dee2b | |||
c094d0ced1 | |||
a90fe3c066 | |||
f3ea7fd0e2 | |||
704ff0df62 | |||
eba17e92b7 | |||
d9d700624e | |||
1e547aeef0 | |||
22899c9e69 | |||
25702f454c | |||
11faf86983 | |||
67d73382b1 | |||
15b9f43f2c | |||
d28555a070 | |||
7bf116629f | |||
fe45b5b54d | |||
bcbd3f5bfd | |||
959433e357 | |||
d18e35b7ea | |||
596c9a0ee6 | |||
9106a81c77 | |||
8b5ebe67b2 | |||
a7f539eca6 | |||
f4c87c994c | |||
3d4ae05145 |
@ -3,3 +3,4 @@ VITE_KC_API_BASE_URL=https://api.dev.zoo.dev
|
||||
VITE_KC_SITE_BASE_URL=https://dev.zoo.dev
|
||||
VITE_KC_SKIP_AUTH=false
|
||||
VITE_KC_CONNECTION_TIMEOUT_MS=5000
|
||||
VITE_KC_DEV_TOKEN="your token from dev.zoo.dev should go in .env.development.local"
|
||||
|
12
.github/dependabot.yml
vendored
@ -9,15 +9,27 @@ updates:
|
||||
directory: '/' # Location of package manifests
|
||||
schedule:
|
||||
interval: 'daily'
|
||||
reviewers:
|
||||
- franknoirot
|
||||
- irev-dev
|
||||
- package-ecosystem: 'github-actions' # See documentation for possible values
|
||||
directory: '/' # Location of package manifests
|
||||
schedule:
|
||||
interval: 'daily'
|
||||
reviewers:
|
||||
- adamchalmers
|
||||
- jessfraz
|
||||
- package-ecosystem: 'cargo' # See documentation for possible values
|
||||
directory: '/src/wasm-lib/' # Location of package manifests
|
||||
schedule:
|
||||
interval: 'daily'
|
||||
reviewers:
|
||||
- adamchalmers
|
||||
- jessfraz
|
||||
- package-ecosystem: 'cargo' # See documentation for possible values
|
||||
directory: '/src-tauri/' # Location of package manifests
|
||||
schedule:
|
||||
interval: 'daily'
|
||||
reviewers:
|
||||
- adamchalmers
|
||||
- jessfraz
|
||||
|
33
.github/workflows/build-and-store-wasm.yml
vendored
Normal file
@ -0,0 +1,33 @@
|
||||
name: Build and Store WASM
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build-and-upload:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'yarn'
|
||||
- name: Install dependencies
|
||||
run: yarn
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
- name: Cache wasm
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: './src/wasm-lib'
|
||||
- name: build wasm
|
||||
run: yarn build:wasm
|
||||
|
||||
|
||||
# Upload the WASM bundle as an artifact
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: wasm-bundle
|
||||
path: src/wasm-lib/pkg
|
17
.github/workflows/cargo-clippy.yml
vendored
@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
dir: ['src/wasm-lib']
|
||||
dir: ['src/wasm-lib', 'src-tauri']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install latest rust
|
||||
@ -31,9 +31,22 @@ jobs:
|
||||
|
||||
- name: install dependencies
|
||||
if: matrix.dir == 'src-tauri'
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
sudo apt-get install -y \
|
||||
libgtk-3-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
webkit2gtk-driver \
|
||||
libsoup-3.0-dev \
|
||||
libjavascriptcoregtk-4.1-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
at-spi2-core \
|
||||
xvfb
|
||||
yarn install
|
||||
yarn build:wasm
|
||||
yarn build:local
|
||||
|
||||
- name: Rust Cache
|
||||
uses: Swatinem/rust-cache@v2.6.1
|
||||
|
||||
|
57
.github/workflows/cargo-test-tauri.yml
vendored
Normal file
@ -0,0 +1,57 @@
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'src-tauri/**.rs'
|
||||
- '**/Cargo.toml'
|
||||
- '**/Cargo.lock'
|
||||
- '**/rust-toolchain.toml'
|
||||
- .github/workflows/cargo-test-tauri.yml
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src-tauri/**.rs'
|
||||
- '**/Cargo.toml'
|
||||
- '**/Cargo.lock'
|
||||
- '**/rust-toolchain.toml'
|
||||
- .github/workflows/cargo-test-tauri.yml
|
||||
workflow_dispatch:
|
||||
permissions: read-all
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
name: cargo test of tauri
|
||||
jobs:
|
||||
cargotest:
|
||||
name: cargo test
|
||||
runs-on: ubuntu-latest-8-cores
|
||||
strategy:
|
||||
matrix:
|
||||
dir: ['src-tauri']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install latest rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
override: true
|
||||
- name: install dependencies
|
||||
if: matrix.dir == 'src-tauri'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libgtk-3-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
webkit2gtk-driver \
|
||||
libsoup-3.0-dev \
|
||||
libjavascriptcoregtk-4.1-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
at-spi2-core \
|
||||
xvfb
|
||||
- name: Rust Cache
|
||||
uses: Swatinem/rust-cache@v2.6.1
|
||||
- name: cargo test
|
||||
shell: bash
|
||||
run: |-
|
||||
cd "${{ matrix.dir }}"
|
||||
cargo test --all
|
134
.github/workflows/ci.yml
vendored
@ -13,7 +13,7 @@ on:
|
||||
# Will checkout the last commit from the default branch (main as of 2023-10-04)
|
||||
|
||||
env:
|
||||
BUILD_RELEASE: ${{ github.event_name == 'release' || github.event_name == 'schedule' || github.event_name == 'pull_request' && contains(github.event.pull_request.title, 'Cut release v') }}
|
||||
BUILD_RELEASE: ${{ github.event_name == 'release' || github.event_name == 'schedule' || github.event_name == 'pull_request' && (contains(github.event.pull_request.title, 'Cut release v')) }}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
@ -104,7 +104,11 @@ jobs:
|
||||
run: |
|
||||
VERSION=$(date +'%-y.%-m.%-d') yarn bump-jsons
|
||||
echo "$(jq --arg url 'https://dl.zoo.dev/releases/modeling-app/nightly/last_update.json' \
|
||||
'.tauri.updater.endpoints[]=$url' src-tauri/tauri.release.conf.json --indent 2)" > src-tauri/tauri.release.conf.json
|
||||
'.plugins.updater.endpoints[]=$url' src-tauri/tauri.release.conf.json --indent 2)" > src-tauri/tauri.release.conf.json
|
||||
echo "$(jq --arg id 'dev.zoo.modeling-app-nightly' \
|
||||
'.identifier=$id' src-tauri/tauri.release.conf.json --indent 2)" > src-tauri/tauri.release.conf.json
|
||||
echo "$(jq --arg name 'Zoo Modeling App (Nightly)' \
|
||||
'.productName=$name' src-tauri/tauri.release.conf.json --indent 2)" > src-tauri/tauri.release.conf.json
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: github.event_name == 'schedule'
|
||||
@ -126,7 +130,9 @@ jobs:
|
||||
matrix:
|
||||
os: [macos-14, ubuntu-latest, windows-latest]
|
||||
env:
|
||||
# Specific Apple Universal target for macos
|
||||
TAURI_ARGS_MACOS: ${{ matrix.os == 'macos-14' && '--target universal-apple-darwin' || '' }}
|
||||
# Only build executable on linux (no appimage or deb)
|
||||
TAURI_ARGS_UBUNTU: ${{ matrix.os == 'ubuntu-latest' && '--bundles' || '' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@ -143,16 +149,16 @@ jobs:
|
||||
|
||||
- name: Install ubuntu system dependencies
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: >
|
||||
sudo apt-get update &&
|
||||
sudo apt-get install -y
|
||||
libgtk-3-dev
|
||||
libayatana-appindicator3-dev
|
||||
webkit2gtk-driver
|
||||
libsoup-3.0-dev
|
||||
libjavascriptcoregtk-4.1-dev
|
||||
libwebkit2gtk-4.1-dev
|
||||
at-spi2-core
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libgtk-3-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
webkit2gtk-driver \
|
||||
libsoup-3.0-dev \
|
||||
libjavascriptcoregtk-4.1-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
at-spi2-core \
|
||||
xvfb
|
||||
|
||||
- name: Sync node version and setup cache
|
||||
@ -233,6 +239,96 @@ jobs:
|
||||
includeDebug: true
|
||||
args: "${{ env.TAURI_ARGS_MACOS }} ${{ env.TAURI_ARGS_UBUNTU }}"
|
||||
|
||||
- name: Mac App Store
|
||||
if: ${{ env.BUILD_RELEASE == 'true' && matrix.os == 'macos-14' }}
|
||||
shell: bash
|
||||
run: |
|
||||
unset APPLE_SIGNING_IDENTITY
|
||||
unset APPLE_CERTIFICATE
|
||||
sign_app="3rd Party Mac Developer Application: KittyCAD Inc (${APPLE_TEAM_ID})"
|
||||
sign_install="3rd Party Mac Developer Installer: KittyCAD Inc (${APPLE_TEAM_ID})"
|
||||
profile="src-tauri/entitlements/Mac_App_Distribution.provisionprofile"
|
||||
|
||||
mkdir -p src-tauri/entitlements
|
||||
echo -n "${APPLE_STORE_PROVISIONING_PROFILE}" | base64 --decode -o "${profile}"
|
||||
|
||||
echo -n "${APPLE_STORE_DISTRIBUTION_CERT}" | base64 --decode -o "dist.cer"
|
||||
echo -n "${APPLE_STORE_INSTALLER_CERT}" | base64 --decode -o "installer.cer"
|
||||
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
KEYCHAIN_PASSWORD="password"
|
||||
|
||||
# create temporary keychain
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
|
||||
# import certificate to keychain
|
||||
security import "dist.cer" -P "$APPLE_STORE_P12_PASSWORD" -k $KEYCHAIN_PATH -f pkcs12 -t cert -A
|
||||
security import "installer.cer" -P "$APPLE_STORE_P12_PASSWORD" -k $KEYCHAIN_PATH -f pkcs12 -t cert -A
|
||||
|
||||
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security list-keychain -d user -s $KEYCHAIN_PATH
|
||||
|
||||
target="universal-apple-darwin"
|
||||
|
||||
# Turn off the default target
|
||||
# We don't want to install the updater for the apple store build
|
||||
sed -i.bu "s/default =/# default =/" src-tauri/Cargo.toml
|
||||
rm src-tauri/Cargo.toml.bu
|
||||
git diff src-tauri/Cargo.toml
|
||||
|
||||
yarn tauri build --target "${target}" --verbose --config src-tauri/tauri.app-store.conf.json
|
||||
|
||||
app_path="src-tauri/target/${target}/release/bundle/macos/Zoo Modeling App.app"
|
||||
build_name="src-tauri/target/${target}/release/bundle/macos/Zoo Modeling App.pkg"
|
||||
cp_dir="src-tauri/target/${target}/release/bundle/macos/Zoo Modeling App.app/Contents/embedded.provisionprofile"
|
||||
entitlements="src-tauri/entitlements/app-store.entitlements"
|
||||
|
||||
cp "${profile}" "${cp_dir}"
|
||||
|
||||
codesign --deep --force -s "${sign_app}" --entitlements "${entitlements}" "${app_path}"
|
||||
|
||||
productbuild --component "${app_path}" /Applications/ --sign "${sign_install}" "${build_name}"
|
||||
|
||||
# Undo the changes to the Cargo.toml
|
||||
git checkout src-tauri/Cargo.toml
|
||||
|
||||
env:
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPLE_STORE_PROVISIONING_PROFILE: ${{ secrets.APPLE_STORE_PROVISIONING_PROFILE }}
|
||||
APPLE_STORE_DISTRIBUTION_CERT: ${{ secrets.APPLE_STORE_DISTRIBUTION_CERT }}
|
||||
APPLE_STORE_INSTALLER_CERT: ${{ secrets.APPLE_STORE_INSTALLER_CERT }}
|
||||
APPLE_STORE_P12_PASSWORD: ${{ secrets.APPLE_STORE_P12_PASSWORD }}
|
||||
|
||||
|
||||
- name: 'Upload app to TestFlight'
|
||||
uses: apple-actions/upload-testflight-build@v1
|
||||
if: ${{ env.BUILD_RELEASE == 'true' && matrix.os == 'macos-14' }}
|
||||
with:
|
||||
app-path: 'src-tauri/target/universal-apple-darwin/release/bundle/macos/Zoo Modeling App.pkg'
|
||||
issuer-id: ${{ secrets.APPLE_STORE_ISSUER_ID }}
|
||||
api-key-id: ${{ secrets.APPLE_STORE_API_KEY_ID }}
|
||||
api-private-key: ${{ secrets.APPLE_STORE_API_PRIVATE_KEY }}
|
||||
app-type: osx
|
||||
|
||||
|
||||
- name: Clean up after Mac App Store
|
||||
if: ${{ env.BUILD_RELEASE == 'true' && matrix.os == 'macos-14' }}
|
||||
shell: bash
|
||||
run: |
|
||||
git status
|
||||
# remove our target builds because we want to make sure the later build
|
||||
# includes the updater, and that anything we changed with the target
|
||||
# does not persist
|
||||
rm -rf src-tauri/target
|
||||
# Lets get rid of the info.plist for the normal mac builds since its
|
||||
# being sketchy.
|
||||
rm src-tauri/Info.plist
|
||||
|
||||
# We do this after the apple store because the apple store build is
|
||||
# specific and we want to overwrite it with the this new build after and
|
||||
# not upload the apple store build to the public bucket
|
||||
- name: Build the app (release) and sign
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
if: ${{ env.BUILD_RELEASE == 'true' }}
|
||||
@ -257,11 +353,10 @@ jobs:
|
||||
with:
|
||||
path: "${{ env.PREFIX }}/${{ env.MODE }}/bundle/*/*"
|
||||
|
||||
# TODO: re-enable linux e2e tests when possible
|
||||
- name: Run e2e tests (linux only)
|
||||
if: false
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
cargo install tauri-driver
|
||||
cargo install tauri-driver --force
|
||||
source .env.${{ env.BUILD_RELEASE == 'true' && 'production' || 'development' }}
|
||||
export VITE_KC_API_BASE_URL
|
||||
xvfb-run yarn test:e2e:tauri
|
||||
@ -281,6 +376,7 @@ jobs:
|
||||
NOTES: ${{ github.event_name == 'release' && github.event.release.body || format('Nightly build, commit {0}', github.sha) }}
|
||||
BUCKET_DIR: ${{ github.event_name == 'release' && 'dl.kittycad.io/releases/modeling-app' || 'dl.kittycad.io/releases/modeling-app/nightly' }}
|
||||
WEBSITE_DIR: ${{ github.event_name == 'release' && 'dl.zoo.dev/releases/modeling-app' || 'dl.zoo.dev/releases/modeling-app/nightly' }}
|
||||
URL_CODED_NAME: ${{ github.event_name == 'schedule' && 'Zoo%20Modeling%20App%20%28Nightly%29' || 'Zoo%20Modeling%20App' }}
|
||||
steps:
|
||||
- uses: actions/download-artifact@v3
|
||||
|
||||
@ -295,9 +391,9 @@ jobs:
|
||||
--arg pub_date "${PUB_DATE}" \
|
||||
--arg notes "${NOTES}" \
|
||||
--arg darwin_sig "$DARWIN_SIG" \
|
||||
--arg darwin_url "$RELEASE_DIR/macos/Zoo%20Modeling%20App.app.tar.gz" \
|
||||
--arg darwin_url "$RELEASE_DIR/macos/${{ env.URL_CODED_NAME }}.app.tar.gz" \
|
||||
--arg windows_sig "$WINDOWS_SIG" \
|
||||
--arg windows_url "$RELEASE_DIR/msi/Zoo%20Modeling%20App_${VERSION_NO_V}_x64_en-US.msi.zip" \
|
||||
--arg windows_url "$RELEASE_DIR/msi/${{ env.URL_CODED_NAME }}_${VERSION_NO_V}_x64_en-US.msi.zip" \
|
||||
'{
|
||||
"version": $version,
|
||||
"pub_date": $pub_date,
|
||||
@ -326,8 +422,8 @@ jobs:
|
||||
--arg version "${VERSION}" \
|
||||
--arg pub_date "${PUB_DATE}" \
|
||||
--arg notes "${NOTES}" \
|
||||
--arg darwin_url "$RELEASE_DIR/dmg/Zoo%20Modeling%20App_${VERSION_NO_V}_universal.dmg" \
|
||||
--arg windows_url "$RELEASE_DIR/msi/Zoo%20Modeling%20App_${VERSION_NO_V}_x64_en-US.msi" \
|
||||
--arg darwin_url "$RELEASE_DIR/dmg/${{ env.URL_CODED_NAME }}_${VERSION_NO_V}_universal.dmg" \
|
||||
--arg windows_url "$RELEASE_DIR/msi/${{ env.URL_CODED_NAME }}_${VERSION_NO_V}_x64_en-US.msi" \
|
||||
'{
|
||||
"version": $version,
|
||||
"pub_date": $pub_date,
|
||||
|
37
.github/workflows/create-release.yml
vendored
Normal file
@ -0,0 +1,37 @@
|
||||
name: Create Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: read
|
||||
if: contains(github.event.head_commit.message, 'Cut release v')
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
name: Read Cut release PR info and create release
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo, sha } = context.repo
|
||||
const pulls = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner,
|
||||
repo,
|
||||
commit_sha: sha,
|
||||
})
|
||||
const { title, body } = pulls.data[0]
|
||||
const version = title.split('Cut release ')[1]
|
||||
|
||||
const result = await github.rest.repos.createRelease({
|
||||
owner,
|
||||
repo,
|
||||
body,
|
||||
tag_name: version,
|
||||
name: version,
|
||||
draft: true,
|
||||
})
|
||||
console.log(result)
|
3
.github/workflows/generate-website-docs.yml
vendored
@ -56,6 +56,9 @@ jobs:
|
||||
gh pr create --title "Update KCL docs" \
|
||||
--body "Updating the generated kcl docs cc @jessfraz @franknoirot merge this" \
|
||||
--head "$NEW_BRANCH" \
|
||||
--reviewer jessfraz \
|
||||
--reviewer irev-dev \
|
||||
--reviewer franknoirot \
|
||||
--base main || true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
|
79
.github/workflows/playwright.yml
vendored
@ -12,11 +12,31 @@ concurrency:
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
actions: read
|
||||
|
||||
|
||||
jobs:
|
||||
|
||||
check-rust-changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
rust-changed: ${{ steps.filter.outputs.rust }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- id: filter
|
||||
name: Check for Rust changes
|
||||
uses: dorny/paths-filter@v3
|
||||
with:
|
||||
filters: |
|
||||
rust:
|
||||
- 'src/wasm-lib/**'
|
||||
|
||||
playwright-ubuntu:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest-8-cores
|
||||
needs: check-rust-changes
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
@ -28,13 +48,38 @@ jobs:
|
||||
run: yarn
|
||||
- name: Install Playwright Browsers
|
||||
run: yarn playwright install --with-deps
|
||||
- name: Download Wasm Cache
|
||||
id: download-wasm
|
||||
if: needs.check-rust-changes.outputs.rust-changed == 'false'
|
||||
uses: dawidd6/action-download-artifact@v3
|
||||
continue-on-error: true
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
name: wasm-bundle
|
||||
workflow: build-and-store-wasm.yml
|
||||
branch: main
|
||||
path: src/wasm-lib/pkg
|
||||
- name: copy wasm blob
|
||||
if: needs.check-rust-changes.outputs.rust-changed == 'false'
|
||||
run: cp src/wasm-lib/pkg/wasm_lib_bg.wasm public
|
||||
continue-on-error: true
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
- name: Cache wasm
|
||||
- name: Cache Wasm (because rust diff)
|
||||
if: needs.check-rust-changes.outputs.rust-changed == 'true'
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: './src/wasm-lib'
|
||||
- name: build wasm
|
||||
- name: OR Cache Wasm (because wasm cache failed)
|
||||
if: steps.download-wasm.outcome == 'failure'
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: './src/wasm-lib'
|
||||
- name: Build Wasm (because rust diff)
|
||||
if: needs.check-rust-changes.outputs.rust-changed == 'true'
|
||||
run: yarn build:wasm
|
||||
- name: OR Build Wasm (because wasm cache failed)
|
||||
if: steps.download-wasm.outcome == 'failure'
|
||||
run: yarn build:wasm
|
||||
- name: build web
|
||||
run: yarn build:local
|
||||
@ -89,6 +134,7 @@ jobs:
|
||||
playwright-macos:
|
||||
timeout-minutes: 60
|
||||
runs-on: macos-14
|
||||
needs: check-rust-changes
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
@ -99,13 +145,38 @@ jobs:
|
||||
run: yarn
|
||||
- name: Install Playwright Browsers
|
||||
run: yarn playwright install --with-deps
|
||||
- name: Download Wasm Cache
|
||||
id: download-wasm
|
||||
if: needs.check-rust-changes.outputs.rust-changed == 'false'
|
||||
uses: dawidd6/action-download-artifact@v3
|
||||
continue-on-error: true
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
name: wasm-bundle
|
||||
workflow: build-and-store-wasm.yml
|
||||
branch: main
|
||||
path: src/wasm-lib/pkg
|
||||
- name: copy wasm blob
|
||||
if: needs.check-rust-changes.outputs.rust-changed == 'false'
|
||||
run: cp src/wasm-lib/pkg/wasm_lib_bg.wasm public
|
||||
continue-on-error: true
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
- name: Cache wasm
|
||||
- name: Cache Wasm (because rust diff)
|
||||
if: needs.check-rust-changes.outputs.rust-changed == 'true'
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: './src/wasm-lib'
|
||||
- name: build wasm
|
||||
- name: OR Cache Wasm (because wasm cache failed)
|
||||
if: steps.download-wasm.outcome == 'failure'
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: './src/wasm-lib'
|
||||
- name: Build Wasm (because rust diff)
|
||||
if: needs.check-rust-changes.outputs.rust-changed == 'true'
|
||||
run: yarn build:wasm
|
||||
- name: OR Build Wasm (because wasm cache failed)
|
||||
if: steps.download-wasm.outcome == 'failure'
|
||||
run: yarn build:wasm
|
||||
- name: build web
|
||||
run: yarn build:local
|
||||
|
1
.gitignore
vendored
@ -54,3 +54,4 @@ src/**/*.typegen.ts
|
||||
src-tauri/gen
|
||||
|
||||
src/wasm-lib/grackle/stdlib_cube_partial.json
|
||||
Mac_App_Distribution.provisionprofile
|
||||
|
12
README.md
@ -59,6 +59,10 @@ followed by:
|
||||
```
|
||||
yarn build:wasm-dev
|
||||
```
|
||||
or if you have the gh cli installed
|
||||
```
|
||||
./get-latest-wasm-bundle.sh # this will download the latest main wasm bundle
|
||||
```
|
||||
|
||||
That will build the WASM binary and put in the `public` dir (though gitignored)
|
||||
|
||||
@ -68,7 +72,13 @@ finally, to run the web app only, run:
|
||||
yarn start
|
||||
```
|
||||
|
||||
## Developing in Chrome
|
||||
If you're not an KittyCAD employee you won't be able to access the dev environment, you should copy everything from `.env.production` to `.env.development` to make it point to production instead, then when you navigate to `localhost:3000` the easiest way to sign in is to paste `localStorage.setItem('TOKEN_PERSIST_KEY', "your-token-from-https://zoo.dev/account/api-tokens")` replacing the with a real token from https://zoo.dev/account/api-tokens ofcourse, then navigate to localhost:3000 again. Note that navigating to localhost:3000/signin removes your token so you will need to set the token again.
|
||||
|
||||
### Development environment variables
|
||||
|
||||
The Copilot LSP plugin in the editor requires a Zoo API token to run. In production, we authenticate this with a token via cookie in the browser and device auth token in the desktop environment, but this token is inaccessible in the dev browser version because the cookie is considered "cross-site" (from `localhost` to `dev.zoo.dev`). There is an optional environment variable called `VITE_KC_DEV_TOKEN` that you can populate with a dev token in a `.env.development.local` file to not check it into Git, which will use that token instead of other methods for the LSP service.
|
||||
|
||||
### Developing in Chrome
|
||||
|
||||
Chrome is in the process of rolling out a new default which
|
||||
[blocks Third-Party Cookies](https://developer.chrome.com/en/docs/privacy-sandbox/third-party-cookie-phase-out/).
|
||||
|
BIN
app-icon.png
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 113 KiB |
@ -1,3 +1,3 @@
|
||||
module.exports = {
|
||||
presets: ["@babel/preset-env"],
|
||||
presets: ['@babel/preset-env'],
|
||||
}
|
||||
|
2806
docs/kcl/std.json
@ -7,16 +7,36 @@ import { spawn } from 'child_process'
|
||||
import { APP_NAME } from 'lib/constants'
|
||||
import JSZip from 'jszip'
|
||||
import path from 'path'
|
||||
import { basicSettings, basicStorageState } from './storageStates'
|
||||
import { TEST_SETTINGS, TEST_SETTINGS_KEY } from './storageStates'
|
||||
import * as TOML from '@iarna/toml'
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// reducedMotion kills animations, which speeds up tests and reduces flakiness
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||||
})
|
||||
|
||||
test.use({
|
||||
storageState: structuredClone(basicStorageState),
|
||||
// set the default settings
|
||||
await page.addInitScript(
|
||||
async ({ token, settingsKey, settings }) => {
|
||||
localStorage.setItem('TOKEN_PERSIST_KEY', token)
|
||||
localStorage.setItem('persistCode', ``)
|
||||
localStorage.setItem(settingsKey, settings)
|
||||
},
|
||||
{
|
||||
token: secrets.token,
|
||||
settingsKey: TEST_SETTINGS_KEY,
|
||||
settings: TOML.stringify({ settings: TEST_SETTINGS }),
|
||||
}
|
||||
)
|
||||
|
||||
// Make the user avatar image always 404
|
||||
// so we see the fallback menu icon for all snapshot tests
|
||||
await page.route('https://lh3.googleusercontent.com/**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
contentType: 'text/plain',
|
||||
body: 'Not Found!',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.setTimeout(60_000)
|
||||
@ -316,10 +336,7 @@ const part001 = startSketchOn('-XZ')
|
||||
}
|
||||
})
|
||||
|
||||
test('extrude on each default plane should be stable', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const extrudeDefaultPlane = async (context: any, page: any, plane: string) => {
|
||||
await context.addInitScript(async () => {
|
||||
localStorage.setItem(
|
||||
'SETTINGS_PERSIST_KEY',
|
||||
@ -336,8 +353,8 @@ test('extrude on each default plane should be stable', async ({
|
||||
})
|
||||
)
|
||||
})
|
||||
const u = getUtils(page)
|
||||
const makeCode = (plane = 'XY') => `const part001 = startSketchOn('${plane}')
|
||||
|
||||
const code = `const part001 = startSketchOn('${plane}')
|
||||
|> startProfileAt([7.00, 4.40], %)
|
||||
|> line([6.60, -0.20], %)
|
||||
|> line([2.80, 5.00], %)
|
||||
@ -346,9 +363,11 @@ test('extrude on each default plane should be stable', async ({
|
||||
|> close(%)
|
||||
|> extrude(10.00, %)
|
||||
`
|
||||
await context.addInitScript(async (code) => {
|
||||
await page.addInitScript(async (code: string) => {
|
||||
localStorage.setItem('persistCode', code)
|
||||
}, makeCode('XY'))
|
||||
})
|
||||
|
||||
const u = getUtils(page)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
await page.goto('/')
|
||||
await u.waitForAuthSkipAppStart()
|
||||
@ -358,32 +377,48 @@ test('extrude on each default plane should be stable', async ({
|
||||
await u.expectCmdLog('[data-message-type="execution-done"]')
|
||||
await u.clearAndCloseDebugPanel()
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
const runSnapshotsForOtherPlanes = async (plane = 'XY') => {
|
||||
// clear code
|
||||
await u.removeCurrentCode()
|
||||
// add makeCode('XZ')
|
||||
await u.openAndClearDebugPanel()
|
||||
await page.locator('.cm-content').fill(makeCode(plane))
|
||||
await u.doAndWaitForImageDiff(
|
||||
() => page.locator('.cm-content').fill(code),
|
||||
200
|
||||
)
|
||||
// wait for execution done
|
||||
await u.expectCmdLog('[data-message-type="execution-done"]')
|
||||
await u.clearAndCloseDebugPanel()
|
||||
|
||||
await page.getByText('Code').click()
|
||||
await page.waitForTimeout(150)
|
||||
await u.closeKclCodePanel()
|
||||
await expect(page).toHaveScreenshot({
|
||||
maxDiffPixels: 100,
|
||||
})
|
||||
await page.getByText('Code').click()
|
||||
}
|
||||
await runSnapshotsForOtherPlanes('XY')
|
||||
await runSnapshotsForOtherPlanes('-XY')
|
||||
await u.openKclCodePanel()
|
||||
}
|
||||
|
||||
await runSnapshotsForOtherPlanes('XZ')
|
||||
await runSnapshotsForOtherPlanes('-XZ')
|
||||
test.describe('extrude on default planes should be stable', () => {
|
||||
test('XY', async ({ page, context }) => {
|
||||
await extrudeDefaultPlane(context, page, 'XY')
|
||||
})
|
||||
|
||||
await runSnapshotsForOtherPlanes('YZ')
|
||||
await runSnapshotsForOtherPlanes('-YZ')
|
||||
test('XZ', async ({ page, context }) => {
|
||||
await extrudeDefaultPlane(context, page, 'XZ')
|
||||
})
|
||||
|
||||
test('YZ', async ({ page, context }) => {
|
||||
await extrudeDefaultPlane(context, page, 'YZ')
|
||||
})
|
||||
|
||||
test('-XY', async ({ page, context }) => {
|
||||
await extrudeDefaultPlane(context, page, '-XY')
|
||||
})
|
||||
|
||||
test('-XZ', async ({ page, context }) => {
|
||||
await extrudeDefaultPlane(context, page, '-XZ')
|
||||
})
|
||||
|
||||
test('-YZ', async ({ page, context }) => {
|
||||
await extrudeDefaultPlane(context, page, '-YZ')
|
||||
})
|
||||
})
|
||||
|
||||
test('Draft segments should look right', async ({ page, context }) => {
|
||||
@ -445,9 +480,7 @@ test('Draft segments should look right', async ({ page, context }) => {
|
||||
})
|
||||
})
|
||||
|
||||
test('Client side scene scale should match engine scale - Inch', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('Draft rectangles should look right', async ({ page, context }) => {
|
||||
const u = getUtils(page)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
const PUR = 400 / 37.5 //pixeltoUnitRatio
|
||||
@ -474,6 +507,55 @@ test('Client side scene scale should match engine scale - Inch', async ({
|
||||
`const part001 = startSketchOn('-XZ')`
|
||||
)
|
||||
|
||||
await page.waitForTimeout(300) // TODO detect animation ending, or disable animation
|
||||
await u.closeDebugPanel()
|
||||
|
||||
const startXPx = 600
|
||||
|
||||
// Equip the rectangle tool
|
||||
await page.getByRole('button', { name: 'Line' }).click()
|
||||
await page.getByRole('button', { name: 'Rectangle' }).click()
|
||||
|
||||
// Draw the rectangle
|
||||
await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 30)
|
||||
await page.mouse.move(startXPx + PUR * 10, 500 - PUR * 10, { steps: 5 })
|
||||
|
||||
// Ensure the draft rectangle looks the same as it usually does
|
||||
await expect(page).toHaveScreenshot({
|
||||
maxDiffPixels: 100,
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Client side scene scale should match engine scale', () => {
|
||||
test('Inch scale', async ({ page }) => {
|
||||
const u = getUtils(page)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
const PUR = 400 / 37.5 //pixeltoUnitRatio
|
||||
await page.goto('/')
|
||||
await u.waitForAuthSkipAppStart()
|
||||
await u.openDebugPanel()
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Start Sketch' })
|
||||
).not.toBeDisabled()
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Start Sketch' })
|
||||
).toBeVisible()
|
||||
|
||||
// click on "Start Sketch" button
|
||||
await u.clearCommandLogs()
|
||||
await u.doAndWaitForImageDiff(
|
||||
() => page.getByRole('button', { name: 'Start Sketch' }).click(),
|
||||
200
|
||||
)
|
||||
|
||||
// select a plane
|
||||
await page.mouse.click(700, 200)
|
||||
|
||||
await expect(page.locator('.cm-content')).toHaveText(
|
||||
`const part001 = startSketchOn('-XZ')`
|
||||
)
|
||||
|
||||
await page.waitForTimeout(300) // TODO detect animation ending, or disable animation
|
||||
|
||||
const startXPx = 600
|
||||
@ -526,24 +608,26 @@ test('Client side scene scale should match engine scale - Inch', async ({
|
||||
await expect(page).toHaveScreenshot({
|
||||
maxDiffPixels: 100,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Client side scene scale should match engine scale - Millimeters', () => {
|
||||
const storageState = structuredClone(basicStorageState)
|
||||
storageState.origins[0].localStorage[2].value = TOML.stringify({
|
||||
test('Millimeter scale', async ({ page }) => {
|
||||
await page.addInitScript(
|
||||
async ({ settingsKey, settings }) => {
|
||||
localStorage.setItem(settingsKey, settings)
|
||||
},
|
||||
{
|
||||
settingsKey: TEST_SETTINGS_KEY,
|
||||
settings: TOML.stringify({
|
||||
settings: {
|
||||
...basicSettings,
|
||||
...TEST_SETTINGS,
|
||||
modeling: {
|
||||
...basicSettings.modeling,
|
||||
...TEST_SETTINGS.modeling,
|
||||
defaultUnit: 'mm',
|
||||
},
|
||||
},
|
||||
})
|
||||
test.use({
|
||||
storageState,
|
||||
})
|
||||
|
||||
test('Millimeters', async ({ page }) => {
|
||||
}),
|
||||
}
|
||||
)
|
||||
const u = getUtils(page)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
const PUR = 400 / 37.5 //pixeltoUnitRatio
|
||||
@ -638,7 +722,7 @@ test('Sketch on face with none z-up', async ({ page, context }) => {
|
||||
|> close(%)
|
||||
|> extrude(5 + 7, %)
|
||||
const part002 = startSketchOn(part001, 'seg01')
|
||||
|> startProfileAt([-2.89, 1.82], %)
|
||||
|> startProfileAt([8, 8], %)
|
||||
|> line([4.68, 3.05], %)
|
||||
|> line([0, -7.79], %, 'seg02')
|
||||
|> close(%)
|
||||
@ -650,6 +734,19 @@ const part002 = startSketchOn(part001, 'seg01')
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
await page.goto('/')
|
||||
await u.waitForAuthSkipAppStart()
|
||||
|
||||
await u.openDebugPanel()
|
||||
// wait for execution done
|
||||
await expect(
|
||||
page.locator('[data-message-type="execution-done"]')
|
||||
).toHaveCount(2)
|
||||
await u.closeDebugPanel()
|
||||
|
||||
// Wait for the second extrusion to appear
|
||||
// TODO: Find a way to truly know that the objects have finished
|
||||
// rendering, because an execution-done message is not sufficient.
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Start Sketch' })
|
||||
).not.toBeDisabled()
|
||||
|
Before Width: | Height: | Size: 43 KiB |
Before Width: | Height: | Size: 47 KiB |
Before Width: | Height: | Size: 47 KiB |
Before Width: | Height: | Size: 48 KiB |
After Width: | Height: | Size: 39 KiB |
After Width: | Height: | Size: 51 KiB |
After Width: | Height: | Size: 45 KiB |
After Width: | Height: | Size: 52 KiB |
Before Width: | Height: | Size: 44 KiB |
Before Width: | Height: | Size: 48 KiB |
Before Width: | Height: | Size: 48 KiB |
Before Width: | Height: | Size: 49 KiB |
After Width: | Height: | Size: 28 KiB |
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 31 KiB |
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 34 KiB |
After Width: | Height: | Size: 66 KiB |
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 72 KiB |
After Width: | Height: | Size: 47 KiB |
After Width: | Height: | Size: 52 KiB |
After Width: | Height: | Size: 47 KiB |
After Width: | Height: | Size: 48 KiB |
After Width: | Height: | Size: 50 KiB |
After Width: | Height: | Size: 46 KiB |
Before Width: | Height: | Size: 48 KiB |
Before Width: | Height: | Size: 47 KiB |
Before Width: | Height: | Size: 48 KiB |
Before Width: | Height: | Size: 50 KiB |
Before Width: | Height: | Size: 48 KiB |
Before Width: | Height: | Size: 48 KiB |
@ -1,13 +1,13 @@
|
||||
import { SaveSettingsPayload } from 'lib/settings/settingsTypes'
|
||||
import { secrets } from './secrets'
|
||||
import * as TOML from '@iarna/toml'
|
||||
import { Themes } from 'lib/theme'
|
||||
|
||||
export const basicSettings = {
|
||||
export const TEST_SETTINGS_KEY = '/settings.toml'
|
||||
export const TEST_SETTINGS = {
|
||||
app: {
|
||||
theme: Themes.Dark,
|
||||
onboardingStatus: 'dismissed',
|
||||
projectDirectory: '',
|
||||
enableSSAO: false,
|
||||
},
|
||||
modeling: {
|
||||
defaultUnit: 'in',
|
||||
@ -22,19 +22,31 @@ export const basicSettings = {
|
||||
},
|
||||
} satisfies Partial<SaveSettingsPayload>
|
||||
|
||||
export const basicStorageState = {
|
||||
cookies: [],
|
||||
origins: [
|
||||
{
|
||||
origin: 'http://localhost:3000',
|
||||
localStorage: [
|
||||
{ name: 'TOKEN_PERSIST_KEY', value: secrets.token },
|
||||
{ name: 'persistCode', value: '' },
|
||||
{
|
||||
name: '/user.toml',
|
||||
value: TOML.stringify({ settings: basicSettings }),
|
||||
export const TEST_SETTINGS_ONBOARDING_EXPORT = {
|
||||
...TEST_SETTINGS,
|
||||
app: { ...TEST_SETTINGS.app, onboardingStatus: '/export' },
|
||||
} satisfies Partial<SaveSettingsPayload>
|
||||
|
||||
export const TEST_SETTINGS_ONBOARDING_START = {
|
||||
...TEST_SETTINGS,
|
||||
app: { ...TEST_SETTINGS.app, onboardingStatus: '' },
|
||||
} satisfies Partial<SaveSettingsPayload>
|
||||
|
||||
export const TEST_SETTINGS_CORRUPTED = {
|
||||
app: {
|
||||
theme: Themes.Dark,
|
||||
onboardingStatus: 'dismissed',
|
||||
projectDirectory: 123 as any,
|
||||
},
|
||||
],
|
||||
modeling: {
|
||||
defaultUnit: 'invalid' as any,
|
||||
mouseControls: `() => alert('hack the planet')` as any,
|
||||
showDebugPanel: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
projects: {
|
||||
defaultProjectName: false as any,
|
||||
},
|
||||
textEditor: {
|
||||
textWrapping: true,
|
||||
},
|
||||
} satisfies Partial<SaveSettingsPayload>
|
||||
|
@ -44,26 +44,44 @@ async function waitForDefaultPlanesToBeVisible(page: Page) {
|
||||
)
|
||||
}
|
||||
|
||||
async function openDebugPanel(page: Page) {
|
||||
const isOpen =
|
||||
(await page
|
||||
.locator('[data-testid="debug-panel"]')
|
||||
?.getAttribute('open')) === ''
|
||||
async function openKclCodePanel(page: Page) {
|
||||
const paneLocator = page.getByRole('tab', { name: 'KCL Code', exact: false })
|
||||
const isOpen = (await paneLocator?.getAttribute('aria-selected')) === 'true'
|
||||
|
||||
if (!isOpen) {
|
||||
await page.getByText('Debug').click()
|
||||
await page.getByTestId('debug-panel').and(page.locator('[open]')).waitFor()
|
||||
await paneLocator.click()
|
||||
await paneLocator.and(page.locator('[aria-selected="true"]')).waitFor()
|
||||
}
|
||||
}
|
||||
|
||||
async function closeKclCodePanel(page: Page) {
|
||||
const paneLocator = page.getByRole('tab', { name: 'KCL Code', exact: false })
|
||||
const isOpen = (await paneLocator?.getAttribute('aria-selected')) === 'true'
|
||||
if (isOpen) {
|
||||
await paneLocator.click()
|
||||
await paneLocator
|
||||
.and(page.locator(':not([aria-selected="true"])'))
|
||||
.waitFor()
|
||||
}
|
||||
}
|
||||
|
||||
async function openDebugPanel(page: Page) {
|
||||
const debugLocator = page.getByRole('tab', { name: 'Debug', exact: false })
|
||||
const isOpen = (await debugLocator?.getAttribute('aria-selected')) === 'true'
|
||||
|
||||
if (!isOpen) {
|
||||
await debugLocator.click()
|
||||
await debugLocator.and(page.locator('[aria-selected="true"]')).waitFor()
|
||||
}
|
||||
}
|
||||
|
||||
async function closeDebugPanel(page: Page) {
|
||||
const isOpen =
|
||||
(await page.getByTestId('debug-panel')?.getAttribute('open')) === ''
|
||||
const debugLocator = page.getByRole('tab', { name: 'Debug', exact: false })
|
||||
const isOpen = (await debugLocator?.getAttribute('aria-selected')) === 'true'
|
||||
if (isOpen) {
|
||||
await page.getByText('Debug').click()
|
||||
await page
|
||||
.getByTestId('debug-panel')
|
||||
.and(page.locator(':not([open])'))
|
||||
await debugLocator.click()
|
||||
await debugLocator
|
||||
.and(page.locator(':not([aria-selected="true"])'))
|
||||
.waitFor()
|
||||
}
|
||||
}
|
||||
@ -81,20 +99,19 @@ export function getUtils(page: Page) {
|
||||
removeCurrentCode: () => removeCurrentCode(page),
|
||||
sendCustomCmd: (cmd: EngineCommand) => sendCustomCmd(page, cmd),
|
||||
updateCamPosition: async (xyz: [number, number, number]) => {
|
||||
const fillInput = async () => {
|
||||
await page.fill('[data-testid="cam-x-position"]', String(xyz[0]))
|
||||
await page.fill('[data-testid="cam-y-position"]', String(xyz[1]))
|
||||
await page.fill('[data-testid="cam-z-position"]', String(xyz[2]))
|
||||
const fillInput = async (axis: 'x' | 'y' | 'z', value: number) => {
|
||||
await page.fill(`[data-testid="cam-${axis}-position"]`, String(value))
|
||||
await page.waitForTimeout(100)
|
||||
}
|
||||
await fillInput()
|
||||
await page.waitForTimeout(100)
|
||||
await fillInput()
|
||||
await page.waitForTimeout(100)
|
||||
await fillInput()
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
await fillInput('x', xyz[0])
|
||||
await fillInput('y', xyz[1])
|
||||
await fillInput('z', xyz[2])
|
||||
},
|
||||
clearCommandLogs: () => clearCommandLogs(page),
|
||||
expectCmdLog: (locatorStr: string) => expectCmdLog(page, locatorStr),
|
||||
openKclCodePanel: () => openKclCodePanel(page),
|
||||
closeKclCodePanel: () => closeKclCodePanel(page),
|
||||
openDebugPanel: () => openDebugPanel(page),
|
||||
closeDebugPanel: () => closeDebugPanel(page),
|
||||
openAndClearDebugPanel: async () => {
|
||||
@ -165,3 +182,76 @@ export function getUtils(page: Page) {
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
type TemplateOptions = Array<number | Array<number>>
|
||||
|
||||
type makeTemplateReturn = {
|
||||
regExp: RegExp
|
||||
genNext: (
|
||||
templateParts: TemplateStringsArray,
|
||||
...options: TemplateOptions
|
||||
) => makeTemplateReturn
|
||||
}
|
||||
|
||||
const escapeRegExp = (string: string) => {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string
|
||||
}
|
||||
|
||||
const _makeTemplate = (
|
||||
templateParts: TemplateStringsArray,
|
||||
...options: TemplateOptions
|
||||
) => {
|
||||
const length = Math.max(...options.map((a) => (Array.isArray(a) ? a[0] : 0)))
|
||||
let reExpTemplate = ''
|
||||
for (let i = 0; i < length; i++) {
|
||||
const currentStr = templateParts.map((str, index) => {
|
||||
const currentOptions = options[index]
|
||||
return (
|
||||
escapeRegExp(str) +
|
||||
String(
|
||||
Array.isArray(currentOptions)
|
||||
? currentOptions[i]
|
||||
: typeof currentOptions === 'number'
|
||||
? currentOptions
|
||||
: ''
|
||||
)
|
||||
)
|
||||
})
|
||||
reExpTemplate += '|' + currentStr.join('')
|
||||
}
|
||||
return new RegExp(reExpTemplate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool for making templates to match code snippets in the editor with some fudge factor,
|
||||
* as there's some level of non-determinism.
|
||||
*
|
||||
* Usage is as such:
|
||||
* ```typescript
|
||||
* const result = makeTemplate`const myVar = aFunc(${[1, 2, 3]})`
|
||||
* await expect(page.locator('.cm-content')).toHaveText(result.regExp)
|
||||
* ```
|
||||
* Where the value `1`, `2` or `3` are all valid and should make the test pass.
|
||||
*
|
||||
* The function also has a `genNext` function that allows you to chain multiple templates
|
||||
* together without having to repeat previous parts of the template.
|
||||
* ```typescript
|
||||
* const result2 = result.genNext`const myVar2 = aFunc(${[4, 5, 6]})`
|
||||
* ```
|
||||
*/
|
||||
export const makeTemplate: (
|
||||
templateParts: TemplateStringsArray,
|
||||
...values: TemplateOptions
|
||||
) => makeTemplateReturn = (templateParts, ...options) => {
|
||||
return {
|
||||
regExp: _makeTemplate(templateParts, ...options),
|
||||
genNext: (
|
||||
nextTemplateParts: TemplateStringsArray,
|
||||
...nextOptions: TemplateOptions
|
||||
) =>
|
||||
makeTemplate(
|
||||
[...templateParts, ...nextTemplateParts] as any as TemplateStringsArray,
|
||||
[...options, ...nextOptions] as any
|
||||
),
|
||||
}
|
||||
}
|
||||
|
@ -2,7 +2,7 @@ import { browser, $, expect } from '@wdio/globals'
|
||||
import fs from 'fs/promises'
|
||||
|
||||
const documentsDir = `${process.env.HOME}/Documents`
|
||||
const userSettingsFile = `${process.env.HOME}/.config/dev.zoo.modeling-app/user.toml`
|
||||
const userSettingsDir = `${process.env.HOME}/.config/dev.zoo.modeling-app`
|
||||
const defaultProjectDir = `${documentsDir}/zoo-modeling-app-projects`
|
||||
const newProjectDir = `${documentsDir}/a-different-directory`
|
||||
const userCodeDir = '/tmp/kittycad_user_code'
|
||||
@ -29,8 +29,10 @@ describe('ZMA (Tauri, Linux)', () => {
|
||||
// Clean up filesystem from previous tests
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
await fs.rm(defaultProjectDir, { force: true, recursive: true })
|
||||
await fs.rm(newProjectDir, { force: true, recursive: true })
|
||||
await fs.rm(userCodeDir, { force: true })
|
||||
await fs.rm(userSettingsFile, { force: true })
|
||||
await fs.rm(userSettingsDir, { force: true, recursive: true })
|
||||
await fs.mkdir(defaultProjectDir, { recursive: true })
|
||||
await fs.mkdir(newProjectDir, { recursive: true })
|
||||
|
||||
const signInButton = await $('[data-testid="sign-in-button"]')
|
||||
@ -70,8 +72,9 @@ describe('ZMA (Tauri, Linux)', () => {
|
||||
console.log(cr.status)
|
||||
|
||||
// Now should be signed in
|
||||
await new Promise((resolve) => setTimeout(resolve, 10000))
|
||||
const newFileButton = await $('[data-testid="home-new-file"]')
|
||||
expect(await newFileButton.getText()).toEqual('New file')
|
||||
expect(await newFileButton.getText()).toEqual('New project')
|
||||
})
|
||||
|
||||
it('opens the settings page, checks filesystem settings, and closes the settings page', async () => {
|
||||
@ -117,8 +120,8 @@ describe('ZMA (Tauri, Linux)', () => {
|
||||
it('opens the new file and expects a loading stream', async () => {
|
||||
const projectLink = await $('[data-testid="project-link"]')
|
||||
await click(projectLink)
|
||||
const loadingText = await $('[data-testid="loading-stream"]')
|
||||
expect(await loadingText.getText()).toContain('Loading stream...')
|
||||
const errorText = await $('[data-testid="unexpected-error"]')
|
||||
expect(await errorText.getText()).toContain('unexpected error')
|
||||
await browser.execute('window.location.href = "tauri://localhost/home"')
|
||||
})
|
||||
|
||||
|
24
get-latest-wasm-bundle.sh
Executable file
@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Set the repository owner and name
|
||||
REPO_OWNER="KittyCAD"
|
||||
REPO_NAME="modeling-app"
|
||||
WORKFLOW_NAME="build-and-store-wasm.yml"
|
||||
ARTIFACT_NAME="wasm-bundle"
|
||||
|
||||
# Fetch the latest completed workflow run ID for the specified workflow
|
||||
# RUN_ID=$(gh api repos/$REPO_OWNER/$REPO_NAME/actions/workflows/$WORKFLOW_NAME/runs --paginate --jq '.workflow_runs[] | select(.status=="completed") | .id' | head -n 1)
|
||||
RUN_ID=$(gh api repos/$REPO_OWNER/$REPO_NAME/actions/workflows/$WORKFLOW_NAME/runs --paginate --jq '.workflow_runs[] | select(.status=="completed" and .conclusion=="success") | .id' | head -n 1)
|
||||
|
||||
echo $RUN_ID
|
||||
|
||||
# Check if a valid RUN_ID was found
|
||||
if [ -z "$RUN_ID" ]; then
|
||||
echo "Failed to find a workflow run for $WORKFLOW_NAME."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
gh run download $RUN_ID --repo $REPO_OWNER/$REPO_NAME --name $ARTIFACT_NAME --dir ./src/wasm-lib/pkg
|
||||
|
||||
cp src/wasm-lib/pkg/wasm_lib_bg.wasm public
|
||||
echo "latest wasm copied to public folder"
|
@ -57,7 +57,7 @@ echo "New version number without 'v': $new_version_number"
|
||||
git checkout -b "cut-release-$new_version"
|
||||
|
||||
echo "$(jq --arg v "$new_version_number" '.version=$v' package.json --indent 2)" > package.json
|
||||
echo "$(jq --arg v "$new_version_number" '.package.version=$v' src-tauri/tauri.conf.json --indent 2)" > src-tauri/tauri.conf.json
|
||||
echo "$(jq --arg v "$new_version_number" '.version=$v' src-tauri/tauri.conf.json --indent 2)" > src-tauri/tauri.conf.json
|
||||
|
||||
git add package.json src-tauri/tauri.conf.json
|
||||
git commit -m "Cut release $new_version"
|
||||
|
71
package.json
@ -1,43 +1,45 @@
|
||||
{
|
||||
"name": "untitled-app",
|
||||
"version": "0.17.3",
|
||||
"version": "0.20.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.15.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.4.2",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.4.2",
|
||||
"@codemirror/autocomplete": "^6.16.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.5.2",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.5.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.4.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||
"@headlessui/react": "^1.7.18",
|
||||
"@headlessui/react": "^1.7.19",
|
||||
"@headlessui/tailwindcss": "^0.2.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@kittycad/lib": "^0.0.56",
|
||||
"@kittycad/lib": "^0.0.58",
|
||||
"@lezer/javascript": "^1.4.9",
|
||||
"@open-rpc/client-js": "^1.8.1",
|
||||
"@react-hook/resize-observer": "^1.2.6",
|
||||
"@replit/codemirror-interact": "^6.3.0",
|
||||
"@tauri-apps/api": "^2.0.0-beta.7",
|
||||
"@replit/codemirror-interact": "^6.3.1",
|
||||
"@tauri-apps/api": "2.0.0-beta.8",
|
||||
"@tauri-apps/plugin-dialog": "^2.0.0-beta.2",
|
||||
"@tauri-apps/plugin-fs": "^2.0.0-beta.2",
|
||||
"@tauri-apps/plugin-http": "^2.0.0-beta.2",
|
||||
"@tauri-apps/plugin-os": "^2.0.0-beta.2",
|
||||
"@tauri-apps/plugin-process": "^2.0.0-beta.2",
|
||||
"@tauri-apps/plugin-shell": "^2.0.0-beta.2",
|
||||
"@tauri-apps/plugin-updater": "^2.0.0-beta.2",
|
||||
"@testing-library/jest-dom": "^5.14.1",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@testing-library/react": "^15.0.2",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@ts-stack/markdown": "^1.5.0",
|
||||
"@tweenjs/tween.js": "^23.1.1",
|
||||
"@types/node": "^18.19.26",
|
||||
"@types/react": "^18.2.73",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@types/node": "^18.19.31",
|
||||
"@types/react": "^18.2.77",
|
||||
"@types/react-dom": "^18.2.25",
|
||||
"@uiw/react-codemirror": "^4.21.25",
|
||||
"@xstate/inspect": "^0.8.0",
|
||||
"@xstate/react": "^3.2.2",
|
||||
"crypto-js": "^4.2.0",
|
||||
"debounce-promise": "^3.1.2",
|
||||
"decamelize": "^6.0.0",
|
||||
"formik": "^2.4.3",
|
||||
"formik": "^2.4.5",
|
||||
"fuse.js": "^7.0.0",
|
||||
"html2canvas-pro": "^1.4.3",
|
||||
"http-server": "^14.1.1",
|
||||
"json-rpc-2.0": "^1.6.0",
|
||||
"jszip": "^3.10.1",
|
||||
@ -52,18 +54,18 @@
|
||||
"react-modal-promise": "^1.0.2",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"sketch-helpers": "^0.0.4",
|
||||
"swr": "^2.2.2",
|
||||
"three": "^0.160.0",
|
||||
"toml": "^3.0.0",
|
||||
"swr": "^2.2.5",
|
||||
"three": "^0.163.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.4.3",
|
||||
"typescript": "^5.4.5",
|
||||
"ua-parser-js": "^1.0.37",
|
||||
"uuid": "^9.0.1",
|
||||
"vitest": "^1.4.0",
|
||||
"vitest": "^1.5.0",
|
||||
"vscode-jsonrpc": "^8.1.0",
|
||||
"vscode-languageserver-protocol": "^3.17.5",
|
||||
"wasm-pack": "^0.12.1",
|
||||
"web-vitals": "^3.5.2",
|
||||
"ws": "^8.13.0",
|
||||
"ws": "^8.16.0",
|
||||
"xstate": "^4.38.2",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
@ -82,8 +84,9 @@
|
||||
"test:e2e:tauri": "E2E_TAURI_ENABLED=true TS_NODE_COMPILER_OPTIONS='{\"module\": \"commonjs\"}' wdio run wdio.conf.ts",
|
||||
"simpleserver:ci": "yarn pretest && http-server ./public --cors -p 3000 &",
|
||||
"simpleserver": "yarn pretest && http-server ./public --cors -p 3000",
|
||||
"fmt": "prettier --write ./src && prettier --write ./e2e",
|
||||
"fmt-check": "prettier --check ./src && prettier --check ./e2e",
|
||||
"fmt": "prettier --write ./src *.ts *.json *.js ./e2e",
|
||||
"fmt-check": "prettier --check ./src *.ts *.json *.js ./e2e",
|
||||
"fetch:wasm": "./get-latest-wasm-bundle.sh",
|
||||
"build:wasm-dev": "(cd src/wasm-lib && wasm-pack build --dev --target web --out-dir pkg && cargo test -p kcl-lib export_bindings) && cp src/wasm-lib/pkg/wasm_lib_bg.wasm public && yarn fmt",
|
||||
"build:wasm": "(cd src/wasm-lib && wasm-pack build --target web --out-dir pkg && cargo test -p kcl-lib export_bindings) && cp src/wasm-lib/pkg/wasm_lib_bg.wasm public && yarn fmt",
|
||||
"build:wasm-clean": "yarn wasm-prep && yarn build:wasm",
|
||||
@ -115,24 +118,28 @@
|
||||
"devDependencies": {
|
||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||
"@babel/preset-env": "^7.24.3",
|
||||
"@playwright/test": "^1.39.0",
|
||||
"@tauri-apps/cli": "^2.0.0-beta.12",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@playwright/test": "^1.43.1",
|
||||
"@tauri-apps/cli": "^2.0.0-beta.13",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/debounce-promise": "^3.1.9",
|
||||
"@types/mocha": "^10.0.6",
|
||||
"@types/pixelmatch": "^5.2.6",
|
||||
"@types/pngjs": "^6.0.4",
|
||||
"@types/react-modal": "^3.16.3",
|
||||
"@types/three": "^0.160.0",
|
||||
"@types/three": "^0.163.0",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@types/wait-on": "^5.3.4",
|
||||
"@types/wicg-file-system-access": "^2023.10.5",
|
||||
"@types/ws": "^8.5.5",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"@vitest/web-worker": "^1.5.0",
|
||||
"@wdio/cli": "^8.24.3",
|
||||
"@wdio/globals": "^8.24.3",
|
||||
"@wdio/local-runner": "^8.35.1",
|
||||
"@wdio/mocha-framework": "^8.35.0",
|
||||
"@wdio/spec-reporter": "^8.32.4",
|
||||
"@wdio/globals": "^8.36.0",
|
||||
"@wdio/local-runner": "^8.36.0",
|
||||
"@wdio/mocha-framework": "^8.36.0",
|
||||
"@wdio/spec-reporter": "^8.36.0",
|
||||
"@xstate/cli": "^0.5.17",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^8.57.0",
|
||||
@ -147,12 +154,12 @@
|
||||
"prettier": "^2.8.0",
|
||||
"setimmediate": "^1.0.5",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"vite": "^5.2.6",
|
||||
"vite": "^5.2.9",
|
||||
"vite-plugin-eslint": "^1.8.1",
|
||||
"vite-plugin-package-version": "^1.1.0",
|
||||
"vite-tsconfig-paths": "^4.3.2",
|
||||
"vitest-webgl-canvas-mock": "^1.1.0",
|
||||
"wait-on": "^7.2.0",
|
||||
"yarn": "^1.22.19"
|
||||
"yarn": "^1.22.22"
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
import { basicStorageState } from './e2e/playwright/storageStates'
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
@ -28,10 +27,7 @@ export default defineConfig({
|
||||
baseURL: 'http://localhost:3000',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
|
||||
/* Use a common shared localStorage */
|
||||
storageState: basicStorageState,
|
||||
trace: 'retain-on-failure',
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
@ -53,8 +49,6 @@ export default defineConfig({
|
||||
// use: { ...devices['Desktop Chrome'] },
|
||||
// },
|
||||
|
||||
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
// {
|
||||
// name: 'Mobile Chrome',
|
||||
|
15
public/.well-known/apple-app-site-association
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"applinks": {
|
||||
"details": [
|
||||
{
|
||||
"appIDs": ["92H8YB3B95.dev.zoo.modeling-app"],
|
||||
"components": [
|
||||
{
|
||||
"/": "/file/*",
|
||||
"comment": "Matches any URL whose path starts with /file/"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 6.8 KiB |
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 12 KiB |