diff --git a/.codespellrc b/.codespellrc index 335462ff5..eba51bad0 100644 --- a/.codespellrc +++ b/.codespellrc @@ -1,3 +1,3 @@ [codespell] ignore-words-list: crate,everytime,inout,co-ordinate,ot,nwo,absolutey,atleast,ue,afterall -skip: **/target,node_modules,build,**/Cargo.lock,./docs/kcl/*.md,./src-tauri/gen/schemas +skip: **/target,node_modules,build,**/Cargo.lock,./docs/kcl/*.md,./src-tauri/gen/schemas,.yarn.lock,**/yarn.lock diff --git a/.github/workflows/build-test-publish-apps.yml b/.github/workflows/build-test-publish-apps.yml new file mode 100644 index 000000000..879cb741b --- /dev/null +++ b/.github/workflows/build-test-publish-apps.yml @@ -0,0 +1,404 @@ +name: build-test-publish-apps + +on: + pull_request: + push: + release: + types: [published] + schedule: + - cron: '0 4 * * *' + # Daily at 04:00 AM UTC + # Will checkout the last commit from the default branch (main as of 2023-10-04) + +env: + CUT_RELEASE_PR: ${{ 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 }} + cancel-in-progress: true + +jobs: + prepare-json-files: + runs-on: ubuntu-22.04 # seperate job on Ubuntu for easy string manipulations (compared to Windows) + outputs: + version: ${{ steps.export_version.outputs.version }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'yarn' + + - name: Set nightly version + if: github.event_name == 'schedule' + run: | + VERSION=$(date +'%-y.%-m.%-d') yarn bump-jsons + + # TODO: see if we need to inject updater nightly URL here https://dl.zoo.dev/releases/modeling-app/nightly/last_update.json + # TODO: see if we ned to add updater test URL here https://dl.zoo.dev/releases/modeling-app/updater-test/last_update.json + + - uses: actions/upload-artifact@v3 + if: ${{ github.event_name == 'schedule' || env.CUT_RELEASE_PR == 'true' }} + with: + path: | + package.json + + - id: export_version + run: echo "version=`cat package.json | jq -r '.version'`" >> "$GITHUB_OUTPUT" + + + build-test-app-macos: + needs: [prepare-json-files] + runs-on: macos-14 + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v3 + if: github.event_name == 'schedule' + + - name: Copy updated .json files + if: github.event_name == 'schedule' + run: | + ls -l artifact + cp artifact/package.json package.json + + - name: Sync node version and setup cache + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'yarn' # Set this to npm, yarn or pnpm. + + - run: yarn install + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: './src/wasm-lib' + + - name: Run build:wasm + run: "yarn build:wasm${{ env.BUILD_RELEASE == 'true' && '-dev' || ''}}" + + # TODO: sign the app (and updater bundle potentially) + - name: Add signing certificate + if: ${{ env.BUILD_RELEASE == 'true' }} + run: chmod +x add-osx-cert.sh && ./add-osx-cert.sh + + - name: Build the app for arm64 + run: "yarn electron-forge make" + + - name: Build the app for x64 + run: "yarn electron-forge make --arch x64" + + - name: List artifacts + run: "ls -R out/make" + + # TODO: add the 'Build for Mac TestFlight (nightly)' stage back + + - uses: actions/upload-artifact@v3 + with: + path: "out/make/*/*/*/*" + + + build-test-app-windows: + needs: [prepare-json-files] + runs-on: windows-2022 + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v3 + + - name: Copy updated .json files + if: github.event_name == 'schedule' + run: | + ls -l artifact + cp artifact/package.json package.json + + - name: Sync node version and setup cache + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'yarn' # Set this to npm, yarn or pnpm. + + - run: yarn install + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: './src/wasm-lib' + + - name: Run build:wasm manually + shell: bash + env: + MODE: ${{ env.BUILD_RELEASE == 'true' && '--release' || '--debug' }} + run: | + mkdir src/wasm-lib/pkg; cd src/wasm-lib + echo "building with ${{ env.MODE }}" + npx wasm-pack build --target web --out-dir pkg ${{ env.MODE }} + cd ../../ + cp src/wasm-lib/pkg/wasm_lib_bg.wasm public + + - name: Prepare certificate and variables (Windows only) + if: ${{ env.BUILD_RELEASE == 'true' }} + run: | + echo "${{secrets.SM_CLIENT_CERT_FILE_B64 }}" | base64 --decode > /d/Certificate_pkcs12.p12 + cat /d/Certificate_pkcs12.p12 + echo "::set-output name=version::${GITHUB_REF#refs/tags/v}" + echo "SM_HOST=${{ secrets.SM_HOST }}" >> "$GITHUB_ENV" + echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> "$GITHUB_ENV" + echo "SM_CLIENT_CERT_FILE=D:\\Certificate_pkcs12.p12" >> "$GITHUB_ENV" + echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> "$GITHUB_ENV" + echo "C:\Program Files (x86)\Windows Kits\10\App Certification Kit" >> $GITHUB_PATH + echo "C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools" >> $GITHUB_PATH + echo "C:\Program Files\DigiCert\DigiCert One Signing Manager Tools" >> $GITHUB_PATH + shell: bash + + - name: Setup certicate with SSM KSP (Windows only) + if: ${{ env.BUILD_RELEASE == 'true' }} + run: | + curl -X GET https://one.digicert.com/signingmanager/api-ui/v1/releases/smtools-windows-x64.msi/download -H "x-api-key:%SM_API_KEY%" -o smtools-windows-x64.msi + msiexec /i smtools-windows-x64.msi /quiet /qn + smksp_registrar.exe list + smctl.exe keypair ls + C:\Windows\System32\certutil.exe -csp "DigiCert Signing Manager KSP" -key -user + smksp_cert_sync.exe + shell: cmd + + - name: Build the app for x64 + run: "yarn electron-forge make --arch x64" + + - name: Build the app for arm64 + run: "yarn electron-forge make --arch arm64" + + - name: List artifacts + run: "ls -R out/make" + + - name: Sign using Signtool + if: ${{ env.BUILD_RELEASE == 'true' }} + env: + THUMBPRINT: "F4C9A52FF7BC26EE5E054946F6B11DEEA94C748D" + X64_FILE: "D:\\a\\modeling-app\\modeling-app\\out\\make\\squirrel.windows\\x64\\Zoo Modeling App-*Setup.exe" + ARM64_FILE: "D:\\a\\modeling-app\\modeling-app\\out\\make\\squirrel.windows\\arm64\\Zoo Modeling App-*Setup.exe" + run: | + signtool.exe sign /sha1 ${{ env.THUMBPRINT }} /tr http://timestamp.digicert.com /td SHA256 /fd SHA256 "${{ env.X64_FILE }}" + signtool.exe verify /v /pa "${{ env.X64_FILE }}" + signtool.exe sign /sha1 ${{ env.THUMBPRINT }} /tr http://timestamp.digicert.com /td SHA256 /fd SHA256 "${{ env.ARM64_FILE }}" + signtool.exe verify /v /pa "${{ env.ARM64_FILE }}" + + - uses: actions/upload-artifact@v3 + with: + path: "out/make/*/*/*" + + # TODO: Run e2e tests + + + build-test-app-ubuntu: + needs: [prepare-json-files] + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v3 + if: github.event_name == 'schedule' + + - name: Copy updated .json files + if: github.event_name == 'schedule' + run: | + ls -l artifact + cp artifact/package.json package.json + + - name: Sync node version and setup cache + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'yarn' # Set this to npm, yarn or pnpm. + + - run: yarn install + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: './src/wasm-lib' + + - name: Run build:wasm + run: "yarn build:wasm${{ env.BUILD_RELEASE == 'true' && '-dev' || ''}}" + + - name: Build the app for arm64 + run: "yarn electron-forge make --arch arm64" + + - name: Build the app for x64 + run: "yarn electron-forge make --arch x64" + + - name: List artifacts + run: "ls -R out/make" + + # TODO: add the 'Build for Mac TestFlight (nightly)' stage back + + # TODO: sign the app (and updater bundle potentially) + + - uses: actions/upload-artifact@v3 + with: + path: "out/make/*/*/*" + + + publish-apps-release: + runs-on: ubuntu-22.04 + permissions: + contents: write + if: ${{ github.event_name == 'release' || github.event_name == 'schedule' }} + needs: [prepare-json-files, build-test-app-macos, build-test-app-windows, build-test-app-ubuntu] + env: + VERSION_NO_V: ${{ needs.prepare-json-files.outputs.version }} + VERSION: ${{ github.event_name == 'release' && format('v{0}', needs.prepare-json-files.outputs.version) || needs.prepare-json-files.outputs.version }} + PUB_DATE: ${{ github.event_name == 'release' && github.event.release.created_at || github.event.repository.updated_at }} + 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 + + - name: Generate the update static endpoint + run: | + ls -l artifact/*/*oo* + DARWIN_SIG=`cat artifact/macos/*.app.tar.gz.sig` + WINDOWS_X86_64_SIG=`cat artifact/msi/*x64*.msi.zip.sig` + WINDOWS_AARCH64_SIG=`cat artifact/msi/*arm64*.msi.zip.sig` + RELEASE_DIR=https://${WEBSITE_DIR}/${VERSION} + jq --null-input \ + --arg version "${VERSION}" \ + --arg pub_date "${PUB_DATE}" \ + --arg notes "${NOTES}" \ + --arg darwin_sig "$DARWIN_SIG" \ + --arg darwin_url "$RELEASE_DIR/macos/${{ env.URL_CODED_NAME }}.app.tar.gz" \ + --arg windows_x86_64_sig "$WINDOWS_X86_64_SIG" \ + --arg windows_x86_64_url "$RELEASE_DIR/msi/${{ env.URL_CODED_NAME }}_${VERSION_NO_V}_x64_en-US.msi.zip" \ + --arg windows_aarch64_sig "$WINDOWS_AARCH64_SIG" \ + --arg windows_aarch64_url "$RELEASE_DIR/msi/${{ env.URL_CODED_NAME }}_${VERSION_NO_V}_arm64_en-US.msi.zip" \ + '{ + "version": $version, + "pub_date": $pub_date, + "notes": $notes, + "platforms": { + "darwin-x86_64": { + "signature": $darwin_sig, + "url": $darwin_url + }, + "darwin-aarch64": { + "signature": $darwin_sig, + "url": $darwin_url + }, + "windows-x86_64": { + "signature": $windows_x86_64_sig, + "url": $windows_x86_64_url + }, + "windows-aarch64": { + "signature": $windows_aarch64_sig, + "url": $windows_aarch64_url + } + } + }' > last_update.json + cat last_update.json + + - name: Generate the download static endpoint + run: | + RELEASE_DIR=https://${WEBSITE_DIR}/${VERSION} + jq --null-input \ + --arg version "${VERSION}" \ + --arg pub_date "${PUB_DATE}" \ + --arg notes "${NOTES}" \ + --arg darwin_url "$RELEASE_DIR/dmg/${{ env.URL_CODED_NAME }}_${VERSION_NO_V}_universal.dmg" \ + --arg windows_x86_64_url "$RELEASE_DIR/msi/${{ env.URL_CODED_NAME }}_${VERSION_NO_V}_x64_en-US.msi" \ + --arg windows_aarch64_url "$RELEASE_DIR/msi/${{ env.URL_CODED_NAME }}_${VERSION_NO_V}_arm64_en-US.msi" \ + '{ + "version": $version, + "pub_date": $pub_date, + "notes": $notes, + "platforms": { + "dmg-universal": { + "url": $darwin_url + }, + "msi-x86_64": { + "url": $windows_x86_64_url + }, + "msi-aarch64": { + "url": $windows_aarch64_url + } + } + }' > last_download.json + cat last_download.json + + - name: Authenticate to Google Cloud + uses: 'google-github-actions/auth@v2.1.3' + with: + credentials_json: '${{ secrets.GOOGLE_CLOUD_DL_SA }}' + + - name: Set up Google Cloud SDK + uses: google-github-actions/setup-gcloud@v2.1.0 + with: + project_id: kittycadapi + + - name: Upload release files to public bucket + uses: google-github-actions/upload-cloud-storage@v2.1.0 + with: + path: artifact + glob: '*/Zoo*' + parent: false + destination: ${{ env.BUCKET_DIR }}/${{ env.VERSION }} + + - name: Upload update endpoint to public bucket + uses: google-github-actions/upload-cloud-storage@v2.1.0 + with: + path: last_update.json + destination: ${{ env.BUCKET_DIR }} + + - name: Upload download endpoint to public bucket + uses: google-github-actions/upload-cloud-storage@v2.1.0 + with: + path: last_download.json + destination: ${{ env.BUCKET_DIR }} + + - name: Upload release files to Github + if: ${{ github.event_name == 'release' }} + uses: softprops/action-gh-release@v2 + with: + files: 'artifact/*/Zoo*' + + announce_release: + needs: [publish-apps-release] + runs-on: ubuntu-22.04 + if: github.event_name == 'release' + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install requests + + - name: Announce Release + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + RELEASE_VERSION: ${{ github.event.release.tag_name }} + RELEASE_BODY: ${{ github.event.release.body}} + run: python public/announce_release.py diff --git a/.github/workflows/build-test-web.yml b/.github/workflows/build-test-web.yml new file mode 100644 index 000000000..430fa11e7 --- /dev/null +++ b/.github/workflows/build-test-web.yml @@ -0,0 +1,76 @@ +name: build-test-web + +on: + pull_request: + push: + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + check-format: + runs-on: 'ubuntu-22.04' + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'yarn' + - run: yarn install + - run: yarn fmt-check + + check-types: + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'yarn' + - run: yarn install + - uses: Swatinem/rust-cache@v2 + with: + workspaces: './src/wasm-lib' + + - run: yarn build:wasm + - run: yarn xstate:typegen + - run: yarn tsc + + + check-typos: + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + - name: Install codespell + run: | + python -m pip install codespell + - name: Run codespell + run: codespell --config .codespellrc # Edit this file to tweak the typo list and other configuration. + + + build-test-web: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'yarn' + + - run: yarn install + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: './src/wasm-lib' + + - run: yarn build:wasm + + - run: yarn simpleserver:ci + + - run: yarn test:nowatch diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 4322df327..000000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,586 +0,0 @@ -name: CI - -on: - pull_request: - push: - branches: - - main - release: - types: [published] - schedule: - - cron: '0 4 * * *' - # Daily at 04:00 AM UTC - # Will checkout the last commit from the default branch (main as of 2023-10-04) - -env: - CUT_RELEASE_PR: ${{ 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 }} - cancel-in-progress: true - -permissions: - contents: write - pull-requests: write - actions: read - -jobs: - check-format: - runs-on: 'ubuntu-latest' - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - cache: 'yarn' - - run: yarn install - - run: yarn fmt-check - - check-types: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - cache: 'yarn' - - run: yarn install - - uses: Swatinem/rust-cache@v2 - with: - workspaces: './src/wasm-lib' - - - run: yarn build:wasm - - run: yarn xstate:typegen - - run: yarn tsc - - - check-typos: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - - name: Install codespell - run: | - python -m pip install codespell - - name: Run codespell - run: codespell --config .codespellrc # Edit this file to tweak the typo list and other configuration. - - - build-test-web: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - cache: 'yarn' - - - run: yarn install - - - uses: Swatinem/rust-cache@v2 - with: - workspaces: './src/wasm-lib' - - - run: yarn build:wasm - - - run: yarn simpleserver:ci - if: ${{ github.event_name != 'release' && github.event_name != 'schedule' }} - - - name: Install Chromium Browser - if: ${{ github.event_name != 'release' && github.event_name != 'schedule' }} - run: yarn playwright install chromium --with-deps - - - name: run unit tests - if: ${{ github.event_name != 'release' && github.event_name != 'schedule' }} - run: yarn test:nowatch - env: - VITE_KC_DEV_TOKEN: ${{ secrets.KITTYCAD_API_TOKEN_DEV }} - - - name: check for changes - if: ${{ github.event_name != 'release' && github.event_name != 'schedule' }} - id: git-check - run: | - git add src/lang/std/artifactMapGraphs - if git status src/lang/std/artifactMapGraphs | grep -q "Changes to be committed" - then echo "modified=true" >> $GITHUB_OUTPUT - else echo "modified=false" >> $GITHUB_OUTPUT - fi - - name: Commit changes, if any - if: ${{ github.event_name != 'release' && github.event_name != 'schedule' && steps.git-check.outputs.modified == 'true' }} - run: | - git config --local user.email "github-actions[bot]@users.noreply.github.com" - git config --local user.name "github-actions[bot]" - git remote set-url origin https://${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git - git fetch origin - echo ${{ github.head_ref }} - git checkout ${{ github.head_ref }} - # TODO when webkit works on ubuntu remove the os part of the commit message - git commit -am "Look at this (photo)Graph *in the voice of Nickelback*" || true - git push - git push origin ${{ github.head_ref }} - - - - - - prepare-json-files: - runs-on: ubuntu-latest # seperate job on Ubuntu for easy string manipulations (compared to Windows) - outputs: - version: ${{ steps.export_version.outputs.version }} - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - cache: 'yarn' - - - name: Set nightly version - if: github.event_name == 'schedule' - run: | - VERSION=$(date +'%-y.%-m.%-d') yarn bump-jsons - echo "$(jq --arg url 'https://dl.zoo.dev/releases/modeling-app/nightly/last_update.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 - - - name: Set updater test version - if: ${{ env.CUT_RELEASE_PR == 'true' }} - run: | - echo "$(jq --arg url 'https://dl.zoo.dev/releases/modeling-app/test/last_update.json' \ - '.plugins.updater.endpoints[]=$url' src-tauri/tauri.release.conf.json --indent 2)" > src-tauri/tauri.release.conf.json - - - uses: actions/upload-artifact@v3 - if: ${{ github.event_name == 'schedule' || env.CUT_RELEASE_PR == 'true' }} - with: - path: | - package.json - src-tauri/tauri.conf.json - src-tauri/tauri.release.conf.json - - - id: export_version - run: echo "version=`cat package.json | jq -r '.version'`" >> "$GITHUB_OUTPUT" - - - build-test-apps: - needs: [prepare-json-files] - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - 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 - - - uses: actions/download-artifact@v3 - if: github.event_name == 'schedule' - - - name: Copy updated .json files - if: github.event_name == 'schedule' - run: | - ls -l artifact - cp artifact/package.json package.json - cp artifact/src-tauri/tauri.conf.json src-tauri/tauri.conf.json - cp artifact/src-tauri/tauri.release.conf.json src-tauri/tauri.release.conf.json - - - name: Update WebView2 on Windows - if: matrix.os == 'windows-latest' - # Workaround needed to build the tauri windows app with matching edge version. - # From https://github.com/actions/runner-images/issues/9538 - run: | - Invoke-WebRequest -Uri 'https://go.microsoft.com/fwlink/p/?LinkId=2124703' -OutFile 'setup.exe' - Start-Process -FilePath setup.exe -Verb RunAs -Wait - - - 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 \ - xvfb - - - name: Sync node version and setup cache - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - cache: 'yarn' # Set this to npm, yarn or pnpm. - - - run: yarn install - - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - - - name: Setup Rust cache - uses: swatinem/rust-cache@v2 - with: - workspaces: './src-tauri -> target' - - - uses: Swatinem/rust-cache@v2 - with: - workspaces: './src/wasm-lib' - - - name: Run build:wasm manually - shell: bash - env: - MODE: ${{ env.BUILD_RELEASE == 'true' && '--release' || '--debug' }} - run: | - mkdir src/wasm-lib/pkg; cd src/wasm-lib - echo "building with ${{ env.MODE }}" - npx wasm-pack build --target web --out-dir pkg ${{ env.MODE }} - cd ../../ - cp src/wasm-lib/pkg/wasm_lib_bg.wasm public - - - name: Run vite build (build:both) - run: yarn vite build --mode ${{ env.BUILD_RELEASE == 'true' && 'production' || 'development' }} - - - name: Fix format - run: yarn fmt - - - name: Install x86 target for Universal builds (MacOS only) - if: matrix.os == 'macos-14' - run: | - rustup target add x86_64-apple-darwin - - - name: Prepare certificate and variables (Windows only) - if: ${{ matrix.os == 'windows-latest' && env.BUILD_RELEASE == 'true' }} - run: | - echo "${{secrets.SM_CLIENT_CERT_FILE_B64 }}" | base64 --decode > /d/Certificate_pkcs12.p12 - cat /d/Certificate_pkcs12.p12 - echo "::set-output name=version::${GITHUB_REF#refs/tags/v}" - echo "SM_HOST=${{ secrets.SM_HOST }}" >> "$GITHUB_ENV" - echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> "$GITHUB_ENV" - echo "SM_CLIENT_CERT_FILE=D:\\Certificate_pkcs12.p12" >> "$GITHUB_ENV" - echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> "$GITHUB_ENV" - echo "C:\Program Files (x86)\Windows Kits\10\App Certification Kit" >> $GITHUB_PATH - echo "C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools" >> $GITHUB_PATH - echo "C:\Program Files\DigiCert\DigiCert One Signing Manager Tools" >> $GITHUB_PATH - shell: bash - - - name: Setup certicate with SSM KSP (Windows only) - if: ${{ matrix.os == 'windows-latest' && env.BUILD_RELEASE == 'true' }} - run: | - curl -X GET https://one.digicert.com/signingmanager/api-ui/v1/releases/smtools-windows-x64.msi/download -H "x-api-key:%SM_API_KEY%" -o smtools-windows-x64.msi - msiexec /i smtools-windows-x64.msi /quiet /qn - smksp_registrar.exe list - smctl.exe keypair ls - C:\Windows\System32\certutil.exe -csp "DigiCert Signing Manager KSP" -key -user - smksp_cert_sync.exe - shell: cmd - - - name: Build the app (debug) - if: ${{ env.BUILD_RELEASE == 'false' }} - run: "yarn tauri build --debug ${{ env.TAURI_ARGS_MACOS }} ${{ env.TAURI_ARGS_UBUNTU }}" - - - name: Build for Mac TestFlight (nightly) - if: ${{ github.event_name == 'schedule' && 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 to Mac TestFlight (nightly)' - uses: apple-actions/upload-testflight-build@v1 - if: ${{ github.event_name == 'schedule' && 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 TestFlight (nightly) - if: ${{ github.event_name == 'schedule' && 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 - if: ${{ env.BUILD_RELEASE == 'true' }} - env: - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - TAURI_CONF_ARGS: "--config ${{ matrix.os == 'windows-latest' && 'src-tauri\\tauri.release.conf.json' || 'src-tauri/tauri.release.conf.json' }}" - run: "yarn tauri build ${{ env.TAURI_CONF_ARGS }} ${{ env.TAURI_ARGS_MACOS }} ${{ env.TAURI_ARGS_UBUNTU }}" - - - uses: actions/upload-artifact@v3 - if: matrix.os != 'ubuntu-latest' - env: - PREFIX: ${{ matrix.os == 'macos-14' && 'src-tauri/target/universal-apple-darwin' || 'src-tauri/target' }} - MODE: ${{ env.BUILD_RELEASE == 'true' && 'release' || 'debug' }} - with: - path: "${{ env.PREFIX }}/${{ env.MODE }}/bundle/*/*" - - - name: Run e2e tests (linux only) - if: ${{ matrix.os == 'ubuntu-latest' && github.event_name != 'release' && github.event_name != 'schedule' }} - run: | - 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 - env: - E2E_APPLICATION: "./src-tauri/target/${{ env.BUILD_RELEASE == 'true' && 'release' || 'debug' }}/zoo-modeling-app" - KITTYCAD_API_TOKEN: ${{ env.BUILD_RELEASE == 'true' && secrets.KITTYCAD_API_TOKEN || secrets.KITTYCAD_API_TOKEN_DEV }} - - - name: Run e2e tests (windows only) - if: ${{ matrix.os == 'windows-latest' && github.event_name != 'release' && github.event_name != 'schedule' }} - run: | - cargo install tauri-driver --force - yarn wdio run wdio.conf.ts - env: - E2E_APPLICATION: ".\\src-tauri\\target\\${{ env.BUILD_RELEASE == 'true' && 'release' || 'debug' }}\\Zoo Modeling App.exe" - KITTYCAD_API_TOKEN: ${{ env.BUILD_RELEASE == 'true' && secrets.KITTYCAD_API_TOKEN || secrets.KITTYCAD_API_TOKEN_DEV }} - VITE_KC_API_BASE_URL: ${{ env.BUILD_RELEASE == 'true' && 'https://api.zoo.dev' || 'https://api.dev.zoo.dev' }} - E2E_TAURI_ENABLED: true - TS_NODE_COMPILER_OPTIONS: '{"module": "commonjs"}' - - - uses: actions/download-artifact@v3 - if: ${{ env.CUT_RELEASE_PR == 'true' }} - - - name: Copy updated .json file for updater test - if: ${{ env.CUT_RELEASE_PR == 'true' }} - run: | - ls -l artifact - cp artifact/src-tauri/tauri.release.conf.json src-tauri/tauri.release.conf.json - cat src-tauri/tauri.release.conf.json - - - name: Build the app (release, updater test) - if: ${{ env.CUT_RELEASE_PR == 'true' && matrix.os != 'ubuntu-latest' }} - env: - TAURI_CONF_ARGS: "-c ${{ matrix.os == 'windows-latest' && 'src-tauri\\tauri.release.conf.json' || 'src-tauri/tauri.release.conf.json' }}" - TAURI_BUNDLE_ARGS: "-b ${{ matrix.os == 'windows-latest' && 'msi' || 'dmg' }}" - run: "yarn tauri build ${{ env.TAURI_CONF_ARGS }} ${{ env.TAURI_BUNDLE_ARGS }} ${{ env.TAURI_ARGS_MACOS }}" - - - uses: actions/upload-artifact@v3 - if: ${{ env.CUT_RELEASE_PR == 'true' && matrix.os != 'ubuntu-latest' }} - with: - path: "${{ matrix.os == 'macos-14' && 'src-tauri/target/universal-apple-darwin/release/bundle/dmg/*.dmg' || 'src-tauri/target/release/bundle/msi/*.msi' }}" - name: updater-test - - - publish-apps-release: - permissions: - contents: write - runs-on: ubuntu-latest - if: ${{ github.event_name == 'release' || github.event_name == 'schedule' }} - needs: [check-format, check-types, check-typos, build-test-web, prepare-json-files, build-test-apps] - env: - VERSION_NO_V: ${{ needs.prepare-json-files.outputs.version }} - VERSION: ${{ github.event_name == 'release' && format('v{0}', needs.prepare-json-files.outputs.version) || needs.prepare-json-files.outputs.version }} - PUB_DATE: ${{ github.event_name == 'release' && github.event.release.created_at || github.event.repository.updated_at }} - 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 - - - name: Generate the update static endpoint - run: | - ls -l artifact/*/*oo* - DARWIN_SIG=`cat artifact/macos/*.app.tar.gz.sig` - WINDOWS_SIG=`cat artifact/msi/*.msi.zip.sig` - RELEASE_DIR=https://${WEBSITE_DIR}/${VERSION} - jq --null-input \ - --arg version "${VERSION}" \ - --arg pub_date "${PUB_DATE}" \ - --arg notes "${NOTES}" \ - --arg darwin_sig "$DARWIN_SIG" \ - --arg darwin_url "$RELEASE_DIR/macos/${{ env.URL_CODED_NAME }}.app.tar.gz" \ - --arg windows_sig "$WINDOWS_SIG" \ - --arg windows_url "$RELEASE_DIR/msi/${{ env.URL_CODED_NAME }}_${VERSION_NO_V}_x64_en-US.msi.zip" \ - '{ - "version": $version, - "pub_date": $pub_date, - "notes": $notes, - "platforms": { - "darwin-x86_64": { - "signature": $darwin_sig, - "url": $darwin_url - }, - "darwin-aarch64": { - "signature": $darwin_sig, - "url": $darwin_url - }, - "windows-x86_64": { - "signature": $windows_sig, - "url": $windows_url - } - } - }' > last_update.json - cat last_update.json - - - name: Generate the download static endpoint - run: | - RELEASE_DIR=https://${WEBSITE_DIR}/${VERSION} - jq --null-input \ - --arg version "${VERSION}" \ - --arg pub_date "${PUB_DATE}" \ - --arg notes "${NOTES}" \ - --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, - "notes": $notes, - "platforms": { - "dmg-universal": { - "url": $darwin_url - }, - "msi-x86_64": { - "url": $windows_url - } - } - }' > last_download.json - cat last_download.json - - - name: Authenticate to Google Cloud - uses: 'google-github-actions/auth@v2.1.3' - with: - credentials_json: '${{ secrets.GOOGLE_CLOUD_DL_SA }}' - - - name: Set up Google Cloud SDK - uses: google-github-actions/setup-gcloud@v2.1.0 - with: - project_id: kittycadapi - - - name: Upload release files to public bucket - uses: google-github-actions/upload-cloud-storage@v2.1.1 - with: - path: artifact - glob: '*/Zoo*' - parent: false - destination: ${{ env.BUCKET_DIR }}/${{ env.VERSION }} - - - name: Upload update endpoint to public bucket - uses: google-github-actions/upload-cloud-storage@v2.1.1 - with: - path: last_update.json - destination: ${{ env.BUCKET_DIR }} - - - name: Upload download endpoint to public bucket - uses: google-github-actions/upload-cloud-storage@v2.1.1 - with: - path: last_download.json - destination: ${{ env.BUCKET_DIR }} - - - name: Upload release files to Github - if: ${{ github.event_name == 'release' }} - uses: softprops/action-gh-release@v2 - with: - files: 'artifact/*/Zoo*' - - announce_release: - needs: [publish-apps-release] - runs-on: ubuntu-latest - if: github.event_name == 'release' - steps: - - name: Check out code - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.x' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install requests - - - name: Announce Release - env: - DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} - RELEASE_VERSION: ${{ github.event.release.tag_name }} - RELEASE_BODY: ${{ github.event.release.body}} - run: python public/announce_release.py diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index f45148282..d84e3a1a5 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -171,7 +171,7 @@ jobs: if [[ ! -f "test-results/.last-run.json" ]]; then # if no last run artifact, than run plawright normally echo "run playwright normally" - yarn playwright test --project="Google Chrome" --config=playwright.ci.config.ts --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --grep-invert=@snapshot || true + yarn playwright test --project="Google Chrome" --config=playwright.ci.config.ts --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --grep-invert="@snapshot|@electron" || true # # send to axiom node playwrightProcess.mjs | tee /tmp/github-actions.log > /dev/null 2>&1 fi @@ -186,7 +186,7 @@ jobs: if [[ $failed_tests -gt 0 ]]; then echo "retried=true" >>$GITHUB_OUTPUT echo "run playwright with last failed tests and retry $retry" - yarn playwright test --project="Google Chrome" --config=playwright.ci.config.ts --last-failed --grep-invert=@snapshot || true + yarn playwright test --project="Google Chrome" --config=playwright.ci.config.ts --last-failed --grep-invert="@snapshot|@electron" || true # send to axiom node playwrightProcess.mjs | tee /tmp/github-actions.log > /dev/null 2>&1 retry=$((retry + 1)) @@ -233,6 +233,7 @@ jobs: retention-days: 30 overwrite: true + playwright-macos: timeout-minutes: 30 runs-on: macos-14 @@ -325,7 +326,7 @@ jobs: if [[ ! -f "test-results/.last-run.json" ]]; then # if no last run artifact, than run plawright normally echo "run playwright normally" - yarn playwright test --project="webkit" --config=playwright.ci.config.ts --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --grep-invert=@snapshot || true + yarn playwright test --project="webkit" --config=playwright.ci.config.ts --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --grep-invert="@snapshot|@electron" || true # # send to axiom node playwrightProcess.mjs | tee /tmp/github-actions.log > /dev/null 2>&1 fi @@ -340,7 +341,7 @@ jobs: if [[ $failed_tests -gt 0 ]]; then echo "retried=true" >>$GITHUB_OUTPUT echo "run playwright with last failed tests and retry $retry" - yarn playwright test --project="webkit" --config=playwright.ci.config.ts --last-failed --grep-invert=@snapshot || true + yarn playwright test --project="webkit" --config=playwright.ci.config.ts --last-failed --grep-invert="@snapshot|@electron" || true # send to axiom node playwrightProcess.mjs | tee /tmp/github-actions.log > /dev/null 2>&1 retry=$((retry + 1)) @@ -381,3 +382,156 @@ jobs: path: playwright-report/ retention-days: 30 overwrite: true + + playwright-electron: + timeout-minutes: 30 + runs-on: ubuntu-latest + needs: check-rust-changes + steps: + - name: Tune GitHub-hosted runner network + uses: smorimoto/tune-github-hosted-runner-network@v1 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'yarn' + - uses: KittyCAD/action-install-cli@main + - name: Install dependencies + run: yarn + - name: Cache Playwright Browsers + uses: actions/cache@v4 + with: + path: | + ~/.cache/ms-playwright/ + key: ${{ runner.os }}-playwright-${{ hashFiles('yarn.lock') }} + - name: Install Playwright Browsers + run: yarn playwright install chromium --with-deps + - name: Download Wasm Cache + id: download-wasm + if: needs.check-rust-changes.outputs.rust-changed == 'false' + uses: dawidd6/action-download-artifact@v6 + 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 (because rust diff) + if: needs.check-rust-changes.outputs.rust-changed == 'true' + uses: Swatinem/rust-cache@v2 + with: + workspaces: './src/wasm-lib' + - 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: Install vector + run: | + curl --proto '=https' --tlsv1.2 -sSfL https://sh.vector.dev > /tmp/vector.sh + chmod +x /tmp/vector.sh + /tmp/vector.sh -y -no-modify-path + mkdir -p /tmp/vector + cp .github/workflows/vector.toml /tmp/vector.toml + sed -i "s#GITHUB_WORKFLOW#${GITHUB_WORKFLOW}#g" /tmp/vector.toml + sed -i "s#GITHUB_REPOSITORY#${GITHUB_REPOSITORY}#g" /tmp/vector.toml + sed -i "s#GITHUB_SHA#${GITHUB_SHA}#g" /tmp/vector.toml + sed -i "s#GITHUB_REF_NAME#${GITHUB_REF_NAME}#g" /tmp/vector.toml + sed -i "s#GH_ACTIONS_AXIOM_TOKEN#${{secrets.GH_ACTIONS_AXIOM_TOKEN}}#g" /tmp/vector.toml + cat /tmp/vector.toml + ${HOME}/.vector/bin/vector --config /tmp/vector.toml & + - 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 + - uses: actions/download-artifact@v4 + if: always() + continue-on-error: true + with: + name: test-results-ubuntu-${{ github.sha }} + path: test-results/ + - name: run electron + run: | + yarn electron:start > electron.log 2>&1 & + while ! grep -q "built in" electron.log; do + sleep 1 + done + - name: Run ubuntu/chrome flow (with retries) + id: retry + if: always() + run: | + if [[ ! -f "test-results/.last-run.json" ]]; then + # if no last run artifact, than run plawright normally + echo "run playwright normally" + yarn playwright test --project="Google Chrome" --grep=@electron || true + # # send to axiom + node playwrightProcess.mjs | tee /tmp/github-actions.log > /dev/null 2>&1 + fi + + retry=1 + max_retrys=4 + + # retry failed tests, doing our own retries because using inbuilt playwright retries causes connection issues + while [[ $retry -le $max_retrys ]]; do + if [[ -f "test-results/.last-run.json" ]]; then + failed_tests=$(jq '.failedTests | length' test-results/.last-run.json) + if [[ $failed_tests -gt 0 ]]; then + echo "retried=true" >>$GITHUB_OUTPUT + echo "run playwright with last failed tests and retry $retry" + yarn playwright test --project="Google Chrome" --last-failed --grep=@electron || true + # send to axiom + node playwrightProcess.mjs | tee /tmp/github-actions.log > /dev/null 2>&1 + retry=$((retry + 1)) + else + echo "retried=false" >>$GITHUB_OUTPUT + exit 0 + fi + else + echo "retried=false" >>$GITHUB_OUTPUT + exit 0 + fi + done + + echo "retried=false" >>$GITHUB_OUTPUT + + if [[ -f "test-results/.last-run.json" ]]; then + failed_tests=$(jq '.failedTests | length' test-results/.last-run.json) + if [[ $failed_tests -gt 0 ]]; then + # if it still fails after 3 retrys, then fail the job + exit 1 + fi + fi + exit 0 + env: + CI: true + token: ${{ secrets.KITTYCAD_API_TOKEN_DEV }} + - name: send to axiom + if: always() + shell: bash + run: | + node playwrightProcess.mjs | tee /tmp/github-actions.log + - uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results-electron-${{ github.sha }} + path: test-results/ + retention-days: 30 + overwrite: true + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report-electron-${{ github.sha }} + path: playwright-report/ + retention-days: 30 + overwrite: true \ No newline at end of file diff --git a/.gitignore b/.gitignore index 0330b8110..28f97c4de 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,10 @@ Mac_App_Distribution.provisionprofile *.tsbuildinfo venv +.vite/ + +# electron +out/ + +src-tauri/target +electron-test-projects-dir \ No newline at end of file diff --git a/README.md b/README.md index 388c57cd0..bd55778e7 100644 --- a/README.md +++ b/README.md @@ -89,26 +89,19 @@ enable third-party cookies. You can enable third-party cookies by clicking on the eye with a slash through it in the URL bar, and clicking on "Enable Third-Party Cookies". -## Tauri +## Desktop -To spin up up tauri dev, `yarn install` and `yarn build:wasm-dev` need to have been done before hand then +To spin up the desktop app, `yarn install` and `yarn build:wasm-dev` need to have been done before hand then ``` -yarn tauri dev +yarn electron:start ``` -Will spin up the web app before opening up the tauri dev desktop app. Note that it's probably a good idea to close the browser tab that gets opened since at the time of writing they can conflict. +This will start the application and hot-reload on changed. -The dev instance automatically opens up the browser devtools which can be disabled by [commenting it out](https://github.com/KittyCAD/modeling-app/blob/main/src-tauri/src/main.rs#L92.) +Devtools can be opened with the usual Cmd/Ctrl-Shift-I. -To build, run `yarn tauri build`, or `yarn tauri build --debug` to keep access to the devtools. - -Note that these became separate apps on Macos, so make sure you open the right one after a build 😉 -![image](https://github.com/KittyCAD/modeling-app/assets/29681384/a08762c5-8d16-42d8-a02f-a5efc9ae5551) - -image - -image (1) +To build, run `yarn electron:package`. ## Checking out commits / Bisecting diff --git a/add-osx-cert.sh b/add-osx-cert.sh new file mode 100644 index 000000000..173322565 --- /dev/null +++ b/add-osx-cert.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env sh +# From https://dev.to/rwwagner90/signing-electron-apps-with-github-actions-4cof + +KEY_CHAIN=build.keychain +CERTIFICATE_P12=certificate.p12 + +# Recreate the certificate from the secure environment variable +echo $APPLE_CERTIFICATE | base64 --decode > $CERTIFICATE_P12 + +#create a keychain +security create-keychain -p actions $KEY_CHAIN + +# Make the keychain the default so identities are found +security default-keychain -s $KEY_CHAIN + +# Unlock the keychain +security unlock-keychain -p actions $KEY_CHAIN + +security import $CERTIFICATE_P12 -k $KEY_CHAIN -P $APPLE_CERTIFICATE_PASSWORD -T /usr/bin/codesign; + +security set-key-partition-list -S apple-tool:,apple: -s -k actions $KEY_CHAIN + +# remove certs +rm -fr *.p12 \ No newline at end of file diff --git a/e2e/playwright/electron-setup.spec.ts b/e2e/playwright/electron-setup.spec.ts new file mode 100644 index 000000000..52e2883f1 --- /dev/null +++ b/e2e/playwright/electron-setup.spec.ts @@ -0,0 +1,46 @@ +import test, { _electron } from '@playwright/test' +import { TEST_SETTINGS_KEY } from './storageStates' +import { _electron as electron } from '@playwright/test' +import * as TOML from '@iarna/toml' +import fs from 'node:fs' +import { secrets } from './secrets' + +test('Electron setup', { tag: '@electron' }, async () => { + // create or otherwise clear the folder ./electron-test-projects-dir + const fileName = './electron-test-projects-dir' + try { + fs.rmSync(fileName, { recursive: true }) + } catch (e) { + console.error(e) + } + + fs.mkdirSync(fileName) + + // get full path for ./electron-test-projects-dir + const fullPath = fs.realpathSync(fileName) + + const electronApp = await electron.launch({ + args: ['.'], + }) + + const page = await electronApp.firstWindow() + + // Set local storage directly using evaluate + await page.evaluate( + (token) => localStorage.setItem('TOKEN_PERSIST_KEY', token), + secrets.token + ) + + // Override settings with electron temporary project directory + await page.addInitScript( + async ({ settingsKey, settings }) => { + localStorage.setItem(settingsKey, settings) + }, + { + settingsKey: TEST_SETTINGS_KEY, + settings: TOML.stringify({ settings: { + app: { projectDirectory: fullPath }, + } }), + } + ) +}) diff --git a/e2e/playwright/projects.spec.ts b/e2e/playwright/projects.spec.ts new file mode 100644 index 000000000..981055da7 --- /dev/null +++ b/e2e/playwright/projects.spec.ts @@ -0,0 +1,122 @@ +import { _electron as electron, test, expect } from '@playwright/test' +import { getUtils, setup, tearDown } from './test-utils' +import fs from 'fs/promises' +import { secrets } from './secrets' +import { join } from 'path' +import { tomlStringify } from 'lang/wasm' + +test.afterEach(async ({ page }, testInfo) => { + await tearDown(page, testInfo) +}) + +test( + 'When the project folder is empty, user can create new project and open it.', + { tag: '@electron' }, + async ({ page: browserPage, context: browserContext }, testInfo) => { + // create or otherwise clear the folder ./electron-test-projects-dir + const settingsFileName = `./${testInfo.title + .replace(/\s/gi, '-') + .replace(/\W/gi, '')}` + const projectDirName = settingsFileName + '-dir' + try { + await fs.rm(projectDirName, { recursive: true }) + } catch (e) { + console.error(e) + } + + await fs.mkdir(projectDirName) + + // get full path for ./electron-test-projects-dir + const fullProjectPath = await fs.realpath(projectDirName) + + const electronApp = await electron.launch({ + args: ['.'], + }) + const context = electronApp.context() + const page = await electronApp.firstWindow() + + const electronTempDirectory = await page.evaluate(async () => { + return await window.electron.getPath( + 'temp' + ) + }) + const tempSettingsFilePath = join(electronTempDirectory, settingsFileName) + const settingsOverrides = tomlStringify({ + app: { + projectDirectory: fullProjectPath, + }, + }) + + if (settingsOverrides instanceof Error) { + throw settingsOverrides + } + await fs.writeFile(tempSettingsFilePath + '.toml', settingsOverrides) + + console.log('from within test setup', { + settingsFileName, + fullPath: fullProjectPath, + electronApp, + page, + settingsFilePath: tempSettingsFilePath + '.toml', + }) + + await setup(context, page, fullProjectPath) + // Set local storage directly using evaluate + + const u = await getUtils(page) + await page.setViewportSize({ width: 1200, height: 500 }) + await page.goto('http://localhost:3000/') + + page.on('console', console.log) + + // expect to see text "No Projects found" + await expect(page.getByText('No Projects found')).toBeVisible() + + await page.getByRole('button', { name: 'New project' }).click() + + await expect(page.getByText('Successfully created')).toBeVisible() + await expect(page.getByText('Successfully created')).not.toBeVisible() + + await expect(page.getByText('project-000')).toBeVisible() + + await page.getByText('project-000').click() + + await expect(page.getByTestId('loading')).toBeAttached() + await expect(page.getByTestId('loading')).not.toBeAttached({ + timeout: 20_000, + }) + + await expect( + page.getByRole('button', { name: 'Start Sketch' }) + ).toBeEnabled({ + timeout: 20_000, + }) + + await page.locator('.cm-content') + .fill(`const sketch001 = startSketchOn('XZ') + |> startProfileAt([-87.4, 282.92], %) + |> line([324.07, 27.199], %, $seg01) + |> line([118.328, -291.754], %) + |> line([-180.04, -202.08], %) + |> lineTo([profileStartX(%), profileStartY(%)], %) + |> close(%) +const extrude001 = extrude(200, sketch001)`) + + const pointOnModel = { x: 660, y: 250 } + + // check the model loaded by checking it's grey + await expect + .poll(() => u.getGreatestPixDiff(pointOnModel, [132, 132, 132]), { + timeout: 10_000, + }) + .toBeLessThan(10) + + await page.mouse.click(pointOnModel.x, pointOnModel.y) + // check user can interact with model by checking it turns yellow + await expect + .poll(() => u.getGreatestPixDiff(pointOnModel, [176, 180, 132])) + .toBeLessThan(10) + + await electronApp.close() + } +) diff --git a/e2e/playwright/test-utils.ts b/e2e/playwright/test-utils.ts index bff42b02c..6a93f09f0 100644 --- a/e2e/playwright/test-utils.ts +++ b/e2e/playwright/test-utils.ts @@ -17,6 +17,7 @@ import waitOn from 'wait-on' import { secrets } from './secrets' import { TEST_SETTINGS_KEY, TEST_SETTINGS } from './storageStates' import * as TOML from '@iarna/toml' +import { SaveSettingsPayload } from 'lib/settings/settingsTypes' type TestColor = [number, number, number] export const TEST_COLORS = { @@ -623,7 +624,9 @@ export async function tearDown(page: Page, testInfo: TestInfo) { await page.waitForTimeout(3000) } -export async function setup(context: BrowserContext, page: Page) { +// settingsOverrides may need to be augmented to take more generic items, +// but we'll be strict for now +export async function setup(context: BrowserContext, page: Page, overrideDirectory?: string) { // wait for Vite preview server to be up await waitOn({ resources: ['tcp:3000'], @@ -640,7 +643,13 @@ export async function setup(context: BrowserContext, page: Page) { { token: secrets.token, settingsKey: TEST_SETTINGS_KEY, - settings: TOML.stringify({ settings: TEST_SETTINGS }), + settings: TOML.stringify({ + ...TEST_SETTINGS, + app: { + ...TEST_SETTINGS.app, + projectDirectory: overrideDirectory || TEST_SETTINGS.app.projectDirectory, + }, + }), } ) // kill animations, speeds up tests and reduced flakiness diff --git a/e2e/tauri/specs/app.spec.ts b/e2e/tauri/specs/app.spec.ts deleted file mode 100644 index 99e428189..000000000 --- a/e2e/tauri/specs/app.spec.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { browser, $, expect } from '@wdio/globals' -import fs from 'fs/promises' -import path from 'path' -import os from 'os' -import { click, setDatasetValue } from '../utils' - -const isWin32 = os.platform() === 'win32' -const documentsDir = path.join(os.homedir(), 'Documents') -const userSettingsDir = path.join( - os.homedir(), - '.config', - 'dev.zoo.modeling-app' -) -const defaultProjectDir = path.join(documentsDir, 'zoo-modeling-app-projects') -const newProjectDir = path.join(documentsDir, 'a-different-directory') -const tmp = process.env.TEMP || '/tmp' -const userCodeDir = path.join(tmp, 'kittycad_user_code') - -describe('ZMA sign in flow', () => { - before(async () => { - // 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(userSettingsDir, { force: true, recursive: true }) - await fs.mkdir(defaultProjectDir, { recursive: true }) - await fs.mkdir(newProjectDir, { recursive: true }) - }) - - it('opens the auth page and signs in', async () => { - const signInButton = await $('[data-testid="sign-in-button"]') - expect(await signInButton.getText()).toEqual('Sign in') - - await click(signInButton) - await new Promise((resolve) => setTimeout(resolve, 2000)) - - // Get from main.rs - const userCode = await (await fs.readFile(userCodeDir)).toString() - console.log(`Found user code ${userCode}`) - - // Device flow: verify - const token = process.env.KITTYCAD_API_TOKEN - const headers = { - Authorization: `Bearer ${token}`, - Accept: 'application/json', - 'Content-Type': 'application/json', - } - const apiBaseUrl = process.env.VITE_KC_API_BASE_URL - const verifyUrl = `${apiBaseUrl}/oauth2/device/verify?user_code=${userCode}` - console.log(`GET ${verifyUrl}`) - const vr = await fetch(verifyUrl, { headers }) - console.log(vr.status) - - // Device flow: confirm - const confirmUrl = `${apiBaseUrl}/oauth2/device/confirm` - const data = JSON.stringify({ user_code: userCode }) - console.log(`POST ${confirmUrl} ${data}`) - const cr = await fetch(confirmUrl, { - headers, - method: 'POST', - body: data, - }) - 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 project') - }) -}) - -describe('ZMA authorized user flows', () => { - // Note: each flow below is intended to start *and* end from the home page - - it('opens the settings page, checks filesystem settings, and closes the settings page', async () => { - const menuButton = await $('[data-testid="user-sidebar-toggle"]') - await click(menuButton) - - const settingsButton = await $('[data-testid="user-settings"]') - await click(settingsButton) - - const projectDirInput = await $('[data-testid="project-directory-input"]') - expect(await projectDirInput.getValue()).toEqual(defaultProjectDir) - - /* - * We've set up the project directory input (in initialSettings.tsx) - * to be able to skip the folder selection dialog if data-testValue - * has a value, allowing us to test the input otherwise works. - */ - // TODO: understand why we need to force double \ on Windows - await setDatasetValue( - projectDirInput, - 'testValue', - isWin32 ? newProjectDir.replaceAll('\\', '\\\\') : newProjectDir - ) - const projectDirButton = await $('[data-testid="project-directory-button"]') - await click(projectDirButton) - await new Promise((resolve) => setTimeout(resolve, 500)) - // This line is broken. I need a different way to grab the toast - await expect(await $('div*=Set project directory to')).toBeDisplayed() - - const nameInput = await $('[data-testid="projects-defaultProjectName"]') - expect(await nameInput.getValue()).toEqual('project-$nnn') - - // Setting it back (for back to back local tests) - await new Promise((resolve) => setTimeout(resolve, 5000)) - await setDatasetValue( - projectDirInput, - 'testValue', - isWin32 ? defaultProjectDir.replaceAll('\\', '\\\\') : newProjectDir - ) - await click(projectDirButton) - - const closeButton = await $('[data-testid="settings-close-button"]') - await click(closeButton) - }) - - it('checks that no file exists, creates a new file', async () => { - const homeSection = await $('[data-testid="home-section"]') - expect(await homeSection.getText()).toContain('No Projects found') - - const newFileButton = await $('[data-testid="home-new-file"]') - await click(newFileButton) - await new Promise((resolve) => setTimeout(resolve, 1000)) - - expect(await homeSection.getText()).toContain('project-000') - }) - - it('opens the new file and expects a loading stream', async () => { - const projectLink = await $('[data-testid="project-link"]') - await click(projectLink) - if (isWin32) { - // TODO: actually do something to check that the stream is up - await new Promise((resolve) => setTimeout(resolve, 5000)) - } else { - const errorText = await $('[data-testid="unexpected-error"]') - expect(await errorText.getText()).toContain('unexpected error') - } - const base = isWin32 ? 'http://tauri.localhost' : 'tauri://localhost' - await browser.execute(`window.location.href = "${base}/home"`) - }) -}) - -describe('ZMA sign out flow', () => { - it('signs out', async () => { - await new Promise((resolve) => setTimeout(resolve, 1000)) - const menuButton = await $('[data-testid="user-sidebar-toggle"]') - await click(menuButton) - const signoutButton = await $('[data-testid="user-sidebar-sign-out"]') - await click(signoutButton) - const newSignInButton = await $('[data-testid="sign-in-button"]') - expect(await newSignInButton.getText()).toEqual('Sign in') - }) -}) diff --git a/e2e/tauri/utils.ts b/e2e/tauri/utils.ts deleted file mode 100644 index 4af446e6f..000000000 --- a/e2e/tauri/utils.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { browser } from '@wdio/globals' - -export async function click(element: WebdriverIO.Element): Promise { - // Workaround for .click(), see https://github.com/tauri-apps/tauri/issues/6541 - await element.waitForClickable() - await browser.execute('arguments[0].click();', element) -} - -/* Shoutout to @Sheap on Github for a great workaround utility: - * https://github.com/tauri-apps/tauri/issues/6541#issue-1638944060 - */ -export async function setDatasetValue( - field: WebdriverIO.Element, - property: string, - value: string -) { - await browser.execute(`arguments[0].dataset.${property} = "${value}"`, field) -} diff --git a/forge.config.ts b/forge.config.ts new file mode 100644 index 000000000..f701bf02d --- /dev/null +++ b/forge.config.ts @@ -0,0 +1,66 @@ +import type { ForgeConfig } from '@electron-forge/shared-types' +import { MakerSquirrel } from '@electron-forge/maker-squirrel' +import { MakerZIP } from '@electron-forge/maker-zip' +import { MakerDeb } from '@electron-forge/maker-deb' +import { MakerRpm } from '@electron-forge/maker-rpm' +import { VitePlugin } from '@electron-forge/plugin-vite' +import { FusesPlugin } from '@electron-forge/plugin-fuses' +import { FuseV1Options, FuseVersion } from '@electron/fuses' + +const config: ForgeConfig = { + packagerConfig: { + asar: true, + osxSign: (process.env.BUILD_RELEASE === 'true' && {}) || undefined, + osxNotarize: + (process.env.BUILD_RELEASE === 'true' && { + appleId: process.env.APPLE_ID || '', + appleIdPassword: process.env.APPLE_PASSWORD || '', + teamId: process.env.APPLE_TEAM_ID || '', + }) || + undefined, + executableName: 'zoo-modeling-app', + }, + rebuildConfig: {}, + makers: [ + new MakerSquirrel({}), + new MakerZIP({}, ['darwin']), + new MakerRpm({}), + new MakerDeb({}), + ], + plugins: [ + new VitePlugin({ + // `build` can specify multiple entry builds, which can be Main process, Preload scripts, Worker process, etc. + // If you are familiar with Vite configuration, it will look really familiar. + build: [ + { + // `entry` is just an alias for `build.lib.entry` in the corresponding file of `config`. + entry: 'src/main.ts', + config: 'vite.main.config.ts', + }, + { + entry: 'src/preload.ts', + config: 'vite.preload.config.ts', + }, + ], + renderer: [ + { + name: 'main_window', + config: 'vite.renderer.config.ts', + }, + ], + }), + // Fuses are used to enable/disable various Electron functionality + // at package time, before code signing the application + new FusesPlugin({ + version: FuseVersion.V1, + [FuseV1Options.RunAsNode]: false, + [FuseV1Options.EnableCookieEncryption]: true, + [FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false, + [FuseV1Options.EnableNodeCliInspectArguments]: false, + [FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true, + [FuseV1Options.OnlyLoadAppFromAsar]: true, + }), + ], +} + +export default config diff --git a/forge.env.d.ts b/forge.env.d.ts new file mode 100644 index 000000000..eb0a5a87e --- /dev/null +++ b/forge.env.d.ts @@ -0,0 +1,35 @@ +export {} // Make this a module + +declare global { + // This allows TypeScript to pick up the magic constants that's auto-generated by Forge's Vite + // plugin that tells the Electron app where to look for the Vite-bundled app code (depending on + // whether you're running in development or production). + const MAIN_WINDOW_VITE_DEV_SERVER_URL: string + const MAIN_WINDOW_VITE_NAME: string + + namespace NodeJS { + interface Process { + // Used for hot reload after preload scripts. + viteDevServers: Record + } + } + + type VitePluginConfig = ConstructorParameters< + typeof import('@electron-forge/plugin-vite').VitePlugin + >[0] + + interface VitePluginRuntimeKeys { + VITE_DEV_SERVER_URL: `${string}_VITE_DEV_SERVER_URL` + VITE_NAME: `${string}_VITE_NAME` + } +} + +declare module 'vite' { + interface ConfigEnv< + K extends keyof VitePluginConfig = keyof VitePluginConfig + > { + root: string + forgeConfig: VitePluginConfig + forgeConfigSelf: VitePluginConfig[K][number] + } +} diff --git a/index.html b/index.html index 282a733e6..d02bfa591 100644 --- a/index.html +++ b/index.html @@ -2,6 +2,10 @@ + + diff --git a/interface.d.ts b/interface.d.ts new file mode 100644 index 000000000..0d3ef898a --- /dev/null +++ b/interface.d.ts @@ -0,0 +1,46 @@ +import fs from 'node:fs/promises' +import path from 'path' +import { dialog, shell } from 'electron' +import kittycad from '@kittycad/lib' +import { MachinesListing } from 'lib/machineManager' + +export interface IElectronAPI { + open: typeof dialog.showOpenDialog + save: typeof dialog.showSaveDialog + openExternal: typeof shell.openExternal + showInFolder: typeof shell.showItemInFolder + login: (host: string) => Promise + platform: typeof process.env.platform + arch: typeof process.env.arch + version: typeof process.env.version + readFile: (path: string) => ReturnType + writeFile: ( + path: string, + data: string | Uint8Array + ) => ReturnType + readdir: (path: string) => ReturnType + getPath: (name: string) => Promise + rm: typeof fs.rm + stat: (path: string) => ReturnType + statIsDirectory: (path: string) => Promise + path: typeof path + mkdir: typeof fs.mkdir + rename: (prev: string, next: string) => typeof fs.rename + packageJson: { + name: string + } + process: { + env: { + BASE_URL: (value?: string) => string + } + } + kittycad + listMachines: () => Promise + getMachineApiIp: () => Promise +} + +declare global { + interface Window { + electron: IElectronAPI + } +} diff --git a/make-release.sh b/make-release.sh index 59deee7f8..39e0b3a4c 100755 --- a/make-release.sh +++ b/make-release.sh @@ -57,9 +57,8 @@ 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" '.version=$v' src-tauri/tauri.conf.json --indent 2)" > src-tauri/tauri.conf.json -git add package.json src-tauri/tauri.conf.json +git add package.json git commit -m "Cut release $new_version" echo "" diff --git a/package.json b/package.json index 7523b1256..fab3522a0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,16 @@ { - "name": "untitled-app", - "version": "0.24.10", "private": true, + "name": "zoo-modeling-app", + "productName": "Zoo Modeling App", + "version": "0.24.10", + "author": { + "name": "Zoo Corporation", + "email": "info@zoo.dev", + "url": "https://zoo.dev" + }, + "description": "Edit CAD visually or with code", + "main": ".vite/build/main.js", + "license": "none", "dependencies": { "@codemirror/autocomplete": "^6.17.0", "@codemirror/commands": "^6.6.0", @@ -22,24 +31,19 @@ "@lezer/lr": "^1.4.1", "@react-hook/resize-observer": "^2.0.1", "@replit/codemirror-interact": "^6.3.1", - "@tauri-apps/api": "^2.0.0-beta.14", - "@tauri-apps/plugin-dialog": "^2.0.0-beta.6", - "@tauri-apps/plugin-fs": "^2.0.0-beta.6", - "@tauri-apps/plugin-http": "^2.0.0-beta.7", - "@tauri-apps/plugin-os": "^2.0.0-beta.6", - "@tauri-apps/plugin-process": "^2.0.0-beta.6", - "@tauri-apps/plugin-shell": "^2.0.0-beta.7", - "@tauri-apps/plugin-updater": "^2.0.0-beta.6", "@ts-stack/markdown": "^1.5.0", "@tweenjs/tween.js": "^23.1.1", "@xstate/inspect": "^0.8.0", "@xstate/react": "^3.2.2", + "bonjour-service": "^1.2.1", "codemirror": "^6.0.1", "decamelize": "^6.0.0", + "electron-squirrel-startup": "^1.0.1", "fuse.js": "^7.0.0", "html2canvas-pro": "^1.5.5", "json-rpc-2.0": "^1.6.0", "jszip": "^3.10.1", + "openid-client": "^5.6.5", "re-resizable": "^6.9.11", "react": "^18.3.1", "react-dom": "^18.2.0", @@ -51,7 +55,6 @@ "react-router-dom": "^6.23.1", "sketch-helpers": "^0.0.4", "three": "^0.166.1", - "typescript": "^5.4.5", "ua-parser-js": "^1.0.37", "uuid": "^9.0.1", "vscode-jsonrpc": "^8.2.1", @@ -72,8 +75,6 @@ "test": "vitest --mode development", "test:nowatch": "vitest run --mode development", "test:rust": "(cd src/wasm-lib && cargo test --all && cargo clippy --all --tests --benches)", - "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 *.ts *.json *.js ./e2e ./packages", "fmt-check": "prettier --check ./src *.ts *.json *.js ./e2e ./packages", @@ -88,7 +89,11 @@ "postinstall": "yarn xstate:typegen", "xstate:typegen": "yarn xstate typegen \"src/**/*.ts?(x)\"", "make:dev": "make dev", - "generate:machine-api": "npx openapi-typescript ./openapi/machine-api.json -o src/lib/machine-api.d.ts" + "generate:machine-api": "npx openapi-typescript ./openapi/machine-api.json -o src/lib/machine-api.d.ts", + "electron:start": "electron-forge start", + "electron:package": "electron-forge package", + "electron:make": "electron-forge make", + "electron:publish": "electron-forge publish" }, "prettier": { "trailingComma": "es5", @@ -110,14 +115,23 @@ }, "devDependencies": { "@babel/plugin-proposal-private-property-in-object": "^7.21.11", - "@babel/preset-env": "^7.25.0", + "@babel/preset-env": "^7.24.3", + "@electron-forge/cli": "^7.4.0", + "@electron-forge/maker-deb": "^7.4.0", + "@electron-forge/maker-rpm": "^7.4.0", + "@electron-forge/maker-squirrel": "^7.4.0", + "@electron-forge/maker-zip": "^7.4.0", + "@electron-forge/plugin-auto-unpack-natives": "^7.4.0", + "@electron-forge/plugin-fuses": "^7.4.0", + "@electron-forge/plugin-vite": "^7.4.0", + "@electron/fuses": "^1.8.0", "@iarna/toml": "^2.2.5", "@lezer/generator": "^1.7.1", "@playwright/test": "^1.45.1", - "@tauri-apps/cli": "==2.0.0-beta.13", "@testing-library/jest-dom": "^5.14.1", "@testing-library/react": "^15.0.2", "@types/d3-force": "^3.0.10", + "@types/electron": "^1.6.10", "@types/mocha": "^10.0.6", "@types/node": "^18.19.31", "@types/pixelmatch": "^5.2.6", @@ -131,19 +145,17 @@ "@types/wait-on": "^5.3.4", "@types/wicg-file-system-access": "^2023.10.5", "@types/ws": "^8.5.10", + "@typescript-eslint/eslint-plugin": "^5.0.0", + "@typescript-eslint/parser": "^5.0.0", "@vitejs/plugin-react": "^4.3.0", "@vitest/web-worker": "^1.5.0", - "@wdio/cli": "^8.24.3", - "@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", - "d3-force": "^3.0.0", - "eslint": "^8.57.0", + "electron": "^31.2.1", + "eslint": "^8.0.1", "eslint-config-react-app": "^7.0.1", "eslint-plugin-css-modules": "^2.12.0", + "eslint-plugin-import": "^2.25.0", "eslint-plugin-suggest-no-throw": "^1.0.0", "happy-dom": "^14.3.10", "http-server": "^14.1.1", @@ -156,7 +168,9 @@ "prettier": "^2.8.8", "setimmediate": "^1.0.5", "tailwindcss": "^3.4.1", - "vite": "^5.3.3", + "ts-node": "^10.0.0", + "typescript": "^5.0.0", + "vite": "^5.0.12", "vite-plugin-eslint": "^1.8.1", "vite-plugin-package-version": "^1.1.0", "vite-tsconfig-paths": "^4.3.2", diff --git a/public/logo-blue.svg b/public/logo-blue.svg new file mode 100644 index 000000000..17f1629e6 --- /dev/null +++ b/public/logo-blue.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/logo-white.svg b/public/logo-white.svg new file mode 100644 index 000000000..d976369a9 --- /dev/null +++ b/public/logo-white.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore deleted file mode 100644 index aba21e242..000000000 --- a/src-tauri/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Generated by Cargo -# will have compiled files and executables -/target/ diff --git a/src-tauri/Info.plist b/src-tauri/Info.plist deleted file mode 100644 index 8a6b8806e..000000000 --- a/src-tauri/Info.plist +++ /dev/null @@ -1,376 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - NSPrincipalClass - NSApplication - CFBundlePackageType - APPL - NSDesktopFolderUsageDescription - Zoo Modeling App accesses the Desktop to load and save your project files and/or exported files here - NSDocumentsFolderUsageDescription - Zoo Modeling App accesses the Documents folder to load and save your project files and/or exported files here - NSDownloadsFolderUsageDescription - Zoo Modeling App accesses the Downloads folder to load and save your project files and/or exported files here - ITSAppUsesNonExemptEncryption - - DTXcode - 1501 - DTXcodeBuild - 15A507 - CFBundleURLTypes - - - CFBundleURLName - dev.zoo.modeling-app - CFBundleURLSchemes - - zoo-modeling-app - zoo - - - - LSFileQuarantineEnabled - - CFBundleDocumentTypes - - - LSItemContentTypes - - dev.zoo.kcl - - CFBundleTypeName - KCL - CFBundleTypeRole - Editor - LSTypeIsPackage - - LSHandlerRank - Owner - - - LSItemContentTypes - - dev.zoo.toml - - CFBundleTypeName - TOML - CFBundleTypeRole - Editor - LSTypeIsPackage - - LSHandlerRank - Default - - - LSItemContentTypes - - dev.zoo.gltf - - CFBundleTypeName - glTF - CFBundleTypeRole - Editor - LSTypeIsPackage - - LSHandlerRank - Default - - - LSItemContentTypes - - dev.zoo.glb - - CFBundleTypeName - glb - CFBundleTypeRole - Editor - LSTypeIsPackage - - LSHandlerRank - Default - - - LSItemContentTypes - - dev.zoo.step - - CFBundleTypeName - STEP - CFBundleTypeRole - Editor - LSTypeIsPackage - - LSHandlerRank - Default - - - LSItemContentTypes - - dev.zoo.fbx - - CFBundleTypeName - FBX - CFBundleTypeRole - Editor - LSTypeIsPackage - - LSHandlerRank - Default - - - LSItemContentTypes - - dev.zoo.sldprt - - CFBundleTypeName - Solidworks Part - CFBundleTypeRole - Viewer - LSTypeIsPackage - - LSHandlerRank - Default - - - LSItemContentTypes - - public.geometry-definition-format - - CFBundleTypeName - OBJ - CFBundleTypeRole - Editor - LSTypeIsPackage - - LSHandlerRank - Default - - - LSItemContentTypes - - public.polygon-file-format - - CFBundleTypeName - PLY - CFBundleTypeRole - Editor - LSTypeIsPackage - - LSHandlerRank - Default - - - LSItemContentTypes - - public.standard-tesselated-geometry-format - - CFBundleTypeName - STL - CFBundleTypeRole - Editor - LSTypeIsPackage - - LSHandlerRank - Default - - - LSItemContentTypes - - public.folder - - CFBundleTypeName - Folders - CFBundleTypeRole - Viewer - LSHandlerRank - Alternate - - - UTExportedTypeDeclarations - - - UTTypeIdentifier - dev.zoo.kcl - UTTypeReferenceURL - https://zoo.dev/docs/kcl - UTTypeConformsTo - - public.source-code - public.data - public.text - public.plain-text - public.3d-content - public.script - - UTTypeDescription - KCL (KittyCAD Language) document - UTTypeTagSpecification - - public.filename-extension - - kcl - - public.mime-type - - text/vnd.zoo.kcl - - - - - UTTypeIdentifier - dev.zoo.gltf - UTTypeReferenceURL - https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html - UTTypeConformsTo - - public.data - public.text - public.plain-text - public.3d-content - public.json - - UTTypeDescription - Graphics Library Transmission Format (glTF) - UTTypeTagSpecification - - public.filename-extension - - gltf - - public.mime-type - - model/gltf+json - - - - - UTTypeIdentifier - dev.zoo.glb - UTTypeReferenceURL - https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html - UTTypeConformsTo - - public.data - public.3d-content - - UTTypeDescription - Graphics Library Transmission Format (glTF) binary - UTTypeTagSpecification - - public.filename-extension - - glb - - public.mime-type - - model/gltf-binary - - - - - UTTypeIdentifier - dev.zoo.step - UTTypeReferenceURL - https://www.loc.gov/preservation/digital/formats/fdd/fdd000448.shtml - UTTypeConformsTo - - public.data - public.3d-content - public.text - public.plain-text - - UTTypeDescription - STEP-file, ISO 10303-21 - UTTypeTagSpecification - - public.filename-extension - - step - stp - - public.mime-type - - model/step - - - - - UTTypeIdentifier - dev.zoo.sldprt - UTTypeReferenceURL - https://docs.fileformat.com/cad/sldprt/ - UTTypeConformsTo - - public.data - public.3d-content - - UTTypeDescription - Solidworks Part - UTTypeTagSpecification - - public.filename-extension - - sldprt - - public.mime-type - - model/vnd.solidworks.sldprt - - - - - UTTypeIdentifier - dev.zoo.fbx - UTTypeReferenceURL - https://en.wikipedia.org/wiki/FBX - UTTypeConformsTo - - public.data - public.3d-content - - UTTypeDescription - Autodesk Filmbox (FBX) format - UTTypeTagSpecification - - public.filename-extension - - fbx - fbxb - - public.mime-type - - model/vnd.autodesk.fbx - - - - - UTTypeIdentifier - dev.zoo.toml - UTTypeReferenceURL - https://toml.io/en/ - UTTypeConformsTo - - public.data - public.text - public.plain-text - - UTTypeDescription - Tom's Obvious Minimal Language - UTTypeTagSpecification - - public.filename-extension - - kcl - - public.mime-type - - text/toml - - - - - - diff --git a/src-tauri/build.rs b/src-tauri/build.rs deleted file mode 100644 index d860e1e6a..000000000 --- a/src-tauri/build.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - tauri_build::build() -} diff --git a/src-tauri/capabilities/desktop.json b/src-tauri/capabilities/desktop.json deleted file mode 100644 index ffaa69f74..000000000 --- a/src-tauri/capabilities/desktop.json +++ /dev/null @@ -1,127 +0,0 @@ -{ - "$schema": "../gen/schemas/desktop-schema.json", - "identifier": "main-capability", - "description": "Capability for the main window", - "context": "local", - "windows": [ - "main" - ], - "permissions": [ - "cli:default", - "deep-link:default", - "log:default", - "path:default", - "event:default", - "window:default", - "app:default", - "resources:default", - "menu:default", - "tray:default", - "fs:allow-create", - "fs:allow-read-file", - "fs:allow-read-text-file", - "fs:allow-write-file", - "fs:allow-write-text-file", - "fs:allow-read-dir", - "fs:allow-copy-file", - "fs:allow-mkdir", - "fs:allow-remove", - "fs:allow-rename", - "fs:allow-exists", - "fs:allow-stat", - { - "identifier": "fs:scope", - "allow": [ - { - "path": "$TEMP" - }, - { - "path": "$TEMP/**/*" - }, - { - "path": "$HOME" - }, - { - "path": "$HOME/**/*" - }, - { - "path": "$HOME/.config" - }, - { - "path": "$HOME/.config/**/*" - }, - { - "path": "$APPCONFIG" - }, - { - "path": "$APPCONFIG/**/*" - }, - { - "path": "$DOCUMENT" - }, - { - "path": "$DOCUMENT/**/*" - } - ] - }, - "shell:allow-open", - { - "identifier": "shell:allow-execute", - "allow": [ - { - "name": "open", - "cmd": "open", - "args": [ - "-R", - { - "validator": "\\S+" - } - ], - "sidecar": false - }, - { - "name": "explorer", - "cmd": "explorer", - "args": [ - "/select", - { - "validator": "\\S+" - } - ], - "sidecar": false - } - ] - }, - "dialog:allow-open", - "dialog:allow-save", - "dialog:allow-message", - "dialog:allow-ask", - "dialog:allow-confirm", - { - "identifier": "http:default", - "allow": [ - "https://dev.kittycad.io/*", - "https://dev.zoo.dev/*", - "https://kittycad.io/*", - "https://zoo.dev/*", - "https://api.dev.kittycad.io/*", - "https://api.dev.zoo.dev/*" - ] - }, - "os:allow-platform", - "os:allow-version", - "os:allow-os-type", - "os:allow-family", - "os:allow-arch", - "os:allow-exe-extension", - "os:allow-locale", - "os:allow-hostname", - "process:allow-restart", - "updater:default" - ], - "platforms": [ - "linux", - "macOS", - "windows" - ] -} diff --git a/src-tauri/entitlements/app-store.entitlements b/src-tauri/entitlements/app-store.entitlements deleted file mode 100644 index 4590d103b..000000000 --- a/src-tauri/entitlements/app-store.entitlements +++ /dev/null @@ -1,24 +0,0 @@ - - - - - com.apple.security.app-sandbox - - com.apple.security.network.server - - com.apple.security.network.client - - com.apple.security.files.user-selected.read-write - - com.apple.security.files.downloads.read-write - - com.apple.application-identifier - 92H8YB3B95.dev.zoo.modeling-app - com.apple.developer.team-identifier - 92H8YB3B95 - com.apple.developer.associated-domains - - applinks:app.zoo.dev - - - diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png deleted file mode 100644 index 306689941..000000000 Binary files a/src-tauri/icons/128x128.png and /dev/null differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png deleted file mode 100644 index 8722ff010..000000000 Binary files a/src-tauri/icons/128x128@2x.png and /dev/null differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png deleted file mode 100644 index edd125d4e..000000000 Binary files a/src-tauri/icons/32x32.png and /dev/null differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png deleted file mode 100644 index 45dd3b39c..000000000 Binary files a/src-tauri/icons/Square107x107Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png deleted file mode 100644 index 91e6415d9..000000000 Binary files a/src-tauri/icons/Square142x142Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png deleted file mode 100644 index eec63b916..000000000 Binary files a/src-tauri/icons/Square150x150Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png deleted file mode 100644 index 675a3c0a7..000000000 Binary files a/src-tauri/icons/Square284x284Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png deleted file mode 100644 index 101cb8bca..000000000 Binary files a/src-tauri/icons/Square30x30Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png deleted file mode 100644 index 59b28a9cb..000000000 Binary files a/src-tauri/icons/Square310x310Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png deleted file mode 100644 index 90e72fd43..000000000 Binary files a/src-tauri/icons/Square44x44Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png deleted file mode 100644 index 760c79460..000000000 Binary files a/src-tauri/icons/Square71x71Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png deleted file mode 100644 index b2e81149f..000000000 Binary files a/src-tauri/icons/Square89x89Logo.png and /dev/null differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png deleted file mode 100644 index f0ad0468a..000000000 Binary files a/src-tauri/icons/StoreLogo.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index 9baad87c1..000000000 Binary files a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png deleted file mode 100644 index 75133df66..000000000 Binary files a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png deleted file mode 100644 index 9baad87c1..000000000 Binary files a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index 1a276e644..000000000 Binary files a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png deleted file mode 100644 index 549603971..000000000 Binary files a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png deleted file mode 100644 index 1a276e644..000000000 Binary files a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 01c072dfe..000000000 Binary files a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png deleted file mode 100644 index 5b2840109..000000000 Binary files a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png deleted file mode 100644 index 01c072dfe..000000000 Binary files a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index 0a049dcb4..000000000 Binary files a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png deleted file mode 100644 index 56c646860..000000000 Binary files a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png deleted file mode 100644 index 0a049dcb4..000000000 Binary files a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 15afbc712..000000000 Binary files a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png deleted file mode 100644 index a4332df54..000000000 Binary files a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png deleted file mode 100644 index 15afbc712..000000000 Binary files a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns deleted file mode 100644 index 642d1efe7..000000000 Binary files a/src-tauri/icons/icon.icns and /dev/null differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico deleted file mode 100644 index 9a2f0c188..000000000 Binary files a/src-tauri/icons/icon.ico and /dev/null differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png deleted file mode 100644 index 5e9e45b85..000000000 Binary files a/src-tauri/icons/icon.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@1x.png b/src-tauri/icons/ios/AppIcon-20x20@1x.png deleted file mode 100644 index 51fab14b9..000000000 Binary files a/src-tauri/icons/ios/AppIcon-20x20@1x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png deleted file mode 100644 index 395d42ab9..000000000 Binary files a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x.png b/src-tauri/icons/ios/AppIcon-20x20@2x.png deleted file mode 100644 index 395d42ab9..000000000 Binary files a/src-tauri/icons/ios/AppIcon-20x20@2x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@3x.png b/src-tauri/icons/ios/AppIcon-20x20@3x.png deleted file mode 100644 index ba603424d..000000000 Binary files a/src-tauri/icons/ios/AppIcon-20x20@3x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@1x.png b/src-tauri/icons/ios/AppIcon-29x29@1x.png deleted file mode 100644 index da210966a..000000000 Binary files a/src-tauri/icons/ios/AppIcon-29x29@1x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png deleted file mode 100644 index 6092c7359..000000000 Binary files a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x.png b/src-tauri/icons/ios/AppIcon-29x29@2x.png deleted file mode 100644 index 6092c7359..000000000 Binary files a/src-tauri/icons/ios/AppIcon-29x29@2x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@3x.png b/src-tauri/icons/ios/AppIcon-29x29@3x.png deleted file mode 100644 index 2585e6170..000000000 Binary files a/src-tauri/icons/ios/AppIcon-29x29@3x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@1x.png b/src-tauri/icons/ios/AppIcon-40x40@1x.png deleted file mode 100644 index 395d42ab9..000000000 Binary files a/src-tauri/icons/ios/AppIcon-40x40@1x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png deleted file mode 100644 index a5584ef26..000000000 Binary files a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x.png b/src-tauri/icons/ios/AppIcon-40x40@2x.png deleted file mode 100644 index a5584ef26..000000000 Binary files a/src-tauri/icons/ios/AppIcon-40x40@2x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@3x.png b/src-tauri/icons/ios/AppIcon-40x40@3x.png deleted file mode 100644 index 093172896..000000000 Binary files a/src-tauri/icons/ios/AppIcon-40x40@3x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-512@2x.png b/src-tauri/icons/ios/AppIcon-512@2x.png deleted file mode 100644 index 6eae216be..000000000 Binary files a/src-tauri/icons/ios/AppIcon-512@2x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@2x.png b/src-tauri/icons/ios/AppIcon-60x60@2x.png deleted file mode 100644 index 093172896..000000000 Binary files a/src-tauri/icons/ios/AppIcon-60x60@2x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@3x.png b/src-tauri/icons/ios/AppIcon-60x60@3x.png deleted file mode 100644 index 28fe7d4d7..000000000 Binary files a/src-tauri/icons/ios/AppIcon-60x60@3x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@1x.png b/src-tauri/icons/ios/AppIcon-76x76@1x.png deleted file mode 100644 index df9bbd19b..000000000 Binary files a/src-tauri/icons/ios/AppIcon-76x76@1x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@2x.png b/src-tauri/icons/ios/AppIcon-76x76@2x.png deleted file mode 100644 index 4bd805e82..000000000 Binary files a/src-tauri/icons/ios/AppIcon-76x76@2x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png deleted file mode 100644 index 56520a6b5..000000000 Binary files a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png and /dev/null differ diff --git a/src-tauri/rustfmt.toml b/src-tauri/rustfmt.toml deleted file mode 100644 index 75b776579..000000000 --- a/src-tauri/rustfmt.toml +++ /dev/null @@ -1,6 +0,0 @@ -max_width = 120 -edition = "2018" -format_code_in_doc_comments = true -format_strings = false -imports_granularity = "Crate" -group_imports = "StdExternalCrate" diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs deleted file mode 100644 index d19ae7fd8..000000000 --- a/src-tauri/src/main.rs +++ /dev/null @@ -1,603 +0,0 @@ -// Prevents additional console window on Windows in release, DO NOT REMOVE!! -#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] - -pub(crate) mod state; - -use std::{ - env, - path::{Path, PathBuf}, -}; - -use anyhow::Result; -use kcl_lib::settings::types::{ - file::{FileEntry, Project, ProjectRoute, ProjectState}, - project::ProjectConfiguration, - Configuration, -}; -use oauth2::TokenResponse; -use tauri::{ipc::InvokeError, Manager}; -use tauri_plugin_cli::CliExt; -use tauri_plugin_shell::ShellExt; - -const DEFAULT_HOST: &str = "https://api.zoo.dev"; -const SETTINGS_FILE_NAME: &str = "settings.toml"; -const PROJECT_SETTINGS_FILE_NAME: &str = "project.toml"; -const PROJECT_FOLDER: &str = "zoo-modeling-app-projects"; - -#[tauri::command] -async fn rename_project_directory(project_path: &str, new_name: &str) -> Result { - let project_dir = std::path::Path::new(project_path); - - kcl_lib::settings::types::file::rename_project_directory(project_dir, new_name) - .await - .map_err(InvokeError::from_anyhow) -} - -#[tauri::command] -fn get_initial_default_dir(app: tauri::AppHandle) -> Result { - let dir = match app.path().document_dir() { - Ok(dir) => dir, - Err(_) => { - // for headless Linux (eg. Github Actions) - let home_dir = app.path().home_dir()?; - home_dir.join("Documents") - } - }; - - Ok(dir.join(PROJECT_FOLDER)) -} - -#[tauri::command] -async fn get_state(app: tauri::AppHandle) -> Result, InvokeError> { - let store = app.state::(); - Ok(store.get().await) -} - -#[tauri::command] -async fn set_state(app: tauri::AppHandle, state: Option) -> Result<(), InvokeError> { - let store = app.state::(); - store.set(state).await; - Ok(()) -} - -async fn get_app_settings_file_path(app: &tauri::AppHandle) -> Result { - let app_config_dir = app.path().app_config_dir()?; - - // Ensure this directory exists. - if !app_config_dir.exists() { - tokio::fs::create_dir_all(&app_config_dir) - .await - .map_err(|e| InvokeError::from_anyhow(e.into()))?; - } - - Ok(app_config_dir.join(SETTINGS_FILE_NAME)) -} - -#[tauri::command] -async fn read_app_settings_file(app: tauri::AppHandle) -> Result { - let mut settings_path = get_app_settings_file_path(&app).await?; - let mut needs_migration = false; - - // Check if this file exists. - if !settings_path.exists() { - // Try the backwards compatible path. - // TODO: Remove this after a few releases. - let app_config_dir = app.path().app_config_dir()?; - settings_path = format!( - "{}user.toml", - app_config_dir.display().to_string().trim_end_matches('/') - ) - .into(); - needs_migration = true; - // Check if this path exists. - if !settings_path.exists() { - let mut default = Configuration::default(); - default.settings.project.directory = get_initial_default_dir(app.clone())?; - // Return the default configuration. - return Ok(default); - } - } - - let contents = tokio::fs::read_to_string(&settings_path) - .await - .map_err(|e| InvokeError::from_anyhow(e.into()))?; - let mut parsed = Configuration::backwards_compatible_toml_parse(&contents).map_err(InvokeError::from_anyhow)?; - if parsed.settings.project.directory == PathBuf::new() { - parsed.settings.project.directory = get_initial_default_dir(app.clone())?; - } - - // TODO: Remove this after a few releases. - if needs_migration { - write_app_settings_file(app, parsed.clone()).await?; - // Delete the old file. - tokio::fs::remove_file(settings_path) - .await - .map_err(|e| InvokeError::from_anyhow(e.into()))?; - } - - Ok(parsed) -} - -#[tauri::command] -async fn write_app_settings_file(app: tauri::AppHandle, configuration: Configuration) -> Result<(), InvokeError> { - let settings_path = get_app_settings_file_path(&app).await?; - let contents = toml::to_string_pretty(&configuration).map_err(|e| InvokeError::from_anyhow(e.into()))?; - tokio::fs::write(settings_path, contents.as_bytes()) - .await - .map_err(|e| InvokeError::from_anyhow(e.into()))?; - - Ok(()) -} - -async fn get_project_settings_file_path(project_path: &str) -> Result { - let project_dir = std::path::Path::new(project_path); - - if !project_dir.exists() { - tokio::fs::create_dir_all(&project_dir) - .await - .map_err(|e| InvokeError::from_anyhow(e.into()))?; - } - - Ok(project_dir.join(PROJECT_SETTINGS_FILE_NAME)) -} - -#[tauri::command] -async fn read_project_settings_file(project_path: &str) -> Result { - let settings_path = get_project_settings_file_path(project_path).await?; - - // Check if this file exists. - if !settings_path.exists() { - // Return the default configuration. - return Ok(ProjectConfiguration::default()); - } - - let contents = tokio::fs::read_to_string(&settings_path) - .await - .map_err(|e| InvokeError::from_anyhow(e.into()))?; - let parsed = ProjectConfiguration::backwards_compatible_toml_parse(&contents).map_err(InvokeError::from_anyhow)?; - - Ok(parsed) -} - -#[tauri::command] -async fn write_project_settings_file( - project_path: &str, - configuration: ProjectConfiguration, -) -> Result<(), InvokeError> { - let settings_path = get_project_settings_file_path(project_path).await?; - let contents = toml::to_string_pretty(&configuration).map_err(|e| InvokeError::from_anyhow(e.into()))?; - tokio::fs::write(settings_path, contents.as_bytes()) - .await - .map_err(|e| InvokeError::from_anyhow(e.into()))?; - - Ok(()) -} - -/// Initialize the directory that holds all the projects. -#[tauri::command] -async fn initialize_project_directory(configuration: Configuration) -> Result { - configuration - .ensure_project_directory_exists() - .await - .map_err(InvokeError::from_anyhow) -} - -/// Create a new project directory. -#[tauri::command] -async fn create_new_project_directory( - configuration: Configuration, - project_name: &str, - initial_code: Option<&str>, -) -> Result { - configuration - .create_new_project_directory(project_name, initial_code) - .await - .map_err(InvokeError::from_anyhow) -} - -/// List all the projects in the project directory. -#[tauri::command] -async fn list_projects(configuration: Configuration) -> Result, InvokeError> { - configuration.list_projects().await.map_err(InvokeError::from_anyhow) -} - -/// Get information about a project. -#[tauri::command] -async fn get_project_info(configuration: Configuration, project_path: &str) -> Result { - configuration - .get_project_info(project_path) - .await - .map_err(InvokeError::from_anyhow) -} - -/// Parse the project route. -#[tauri::command] -async fn parse_project_route(configuration: Configuration, route: &str) -> Result { - ProjectRoute::from_route(&configuration, route).map_err(InvokeError::from_anyhow) -} - -#[tauri::command] -async fn read_dir_recursive(path: &str) -> Result { - kcl_lib::settings::utils::walk_dir(Path::new(path).to_path_buf()) - .await - .map_err(InvokeError::from_anyhow) -} - -/// This command instantiates a new window with auth. -/// The string returned from this method is the access token. -#[tauri::command] -async fn login(app: tauri::AppHandle, host: &str) -> Result { - log::debug!("Logging in..."); - // Do an OAuth 2.0 Device Authorization Grant dance to get a token. - let device_auth_url = oauth2::DeviceAuthorizationUrl::new(format!("{host}/oauth2/device/auth")) - .map_err(|e| InvokeError::from_anyhow(e.into()))?; - // We can hardcode the client ID. - // This value is safe to be embedded in version control. - // This is the client ID of the KittyCAD app. - let client_id = "2af127fb-e14e-400a-9c57-a9ed08d1a5b7".to_string(); - let auth_client = oauth2::basic::BasicClient::new( - oauth2::ClientId::new(client_id), - None, - oauth2::AuthUrl::new(format!("{host}/authorize")).map_err(|e| InvokeError::from_anyhow(e.into()))?, - Some( - oauth2::TokenUrl::new(format!("{host}/oauth2/device/token")) - .map_err(|e| InvokeError::from_anyhow(e.into()))?, - ), - ) - .set_auth_type(oauth2::AuthType::RequestBody) - .set_device_authorization_url(device_auth_url); - - let details: oauth2::devicecode::StandardDeviceAuthorizationResponse = auth_client - .exchange_device_code() - .map_err(|e| InvokeError::from_anyhow(e.into()))? - .request_async(oauth2::reqwest::async_http_client) - .await - .map_err(|e| InvokeError::from_anyhow(e.into()))?; - - let Some(auth_uri) = details.verification_uri_complete() else { - return Err(InvokeError::from("getting the verification uri failed")); - }; - - // Open the system browser with the auth_uri. - // We do this in the browser and not a separate window because we want 1password and - // other crap to work well. - // TODO: find a better way to share this value with tauri e2e tests - // Here we're using an env var to enable the /tmp file (windows not supported for now) - // and bypass the shell::open call as it fails on GitHub Actions. - let e2e_tauri_enabled = env::var("E2E_TAURI_ENABLED").is_ok(); - if e2e_tauri_enabled { - log::warn!("E2E_TAURI_ENABLED is set, won't open {} externally", auth_uri.secret()); - let mut temp = String::from("/tmp"); - // Overwrite with Windows variable - match env::var("TEMP") { - Ok(val) => temp = val, - Err(_e) => println!("Fallback to default /tmp"), - } - let path = Path::new(&temp).join("kittycad_user_code"); - println!("Writing to {}", path.to_string_lossy()); - tokio::fs::write(path, details.user_code().secret()) - .await - .map_err(|e| InvokeError::from_anyhow(e.into()))?; - } else { - app.shell() - .open(auth_uri.secret(), None) - .map_err(|e| InvokeError::from_anyhow(e.into()))?; - } - - // Wait for the user to login. - let token = auth_client - .exchange_device_access_token(&details) - .request_async(oauth2::reqwest::async_http_client, tokio::time::sleep, None) - .await - .map_err(|e| InvokeError::from_anyhow(e.into()))? - .access_token() - .secret() - .to_string(); - - Ok(token) -} - -///This command returns the KittyCAD user info given a token. -/// The string returned from this method is the user info as a json string. -#[tauri::command] -async fn get_user(token: &str, hostname: &str) -> Result { - // Use the host passed in if it's set. - // Otherwise, use the default host. - let host = if hostname.is_empty() { - DEFAULT_HOST.to_string() - } else { - hostname.to_string() - }; - - // Change the baseURL to the one we want. - let mut baseurl = host.to_string(); - if !host.starts_with("http://") && !host.starts_with("https://") { - baseurl = format!("https://{host}"); - if host.starts_with("localhost") { - baseurl = format!("http://{host}") - } - } - log::debug!("Getting user info..."); - - // use kittycad library to fetch the user info from /user/me - let mut client = kittycad::Client::new(token); - - if baseurl != DEFAULT_HOST { - client.set_base_url(&baseurl); - } - - let user_info: kittycad::types::User = client - .users() - .get_self() - .await - .map_err(|e| InvokeError::from_anyhow(e.into()))?; - - Ok(user_info) -} - -/// Open the selected path in the system file manager. -/// From this GitHub comment: https://github.com/tauri-apps/tauri/issues/4062#issuecomment-1338048169 -/// But with the Linux support removed since we don't need it for now. -#[tauri::command] -fn show_in_folder(app: tauri::AppHandle, path: &str) -> Result<(), InvokeError> { - // Check if the file exists. - // If it doesn't, return an error. - if !Path::new(path).exists() { - return Err(InvokeError::from_anyhow(anyhow::anyhow!( - "The file `{}` does not exist", - path - ))); - } - - #[cfg(not(unix))] - { - app.shell() - .command("explorer") - .args(["/select,", path]) // The comma after select is not a typo - .spawn() - .map_err(|e| InvokeError::from_anyhow(e.into()))?; - } - - #[cfg(unix)] - { - app.shell() - .command("open") - .args(["-R", path]) - .spawn() - .map_err(|e| InvokeError::from_anyhow(e.into()))?; - } - - Ok(()) -} - -const SERVICE_NAME: &str = "_machine-api._tcp.local."; - -async fn find_machine_api() -> Result> { - println!("Looking for machine API..."); - // Timeout if no response is received after 5 seconds. - let timeout_duration = std::time::Duration::from_secs(5); - - let mdns = mdns_sd::ServiceDaemon::new()?; - - // Browse for a service type. - let receiver = mdns.browse(SERVICE_NAME)?; - let resp = tokio::time::timeout( - timeout_duration, - tokio::spawn(async move { - while let Ok(event) = receiver.recv() { - if let mdns_sd::ServiceEvent::ServiceResolved(info) = event { - if let Some(addr) = info.get_addresses().iter().next() { - return Some(format!("{}:{}", addr, info.get_port())); - } - } - } - - None - }), - ) - .await; - - // Shut down. - mdns.shutdown()?; - - let Ok(Ok(Some(addr))) = resp else { - return Ok(None); - }; - - Ok(Some(addr)) -} - -#[tauri::command] -async fn get_machine_api_ip() -> Result, InvokeError> { - let machine_api = find_machine_api().await.map_err(InvokeError::from_anyhow)?; - - Ok(machine_api) -} - -#[tauri::command] -async fn list_machines() -> Result { - let machine_api = find_machine_api().await.map_err(InvokeError::from_anyhow)?; - - let Some(machine_api) = machine_api else { - // Empty array. - return Ok("[]".to_string()); - }; - - let client = reqwest::Client::new(); - let response = client - .get(format!("http://{}/machines", machine_api)) - .send() - .await - .map_err(|e| InvokeError::from_anyhow(e.into()))?; - - let text = response.text().await.map_err(|e| InvokeError::from_anyhow(e.into()))?; - Ok(text) -} - -#[allow(dead_code)] -fn open_url_sync(app: &tauri::AppHandle, url: &url::Url) { - log::debug!("Opening URL: {:?}", url); - let cloned_url = url.clone(); - let runner: tauri::async_runtime::JoinHandle> = tauri::async_runtime::spawn(async move { - let url_str = cloned_url.path().to_string(); - - log::debug!("Opening URL path : {}", url_str); - let path = Path::new(url_str.as_str()); - ProjectState::new_from_path(path.to_path_buf()).await - }); - - // Block on the handle. - match tauri::async_runtime::block_on(runner) { - Ok(Ok(store)) => { - // Create a state object to hold the project. - app.manage(state::Store::new(store)); - } - Err(e) => { - log::warn!("Error opening URL:{} {:?}", url, e); - } - Ok(Err(e)) => { - log::warn!("Error opening URL:{} {:?}", url, e); - } - } -} - -fn main() -> Result<()> { - tauri::Builder::default() - .invoke_handler(tauri::generate_handler![ - get_state, - set_state, - get_initial_default_dir, - initialize_project_directory, - create_new_project_directory, - list_projects, - get_project_info, - parse_project_route, - get_user, - login, - read_dir_recursive, - show_in_folder, - read_app_settings_file, - write_app_settings_file, - read_project_settings_file, - write_project_settings_file, - rename_project_directory, - get_machine_api_ip, - list_machines - ]) - .plugin(tauri_plugin_cli::init()) - .plugin(tauri_plugin_deep_link::init()) - .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_fs::init()) - .plugin(tauri_plugin_http::init()) - .plugin( - tauri_plugin_log::Builder::new() - .targets([ - tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout), - tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::LogDir { file_name: None }), - ]) - .level(log::LevelFilter::Debug) - .build(), - ) - .plugin(tauri_plugin_os::init()) - .plugin(tauri_plugin_persisted_scope::init()) - .plugin(tauri_plugin_process::init()) - .plugin(tauri_plugin_shell::init()) - .setup(|app| { - // Do update things. - #[cfg(debug_assertions)] - { - app.get_webview("main").unwrap().open_devtools(); - } - #[cfg(not(debug_assertions))] - #[cfg(feature = "updater")] - { - app.handle().plugin(tauri_plugin_updater::Builder::new().build())?; - } - - let mut verbose = false; - let mut source_path: Option = None; - match app.cli().matches() { - // `matches` here is a Struct with { args, subcommand }. - // `args` is `HashMap` where `ArgData` is a struct with { value, occurrences }. - // `subcommand` is `Option>` where `SubcommandMatches` is a struct with { name, matches }. - Ok(matches) => { - if let Some(verbose_flag) = matches.args.get("verbose") { - let Some(value) = verbose_flag.value.as_bool() else { - return Err( - anyhow::anyhow!("Error parsing CLI arguments: verbose flag is not a boolean").into(), - ); - }; - verbose = value; - } - - // Get the path we are trying to open. - if let Some(source_arg) = matches.args.get("source") { - // We don't do an else here because this can be null. - if let Some(value) = source_arg.value.as_str() { - log::info!("Got path in cli argument: {}", value); - source_path = Some(Path::new(value).to_path_buf()); - } - } - } - Err(err) => { - return Err(anyhow::anyhow!("Error parsing CLI arguments: {:?}", err).into()); - } - } - - if verbose { - log::debug!("Verbose mode enabled."); - } - - // If we have a source path to open, make sure it exists. - let Some(source_path) = source_path else { - // The user didn't provide a source path to open. - // Run the app as normal. - app.manage(state::Store::default()); - return Ok(()); - }; - - if !source_path.exists() { - return Err(anyhow::anyhow!( - "Error: the path `{}` you are trying to open does not exist", - source_path.display() - ) - .into()); - } - - let runner: tauri::async_runtime::JoinHandle> = - tauri::async_runtime::spawn(async move { ProjectState::new_from_path(source_path).await }); - - // Block on the handle. - let store = tauri::async_runtime::block_on(runner)??; - - // Create a state object to hold the project. - app.manage(state::Store::new(store)); - - // Listen on the deep links. - app.listen("deep-link://new-url", |event| { - log::info!("got deep-link url: {:?}", event); - // TODO: open_url_sync(app.handle(), event.url); - }); - - Ok(()) - }) - .build(tauri::generate_context!())? - .run( - #[allow(unused_variables)] - |app, event| { - #[cfg(any(target_os = "macos", target_os = "ios"))] - if let tauri::RunEvent::Opened { urls } = event { - log::info!("Opened URLs: {:?}", urls); - - // Handle the first URL. - // TODO: do we want to handle more than one URL? - // Under what conditions would we even have more than one? - if let Some(url) = urls.first() { - open_url_sync(app, url); - } - } - }, - ); - - Ok(()) -} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs deleted file mode 100644 index 310f262ea..000000000 --- a/src-tauri/src/state.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! State management for the application. - -use kcl_lib::settings::types::file::ProjectState; -use tokio::sync::Mutex; - -#[derive(Debug, Default)] -pub struct Store(Mutex>); - -impl Store { - pub fn new(p: ProjectState) -> Self { - Self(Mutex::new(Some(p))) - } - - pub async fn get(&self) -> Option { - self.0.lock().await.clone() - } - - pub async fn set(&self, p: Option) { - *self.0.lock().await = p; - } -} diff --git a/src-tauri/tauri.app-store.conf.json b/src-tauri/tauri.app-store.conf.json deleted file mode 100644 index 422f7978c..000000000 --- a/src-tauri/tauri.app-store.conf.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$schema": "../node_modules/@tauri-apps/cli/schema.json", - "bundle": { - "macOS": { - "entitlements": "entitlements/app-store.entitlements" - } - } -} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json deleted file mode 100644 index b7dda9cc3..000000000 --- a/src-tauri/tauri.conf.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "$schema": "../node_modules/@tauri-apps/cli/schema.json", - "app": { - "security": { - "csp": null - }, - "windows": [ - { - "fullscreen": false, - "height": 1200, - "resizable": true, - "title": "Zoo Modeling App", - "width": 1800 - } - ] - }, - "build": { - "beforeDevCommand": "yarn start", - "devUrl": "http://localhost:3000", - "frontendDist": "../build" - }, - "bundle": { - "active": true, - "category": "DeveloperTool", - "copyright": "", - "externalBin": [], - "icon": [ - "icons/32x32.png", - "icons/128x128.png", - "icons/128x128@2x.png", - "icons/icon.icns", - "icons/icon.ico" - ], - "linux": { - "deb": { - "depends": [] - } - }, - "longDescription": "", - "macOS": {}, - "resources": [], - "shortDescription": "", - "targets": "all" - }, - "identifier": "dev.zoo.modeling-app", - "plugins": { - "cli": { - "description": "Zoo Modeling App CLI", - "args": [ - { - "short": "v", - "name": "verbose", - "description": "Verbosity level" - }, - { - "name": "source", - "description": "The file or directory to open", - "required": false, - "index": 1, - "takesValue": true - } - ], - "subcommands": {} - }, - "deep-link": { - "mobile": [ - { - "host": "app.zoo.dev" - } - ], - "desktop": { - "schemes": [ - "zoo", - "zoo-modeling-app" - ] - } - }, - "shell": { - "open": true - } - }, - "productName": "Zoo Modeling App", - "version": "0.24.10" -} diff --git a/src-tauri/tauri.release.conf.json b/src-tauri/tauri.release.conf.json deleted file mode 100644 index 7d88d3201..000000000 --- a/src-tauri/tauri.release.conf.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "$schema": "../node_modules/@tauri-apps/cli/schema.json", - "bundle": { - "windows": { - "certificateThumbprint": "F4C9A52FF7BC26EE5E054946F6B11DEEA94C748D", - "digestAlgorithm": "sha256", - "timestampUrl": "http://timestamp.digicert.com" - }, - "fileAssociations": [ - { - "ext": ["kcl"], - "mimeType": "text/vnd.zoo.kcl" - }, - { - "ext": ["obj"], - "mimeType": "model/obj" - }, - { - "ext": ["gltf"], - "mimeType": "model/gltf+json" - }, - { - "ext": ["glb"], - "mimeType": "model/gltf+binary" - }, - { - "ext": ["fbx", "fbxb"], - "mimeType": "model/fbx" - }, - { - "ext": ["stl"], - "mimeType": "model/stl" - }, - { - "ext": ["ply"], - "mimeType": "model/ply" - }, - { - "ext": ["step", "stp"], - "mimeType": "model/step" - }, - { - "ext": ["sldprt"], - "mimeType": "model/sldprt" - } - ] - }, - "plugins": { - "updater": { - "active": true, - "endpoints": [ - "https://dl.zoo.dev/releases/modeling-app/last_update.json" - ], - "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEUzNzA4MjBEQjFBRTY4NzYKUldSMmFLNnhEWUp3NCtsT21Jd05wQktOaGVkOVp6MUFma0hNTDRDSnI2RkJJTEZOWG1ncFhqcU8K" - } - } -} diff --git a/src/App.tsx b/src/App.tsx index 9bbc9a490..d54f56040 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,7 +16,7 @@ import { useEngineConnectionSubscriptions } from 'hooks/useEngineConnectionSubsc import { engineCommandManager } from 'lib/singletons' import { useModelingContext } from 'hooks/useModelingContext' import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath' -import { isTauri } from 'lib/isTauri' +import { isDesktop } from 'lib/isDesktop' import { useLspContext } from 'components/LspProvider' import { useRefreshSettings } from 'hooks/useRefreshSettings' import { ModelingSidebar } from 'components/ModelingSidebar/ModelingSidebar' @@ -28,8 +28,8 @@ import { CoreDumpManager } from 'lib/coredump' import { UnitsMenu } from 'components/UnitsMenu' export function App() { - useRefreshSettings(PATHS.FILE + 'SETTINGS') const { project, file } = useLoaderData() as IndexLoaderData + useRefreshSettings(PATHS.FILE + 'SETTINGS') const navigate = useNavigate() const filePath = useAbsoluteFilePath() const { onProjectOpen } = useLspContext() @@ -62,7 +62,7 @@ export function App() { e.preventDefault() }) useHotkeyWrapper( - [isTauri() ? 'mod + ,' : 'shift + mod + ,'], + [isDesktop() ? 'mod + ,' : 'shift + mod + ,'], () => navigate(filePath + PATHS.SETTINGS), { splitKey: '|', diff --git a/src/Router.tsx b/src/Router.tsx index ab5b98c80..be8555bf9 100644 --- a/src/Router.tsx +++ b/src/Router.tsx @@ -10,7 +10,7 @@ import { Settings } from './routes/Settings' import Onboarding, { onboardingRoutes } from './routes/Onboarding' import SignIn from './routes/SignIn' import { Auth } from './Auth' -import { isTauri } from './lib/isTauri' +import { isDesktop } from './lib/isDesktop' import Home from './routes/Home' import { NetworkContext } from './hooks/useNetworkContext' import { useNetworkStatus } from './hooks/useNetworkStatus' @@ -32,7 +32,7 @@ import SettingsAuthProvider from 'components/SettingsAuthProvider' import LspProvider from 'components/LspProvider' import { KclContextProvider } from 'lang/KclProvider' import { BROWSER_PROJECT_NAME } from 'lib/constants' -import { getState, setState } from 'lib/tauri' +import { getState, setState } from 'lib/desktop' import { CoreDumpManager } from 'lib/coredump' import { engineCommandManager } from 'lib/singletons' import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' @@ -66,8 +66,8 @@ const router = createBrowserRouter([ { path: PATHS.INDEX, loader: async () => { - const inTauri = isTauri() - if (inTauri) { + const onDesktop = isDesktop() + if (onDesktop) { const appState = await getState() if (appState) { @@ -84,7 +84,7 @@ const router = createBrowserRouter([ } } - return inTauri + return onDesktop ? redirect(PATHS.HOME) : redirect(PATHS.FILE + '/%2F' + BROWSER_PROJECT_NAME) }, @@ -101,7 +101,10 @@ const router = createBrowserRouter([ - {!isTauri() && import.meta.env.PROD && } + { + // @ts-ignore + !isDesktop() && import.meta.env.PROD && + } diff --git a/src/clientSideScene/sceneEntities.ts b/src/clientSideScene/sceneEntities.ts index dce10916f..e454c3297 100644 --- a/src/clientSideScene/sceneEntities.ts +++ b/src/clientSideScene/sceneEntities.ts @@ -591,7 +591,7 @@ export class SceneEntities { const sg = kclManager.programMemory.get( variableDeclarationName ) as SketchGroup - const lastSeg = sg.value.slice(-1)[0] || sg.start + const lastSeg = sg?.value?.slice(-1)[0] || sg.start const index = sg.value.length // because we've added a new segment that's not in the memory yet, no need for `-1` diff --git a/src/components/ActionButton.tsx b/src/components/ActionButton.tsx index eed2d00ba..25854fb02 100644 --- a/src/components/ActionButton.tsx +++ b/src/components/ActionButton.tsx @@ -1,4 +1,5 @@ import { ActionIcon, ActionIconProps } from './ActionIcon' +import { openExternalBrowserIfDesktop } from 'lib/openWindow' import React, { ForwardedRef, forwardRef } from 'react' import { PATHS } from 'lib/paths' import { Link } from 'react-router-dom' @@ -107,6 +108,7 @@ export const ActionButton = forwardRef((props: ActionButtonProps, ref) => { ref={ref as ForwardedRef} to={to || PATHS.INDEX} className={classNames} + onClick={openExternalBrowserIfDesktop(to as string)} {...rest} target="_blank" > diff --git a/src/components/ErrorPage.tsx b/src/components/ErrorPage.tsx index 52d2185c6..a95643de2 100644 --- a/src/components/ErrorPage.tsx +++ b/src/components/ErrorPage.tsx @@ -1,4 +1,4 @@ -import { isTauri } from 'lib/isTauri' +import { isDesktop } from 'lib/isDesktop' import { useRouteError, isRouteErrorResponse } from 'react-router-dom' import { ActionButton } from './ActionButton' import { @@ -25,7 +25,7 @@ export const ErrorPage = () => {

)}
- {isTauri() && ( + {isDesktop() && ( = { state: StateFrom @@ -51,7 +49,9 @@ export const FileMachineProvider = ({ commandBarSend({ type: 'Close' }) navigate( `${PATHS.FILE}/${encodeURIComponent( - context.selectedDirectory + sep() + event.data.name + context.selectedDirectory + + window.electron.path.sep + + event.data.name )}` ) } else if ( @@ -86,7 +86,7 @@ export const FileMachineProvider = ({ }, services: { readFiles: async (context: ContextFrom) => { - const newFiles = isTauri() + const newFiles = isDesktop() ? (await getProjectInfo(context.project.path)).children : [] return { @@ -99,15 +99,18 @@ export const FileMachineProvider = ({ let createdPath: string if (event.data.makeDir) { - createdPath = await join(context.selectedDirectory.path, createdName) - await mkdir(createdPath) + createdPath = window.electron.path.join( + context.selectedDirectory.path, + createdName + ) + await window.electron.mkdir(createdPath) } else { createdPath = context.selectedDirectory.path + - sep() + + window.electron.path.sep + createdName + (createdName.endsWith(FILE_EXT) ? '' : FILE_EXT) - await create(createdPath) + await window.electron.writeFile(createdPath, '') } return { @@ -121,14 +124,25 @@ export const FileMachineProvider = ({ ) => { const { oldName, newName, isDir } = event.data const name = newName ? newName : DEFAULT_FILE_NAME - const oldPath = await join(context.selectedDirectory.path, oldName) - const newDirPath = await join(context.selectedDirectory.path, name) + const oldPath = window.electron.path.join( + context.selectedDirectory.path, + oldName + ) + const newDirPath = window.electron.path.join( + context.selectedDirectory.path, + name + ) const newPath = newDirPath + (name.endsWith(FILE_EXT) || isDir ? '' : FILE_EXT) - await rename(oldPath, newPath, {}) + await window.electron.rename(oldPath, newPath) - if (oldPath === file?.path && project?.path) { + if (!file) { + return Promise.reject(new Error('file is not defined')) + } + + const currentFilePath = window.electron.path.join(file.path, file.name) + if (oldPath === currentFilePath && project?.path) { // If we just renamed the current file, navigate to the new path navigate(PATHS.FILE + '/' + encodeURIComponent(newPath)) } else if (file?.path.includes(oldPath)) { @@ -153,13 +167,15 @@ export const FileMachineProvider = ({ const isDir = !!event.data.children if (isDir) { - await remove(event.data.path, { - recursive: true, - }).catch((e) => console.error('Error deleting directory', e)) + await window.electron + .rm(event.data.path, { + recursive: true, + }) + .catch((e) => console.error('Error deleting directory', e)) } else { - await remove(event.data.path).catch((e) => - console.error('Error deleting file', e) - ) + await window.electron + .rm(event.data.path) + .catch((e) => console.error('Error deleting file', e)) } // If we just deleted the current file or one of its parent directories, diff --git a/src/components/FileTree.tsx b/src/components/FileTree.tsx index f8ba9da0b..69221a0f3 100644 --- a/src/components/FileTree.tsx +++ b/src/components/FileTree.tsx @@ -9,7 +9,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faChevronRight } from '@fortawesome/free-solid-svg-icons' import { useFileContext } from 'hooks/useFileContext' import styles from './FileTree.module.css' -import { sortProject } from 'lib/tauriFS' +import { sortProject } from 'lib/desktopFS' import { FILE_EXT } from 'lib/constants' import { CustomIcon } from './CustomIcon' import { codeManager, kclManager } from 'lib/singletons' @@ -44,7 +44,7 @@ function RenameForm({ data: { oldName: fileOrDir.name || '', newName: inputRef.current?.value || fileOrDir.name || '', - isDir: fileOrDir.children !== undefined, + isDir: fileOrDir.children !== null, }, }) } @@ -90,7 +90,7 @@ function DeleteFileTreeItemDialog({ const { send } = useFileContext() return ( setIsOpen(false)} onConfirm={() => { send({ type: 'Delete file', data: fileOrDir }) @@ -99,7 +99,7 @@ function DeleteFileTreeItemDialog({ >

This will permanently delete "{fileOrDir.name || 'this file'}" - {fileOrDir.children !== undefined ? ' and all of its contents. ' : '. '} + {fileOrDir.children !== null ? ' and all of its contents. ' : '. '}

Are you sure you want to delete "{fileOrDir.name || 'this file'} @@ -165,7 +165,7 @@ const FileTreeItem = ({ } function handleClick() { - if (fileOrDir.children !== undefined) return // Don't open directories + if (fileOrDir.children !== null) return // Don't open directories if (fileOrDir.name?.endsWith(FILE_EXT) === false && project?.path) { // Import non-kcl files @@ -194,7 +194,7 @@ const FileTreeItem = ({ return (

- {fileOrDir.children === undefined ? ( + {fileOrDir.children === null ? (
  • (
    @@ -141,6 +142,9 @@ function HelpMenuItem({ {as === 'a' ? ( )} + onClick={openExternalBrowserIfDesktop( + (props as React.ComponentProps<'a'>).href + )} className={`no-underline text-inherit ${baseClassName} ${className}`} > {children} diff --git a/src/components/LowerRightControls.tsx b/src/components/LowerRightControls.tsx index a72793f94..e7d960e13 100644 --- a/src/components/LowerRightControls.tsx +++ b/src/components/LowerRightControls.tsx @@ -26,8 +26,12 @@ export function LowerRightControls({ const isPlayWright = window?.localStorage.getItem('playwright') === 'true' - async function reportbug(event: { preventDefault: () => void }) { + async function reportbug(event: { + preventDefault: () => void + stopPropagation: () => void + }) { event?.preventDefault() + event?.stopPropagation() if (!coreDumpManager) { // open default reporting option @@ -88,7 +92,7 @@ export function LowerRightControls({ diff --git a/src/components/LspProvider.tsx b/src/components/LspProvider.tsx index 5172869bc..345e80472 100644 --- a/src/components/LspProvider.tsx +++ b/src/components/LspProvider.tsx @@ -25,7 +25,7 @@ import { import { wasmUrl } from 'lang/wasm' import { PROJECT_ENTRYPOINT } from 'lib/constants' import { err } from 'lib/trap' -import { isTauri } from 'lib/isTauri' +import { isDesktop } from 'lib/isDesktop' import { codeManager } from 'lib/singletons' function getWorkspaceFolders(): LSP.WorkspaceFolder[] { @@ -125,7 +125,7 @@ export const LspProvider = ({ children }: { children: React.ReactNode }) => { ]) useMemo(() => { - if (!isTauri() && isKclLspReady && kclLspClient && codeManager.code) { + if (!isDesktop() && isKclLspReady && kclLspClient && codeManager.code) { kclLspClient.textDocumentDidOpen({ textDocument: { uri: `file:///${PROJECT_ENTRYPOINT}`, diff --git a/src/components/ModelingSidebar/ModelingPanes/KclEditorMenu.tsx b/src/components/ModelingSidebar/ModelingPanes/KclEditorMenu.tsx index 7a2093c4a..fc46f7213 100644 --- a/src/components/ModelingSidebar/ModelingPanes/KclEditorMenu.tsx +++ b/src/components/ModelingSidebar/ModelingPanes/KclEditorMenu.tsx @@ -7,6 +7,7 @@ import { useConvertToVariable } from 'hooks/useToolbarGuards' import { editorShortcutMeta } from './KclEditorPane' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { kclManager } from 'lib/singletons' +import { openExternalBrowserIfDesktop } from 'lib/openWindow' export const KclEditorMenu = ({ children }: PropsWithChildren) => { const { enable: convertToVarEnabled, handleClick: handleConvertToVarClick } = @@ -60,6 +61,7 @@ export const KclEditorMenu = ({ children }: PropsWithChildren) => { href="https://zoo.dev/docs/kcl" target="_blank" rel="noopener noreferrer" + onClick={openExternalBrowserIfDesktop()} > Read the KCL docs @@ -78,6 +80,7 @@ export const KclEditorMenu = ({ children }: PropsWithChildren) => { href="https://zoo.dev/docs/kcl-samples" target="_blank" rel="noopener noreferrer" + onClick={openExternalBrowserIfDesktop()} > KCL samples diff --git a/src/components/ModelingSidebar/ModelingSidebar.tsx b/src/components/ModelingSidebar/ModelingSidebar.tsx index a4699112c..8ac57ce72 100644 --- a/src/components/ModelingSidebar/ModelingSidebar.tsx +++ b/src/components/ModelingSidebar/ModelingSidebar.tsx @@ -7,7 +7,7 @@ import Tooltip from 'components/Tooltip' import { ActionIcon } from 'components/ActionIcon' import styles from './ModelingSidebar.module.css' import { ModelingPane } from './ModelingPane' -import { isTauri } from 'lib/isTauri' +import { isDesktop } from 'lib/isDesktop' import { useModelingContext } from 'hooks/useModelingContext' import { CustomIconName } from 'components/CustomIcon' import { useCommandsContext } from 'hooks/useCommandsContext' @@ -71,7 +71,7 @@ export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) { (action) => (!action.hide || (action.hide instanceof Function && !action.hide())) && (!action.hideOnPlatform || - (isTauri() + (isDesktop() ? action.hideOnPlatform === 'web' : action.hideOnPlatform === 'desktop')) ) @@ -86,7 +86,7 @@ export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) { ).filter( (pane) => !pane.hideOnPlatform || - (isTauri() + (isDesktop() ? pane.hideOnPlatform === 'web' : pane.hideOnPlatform === 'desktop') ), diff --git a/src/components/NetworkMachineIndicator.tsx b/src/components/NetworkMachineIndicator.tsx index b0003d4e0..b01401ebb 100644 --- a/src/components/NetworkMachineIndicator.tsx +++ b/src/components/NetworkMachineIndicator.tsx @@ -1,7 +1,7 @@ import { Popover } from '@headlessui/react' import Tooltip from './Tooltip' import { machineManager } from 'lib/machineManager' -import { isTauri } from 'lib/isTauri' +import { isDesktop } from 'lib/isDesktop' import { CustomIcon } from './CustomIcon' export const NetworkMachineIndicator = ({ @@ -10,7 +10,7 @@ export const NetworkMachineIndicator = ({ className?: string }) => { const machineCount = Object.keys(machineManager.machines).length - return isTauri() ? ( + return isDesktop() ? ( setIsEditing(false)) } - function getDisplayedTime(dateStr: string) { - const date = new Date(dateStr) + function getDisplayedTime(dateTimeMs: number) { + const date = new Date(dateTimeMs) const startOfToday = new Date() startOfToday.setHours(0, 0, 0, 0) return date.getTime() < startOfToday.getTime() @@ -52,7 +52,7 @@ function ProjectCard({ } // async function setupImageUrl() { - // const projectImagePath = await join(project.path, PROJECT_IMAGE_NAME) + // const projectImagePath = await join(project.file.path, PROJECT_IMAGE_NAME) // if (await exists(projectImagePath)) { // const imageData = await readFile(projectImagePath) // const blob = new Blob([imageData], { type: 'image/jpg' }) @@ -113,8 +113,8 @@ function ProjectCard({ Edited{' '} - {project.metadata && project.metadata?.modified - ? getDisplayedTime(project.metadata.modified) + {project.metadata && project.metadata.modified + ? getDisplayedTime(parseInt(project.metadata.modified)) : 'never'}
    diff --git a/src/components/ProjectSidebarMenu.tsx b/src/components/ProjectSidebarMenu.tsx index 0528f8ed1..ac4495e72 100644 --- a/src/components/ProjectSidebarMenu.tsx +++ b/src/components/ProjectSidebarMenu.tsx @@ -3,9 +3,9 @@ import { ActionButton, ActionButtonProps } from './ActionButton' import { type IndexLoaderData } from 'lib/types' import { PATHS } from 'lib/paths' import { isTauri } from '../lib/isTauri' +import { isDesktop } from '../lib/isDesktop' import { Link, useLocation, useNavigate } from 'react-router-dom' import { Fragment, useMemo } from 'react' -import { sep } from '@tauri-apps/api/path' import { Logo } from './Logo' import { APP_NAME } from 'lib/constants' import { useCommandsContext } from 'hooks/useCommandsContext' @@ -55,7 +55,7 @@ function AppLogoLink({ "relative h-full grid place-content-center group p-1.5 before:block before:content-[''] before:absolute before:inset-0 before:bottom-2.5 before:z-[-1] before:bg-primary before:rounded-b-sm" const logoClassName = 'w-auto h-4 text-chalkboard-10' - return isTauri() ? ( + return isDesktop() ? ( { @@ -111,7 +111,7 @@ function ProjectMenuPopover({ <> Project settings {`${platform === 'macos' ? '⌘' : 'Ctrl'}${ - isTauri() ? '' : '⬆' + isDesktop() ? '' : '⬆' },`} ), @@ -150,7 +150,7 @@ function ProjectMenuPopover({ { id: 'make', Element: 'button', - className: !isTauri() ? 'hidden' : '', + className: !isDesktop() ? 'hidden' : '', children: ( <> Make current part @@ -177,7 +177,7 @@ function ProjectMenuPopover({ id: 'go-home', Element: 'button', children: 'Go to Home', - className: !isTauri() ? 'hidden' : '', + className: !isDesktop() ? 'hidden' : '', onClick: () => { onProjectClose(file || null, project?.path || null, true) // Clear the scene and end the session. @@ -195,7 +195,7 @@ function ProjectMenuPopover({ commandBarSend, engineCommandManager, onProjectClose, - isTauri, + isDesktop, ] ) @@ -207,11 +207,13 @@ function ProjectMenuPopover({ >
    - {isTauri() && file?.name - ? file.name.slice(file.name.lastIndexOf(sep()) + 1) + {isDesktop() && file?.name + ? file.name.slice( + file.name.lastIndexOf(window.electron.path.sep) + 1 + ) : APP_NAME} - {isTauri() && project?.name && ( + {isDesktop() && project?.name && ( {project.name} diff --git a/src/components/Settings/AllSettingsFields.tsx b/src/components/Settings/AllSettingsFields.tsx index df24e9701..7e41ca780 100644 --- a/src/components/Settings/AllSettingsFields.tsx +++ b/src/components/Settings/AllSettingsFields.tsx @@ -9,16 +9,15 @@ import { import { Fragment } from 'react/jsx-runtime' import { SettingsSection } from './SettingsSection' import { useLocation, useNavigate } from 'react-router-dom' -import { isTauri } from 'lib/isTauri' +import { isDesktop } from 'lib/isDesktop' import { ActionButton } from 'components/ActionButton' import { SettingsFieldInput } from './SettingsFieldInput' -import { getInitialDefaultDir, showInFolder } from 'lib/tauri' +import { getInitialDefaultDir } from 'lib/desktop' import toast from 'react-hot-toast' import { APP_VERSION } from 'routes/Settings' -import { createAndOpenNewProject, getSettingsFolderPaths } from 'lib/tauriFS' import { PATHS } from 'lib/paths' +import { createAndOpenNewProject, getSettingsFolderPaths } from 'lib/desktopFS' import { useDotDotSlash } from 'hooks/useDotDotSlash' -import { sep } from '@tauri-apps/api/path' import { ForwardedRef, forwardRef, useEffect } from 'react' import { useLspContext } from 'components/LspProvider' @@ -41,12 +40,17 @@ export const AllSettingsFields = forwardRef( } = useSettingsAuthContext() const projectPath = - isFileSettings && isTauri() + isFileSettings && isDesktop() ? decodeURI( location.pathname .replace(PATHS.FILE + '/', '') .replace(PATHS.SETTINGS, '') - .slice(0, decodeURI(location.pathname).lastIndexOf(sep())) + .slice( + 0, + decodeURI(location.pathname).lastIndexOf( + window.electron.path.sep + ) + ) ) : undefined @@ -176,21 +180,25 @@ export const AllSettingsFields = forwardRef( title="Reset settings" description={`Restore settings to their default values. Your settings are saved in ${ - isTauri() + isDesktop() ? ' a file in the app data folder for your OS.' : " your browser's local storage." } `} >
    - {isTauri() && ( + {isDesktop() && ( { const paths = await getSettingsFolderPaths( projectPath ? decodeURIComponent(projectPath) : undefined ) - showInFolder(paths[searchParamTab]) + const finalPath = paths[searchParamTab] + if (!finalPath) { + return new Error('finalPath undefined') + } + window.electron.showInFolder(finalPath) }} iconStart={{ icon: 'folder', diff --git a/src/components/SettingsAuthProvider.tsx b/src/components/SettingsAuthProvider.tsx index bf30b99da..8caedd732 100644 --- a/src/components/SettingsAuthProvider.tsx +++ b/src/components/SettingsAuthProvider.tsx @@ -21,7 +21,7 @@ import { Prop, StateFrom, } from 'xstate' -import { isTauri } from 'lib/isTauri' +import { isDesktop } from 'lib/isDesktop' import { authCommandBarConfig } from 'lib/commandBarConfigs/authCommandConfig' import { kclManager, sceneInfra, engineCommandManager } from 'lib/singletons' import { uuidv4 } from 'lib/utils' @@ -341,7 +341,7 @@ export default SettingsAuthProvider export function logout() { localStorage.removeItem(TOKEN_PERSIST_KEY) return ( - !isTauri() && + !isDesktop() && fetch(withBaseUrl('/logout'), { method: 'POST', credentials: 'include', diff --git a/src/components/Stream.tsx b/src/components/Stream.tsx index e852b22d3..32ecf1aa1 100644 --- a/src/components/Stream.tsx +++ b/src/components/Stream.tsx @@ -238,6 +238,7 @@ export const Stream = () => { if (!videoRef.current) return if (!mediaStream) return + // The browser complains if we try to load a new stream without pausing first. // Do not immediately play the stream! try { videoRef.current.srcObject = mediaStream diff --git a/src/components/UserSidebarMenu.tsx b/src/components/UserSidebarMenu.tsx index fc05beb65..193b6361e 100644 --- a/src/components/UserSidebarMenu.tsx +++ b/src/components/UserSidebarMenu.tsx @@ -8,7 +8,7 @@ import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath' import Tooltip from './Tooltip' import usePlatform from 'hooks/usePlatform' -import { isTauri } from 'lib/isTauri' +import { isDesktop } from 'lib/isDesktop' import { CustomIcon } from './CustomIcon' type User = Models['User_type'] @@ -33,7 +33,7 @@ const UserSidebarMenu = ({ user }: { user?: User }) => { <> User settings {`${platform === 'macos' ? '⌘' : 'Ctrl'}${ - isTauri() ? '' : '⬆' + isDesktop() ? '' : '⬆' },`} ), diff --git a/src/env.ts b/src/env.ts index f133e2a7b..c238c27ec 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,15 +1,29 @@ // env vars were centralised so they could be mocked in jest // but isn't needed anymore with vite, so is now just a convention +// Even though we transpile to a module system that supports import, the +// typescript checker doesn't know that, so it's causing problems +// to properly type check anything with "import.meta". I've tried for a good +// hour to fix this but nothing has worked. This is the pain the JS ecosystem +// gets for like 6 different module systems. + +// @ts-ignore export const VITE_KC_API_WS_MODELING_URL = import.meta.env .VITE_KC_API_WS_MODELING_URL +// @ts-ignore export const VITE_KC_API_BASE_URL = import.meta.env.VITE_KC_API_BASE_URL +// @ts-ignore export const VITE_KC_SITE_BASE_URL = import.meta.env.VITE_KC_SITE_BASE_URL +// @ts-ignore export const VITE_KC_CONNECTION_TIMEOUT_MS = import.meta.env .VITE_KC_CONNECTION_TIMEOUT_MS +// @ts-ignore export const VITE_KC_DEV_TOKEN = import.meta.env.VITE_KC_DEV_TOKEN as | string | undefined +// @ts-ignore export const TEST = import.meta.env.TEST +// @ts-ignore export const DEV = import.meta.env.DEV +// @ts-ignore export const CI = import.meta.env.CI diff --git a/src/hooks/usePlatform.ts b/src/hooks/usePlatform.ts index 58a0dbc18..8ef85a892 100644 --- a/src/hooks/usePlatform.ts +++ b/src/hooks/usePlatform.ts @@ -1,16 +1,17 @@ -import { Platform, platform } from '@tauri-apps/plugin-os' -import { isTauri } from 'lib/isTauri' +import { isDesktop } from 'lib/isDesktop' import { useEffect, useState } from 'react' +export type Platform = 'macos' | 'windows' | 'linux' | '' + export default function usePlatform() { - const [platformName, setPlatformName] = useState('') + const [platformName, setPlatformName] = useState('') useEffect(() => { async function getPlatform() { - setPlatformName(await platform()) + setPlatformName((window.electron.platform ?? '') as Platform) } - if (isTauri()) { + if (isDesktop()) { void getPlatform() } else { if (navigator.userAgent.indexOf('Mac') !== -1) { diff --git a/src/hooks/useSetupEngineManager.ts b/src/hooks/useSetupEngineManager.ts index fdf4beba2..e9a6c3314 100644 --- a/src/hooks/useSetupEngineManager.ts +++ b/src/hooks/useSetupEngineManager.ts @@ -104,7 +104,7 @@ export function useSetupEngineManager( }, [immediateState]) useEffect(() => { - engineCommandManager.settings.theme = settings.theme + engineCommandManager.settings = settings const handleResize = deferExecution(() => { const { width, height } = getDimensions( @@ -194,13 +194,7 @@ export function useSetupEngineManager( // Engine relies on many settings so we should rebind events when it changes // We have to list out the ones we care about because the settings object holds // non-settings too... - }, [ - settings.enableSSAO, - settings.highlightEdges, - settings.showScaleGrid, - settings.theme, - settings.pool, - ]) + }, [...Object.values(settings)]) } function getDimensions(streamWidth?: number, streamHeight?: number) { diff --git a/src/index.tsx b/src/index.tsx index 49b11ba77..4ed2a255a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -6,9 +6,7 @@ import { Router } from './Router' import { HotkeysProvider } from 'react-hotkeys-hook' import ModalContainer from 'react-modal-promise' import { UpdaterModal, createUpdaterModal } from 'components/UpdaterModal' -import { isTauri } from 'lib/isTauri' -import { relaunch } from '@tauri-apps/plugin-process' -import { check } from '@tauri-apps/plugin-updater' +import { isDesktop } from 'lib/isDesktop' import { UpdaterRestartModal, createUpdaterRestartModal, @@ -59,29 +57,4 @@ root.render( // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals reportWebVitals() -const runTauriUpdater = async () => { - try { - const update = await check() - if (update && update.available) { - const { date, version, body } = update - const modal = createUpdaterModal(UpdaterModal) - const { wantUpdate } = await modal({ date, version, body }) - if (wantUpdate) { - await update.downloadAndInstall() - // On macOS and Linux, the restart needs to be manually triggered - const isNotWindows = navigator.userAgent.indexOf('Win') === -1 - if (isNotWindows) { - const relaunchModal = createUpdaterRestartModal(UpdaterRestartModal) - const { wantRestart } = await relaunchModal({ version }) - if (wantRestart) { - await relaunch() - } - } - } - } - } catch (error) { - console.error(error) - } -} - -isTauri() && runTauriUpdater() +isDesktop() diff --git a/src/lang/codeManager.ts b/src/lang/codeManager.ts index 6fffb0515..b55a472bc 100644 --- a/src/lang/codeManager.ts +++ b/src/lang/codeManager.ts @@ -2,8 +2,7 @@ // NOT updating the code state when we don't need to. // This prevents re-renders of the codemirror editor, when typing. import { bracket } from 'lib/exampleKcl' -import { isTauri } from 'lib/isTauri' -import { writeTextFile } from '@tauri-apps/plugin-fs' +import { isDesktop } from 'lib/isDesktop' import toast from 'react-hot-toast' import { editorManager } from 'lib/singletons' import { Annotation, Transaction } from '@codemirror/state' @@ -21,14 +20,14 @@ export default class CodeManager { private _hotkeys: { [key: string]: () => void } = {} constructor() { - if (isTauri()) { + if (isDesktop()) { this.code = '' return } const storedCode = safeLSGetItem(PERSIST_CODE_KEY) // TODO #819 remove zustand persistence logic in a few months - // short term migration, shouldn't make a difference for tauri app users + // short term migration, shouldn't make a difference for desktop app users // anyway since that's filesystem based. const zustandStore = JSON.parse(safeLSGetItem('store') || '{}') if (storedCode === null && zustandStore?.state?.code) { @@ -115,16 +114,18 @@ export default class CodeManager { } async writeToFile() { - if (isTauri()) { + if (isDesktop()) { setTimeout(() => { // Wait one event loop to give a chance for params to be set // Save the file to disk this._currentFilePath && - writeTextFile(this._currentFilePath, this.code).catch((err) => { - // TODO: add tracing per GH issue #254 (https://github.com/KittyCAD/modeling-app/issues/254) - console.error('error saving file', err) - toast.error('Error saving file, please check file permissions') - }) + window.electron + .writeFile(this._currentFilePath, this.code ?? '') + .catch((err: Error) => { + // TODO: add tracing per GH issue #254 (https://github.com/KittyCAD/modeling-app/issues/254) + console.error('error saving file', err) + toast.error('Error saving file, please check file permissions') + }) }) } else { safeLSSetItem(PERSIST_CODE_KEY, this.code) diff --git a/src/lang/std/fileSystemManager.ts b/src/lang/std/fileSystemManager.ts index 1230494e3..0bb71ed1a 100644 --- a/src/lang/std/fileSystemManager.ts +++ b/src/lang/std/fileSystemManager.ts @@ -1,7 +1,4 @@ -import { readFile, exists as tauriExists } from '@tauri-apps/plugin-fs' -import { isTauri } from 'lib/isTauri' -import { join } from '@tauri-apps/api/path' -import { readDirRecursive } from 'lib/tauri' +import { isDesktop } from 'lib/isDesktop' /// FileSystemManager is a class that provides a way to read files from the local file system. /// It assumes that you are in a project since it is solely used by the std lib @@ -17,61 +14,67 @@ class FileSystemManager { this._dir = dir } + async join(dir: string, path: string): Promise { + return Promise.resolve(window.electron.path.join(dir, path)) + } + async readFile(path: string): Promise { - // Using local file system only works from Tauri. - if (!isTauri()) { + // Using local file system only works from desktop. + if (!isDesktop()) { return Promise.reject( - new Error('This function can only be called from a Tauri application') + new Error( + 'This function can only be called from the desktop application' + ) ) } - return join(this.dir, path) - .catch((error) => { - return Promise.reject(new Error(`Error reading file: ${error}`)) - }) - .then((file) => { - return readFile(file) - }) + return this.join(this.dir, path).then((filePath) => { + return window.electron.readFile(filePath) + }) } async exists(path: string): Promise { - // Using local file system only works from Tauri. - if (!isTauri()) { + // Using local file system only works from desktop. + if (!isDesktop()) { return Promise.reject( - new Error('This function can only be called from a Tauri application') + new Error( + 'This function can only be called from the desktop application' + ) ) } - return join(this.dir, path) - .catch((error) => { - return Promise.reject(new Error(`Error checking file exists: ${error}`)) - }) - .then((file) => { - return tauriExists(file) - }) + return this.join(this.dir, path).then(async (file) => { + try { + await window.electron.stat(file) + } catch (e) { + if (e === 'ENOENT') { + return false + } + } + return true + }) } async getAllFiles(path: string): Promise { - // Using local file system only works from Tauri. - if (!isTauri()) { + // Using local file system only works from desktop. + if (!isDesktop()) { return Promise.reject( - new Error('This function can only be called from a Tauri application') + new Error( + 'This function can only be called from the desktop application' + ) ) } - return join(this.dir, path) - .catch((error) => { - return Promise.reject(new Error(`Error joining dir: ${error}`)) - }) - .then((p) => { - readDirRecursive(p) - .catch((error) => { - return Promise.reject(new Error(`Error reading dir: ${error}`)) - }) - .then((files) => { - return files.map((file) => file.path) - }) - }) + return this.join(this.dir, path).then((filepath) => { + return window.electron + .readdir(filepath) + .catch((error: Error) => { + return Promise.reject(new Error(`Error reading dir: ${error}`)) + }) + .then((files: string[]) => { + return files.map((filePath) => filePath) + }) + }) } } diff --git a/src/lang/wasm.ts b/src/lang/wasm.ts index 1c01a636a..af3d5a94e 100644 --- a/src/lang/wasm.ts +++ b/src/lang/wasm.ts @@ -18,6 +18,11 @@ import init, { default_project_settings, parse_project_route, } from '../wasm-lib/pkg/wasm_lib' +import { + configurationToSettingsPayload, + projectConfigurationToSettingsPayload, +} from 'lib/settings/settingsUtils' +import { SaveSettingsPayload } from 'lib/settings/settingsTypes' import { KCLError } from './errors' import { KclError as RustKclError } from '../wasm-lib/kcl/bindings/KclError' import { EngineCommandManager } from './std/engineConnection' @@ -32,8 +37,6 @@ import { CoreDumpManager } from 'lib/coredump' import openWindow from 'lib/openWindow' import { DefaultPlanes } from 'wasm-lib/kcl/bindings/DefaultPlanes' import { TEST } from 'env' -import { Configuration } from 'wasm-lib/kcl/bindings/Configuration' -import { ProjectConfiguration } from 'wasm-lib/kcl/bindings/ProjectConfiguration' import { ProjectRoute } from 'wasm-lib/kcl/bindings/ProjectRoute' import { err } from 'lib/trap' @@ -84,19 +87,7 @@ export type { KclValue } from '../wasm-lib/kcl/bindings/KclValue' export type { ExtrudeSurface } from '../wasm-lib/kcl/bindings/ExtrudeSurface' export const wasmUrl = () => { - const baseUrl = - typeof window === 'undefined' - ? 'http://127.0.0.1:3000' - : window.location.origin.includes('tauri://localhost') - ? 'tauri://localhost' // custom protocol for macOS - : window.location.origin.includes('tauri.localhost') - ? 'http://tauri.localhost' // fallback for Windows - : window.location.origin.includes('localhost') - ? 'http://localhost:3000' - : window.location.origin && window.location.origin !== 'null' - ? window.location.origin - : 'http://localhost:3000' - const fullUrl = baseUrl + '/wasm_lib_bg.wasm' + const fullUrl = '/wasm_lib_bg.wasm' console.log(`Full URL for WASM: ${fullUrl}`) return fullUrl @@ -569,26 +560,30 @@ export function tomlStringify(toml: any): string | Error { return toml_stringify(JSON.stringify(toml)) } -export function defaultAppSettings(): Configuration | Error { - return default_app_settings() +export function defaultAppSettings(): Partial { + // Immediately go from Configuration -> Partial + // The returned Rust type is Configuration but it's a lie. Every + // property in that returned object is optional. The Partial essentially + // brings that type in-line with that definition. + return configurationToSettingsPayload(default_app_settings()) } -export function parseAppSettings(toml: string): Configuration | Error { - return parse_app_settings(toml) +export function parseAppSettings(toml: string): Partial { + return configurationToSettingsPayload(parse_app_settings(toml)) } -export function defaultProjectSettings(): ProjectConfiguration | Error { - return default_project_settings() +export function defaultProjectSettings(): Partial { + return projectConfigurationToSettingsPayload(default_project_settings()) } export function parseProjectSettings( toml: string -): ProjectConfiguration | Error { - return parse_project_settings(toml) +): Partial { + return projectConfigurationToSettingsPayload(parse_project_settings(toml)) } export function parseProjectRoute( - configuration: Configuration, + configuration: Partial, route_str: string ): ProjectRoute | Error { return parse_project_route(JSON.stringify(configuration), route_str) diff --git a/src/lib/commandBarConfigs/settingsCommandConfig.ts b/src/lib/commandBarConfigs/settingsCommandConfig.ts index f9a5e795d..2a6ea3708 100644 --- a/src/lib/commandBarConfigs/settingsCommandConfig.ts +++ b/src/lib/commandBarConfigs/settingsCommandConfig.ts @@ -14,7 +14,7 @@ import { AnyStateMachine, ContextFrom, InterpreterFrom } from 'xstate' import { getPropertyByPath } from 'lib/objectPropertyByPath' import { buildCommandArgument } from 'lib/createMachineCommand' import decamelize from 'decamelize' -import { isTauri } from 'lib/isTauri' +import { isDesktop } from 'lib/isDesktop' import { Setting } from 'lib/settings/initialSettings' // An array of the paths to all of the settings that have commandConfigs @@ -78,7 +78,7 @@ export function createSettingsCommand({ settingConfig?.hideOnLevel === 'user' && !isProjectAvailable const shouldHideOnThisPlatform = settingConfig.hideOnPlatform && - (isTauri() + (isDesktop() ? settingConfig.hideOnPlatform === 'desktop' : settingConfig.hideOnPlatform === 'web') if ( diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 092dc1582..310af6b72 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -55,3 +55,5 @@ export const KCL_DEFAULT_CONSTANT_PREFIXES = { } as const /** The default KCL length expression */ export const KCL_DEFAULT_LENGTH = `5` +/** localStorage key for the playwright test-specific app settings file */ +export const TEST_SETTINGS_FILE_KEY = 'playwright-test-settings' \ No newline at end of file diff --git a/src/lib/coredump.ts b/src/lib/coredump.ts index 517cee911..1d1169faf 100644 --- a/src/lib/coredump.ts +++ b/src/lib/coredump.ts @@ -1,12 +1,7 @@ import { CommandLog, EngineCommandManager } from 'lang/std/engineConnection' import { WebrtcStats } from 'wasm-lib/kcl/bindings/WebrtcStats' import { OsInfo } from 'wasm-lib/kcl/bindings/OsInfo' -import { isTauri } from 'lib/isTauri' -import { - platform as tauriPlatform, - arch as tauriArch, - version as tauriKernelVersion, -} from '@tauri-apps/plugin-os' +import { isDesktop } from 'lib/isDesktop' import { APP_VERSION } from 'routes/Settings' import { UAParser } from 'ua-parser-js' import screenshot from 'lib/screenshot' @@ -68,14 +63,15 @@ export class CoreDumpManager { // Get the os information. getOsInfo(): Promise { - if (this.isTauri()) { + if (this.isDesktop()) { const osinfo: OsInfo = { - platform: tauriPlatform(), - arch: tauriArch(), - browser: 'tauri', - version: tauriKernelVersion(), + platform: window.electron.platform ?? null, + arch: window.electron.arch ?? null, + browser: 'desktop', + version: window.electron.version ?? null, } return new Promise((resolve) => resolve(JSON.stringify(osinfo))) + // (lf94) I'm not sure if this comment is specific to tauri or just desktop... // TODO: get rid of promises now that the tauri api doesn't require them anymore } @@ -101,8 +97,8 @@ export class CoreDumpManager { return new Promise((resolve) => resolve(JSON.stringify(osinfo))) } - isTauri(): boolean { - return isTauri() + isDesktop(): boolean { + return isDesktop() } getWebrtcStats(): Promise { diff --git a/src/lib/createMachineCommand.ts b/src/lib/createMachineCommand.ts index 3cf66d6f9..3cc978477 100644 --- a/src/lib/createMachineCommand.ts +++ b/src/lib/createMachineCommand.ts @@ -5,7 +5,7 @@ import { InterpreterFrom, StateFrom, } from 'xstate' -import { isTauri } from './isTauri' +import { isDesktop } from './isDesktop' import { Command, CommandArgument, @@ -76,8 +76,8 @@ export function createMachineCommand< if ('hide' in commandConfig) { const { hide } = commandConfig if (hide === 'both') return null - else if (hide === 'desktop' && isTauri()) return null - else if (hide === 'web' && !isTauri()) return null + else if (hide === 'desktop' && isDesktop()) return null + else if (hide === 'web' && !isDesktop()) return null } const icon = ('icon' in commandConfig && commandConfig.icon) || undefined diff --git a/src/lib/desktop.ts b/src/lib/desktop.ts new file mode 100644 index 000000000..75bdbb571 --- /dev/null +++ b/src/lib/desktop.ts @@ -0,0 +1,513 @@ +import { err } from 'lib/trap' +import { Models } from '@kittycad/lib' +import { ProjectConfiguration } from 'wasm-lib/kcl/bindings/ProjectConfiguration' +import { Project } from 'wasm-lib/kcl/bindings/Project' +import { ProjectState } from 'wasm-lib/kcl/bindings/ProjectState' +import { ProjectRoute } from 'wasm-lib/kcl/bindings/ProjectRoute' +import { components } from './machine-api' +import { isDesktop } from './isDesktop' +import { FileEntry } from 'wasm-lib/kcl/bindings/FileEntry' +import { SaveSettingsPayload } from 'lib/settings/settingsTypes' +import * as TOML from '@iarna/toml' + +import { + defaultAppSettings, + tomlStringify, + parseAppSettings, + parseProjectSettings, +} from 'lang/wasm' +import { TEST_SETTINGS_KEY } from '../../e2e/playwright/storageStates' +import { TEST_SETTINGS_FILE_KEY } from './constants' +export { parseProjectRoute } from 'lang/wasm' + +const DEFAULT_HOST = 'https://api.zoo.dev' +const SETTINGS_FILE_NAME = 'settings.toml' +const PROJECT_SETTINGS_FILE_NAME = 'project.toml' +const PROJECT_FOLDER = 'zoo-modeling-app-projects' +const DEFAULT_PROJECT_KCL_FILE = 'main.kcl' + +export async function renameProjectDirectory( + projectPath: string, + newName: string +): Promise { + if (!newName) { + return Promise.reject(new Error(`New name for project cannot be empty`)) + } + + try { + await window.electron.stat(projectPath) + } catch (e) { + if (e === 'ENOENT') { + return Promise.reject(new Error(`Path ${projectPath} is not a directory`)) + } + } + + // Make sure the new name does not exist. + const newPath = window.electron.path.join( + projectPath.split('/').slice(0, -1).join('/'), + newName + ) + try { + await window.electron.stat(newPath) + // If we get here it means the stat succeeded and there's a file already + // with the same name... + return Promise.reject( + new Error( + `Path ${newPath} already exists, cannot rename to an existing path` + ) + ) + } catch (e) { + // Otherwise if it failed and the failure is "it doesn't exist" then rename it! + if (e === 'ENOENT') { + await window.electron.rename(projectPath, newPath) + return newPath + } + } + return Promise.reject(new Error('Unreachable')) +} + +export async function ensureProjectDirectoryExists( + config: Partial +): Promise { + const projectDir = config.app?.projectDirectory + if (!projectDir) { + return Promise.reject(new Error('projectDir is falsey')) + } + try { + await window.electron.stat(projectDir) + } catch (e) { + if (e === 'ENOENT') { + await window.electron.mkdir(projectDir, { recursive: true }) + } + } + + return projectDir +} + +export async function createNewProjectDirectory( + projectName: string, + initialCode?: string, + configuration?: Partial +): Promise { + if (!configuration) { + configuration = await readAppSettingsFile() + } + + const mainDir = await ensureProjectDirectoryExists(configuration) + + if (!projectName) { + return Promise.reject('Project name cannot be empty.') + } + + if (!mainDir) { + return Promise.reject(new Error('mainDir is falsey')) + } + const projectDir = window.electron.path.join(mainDir, projectName) + + try { + await window.electron.stat(projectDir) + } catch (e) { + if (e === 'ENOENT') { + await window.electron.mkdir(projectDir, { recursive: true }) + } + } + + const projectFile = window.electron.path.join( + projectDir, + DEFAULT_PROJECT_KCL_FILE + ) + await window.electron.writeFile(projectFile, initialCode ?? '') + const metadata = await window.electron.stat(projectFile) + + return { + path: projectDir, + name: projectName, + // We don't need to recursively get all files in the project directory. + // Because we just created it and it's empty. + children: null, + default_file: projectFile, + metadata, + kcl_file_count: 1, + directory_count: 0, + } +} + +export async function listProjects( + configuration?: Partial +): Promise { + if (configuration === undefined) { + configuration = await readAppSettingsFile() + } + const projectDir = await ensureProjectDirectoryExists(configuration) + const projects = [] + if (!projectDir) return Promise.reject(new Error('projectDir was falsey')) + + const entries = await window.electron.readdir(projectDir) + for (let entry of entries) { + const projectPath = window.electron.path.join(projectDir, entry) + // if it's not a directory ignore. + const isDirectory = await window.electron.statIsDirectory(projectPath) + if (!isDirectory) { + continue + } + + const project = await getProjectInfo(projectPath) + // Needs at least one file to be added to the projects list + if (project.kcl_file_count === 0) { + continue + } + projects.push(project) + } + return projects +} + +const IMPORT_FILE_EXTENSIONS = [ + // TODO Use ImportFormat enum + 'stp', + 'glb', + 'fbxb', + 'kcl', +] + +const isRelevantFile = (filename: string): boolean => + IMPORT_FILE_EXTENSIONS.some((ext) => filename.endsWith('.' + ext)) + +const collectAllFilesRecursiveFrom = async (path: string) => { + // Make sure the filesystem object exists. + try { + await window.electron.stat(path) + } catch (e) { + if (e === 'ENOENT') { + return Promise.reject(new Error(`Directory ${path} does not exist`)) + } + } + + // Make sure the path is a directory. + const isPathDir = await window.electron.statIsDirectory(path) + if (!isPathDir) { + return Promise.reject(new Error(`Path ${path} is not a directory`)) + } + + const pathParts = path.split('/') + let entry: FileEntry = { + name: pathParts.slice(-1)[0], + path, + children: [], + } + + const children = [] + + const entries = await window.electron.readdir(path) + + // Sort all entries so files come first and directories last + // so a top-most KCL file is returned first. + entries.sort((a: string, b: string) => { + if (a.endsWith('.kcl') && !b.endsWith('.kcl')) { + return -1 + } + if (!a.endsWith('.kcl') && b.endsWith('.kcl')) { + return 1 + } + return 0 + }) + + for (let e of entries) { + // ignore hidden files and directories (starting with a dot) + if (e.indexOf('.') === 0) { + continue + } + + const ePath = window.electron.path.join(path, e) + const isEDir = await window.electron.statIsDirectory(ePath) + + if (isEDir) { + const subChildren = await collectAllFilesRecursiveFrom(ePath) + children.push(subChildren) + } else { + if (!isRelevantFile(ePath)) { + continue + } + children.push( + /* FileEntry */ { + name: e, + path: ePath, + children: null, + } + ) + } + } + + // We don't set this to none if there are no children, because it's a directory. + entry.children = children + + return entry +} + +export async function getDefaultKclFileForDir( + projectDir: string, + file: FileEntry +) { + // Make sure the dir is a directory. + const isFileEntryDir = await window.electron.statIsDirectory(projectDir) + if (!isFileEntryDir) { + return Promise.reject(new Error(`Path ${projectDir} is not a directory`)) + } + + let defaultFilePath = window.electron.path.join( + projectDir, + DEFAULT_PROJECT_KCL_FILE + ) + try { + await window.electron.stat(defaultFilePath) + } catch (e) { + if (e === 'ENOENT') { + // Find a kcl file in the directory. + if (file.children) { + for (let entry of file.children) { + if (entry.name.endsWith('.kcl')) { + return window.electron.path.join(projectDir, entry.name) + } else if ((entry.children?.length ?? 0) > 0) { + // Recursively find a kcl file in the directory. + return getDefaultKclFileForDir(entry.path, entry) + } + } + // If we didn't find a kcl file, create one. + await window.electron.writeFile(defaultFilePath, '') + return defaultFilePath + } + } + } + + if (!file.children) { + return file.name + } + + return defaultFilePath +} + +const kclFileCount = (file: FileEntry) => { + let count = 0 + if (file.children) { + for (let entry of file.children) { + if (entry.name.endsWith('.kcl')) { + count += 1 + } else { + count += kclFileCount(entry) + } + } + } + + return count +} + +/// Populate the number of directories in the project. +const directoryCount = (file: FileEntry) => { + let count = 0 + if (file.children) { + for (let entry of file.children) { + count += 1 + directoryCount(entry) + } + } + + return count +} + +export async function getProjectInfo(projectPath: string): Promise { + // Check the directory. + try { + await window.electron.stat(projectPath) + } catch (e) { + if (e === 'ENOENT') { + return Promise.reject( + new Error(`Project directory does not exist: ${projectPath}`) + ) + } + } + + // Make sure it is a directory. + const projectPathIsDir = await window.electron.statIsDirectory(projectPath) + if (!projectPathIsDir) { + return Promise.reject( + new Error(`Project path is not a directory: ${projectPath}`) + ) + } + + let walked = await collectAllFilesRecursiveFrom(projectPath) + let default_file = await getDefaultKclFileForDir(projectPath, walked) + const metadata = await window.electron.stat(projectPath) + + let project = { + ...walked, + // We need to map from node fs.Stats to FileMetadata + metadata: { + modified: metadata.mtimeMs, + accessed: metadata.atimeMs, + created: metadata.ctimeMs, + // this is not used anywhere and we use statIsDirectory in other places + // that need to know if it's a file or directory. + type: null, + size: metadata.size, + permission: metadata.mode, + }, + kcl_file_count: 0, + directory_count: 0, + default_file, + } + + // Populate the number of KCL files in the project. + project.kcl_file_count = kclFileCount(project) + + //Populate the number of directories in the project. + project.directory_count = directoryCount(project) + + return project +} + +// Write project settings file. +export async function writeProjectSettingsFile( + projectPath: string, + configuration: Partial +): Promise { + const projectSettingsFilePath = await getProjectSettingsFilePath(projectPath) + const tomlStr = tomlStringify({ settings: configuration }) + if (err(tomlStr)) return Promise.reject(tomlStr) + return window.electron.writeFile(projectSettingsFilePath, tomlStr) +} + +const getAppSettingsFilePath = async () => { + const isPlaywright = window.localStorage.getItem('playwright') === 'true' + const testDirectoryName = window.localStorage.getItem(TEST_SETTINGS_FILE_KEY) ?? '' + const appConfig = await window.electron.getPath( + isPlaywright ? 'temp' : 'appData' + ) + const fullPath = window.electron.path.join( + appConfig, + isPlaywright ? testDirectoryName : '', + window.electron.packageJson.name + ) + try { + await window.electron.stat(fullPath) + } catch (e) { + // File/path doesn't exist + if (e === 'ENOENT') { + await window.electron.mkdir(fullPath, { recursive: true }) + } + } + return window.electron.path.join(fullPath, SETTINGS_FILE_NAME) +} + +const getProjectSettingsFilePath = async (projectPath: string) => { + try { + await window.electron.stat(projectPath) + } catch (e) { + if (e === 'ENOENT') { + await window.electron.mkdir(projectPath, { recursive: true }) + } + } + return window.electron.path.join(projectPath, PROJECT_SETTINGS_FILE_NAME) +} + +export const getInitialDefaultDir = async () => { + const dir = await window.electron.getPath('documents') + return window.electron.path.join(dir, PROJECT_FOLDER) +} + +export const readProjectSettingsFile = async ( + projectPath: string +): Promise> => { + let settingsPath = await getProjectSettingsFilePath(projectPath) + + // Check if this file exists. + try { + await window.electron.stat(settingsPath) + } catch (e) { + if (e === 'ENOENT') { + // Return the default configuration. + return {} + } + } + + const configToml = await window.electron.readFile(settingsPath) + const configObj = parseProjectSettings(configToml) + return configObj +} + +export const readAppSettingsFile = async () => { + let settingsPath = await getAppSettingsFilePath() + try { + await window.electron.stat(settingsPath) + } catch (e) { + if (e === 'ENOENT') { + const config = defaultAppSettings() + if (!config.app) { + return Promise.reject(new Error('config.app is falsey')) + } + config.app.projectDirectory = await getInitialDefaultDir() + return config + } + } + const configToml = await window.electron.readFile(settingsPath) + const configObj = parseAppSettings(configToml) + const overrideJSON = localStorage.getItem('APP_SETTINGS_OVERRIDE') + if (overrideJSON) { + try { + const override = JSON.parse(overrideJSON) + configObj.app = { ...configObj.app, ...override } + } catch (e) { + console.error('Error parsing APP_SETTINGS_OVERRIDE:', e) + } + } + return configObj +} + +export const writeAppSettingsFile = async ( + config: Partial +) => { + const appSettingsFilePath = await getAppSettingsFilePath() + const tomlStr = tomlStringify({ settings: config }) + if (err(tomlStr)) return Promise.reject(tomlStr) + return window.electron.writeFile(appSettingsFilePath, tomlStr) +} + +let appStateStore: ProjectState | undefined = undefined + +export const getState = async (): Promise => { + return Promise.resolve(appStateStore) +} + +export const setState = async ( + state: ProjectState | undefined +): Promise => { + appStateStore = state +} + +export const getUser = async ( + token: string, + hostname: string +): Promise => { + // Use the host passed in if it's set. + // Otherwise, use the default host. + const host = !hostname ? DEFAULT_HOST : hostname + + // Change the baseURL to the one we want. + let baseurl = host + if (!(host.indexOf('http://') === 0) && !(host.indexOf('https://') === 0)) { + baseurl = `https://${host}` + if (host.indexOf('localhost') === 0) { + baseurl = `http://${host}` + } + } + + // Use kittycad library to fetch the user info from /user/me + if (baseurl !== DEFAULT_HOST) { + // The TypeScript generated library uses environment variables for this + // because it was intended for NodeJS. + window.electron.process.env.BASE_URL(baseurl) + } + + const user = await window.electron.kittycad.users.get_user_self({ + client: { token }, + }) + return user +} diff --git a/src/lib/tauriFS.ts b/src/lib/desktopFS.ts similarity index 88% rename from src/lib/tauriFS.ts rename to src/lib/desktopFS.ts index 964078420..934ddc7fc 100644 --- a/src/lib/tauriFS.ts +++ b/src/lib/desktopFS.ts @@ -1,5 +1,4 @@ -import { appConfigDir } from '@tauri-apps/api/path' -import { isTauri } from './isTauri' +import { isDesktop } from './isDesktop' import type { FileEntry } from 'lib/types' import { INDEX_IDENTIFIER, @@ -13,7 +12,7 @@ import { createNewProjectDirectory, listProjects, readAppSettingsFile, -} from './tauri' +} from './desktop' import { engineCommandManager } from './singletons' export const isHidden = (fileOrDir: FileEntry) => @@ -31,9 +30,9 @@ export function sortProject(project: FileEntry[]): FileEntry[] { return -1 } else if (b.name === PROJECT_ENTRYPOINT) { return 1 - } else if (a.children === undefined && b.children !== undefined) { + } else if (a.children === null && b.children !== null) { return -1 - } else if (a.children !== undefined && b.children === undefined) { + } else if (a.children !== null && b.children === null) { return 1 } else if (a.name && b.name) { return a.name.localeCompare(b.name) @@ -43,7 +42,7 @@ export function sortProject(project: FileEntry[]): FileEntry[] { }) return sortedProject.map((fileOrDir: FileEntry) => { - if ('children' in fileOrDir && fileOrDir.children !== undefined) { + if ('children' in fileOrDir && fileOrDir.children !== null) { return { ...fileOrDir, children: sortProject(fileOrDir.children || []), @@ -64,9 +63,12 @@ function interpolateProjectName(projectName: string) { } // Returns the next available index for a project name -export function getNextProjectIndex(projectName: string, files: FileEntry[]) { +export function getNextProjectIndex( + projectName: string, + projects: FileEntry[] +) { const regex = interpolateProjectName(projectName) - const matches = files.map((file) => file.name?.match(regex)) + const matches = projects.map((project) => project.name?.match(regex)) const indices = matches .filter(Boolean) .map((match) => match![1]) @@ -108,7 +110,7 @@ function getPaddedIdentifierRegExp() { } export async function getSettingsFolderPaths(projectPath?: string) { - const user = isTauri() ? await appConfigDir() : '/' + const user = isDesktop() ? await window.electron.getPath('appData') : '/' const project = projectPath !== undefined ? projectPath : undefined return { diff --git a/src/lib/electron.ts b/src/lib/electron.ts new file mode 100644 index 000000000..1d8e9059d --- /dev/null +++ b/src/lib/electron.ts @@ -0,0 +1,90 @@ +import { ipcRenderer, contextBridge } from 'electron' +import path from 'path' +import fs from 'node:fs/promises' +import packageJson from '../../package.json' +import { components } from 'lib/machine-api' +import { MachinesListing } from 'lib/machineManager' + +const open = (args: any) => ipcRenderer.invoke('dialog.showOpenDialog', args) +const save = (args: any) => ipcRenderer.invoke('dialog.showSaveDialog', args) +const openExternal = (url: any) => ipcRenderer.invoke('shell.openExternal', url) +const showInFolder = (path: string) => + ipcRenderer.invoke('shell.showItemInFolder', path) +const login = (host: string): Promise => + ipcRenderer.invoke('login', host) + +const readFile = (path: string) => fs.readFile(path, 'utf-8') +const rename = (prev: string, next: string) => fs.rename(prev, next) +const writeFile = (path: string, data: string | Uint8Array) => + fs.writeFile(path, data, 'utf-8') +const readdir = (path: string) => fs.readdir(path, 'utf-8') +const stat = (path: string) => + fs.stat(path).catch((e) => Promise.reject(e.code)) +// Electron has behavior where it doesn't clone the prototype chain over. +// So we need to call stat.isDirectory on this side. +const statIsDirectory = (path: string) => + stat(path).then((res) => res.isDirectory()) +const getPath = async (name: string) => ipcRenderer.invoke('app.getPath', name) + +const exposeProcessEnv = (varName: string) => { + return { + [varName](value?: string) { + if (value !== undefined) { + process.env[varName] = value + } else { + return process.env[varName] + } + }, + } +} + +// We could probably do this from the renderer side, but I fear CORS will +// bite our butts. +const listMachines = async (): Promise => { + const machineApi = await ipcRenderer.invoke('find_machine_api') + if (!machineApi) return {} + + return fetch(`http://${machineApi}/machines`).then((resp) => resp.json()) +} + +const getMachineApiIp = async (): Promise => + ipcRenderer.invoke('find_machine_api') + +import('@kittycad/lib').then((kittycad) => { + contextBridge.exposeInMainWorld('electron', { + login, + // Passing fs directly is not recommended since it gives a lot of power + // to the browser side / potential malicious code. We restrict what is + // exported. + readFile, + writeFile, + readdir, + rename, + rm: fs.rm, + path, + stat, + statIsDirectory, + mkdir: fs.mkdir, + // opens a dialog + open, + save, + // opens the URL + openExternal, + showInFolder, + getPath, + packageJson, + arch: process.arch, + platform: process.platform, + version: process.version, + process: { + // Setter/getter has to be created because + // these are read-only over the boundary. + env: Object.assign({}, exposeProcessEnv('BASE_URL')), + }, + kittycad: { + users: kittycad.users, + }, + listMachines, + getMachineApiIp, + }) +}) diff --git a/src/lib/exportSave.ts b/src/lib/exportSave.ts index 443a2c555..dc22df178 100644 --- a/src/lib/exportSave.ts +++ b/src/lib/exportSave.ts @@ -1,15 +1,13 @@ -import { isTauri } from './isTauri' +import { isDesktop } from './isDesktop' import { deserialize_files } from '../wasm-lib/pkg/wasm_lib' import { browserSaveFile } from './browserSaveFile' -import { save } from '@tauri-apps/plugin-dialog' -import { writeFile } from '@tauri-apps/plugin-fs' import JSZip from 'jszip' import ModelingAppFile from './modelingAppFile' const save_ = async (file: ModelingAppFile) => { try { - if (isTauri()) { + if (isDesktop()) { const extension = file.name.split('.').pop() || null let extensions: string[] = [] if (extension !== null) { @@ -17,7 +15,7 @@ const save_ = async (file: ModelingAppFile) => { } // Open a dialog to save the file. - const filePath = await save({ + const filePathMeta = await window.electron.save({ defaultPath: file.name, filters: [ { @@ -27,14 +25,15 @@ const save_ = async (file: ModelingAppFile) => { ], }) - if (filePath === null) { - // The user canceled the save. - // Return early. - return - } + // The user canceled the save. + // Return early. + if (filePathMeta.canceled) return // Write the file. - await writeFile(filePath, new Uint8Array(file.contents)) + await window.electron.writeFile( + filePathMeta.filePath, + new Uint8Array(file.contents) + ) } else { // Download the file to the user's computer. // Now we need to download the files to the user's downloads folder. diff --git a/src/lib/isDesktop.ts b/src/lib/isDesktop.ts new file mode 100644 index 000000000..f0ff96f2f --- /dev/null +++ b/src/lib/isDesktop.ts @@ -0,0 +1,5 @@ +// https://github.com/electron/electron/issues/2288#issuecomment-337858978 +// Thank you +export function isDesktop(): boolean { + return navigator.userAgent.toLowerCase().indexOf('electron') > -1 +} diff --git a/src/lib/isTauri.ts b/src/lib/isTauri.ts deleted file mode 100644 index 747fa22a0..000000000 --- a/src/lib/isTauri.ts +++ /dev/null @@ -1,6 +0,0 @@ -export function isTauri(): boolean { - if (globalThis.window && typeof globalThis.window !== 'undefined') { - return '__TAURI_INTERNALS__' in globalThis.window - } - return false -} diff --git a/src/lib/machineManager.ts b/src/lib/machineManager.ts index 99f6e5c73..0948d0561 100644 --- a/src/lib/machineManager.ts +++ b/src/lib/machineManager.ts @@ -1,17 +1,18 @@ -import { isTauri } from './isTauri' +import { isDesktop } from './isDesktop' import { components } from './machine-api' -import { getMachineApiIp, listMachines } from './tauri' + +export type MachinesListing = { + [key: string]: components['schemas']['Machine'] +} export class MachineManager { - private _isTauri: boolean = isTauri() - private _machines: { - [key: string]: components['schemas']['Machine'] - } = {} + private _isDesktop: boolean = isDesktop() + private _machines: MachinesListing = {} private _machineApiIp: string | null = null private _currentMachine: components['schemas']['Machine'] | null = null constructor() { - if (!this._isTauri) { + if (!this._isDesktop) { return } @@ -19,20 +20,26 @@ export class MachineManager { } start() { - if (!this._isTauri) { + if (!this._isDesktop) { return } // Start a background job to update the machines every ten seconds. - setInterval(() => { - this.updateMachineApiIp() - this.updateMachines() - }, 10000) + // If MDNS is already watching, this timeout will wait until it's done to trigger the + // finding again. + let timeoutId: ReturnType | undefined = undefined + const timeoutLoop = () => { + clearTimeout(timeoutId) + timeoutId = setTimeout(async () => { + await this.updateMachineApiIp() + await this.updateMachines() + timeoutLoop() + }, 10000) + } + timeoutLoop() } - get machines(): { - [key: string]: components['schemas']['Machine'] - } { + get machines(): MachinesListing { return this._machines } @@ -53,20 +60,20 @@ export class MachineManager { } private async updateMachines(): Promise { - if (!this._isTauri) { + if (!this._isDesktop) { return } - this._machines = await listMachines() + this._machines = await window.electron.listMachines() console.log('Machines:', this._machines) } private async updateMachineApiIp(): Promise { - if (!this._isTauri) { + if (!this._isDesktop) { return } - this._machineApiIp = await getMachineApiIp() + this._machineApiIp = await window.electron.getMachineApiIp() } } diff --git a/src/lib/openWindow.ts b/src/lib/openWindow.ts index 8d04469cc..5f2689ee3 100644 --- a/src/lib/openWindow.ts +++ b/src/lib/openWindow.ts @@ -1,10 +1,22 @@ -import { isTauri } from 'lib/isTauri' -import { open as tauriOpen } from '@tauri-apps/plugin-shell' +import { MouseEventHandler } from 'react' +import { isDesktop } from 'lib/isDesktop' -// Open a new browser window tauri style or browser style. +export const openExternalBrowserIfDesktop = (to?: string) => + function (e) { + if (isDesktop()) { + // Ignoring because currentTarget could be a few different things + // @ts-ignore + window.electron.openExternal(to || e.currentTarget?.href) + e.preventDefault() + e.stopPropagation() + return false + } + } as MouseEventHandler + +// Open a new browser window desktop style or browser style. export default async function openWindow(url: string) { - if (isTauri()) { - await tauriOpen(url) + if (isDesktop()) { + await window.electron.openExternal(url) } else { window.open(url, '_blank') } diff --git a/src/lib/paths.ts b/src/lib/paths.ts index 6c9226e84..cb25527cd 100644 --- a/src/lib/paths.ts +++ b/src/lib/paths.ts @@ -1,11 +1,11 @@ import { onboardingPaths } from 'routes/Onboarding/paths' import { BROWSER_FILE_NAME, BROWSER_PROJECT_NAME, FILE_EXT } from './constants' -import { isTauri } from './isTauri' +import { isDesktop } from './isDesktop' import { Configuration } from 'wasm-lib/kcl/bindings/Configuration' import { ProjectRoute } from 'wasm-lib/kcl/bindings/ProjectRoute' -import { parseProjectRoute, readAppSettingsFile } from './tauri' -import { parseProjectRoute as parseProjectRouteWasm } from 'lang/wasm' +import { parseProjectRoute, readAppSettingsFile } from './desktop' import { readLocalStorageAppSettingsFile } from './settings/settingsUtils' +import { SaveSettingsPayload } from './settings/settingsTypes' import { err } from 'lib/trap' const prependRoutes = @@ -39,23 +39,26 @@ export const BROWSER_PATH = `%2F${BROWSER_PROJECT_NAME}%2F${BROWSER_FILE_NAME}${ export async function getProjectMetaByRouteId( id?: string, - configuration?: Configuration | Error + configuration?: Partial | Error ): Promise { if (!id) return undefined - const inTauri = isTauri() + const onDesktop = isDesktop() if (configuration === undefined) { - configuration = inTauri + configuration = onDesktop ? await readAppSettingsFile() : readLocalStorageAppSettingsFile() } if (err(configuration)) return Promise.reject(configuration) - const route = inTauri - ? await parseProjectRoute(configuration, id) - : parseProjectRouteWasm(configuration, id) + // Should not be possible but I guess logically it could be + if (configuration === undefined) { + return Promise.reject(new Error('No configuration found')) + } + + const route = parseProjectRoute(configuration, id) if (err(route)) return Promise.reject(route) diff --git a/src/lib/routeLoaders.ts b/src/lib/routeLoaders.ts index f7af34912..35325d1d7 100644 --- a/src/lib/routeLoaders.ts +++ b/src/lib/routeLoaders.ts @@ -1,7 +1,7 @@ import { ActionFunction, LoaderFunction, redirect } from 'react-router-dom' import { FileLoaderData, HomeLoaderData, IndexLoaderData } from './types' -import { isTauri } from './isTauri' import { getProjectMetaByRouteId, PATHS } from './paths' +import { isDesktop } from './isDesktop' import { BROWSER_PATH } from 'lib/paths' import { BROWSER_FILE_NAME, @@ -10,15 +10,13 @@ import { } from 'lib/constants' import { loadAndValidateSettings } from './settings/settingsUtils' import makeUrlPathRelative from './makeUrlPathRelative' -import { sep } from '@tauri-apps/api/path' -import { readTextFile } from '@tauri-apps/plugin-fs' import { codeManager } from 'lib/singletons' import { fileSystemManager } from 'lang/std/fileSystemManager' import { getProjectInfo, - initializeProjectDirectory, + ensureProjectDirectoryExists, listProjects, -} from './tauri' +} from './desktop' import { createSettings } from './settings/initialSettings' // The root loader simply resolves the settings and any errors that @@ -42,7 +40,7 @@ export const settingsLoader: LoaderFunction = async ({ const { settings: s } = await loadAndValidateSettings( project_path || undefined ) - settings = s + return s } } @@ -72,9 +70,10 @@ export const onboardingRedirectLoader: ActionFunction = async (args) => { return settingsLoader(args) } -export const fileLoader: LoaderFunction = async ({ - params, -}): Promise => { +export const fileLoader: LoaderFunction = async ( + routerData +): Promise => { + const { params } = routerData let { configuration } = await loadAndValidateSettings() const projectPathData = await getProjectMetaByRouteId( @@ -90,14 +89,16 @@ export const fileLoader: LoaderFunction = async ({ if (!current_file_name || !current_file_path || !project_name) { return redirect( `${PATHS.FILE}/${encodeURIComponent( - `${params.id}${isTauri() ? sep() : '/'}${PROJECT_ENTRYPOINT}` + `${params.id}${ + isDesktop() ? window.electron.path.sep : '/' + }${PROJECT_ENTRYPOINT}` )}` ) } // TODO: PROJECT_ENTRYPOINT is hardcoded // until we support setting a project's entrypoint file - const code = await readTextFile(current_file_path) + const code = await window.electron.readFile(current_file_path) // Update both the state and the editor's code. // We explicitly do not write to the file here since we are loading from @@ -109,22 +110,24 @@ export const fileLoader: LoaderFunction = async ({ // So that WASM gets an updated path for operations fileSystemManager.dir = project_path + const defaultProjectData = { + name: project_name || 'unnamed', + path: project_path, + children: [], + kcl_file_count: 0, + directory_count: 0, + metadata: null, + default_file: project_path, + } + const projectData: IndexLoaderData = { code, - project: isTauri() - ? await getProjectInfo(project_path, configuration) - : { - name: project_name, - path: project_path, - children: [], - kcl_file_count: 0, - directory_count: 0, - metadata: null, - default_file: project_path, - }, + project: isDesktop() + ? (await getProjectInfo(project_path)) ?? defaultProjectData + : defaultProjectData, file: { - name: current_file_name, - path: current_file_path, + name: current_file_name || '', + path: current_file_path?.split('/').slice(0, -1).join('/') ?? '', children: [], }, } @@ -154,12 +157,12 @@ export const fileLoader: LoaderFunction = async ({ export const homeLoader: LoaderFunction = async (): Promise< HomeLoaderData | Response > => { - if (!isTauri()) { + if (!isDesktop()) { return redirect(PATHS.FILE + '/%2F' + BROWSER_PROJECT_NAME) } const { configuration } = await loadAndValidateSettings() - const projectDir = await initializeProjectDirectory(configuration) + const projectDir = await ensureProjectDirectoryExists(configuration) if (projectDir) { const projects = await listProjects(configuration) diff --git a/src/lib/settings/initialKeybindings.ts b/src/lib/settings/initialKeybindings.ts index f37af6fc8..9c51bfdc3 100644 --- a/src/lib/settings/initialKeybindings.ts +++ b/src/lib/settings/initialKeybindings.ts @@ -1,4 +1,4 @@ -import { isTauri } from 'lib/isTauri' +import { isDesktop } from 'lib/isDesktop' export type InteractionMapItem = { name: string @@ -38,7 +38,7 @@ export const interactionMap: Record< Settings: [ { name: 'toggle-settings', - sequence: isTauri() ? 'Meta+,' : 'Shift+Meta+,', + sequence: isDesktop() ? 'Meta+,' : 'Shift+Meta+,', title: 'Toggle Settings', description: 'Opens the settings dialog. Always available.', }, diff --git a/src/lib/settings/initialSettings.tsx b/src/lib/settings/initialSettings.tsx index 338527ceb..13a4c68b1 100644 --- a/src/lib/settings/initialSettings.tsx +++ b/src/lib/settings/initialSettings.tsx @@ -12,9 +12,8 @@ import { cameraMouseDragGuards, cameraSystems, } from 'lib/cameraControls' -import { isTauri } from 'lib/isTauri' +import { isDesktop } from 'lib/isDesktop' import { useRef } from 'react' -import { open } from '@tauri-apps/plugin-dialog' import { CustomIcon } from 'components/CustomIcon' import Tooltip from 'components/Tooltip' @@ -63,8 +62,8 @@ export class Setting { get user(): T | undefined { return this._user } - set user(v: T) { - this._user = this.validate(v) ? v : this._user + set user(v: T | undefined) { + this._user = v !== undefined ? (this.validate(v) ? v : this._user) : v this.current = this.resolve() } /** @@ -73,8 +72,8 @@ export class Setting { get project(): T | undefined { return this._project } - set project(v: T) { - this._project = this.validate(v) ? v : this._project + set project(v: T | undefined) { + this._project = v !== undefined ? (this.validate(v) ? v : this._project) : v this.current = this.resolve() } /** @@ -193,7 +192,8 @@ export function createSettings() { description: 'The directory to save and load projects from', hideOnLevel: 'project', hideOnPlatform: 'web', - validate: (v) => typeof v === 'string' && (v.length > 0 || !isTauri()), + validate: (v) => + typeof v === 'string' && (v.length > 0 || !isDesktop()), Component: ({ value, updateValue }) => { const inputRef = useRef(null) return ( @@ -207,24 +207,23 @@ export function createSettings() { />