Compare commits
102 Commits
v0.5.4-can
...
v0.5.4-can
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
549dddc65f | ||
|
|
9f8b38f9f3 | ||
|
|
3a5a66a5a3 | ||
|
|
b4bb57b2a5 | ||
|
|
3df3498523 | ||
|
|
567092a1ff | ||
|
|
f3e1c1eb08 | ||
|
|
a04cfe2b68 | ||
|
|
c1a65b6b76 | ||
|
|
f3cbe54625 | ||
|
|
dcf7e83eec | ||
|
|
50006efb57 | ||
|
|
606f6652ac | ||
|
|
afff15c435 | ||
|
|
f7b8797bb2 | ||
|
|
2b05a1254b | ||
|
|
40e7074475 | ||
|
|
e1ad3e38b9 | ||
|
|
f03fdde770 | ||
|
|
d2eba54550 | ||
|
|
fa7baaf5c1 | ||
|
|
a4d8b65eef | ||
|
|
83dafa149c | ||
|
|
3a25f13734 | ||
|
|
db52c63d25 | ||
|
|
80f4578f76 | ||
|
|
15a7e93058 | ||
|
|
1c41731b4e | ||
|
|
a807647639 | ||
|
|
3f1293ca3c | ||
|
|
ad58b4d1e9 | ||
|
|
7e61708850 | ||
|
|
5c673a8ffc | ||
|
|
4528df07a5 | ||
|
|
b6eb017bd4 | ||
|
|
9d3b9e9848 | ||
|
|
04fc619f52 | ||
|
|
06ef6da370 | ||
|
|
d3ce90e721 | ||
|
|
9c94d05dd8 | ||
|
|
ef8dea8cb2 | ||
|
|
c27c241482 | ||
|
|
b73e9189ef | ||
|
|
c95b8e9d71 | ||
|
|
ab8669882a | ||
|
|
7ff12a6d0f | ||
|
|
339b133e3f | ||
|
|
be9095ec19 | ||
|
|
33261558f6 | ||
|
|
2ad1b770d0 | ||
|
|
74e21311dc | ||
|
|
bf83bfcf63 | ||
|
|
70d8f9a0a7 | ||
|
|
7d246f87e7 | ||
|
|
1ca9fb8ff4 | ||
|
|
2c95a0a757 | ||
|
|
a49d5ea1e2 | ||
|
|
84e2710e87 | ||
|
|
044e6da00d | ||
|
|
023cbc30ea | ||
|
|
7094385d8b | ||
|
|
f66d402cf7 | ||
|
|
971e256cd3 | ||
|
|
88a297c3c1 | ||
|
|
4bb50e8c25 | ||
|
|
acc5afdd4f | ||
|
|
9ec6768272 | ||
|
|
5a124831b8 | ||
|
|
01115f8957 | ||
|
|
a5a6203a95 | ||
|
|
4a473f5518 | ||
|
|
6cddacb953 | ||
|
|
00f44c72ce | ||
|
|
44011b4695 | ||
|
|
e0cd2e780b | ||
|
|
985bb55d82 | ||
|
|
66d0640042 | ||
|
|
e399682cad | ||
|
|
c4e90f2d8b | ||
|
|
b38b01fc98 | ||
|
|
0a0f825a15 | ||
|
|
d24c43e750 | ||
|
|
90b51031d2 | ||
|
|
1e771131b0 | ||
|
|
4d7a3e5bf1 | ||
|
|
92b1244fd7 | ||
|
|
d6b1b9f6cf | ||
|
|
b2e93433e1 | ||
|
|
97b1a31f8d | ||
|
|
4a1c15c1e9 | ||
|
|
f8d1513bb6 | ||
|
|
372377dd6b | ||
|
|
63f7b2556e | ||
|
|
c08c587efb | ||
|
|
65c1bee7f0 | ||
|
|
227f59cadc | ||
|
|
031ab2cfa2 | ||
|
|
9f33e73429 | ||
|
|
f1670af15d | ||
|
|
0d7f65ab36 | ||
|
|
3a053af50c | ||
|
|
c6be29f944 |
@@ -12,6 +12,7 @@
|
||||
"component",
|
||||
"workspace",
|
||||
"env",
|
||||
"graphql",
|
||||
"cli",
|
||||
"hooks",
|
||||
"i18n",
|
||||
@@ -19,7 +20,8 @@
|
||||
"octobase-node",
|
||||
"templates",
|
||||
"y-indexeddb",
|
||||
"debug"
|
||||
"debug",
|
||||
"theme"
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
@@ -75,6 +75,12 @@ const config = {
|
||||
'@typescript-eslint/consistent-type-imports': 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: '*.cjs',
|
||||
rules: {
|
||||
'@typescript-eslint/no-var-requires': 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
3
.github/CLA.md
vendored
@@ -55,3 +55,6 @@ Example:
|
||||
- Zhilin Liu, @lzlme, 2023/04/09
|
||||
- Skye Sun, @skyesun, 2023/04/14
|
||||
- Jordy Delgado, @Jdelgad8, 2023/04/17
|
||||
- Howard Do, @howarddo2208, 2023/04/20
|
||||
- Kevin Deng, @sxzz, 2023/04/21
|
||||
- Moeyua, @moeyua, 2023/04/22
|
||||
|
||||
2
.github/labeler.yml
vendored
@@ -15,6 +15,8 @@ mod:dev:
|
||||
|
||||
mod:workspace: 'packages/workspace/**/*'
|
||||
|
||||
mod:theme: 'packages/theme/**/*'
|
||||
|
||||
mod:i18n: 'packages/i18n/**/*'
|
||||
|
||||
mod:env: 'packages/env/**/*'
|
||||
|
||||
321
.github/workflows/build-master.yml
vendored
@@ -1,321 +0,0 @@
|
||||
name: Build & Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
environment: development
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
- run: yarn lint --max-warnings=0
|
||||
|
||||
build-storybook:
|
||||
name: Build Storybook
|
||||
runs-on: ubuntu-latest
|
||||
environment: development
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
- run: yarn build:storybook
|
||||
- name: Upload storybook artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: storybook
|
||||
path: ./packages/component/storybook-static
|
||||
if-no-files-found: error
|
||||
|
||||
build-frontend:
|
||||
name: Build @affine/web
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
- name: Cache Next.js
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/apps/web/.next/cache
|
||||
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-nextjs-${{ hashFiles('**/yarn.lock') }}-
|
||||
|
||||
- name: Build
|
||||
run: yarn build
|
||||
env:
|
||||
NEXT_PUBLIC_FIREBASE_API_KEY: ${{ secrets.NEXT_PUBLIC_FIREBASE_API_KEY }}
|
||||
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN: ${{ secrets.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN }}
|
||||
NEXT_PUBLIC_FIREBASE_PROJECT_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_PROJECT_ID }}
|
||||
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET: ${{ secrets.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET }}
|
||||
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID }}
|
||||
NEXT_PUBLIC_FIREBASE_APP_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_APP_ID }}
|
||||
NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID }}
|
||||
PERFSEE_TOKEN: ${{ secrets.PERFSEE_TOKEN }}
|
||||
|
||||
- name: Export
|
||||
run: yarn export
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: next-js
|
||||
path: ./apps/web/out
|
||||
if-no-files-found: error
|
||||
|
||||
publish-frontend:
|
||||
name: Push frontend image
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-frontend
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: 'toeverything/affine-pathfinder'
|
||||
IMAGE_TAG: canary-${{ github.sha }}
|
||||
IMAGE_TAG_LATEST: nightly-latest
|
||||
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Download artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: next-js
|
||||
path: ./apps/web/out
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
${{ env.IMAGE_TAG }}
|
||||
${{ env.IMAGE_TAG_LATEST }}
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
file: ./.github/deployment/Dockerfile
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
build-frontend-dev:
|
||||
name: Build @affine/web dev
|
||||
runs-on: ubuntu-latest
|
||||
environment: development
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
- name: Cache Next.js
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/apps/web/.next/cache
|
||||
key: ${{ runner.os }}-nextjs-dev-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-nextjs-dev-${{ hashFiles('**/yarn.lock') }}-
|
||||
|
||||
- name: Build
|
||||
run: yarn build
|
||||
env:
|
||||
NEXT_PUBLIC_FIREBASE_API_KEY: ${{ secrets.NEXT_PUBLIC_FIREBASE_API_KEY }}
|
||||
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN: ${{ secrets.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN }}
|
||||
NEXT_PUBLIC_FIREBASE_PROJECT_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_PROJECT_ID }}
|
||||
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET: ${{ secrets.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET }}
|
||||
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID }}
|
||||
NEXT_PUBLIC_FIREBASE_APP_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_APP_ID }}
|
||||
NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID }}
|
||||
API_SERVER_PROFILE: local
|
||||
ENABLE_DEBUG_PAGE: true
|
||||
COVERAGE: true
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: next-js-dev
|
||||
path: ./apps/web/.next
|
||||
if-no-files-found: error
|
||||
|
||||
storybook-test:
|
||||
name: Storybook Test
|
||||
runs-on: ubuntu-latest
|
||||
environment: development
|
||||
needs: [build-storybook]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
playwright-install: true
|
||||
- name: Download storybook artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: storybook
|
||||
path: ./packages/component/storybook-static
|
||||
- name: Run storybook tests
|
||||
working-directory: ./packages/component
|
||||
run: |
|
||||
yarn exec concurrently -k -s first -n "SB,TEST" -c "magenta,blue" "yarn exec serve ./storybook-static -l 6006" "yarn exec wait-on tcp:6006 && yarn test-storybook --coverage"
|
||||
- name: Upload storybook test coverage results
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./packages/component/coverage/storybook/coverage-storybook.json
|
||||
flags: storybook-test
|
||||
name: affine
|
||||
fail_ci_if_error: true
|
||||
|
||||
server-test:
|
||||
name: Server Test
|
||||
runs-on: ubuntu-latest
|
||||
environment: development
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
- name: Run server tests
|
||||
run: yarn test:coverage
|
||||
working-directory: apps/server
|
||||
- name: Upload server test coverage results
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./apps/server/.coverage/lcov.info
|
||||
flags: server-test
|
||||
name: affine
|
||||
fail_ci_if_error: true
|
||||
|
||||
e2e-test:
|
||||
name: E2E Test
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
environment: development
|
||||
needs: [build-frontend-dev, build-storybook]
|
||||
services:
|
||||
octobase:
|
||||
image: ghcr.io/toeverything/cloud-self-hosted:nightly-latest
|
||||
ports:
|
||||
- 3000:3000
|
||||
env:
|
||||
SIGN_KEY: 'test123'
|
||||
RUST_LOG: 'debug'
|
||||
JWST_DEV: '1'
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.ACTIONS_PACKAGE_PUBLISH }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
playwright-install: true
|
||||
- name: Download artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: next-js-dev
|
||||
path: ./apps/web/.next
|
||||
|
||||
- name: Download storybook artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: storybook
|
||||
path: ./packages/component/storybook-static
|
||||
|
||||
- name: Wait for Octobase Ready
|
||||
run: |
|
||||
node ./scripts/wait-3000-healthz.mjs
|
||||
|
||||
- name: Run playwright tests
|
||||
run: yarn test --forbid-only --shard=${{ matrix.shard }}/${{ strategy.job-total }}
|
||||
env:
|
||||
COVERAGE: true
|
||||
|
||||
- name: Collect code coverage report
|
||||
run: yarn exec nyc report -t .nyc_output --report-dir .coverage --reporter=lcov
|
||||
|
||||
- name: Upload e2e test coverage results
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./.coverage/lcov.info
|
||||
flags: e2etest
|
||||
name: affine
|
||||
fail_ci_if_error: true
|
||||
|
||||
- name: Upload test results
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: test-results-e2e-${{ matrix.shard }}
|
||||
path: ./test-results
|
||||
if-no-files-found: ignore
|
||||
|
||||
unit-test:
|
||||
name: Unit Test
|
||||
runs-on: ubuntu-latest
|
||||
environment: development
|
||||
needs: build-frontend
|
||||
services:
|
||||
octobase:
|
||||
image: ghcr.io/toeverything/cloud-self-hosted:nightly-latest
|
||||
ports:
|
||||
- 3000:3000
|
||||
env:
|
||||
SIGN_KEY: 'test123'
|
||||
RUST_LOG: 'debug'
|
||||
JWST_DEV: '1'
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.ACTIONS_PACKAGE_PUBLISH }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
- name: Download artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: next-js
|
||||
path: ./apps/web/.next
|
||||
|
||||
- name: Unit Test
|
||||
run: yarn run test:unit:coverage
|
||||
|
||||
- name: Upload unit test coverage results
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./.coverage/store/lcov.info
|
||||
flags: unittest
|
||||
name: affine
|
||||
fail_ci_if_error: true
|
||||
105
.github/workflows/build-test-version.yml
vendored
@@ -1,105 +0,0 @@
|
||||
name: Build Test Version
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Custom Tag. Set nightly-latest will publish to development.'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
# Cancels all previous workflow runs for pull requests that have not completed.
|
||||
# See https://docs.github.com/en/actions/using-jobs/using-concurrency
|
||||
concurrency:
|
||||
# The concurrency group contains the workflow name and the branch name for
|
||||
# pull requests or the commit hash for any other events.
|
||||
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Lint and Build
|
||||
runs-on: self-hosted
|
||||
environment: development
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
|
||||
- name: Lint
|
||||
run: |
|
||||
yarn lint --max-warnings=0
|
||||
|
||||
# - name: Test
|
||||
# run: yarn test
|
||||
|
||||
- name: Build
|
||||
run: yarn build
|
||||
env:
|
||||
NEXT_PUBLIC_FIREBASE_API_KEY: ${{ secrets.NEXT_PUBLIC_FIREBASE_API_KEY }}
|
||||
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN: ${{ secrets.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN }}
|
||||
NEXT_PUBLIC_FIREBASE_PROJECT_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_PROJECT_ID }}
|
||||
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET: ${{ secrets.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET }}
|
||||
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID }}
|
||||
NEXT_PUBLIC_FIREBASE_APP_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_APP_ID }}
|
||||
NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID }}
|
||||
|
||||
- name: Export
|
||||
run: yarn export
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
path: ./apps/web/out
|
||||
|
||||
push_to_registry:
|
||||
# See https://docs.github.com/en/actions/publishing-packages/publishing-docker-images
|
||||
name: Push Docker image to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: 'toeverything/affine-pathfinder-testing'
|
||||
IMAGE_TAG: canary-${{ github.sha }}
|
||||
IMAGE_TAG_LATEST: nightly-latest
|
||||
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Download artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: artifact
|
||||
path: apps/web/out/
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
${{ env.IMAGE_TAG }}
|
||||
${{ inputs.tag }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
file: ./.github/deployment/Dockerfile
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
125
.github/workflows/build.yml
vendored
@@ -1,6 +1,9 @@
|
||||
name: Build & Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
@@ -17,16 +20,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-node
|
||||
- run: yarn lint --max-warnings=0
|
||||
|
||||
install-all:
|
||||
name: Install All Dependencies
|
||||
runs-on: ubuntu-latest
|
||||
environment: development
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Install All Dependencies
|
||||
uses: ./.github/actions/setup-node
|
||||
|
||||
build-storybook:
|
||||
name: Build Storybook
|
||||
runs-on: ubuntu-latest
|
||||
@@ -44,6 +37,23 @@ jobs:
|
||||
path: ./packages/component/storybook-static
|
||||
if-no-files-found: error
|
||||
|
||||
build-electron:
|
||||
name: Build @affine/electron
|
||||
runs-on: ubuntu-latest
|
||||
environment: development
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
- name: Build Electron
|
||||
working-directory: apps/electron
|
||||
run: yarn exec ts-node-esm ./scripts/build-ci.mts
|
||||
- name: Upload Ubuntu desktop artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: affine-ubuntu
|
||||
path: ./apps/electron/dist
|
||||
|
||||
build:
|
||||
name: Build @affine/web
|
||||
runs-on: ubuntu-latest
|
||||
@@ -73,6 +83,7 @@ jobs:
|
||||
NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID }}
|
||||
API_SERVER_PROFILE: local
|
||||
ENABLE_DEBUG_PAGE: true
|
||||
ENABLE_LEGACY_PROVIDER: true
|
||||
COVERAGE: true
|
||||
|
||||
- name: Upload artifact
|
||||
@@ -82,17 +93,76 @@ jobs:
|
||||
path: ./apps/web/.next
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Build @affine/web for desktop
|
||||
run: yarn build
|
||||
env:
|
||||
NEXT_PUBLIC_FIREBASE_API_KEY: ${{ secrets.NEXT_PUBLIC_FIREBASE_API_KEY }}
|
||||
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN: ${{ secrets.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN }}
|
||||
NEXT_PUBLIC_FIREBASE_PROJECT_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_PROJECT_ID }}
|
||||
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET: ${{ secrets.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET }}
|
||||
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID }}
|
||||
NEXT_PUBLIC_FIREBASE_APP_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_APP_ID }}
|
||||
NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID }}
|
||||
API_SERVER_PROFILE: affine
|
||||
ENABLE_DEBUG_PAGE: true
|
||||
ENABLE_LEGACY_PROVIDER: false
|
||||
COVERAGE: true
|
||||
|
||||
- name: Export static resources
|
||||
run: yarn export
|
||||
working-directory: apps/web
|
||||
|
||||
- name: Upload static resources artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: next-js-static
|
||||
path: ./apps/web/out
|
||||
if-no-files-found: error
|
||||
|
||||
server-test:
|
||||
name: Server Test
|
||||
runs-on: ubuntu-latest
|
||||
environment: development
|
||||
services:
|
||||
postgres:
|
||||
image: postgres
|
||||
env:
|
||||
POSTGRES_PASSWORD: affine
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
- name: Initialize database
|
||||
run: |
|
||||
psql -h localhost -U postgres -c "CREATE DATABASE affine;"
|
||||
psql -h localhost -U postgres -c "CREATE USER affine WITH PASSWORD 'affine';"
|
||||
psql -h localhost -U postgres -c "ALTER USER affine WITH SUPERUSER;"
|
||||
env:
|
||||
PGPASSWORD: affine
|
||||
- name: Generate prisma client
|
||||
run: |
|
||||
yarn exec prisma generate
|
||||
yarn exec prisma db push
|
||||
working-directory: apps/server
|
||||
env:
|
||||
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
|
||||
- name: Run init-db script
|
||||
run: yarn exec ts-node-esm ./scripts/init-db.ts
|
||||
working-directory: apps/server
|
||||
env:
|
||||
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
|
||||
- name: Run server tests
|
||||
run: yarn test:coverage
|
||||
working-directory: apps/server
|
||||
env:
|
||||
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
|
||||
- name: Upload server test coverage results
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
@@ -197,6 +267,41 @@ jobs:
|
||||
path: ./test-results
|
||||
if-no-files-found: ignore
|
||||
|
||||
dekstop-test:
|
||||
name: Desktop Test
|
||||
runs-on: ubuntu-latest
|
||||
environment: development
|
||||
needs: [build, build-electron]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
playwright-install: true
|
||||
- name: Download Ubuntu desktop artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: affine-ubuntu
|
||||
path: ./apps/electron/dist
|
||||
|
||||
- name: Download static resource artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: next-js-static
|
||||
path: ./apps/electron/resources/web-static
|
||||
|
||||
- name: Run desktop tests
|
||||
run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- yarn test
|
||||
working-directory: apps/electron
|
||||
|
||||
- name: Upload test results
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: test-results-e2e-${{ matrix.shard }}
|
||||
path: ./test-results
|
||||
if-no-files-found: ignore
|
||||
|
||||
unit-test:
|
||||
name: Unit Test
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
12
.github/workflows/release-desktop-app.yml
vendored
@@ -17,11 +17,11 @@ on:
|
||||
type: boolean
|
||||
required: true
|
||||
default: true
|
||||
is-canary:
|
||||
description: 'Canary Release? The app will be named as "AFFiNE Canary"'
|
||||
type: boolean
|
||||
build-type:
|
||||
description: 'Build Type (canary, beta or stable)'
|
||||
type: string
|
||||
required: true
|
||||
default: true
|
||||
default: canary
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
@@ -35,7 +35,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
BUILD_TYPE: ${{ github.event.inputs.is-canary == 'true' && 'canary' || 'stable' }}
|
||||
BUILD_TYPE: ${{ github.event.inputs.build-type }}
|
||||
|
||||
jobs:
|
||||
before-make:
|
||||
@@ -173,7 +173,7 @@ jobs:
|
||||
- name: Create Release Draft
|
||||
uses: softprops/action-gh-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
with:
|
||||
name: Desktop APP ${{ github.event.inputs.version }}
|
||||
body: 'TODO: Add release notes here'
|
||||
|
||||
19
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Try publishing npm@latest release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
- name: Try publishing to NPM
|
||||
run: ./scripts/publish.sh
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -1,2 +1 @@
|
||||
pnpm-lock.yaml
|
||||
apps/electron/layers/preload/preload.d.ts
|
||||
|
||||
@@ -2,7 +2,7 @@ nmMode: hardlinks-local
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
npmAuthToken: '${NODE_AUTH_TOKEN:-NONE}'
|
||||
npmAuthToken: '${NPM_TOKEN:-NONE}'
|
||||
|
||||
npmPublishAccess: public
|
||||
|
||||
|
||||
114
README.md
@@ -25,9 +25,10 @@ See https://github.com/all-?/all-contributors/issues/361#issuecomment-637166066
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
|
||||
[?style=flat-square&logoColor=white&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAADAAAAAwAEwd99eAAABjElEQVRYhe1W0U3DMBB9RfyTDeoNyAYNG2QDOgJsECYgGxA26AZ4hIxgJqCZ4PjIGV+tUxK7raqiPsmKdXe5e3fOs7IiIlwSdxetfiNw7QRKAD0Ax/ssrI5QgQOw5v03AJOTJHcCL1x84LVmWzJyJlBg7P4BwCvb3pmIAbBPykZEqaulEU7YHNva1HypxUsKqIS9EvbynASs0n3ss+ciUIsuO8VvhL9emjdFBa3YO8XvALwpsZNYSqBB0PwUWgRZNksSL5GhlN0ngGd+dkpsD6AG8IGlslxwTh2fa09EBc3Dir32rRysuQlUAL54/wTAcpePPAXHPsOTGXhSEv69rAlYpZOt6DSO29J4D/TRRLJk6AvtaZSY9PkCFYVLqI9i/NF5YkkECgrXa6P4fVEn4iolrhNxRQqBZu7FqMNdZiMqAUPj2KdGZyicu1dHzlGqBHxn2sdTR53bmeJ+ebJd7LtXhGH4uQEwd0ttAPzMxGi5/6BdxTuMej41Bs59gGP+CU+Cq/4tvxH4HwR+Ab3Uqr/VGbqEAAAAAElFTkSuQmCC>)](https://app.affine.pro)
|
||||
[](https://github.com/toeverything/AFFiNE/releases/latest)
|
||||
[](https://github.com/toeverything/AFFiNE/releases/latest)
|
||||
[](https://github.com/toeverything/AFFiNE/releases/latest)
|
||||
[](https://affine.pro/download)
|
||||
[](https://affine.pro/download)
|
||||
[](https://affine.pro/download)
|
||||
[](https://affine.pro/download)
|
||||
|
||||
[![stars-icon]](https://github.com/toeverything/AFFiNE)
|
||||
[![All Contributors][all-contributors-badge]](#contributors)
|
||||
@@ -110,6 +111,14 @@ Looking for **others ways to contribute** and wondering where to start? Check ou
|
||||
|
||||
If you have questions, you are welcome to contact us. One of the best places to get more info and learn more is in the [AFFiNE Community](https://community.affine.pro) where you can engage with other like-minded individuals.
|
||||
|
||||
## Ecosystem
|
||||
|
||||
| Name | | |
|
||||
| --------------------------------------------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [@affine/component](https://affine-storybook.vercel.app/) | AFFiNE Component Resources | [](https://affine-storybook.vercel.app/) |
|
||||
| [@toeverything/y-indexeddb](packages/y-indexeddb) | IndexedDB database adapter for Yjs | [](https://www.npmjs.com/package/@toeverything/y-indexeddb) |
|
||||
| [@toeverything/theme](packages/theme) | AFFiNE theme | [](https://www.npmjs.com/package/@toeverything/theme) |
|
||||
|
||||
## Thanks
|
||||
|
||||
We would also like to give thanks to open-source projects that make AFFiNE possible:
|
||||
@@ -128,102 +137,11 @@ Thanks a lot to the community for providing such powerful and simple libraries,
|
||||
|
||||
# Contributors
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
|
||||
<!-- prettier-ignore-start -->
|
||||
<!-- markdownlint-disable -->
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><a href="https://github.com/doodlewind"><img src="https://avatars.githubusercontent.com/u/7312949?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Yifeng Wang</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=doodlewind" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=doodlewind" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://darksky.eu.org/"><img src="https://avatars.githubusercontent.com/u/25152247?v=4?s=50" width="50px;" alt=""/><br /><sub><b>DarkSky</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=darkskygit" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=darkskygit" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="http://zhangchi.page/"><img src="https://avatars.githubusercontent.com/u/5910926?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Chi Zhang</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=tzhangchi" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=tzhangchi" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/alt1o"><img src="https://avatars.githubusercontent.com/u/21084335?v=4?s=50" width="50px;" alt=""/><br /><sub><b>wang xinglong</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=alt1o" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=alt1o" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/Brooooooklyn"><img src="https://avatars.githubusercontent.com/u/3468483?v=4?s=50" width="50px;" alt=""/><br /><sub><b>LongYinan</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=Brooooooklyn" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=Brooooooklyn" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/hwangdev97"><img src="https://avatars.githubusercontent.com/u/24713927?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Hwang</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=hwangdev97" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=hwangdev97" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/kobeshanks"><img src="https://avatars.githubusercontent.com/u/82570088?v=4?s=50" width="50px;" alt=""/><br /><sub><b>kobeshanks</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=kobeshanks" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=kobeshanks" title="Documentation">📖</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://pengx17.vercel.app/"><img src="https://avatars.githubusercontent.com/u/584378?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Peng Xiao</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=pengx17" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=pengx17" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://mirone.me/"><img src="https://avatars.githubusercontent.com/u/10047788?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Mirone</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=Saul-Mirone" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=Saul-Mirone" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/zqran"><img src="https://avatars.githubusercontent.com/u/15389209?v=4?s=50" width="50px;" alt=""/><br /><sub><b>zqran</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=zqran" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=zqran" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://sunebear.com/"><img src="https://avatars.githubusercontent.com/u/7693264?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Shule Hsiung</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=SuneBear" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=SuneBear" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://fundon.viz.rs/"><img src="https://avatars.githubusercontent.com/u/27926?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Fangdun Tsai</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=fundon" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=fundon" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://lawvs.github.io/profile/"><img src="https://avatars.githubusercontent.com/u/18554747?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Whitewater</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=lawvs" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=lawvs" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/zuoxiaodong0815"><img src="https://avatars.githubusercontent.com/u/53252747?v=4?s=50" width="50px;" alt=""/><br /><sub><b>xiaodong zuo</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=zuoxiaodong0815" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=zuoxiaodong0815" title="Documentation">📖</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://github.com/Himself65"><img src="https://avatars.githubusercontent.com/u/14026360?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Himself65</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=Himself65" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=Himself65" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/DiamondThree"><img src="https://avatars.githubusercontent.com/u/24630517?v=4?s=50" width="50px;" alt=""/><br /><sub><b>DiamondThree</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=DiamondThree" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=DiamondThree" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/QiShaoXuan"><img src="https://avatars.githubusercontent.com/u/22772830?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Qi</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=QiShaoXuan" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=QiShaoXuan" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://colelawrence.com/"><img src="https://avatars.githubusercontent.com/u/2925395?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Cole Lawrence</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=colelawrence" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=colelawrence" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://onetwo.ren/wiki"><img src="https://avatars.githubusercontent.com/u/3746270?v=4?s=50" width="50px;" alt=""/><br /><sub><b>lin onetwo</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=linonetwo" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=linonetwo" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/thorseraq"><img src="https://avatars.githubusercontent.com/u/20554850?v=4?s=50" width="50px;" alt=""/><br /><sub><b>x1a0t</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=thorseraq" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=thorseraq" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/HeJiachen-PM"><img src="https://avatars.githubusercontent.com/u/79301703?v=4?s=50" width="50px;" alt=""/><br /><sub><b>HeJiachen-PM</b></sub></a><br /><a href="#research-HeJiachen-PM" title="Research">🔬</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=HeJiachen-PM" title="Documentation">📖</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://www.notion.so/houjoe/Joe-2a85f5be01004cd2b6a5ad26fbb948b1"><img src="https://avatars.githubusercontent.com/u/22443345?v=4?s=50" width="50px;" alt=""/><br /><sub><b>houjoe</b></sub></a><br /><a href="#research-joebeijing" title="Research">🔬</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=joebeijing" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/Yipei-Operation"><img src="https://avatars.githubusercontent.com/u/79373028?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Yipei Wei</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=Yipei-Operation" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/VelikaHF"><img src="https://avatars.githubusercontent.com/u/121547898?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Velika</b></sub></a><br /><a href="#design-VelikaHF" title="Design">🎨</a></td>
|
||||
<td align="center"><a href="https://github.com/Svaney-ssman"><img src="https://avatars.githubusercontent.com/u/110808979?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Svaney</b></sub></a><br /><a href="#design-Svaney-ssman" title="Design">🎨</a></td>
|
||||
<td align="center"><a href="http://xell.me/"><img src="https://avatars.githubusercontent.com/u/132558?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Guozhu Liu</b></sub></a><br /><a href="#design-xell" title="Design">🎨</a></td>
|
||||
<td align="center"><a href="https://github.com/fyZheng07"><img src="https://avatars.githubusercontent.com/u/63830919?v=4?s=50" width="50px;" alt=""/><br /><sub><b>fyZheng07</b></sub></a><br /><a href="#eventOrganizing-fyZheng07" title="Event Organizing">📋</a> <a href="#userTesting-fyZheng07" title="User Testing">📓</a></td>
|
||||
<td align="center"><a href="https://github.com/CJSS"><img src="https://avatars.githubusercontent.com/u/4605025?v=4?s=50" width="50px;" alt=""/><br /><sub><b>CJSS</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=CJSS" title="Documentation">📖</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://github.com/JimmFly"><img src="https://avatars.githubusercontent.com/u/102217452?v=4?s=50" width="50px;" alt=""/><br /><sub><b>JimmFly</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=JimmFly" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/mitsuhatu"><img src="https://avatars.githubusercontent.com/u/110213079?v=4?s=50" width="50px;" alt=""/><br /><sub><b>mitsuhatu</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=mitsuhatu" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=mitsuhatu" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://shockwave.me/"><img src="https://avatars.githubusercontent.com/u/15013925?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Austaras</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=Austaras" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=Austaras" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/uptonking"><img src="https://avatars.githubusercontent.com/u/11391549?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Jin Yao</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=uptonking" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=uptonking" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/CarlosZoft"><img src="https://avatars.githubusercontent.com/u/62192072?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Carlos Rafael </b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=CarlosZoft" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/caleboleary"><img src="https://avatars.githubusercontent.com/u/12816579?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Caleb OLeary</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=caleboleary" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/westongraham"><img src="https://avatars.githubusercontent.com/u/89493023?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Weston Graham</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=westongraham" title="Documentation">📖</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://github.com/SaikaSakura"><img src="https://avatars.githubusercontent.com/u/11530942?v=4?s=50" width="50px;" alt=""/><br /><sub><b>MingLIang Wang</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=SaikaSakura" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=SaikaSakura" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/fanjing22"><img src="https://avatars.githubusercontent.com/u/109729699?v=4?s=50" width="50px;" alt=""/><br /><sub><b>fanjing22</b></sub></a><br /><a href="#design-fanjing22" title="Design">🎨</a></td>
|
||||
<td align="center"><a href="https://github.com/pointmax"><img src="https://avatars.githubusercontent.com/u/49361135?v=4?s=50" width="50px;" alt=""/><br /><sub><b>pointmax</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=pointmax" title="Documentation">📖</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=pointmax" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://liby.github.io/notes"><img src="https://avatars.githubusercontent.com/u/38807139?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Bryan Lee</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=liby" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/chenmoonmo"><img src="https://avatars.githubusercontent.com/u/36295999?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Simon Li</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=chenmoonmo" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/githbq"><img src="https://avatars.githubusercontent.com/u/10009709?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Bob Hu</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=githbq" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://quavo.vercel.app/"><img src="https://avatars.githubusercontent.com/u/67266933?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Quavo</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=lucky-chap" title="Documentation">📖</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=lucky-chap" title="Code">💻</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://github.com/LuciNyan"><img src="https://avatars.githubusercontent.com/u/22126563?v=4?s=50" width="50px;" alt=""/><br /><sub><b>子瞻 Luci</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=LuciNyan" title="Code">💻</a></td>
|
||||
<td align="center"><a href="http://blog.ipili.me/"><img src="https://avatars.githubusercontent.com/u/4948120?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Horus</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=m1911star" title="Code">💻</a> <a href="#platform-m1911star" title="Packaging/porting to new platform">📦</a></td>
|
||||
<td align="center"><a href="https://segmentfault.com/u/qzuser_584786517d31a"><img src="https://avatars.githubusercontent.com/u/15103283?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Super.x</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=fanshyiis" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://wangyu-1999.github.io/"><img src="https://avatars.githubusercontent.com/u/80874770?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Wang Yu</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=wangyu-1999" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://felixc.at/"><img src="https://avatars.githubusercontent.com/u/1006477?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Felix Yan</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=felixonmars" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/lynettelopez"><img src="https://avatars.githubusercontent.com/u/32908859?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Lynette Lopez</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=lynettelopez" title="Code">💻</a></td>
|
||||
<td align="center"><a href="http://manjusaka.itscoder.com/"><img src="https://avatars.githubusercontent.com/u/7054676?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Manjusaka</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=Zheaoli" title="Code">💻</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://juejin.cn/user/2867982785579102/posts?sort=popular"><img src="https://avatars.githubusercontent.com/u/76603360?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Frozen FIsh</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=sudongyuer" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/MuhammedFaraz"><img src="https://avatars.githubusercontent.com/u/92734739?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Mohammed Faraz</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=MuhammedFaraz" title="Documentation">📖</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=MuhammedFaraz" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://pranavsriram.dev/"><img src="https://avatars.githubusercontent.com/u/28348429?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Pranav Sriram </b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=Pranav4399" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/Reson-a"><img src="https://avatars.githubusercontent.com/u/20806266?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Reson-a</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=Reson-a" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://t.me/littlepoint"><img src="https://avatars.githubusercontent.com/u/7611700?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Zhizhen He</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=hezhizhen" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://akr.moe/"><img src="https://avatars.githubusercontent.com/u/85140972?v=4?s=50" width="50px;" alt=""/><br /><sub><b>AkaraChen</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=AkaraChen" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/suyanhanx"><img src="https://avatars.githubusercontent.com/u/24221472?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Suyan</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=suyanhanx" title="Code">💻</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://github.com/hehex9"><img src="https://avatars.githubusercontent.com/u/9209882?v=4?s=50" width="50px;" alt=""/><br /><sub><b>hehe</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=hehex9" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/albertodlc"><img src="https://avatars.githubusercontent.com/u/32411964?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Alberto de la Cruz</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=albertodlc" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/AlessioGr"><img src="https://avatars.githubusercontent.com/u/70709113?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Alessio Gravili</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=AlessioGr" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/lzlme"><img src="https://avatars.githubusercontent.com/u/117659326?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Zhilin Liu</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=lzlme" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/suica"><img src="https://avatars.githubusercontent.com/u/8041462?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Sg</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=suica" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://sinchang.me/"><img src="https://avatars.githubusercontent.com/u/3297859?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Jeff Wen</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=sinchang" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://m1212e.github.io/portfolio/"><img src="https://avatars.githubusercontent.com/u/14091540?v=4?s=50" width="50px;" alt=""/><br /><sub><b>m1212e</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=m1212e" title="Code">💻</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://adityash1.github.io/"><img src="https://avatars.githubusercontent.com/u/65771169?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Aditya Sharma</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=adityash1" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/sheben404"><img src="https://avatars.githubusercontent.com/u/61317160?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Kehan Wang</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=sheben404" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/VictorNanka"><img src="https://avatars.githubusercontent.com/u/30154366?v=4?s=50" width="50px;" alt=""/><br /><sub><b>VictorNanka</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=VictorNanka" title="Code">💻</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
We would like to express our gratitude to all the individuals who have already contributed to AFFiNE! If you have any AFFiNE-related project, documentation, tool or template, please feel free to contribute it by submitting a pull request to our curated list on GitHub: [awesome-affine](https://github.com/toeverything/awesome-affine).
|
||||
|
||||
<!-- markdownlint-restore -->
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
||||
<a href="https://github.com/toeverything/affine/graphs/contributors">
|
||||
<img src="https://user-images.githubusercontent.com/5910926/233382206-312428ca-094a-4579-ae06-213961ed7eab.svg" />
|
||||
</a>
|
||||
|
||||
## Self-Host
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
cacheFolder: '../../.yarn/cache'
|
||||
deferredVersionFolder: '../../.yarn/versions'
|
||||
globalFolder: '../../.yarn/global'
|
||||
installStatePath: '../../.yarn/install-state.gz'
|
||||
patchFolder: '../../.yarn/patches'
|
||||
pnpUnpluggedFolder: '../../.yarn/unplugged'
|
||||
yarnPath: '../../.yarn/releases/yarn-3.5.0.cjs'
|
||||
virtualFolder: '../../.yarn/__virtual__'
|
||||
@@ -14,6 +14,24 @@ yarn generate-assets
|
||||
yarn dev # or yarn prod for production build
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### better-sqlite3 error
|
||||
|
||||
When running tests or starting electron, you may encounter the following error:
|
||||
|
||||
> Error: The module 'apps/electron/node_modules/better-sqlite3/build/Release/better_sqlite3.node'
|
||||
|
||||
This is due to the fact that the `better-sqlite3` package is built for the Node.js version in Electron & in your machine. To fix this, run the following command based on different cases:
|
||||
|
||||
```sh
|
||||
# for running unit tests, we are not using Electron's node:
|
||||
yarn rebuild better-sqlite3
|
||||
|
||||
# for running Electron, we are using Electron's node:
|
||||
yarn postinstall
|
||||
```
|
||||
|
||||
## Credits
|
||||
|
||||
Most of the boilerplate code is generously borrowed from the following
|
||||
|
||||
@@ -3,25 +3,33 @@ const {
|
||||
utils: { fromBuildIdentifier },
|
||||
} = require('@electron-forge/core');
|
||||
|
||||
const isCanary = process.env.BUILD_TYPE === 'canary';
|
||||
const path = require('node:path');
|
||||
|
||||
const productName = isCanary ? 'AFFiNE-Canary' : 'AFFiNE';
|
||||
const icoPath = isCanary
|
||||
? './resources/icons/icon_canary.ico'
|
||||
const buildType = (process.env.BUILD_TYPE || 'stable').trim().toLowerCase();
|
||||
const stableBuild = buildType === 'stable';
|
||||
const productName = !stableBuild ? `AFFiNE-${buildType}` : 'AFFiNE';
|
||||
const icoPath = !stableBuild
|
||||
? `./resources/icons/icon_${buildType}.ico`
|
||||
: './resources/icons/icon.ico';
|
||||
const icnsPath = isCanary
|
||||
? './resources/icons/icon_canary.icns'
|
||||
const icnsPath = !stableBuild
|
||||
? `./resources/icons/icon_${buildType}.icns`
|
||||
: './resources/icons/icon.icns';
|
||||
|
||||
const arch =
|
||||
process.argv.indexOf('--arch') > 0
|
||||
? process.argv[process.argv.indexOf('--arch') + 1]
|
||||
: process.arch;
|
||||
|
||||
/**
|
||||
* @type {import('@electron-forge/shared-types').ForgeConfig}
|
||||
*/
|
||||
module.exports = {
|
||||
buildIdentifier: isCanary ? 'canary' : 'stable',
|
||||
buildIdentifier: buildType,
|
||||
packagerConfig: {
|
||||
name: productName,
|
||||
appBundleId: fromBuildIdentifier({
|
||||
canary: 'pro.affine.canary',
|
||||
beta: 'pro.affine.beta',
|
||||
stable: 'pro.affine.app',
|
||||
}),
|
||||
icon: icnsPath,
|
||||
@@ -45,6 +53,23 @@ module.exports = {
|
||||
format: 'ULFO',
|
||||
icon: icnsPath,
|
||||
name: 'AFFiNE',
|
||||
'icon-size': 128,
|
||||
background: './resources/icons/dmg-background.png',
|
||||
contents: [
|
||||
{
|
||||
x: 176,
|
||||
y: 192,
|
||||
type: 'file',
|
||||
path: path.resolve(
|
||||
__dirname,
|
||||
'out',
|
||||
buildType,
|
||||
`${productName}-darwin-${arch}`,
|
||||
`${productName}.app`
|
||||
),
|
||||
},
|
||||
{ x: 432, y: 192, type: 'link', path: '/Applications' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
3
apps/electron/layers/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import log from 'electron-log';
|
||||
|
||||
export const logger = log;
|
||||
5
apps/electron/layers/main-events.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// This file contains the main process events
|
||||
// It will guide preload and main process on the correct event types and payloads
|
||||
export interface MainEventMap {
|
||||
'main:on-db-update': (workspaceId: string) => void;
|
||||
}
|
||||
1
apps/electron/layers/main/src/__tests__/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
tmp
|
||||
226
apps/electron/layers/main/src/__tests__/handlers.spec.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import assert from 'node:assert';
|
||||
import path from 'node:path';
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
const registeredHandlers = new Map<string, (...args: any[]) => any>();
|
||||
|
||||
// common mock dispatcher for ipcMain.handle and app.on
|
||||
async function dispatch(key: string, ...args: any[]) {
|
||||
const handler = registeredHandlers.get(key);
|
||||
assert(handler);
|
||||
return await handler(null, ...args);
|
||||
}
|
||||
|
||||
const APP_PATH = path.join(__dirname, './tmp');
|
||||
|
||||
const browserWindow = {
|
||||
isDestroyed: () => {
|
||||
return false;
|
||||
},
|
||||
setWindowButtonVisibility: (v: boolean) => {
|
||||
// will be stubbed later
|
||||
},
|
||||
webContents: {
|
||||
send: (type: string, ...args: any[]) => {
|
||||
// ...
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const ipcMain = {
|
||||
handle: (key: string, callback: (...args: any[]) => any) => {
|
||||
registeredHandlers.set(key, callback);
|
||||
},
|
||||
};
|
||||
|
||||
const nativeTheme = {
|
||||
themeSource: 'light',
|
||||
};
|
||||
|
||||
function compareBuffer(a: Uint8Array, b: Uint8Array) {
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// dynamically import handlers so that we can inject local variables to mocks
|
||||
vi.doMock('electron', () => {
|
||||
return {
|
||||
app: {
|
||||
getPath: (name: string) => {
|
||||
assert(name === 'appData');
|
||||
return APP_PATH;
|
||||
},
|
||||
name: 'affine-test',
|
||||
on: (name: string, callback: (...args: any[]) => any) => {
|
||||
registeredHandlers.set(name, callback);
|
||||
},
|
||||
},
|
||||
BrowserWindow: {
|
||||
getAllWindows: () => {
|
||||
return [browserWindow];
|
||||
},
|
||||
},
|
||||
nativeTheme: nativeTheme,
|
||||
ipcMain,
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// clean up tmp folder
|
||||
const { registerHandlers } = await import('../handlers');
|
||||
registerHandlers();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const { cleanupWorkspaceDBs } = await import('../handlers');
|
||||
cleanupWorkspaceDBs();
|
||||
await fs.remove(APP_PATH);
|
||||
});
|
||||
|
||||
describe('ensureWorkspaceDB', () => {
|
||||
test('should create db file on connection if it does not exist', async () => {
|
||||
const id = 'test-workspace-id';
|
||||
const { ensureWorkspaceDB } = await import('../handlers');
|
||||
const workspaceDB = await ensureWorkspaceDB(id);
|
||||
const file = workspaceDB.path;
|
||||
const fileExists = await fs.pathExists(file);
|
||||
expect(fileExists).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('workspace handlers', () => {
|
||||
test('list all workspace ids', async () => {
|
||||
const ids = ['test-workspace-id', 'test-workspace-id-2'];
|
||||
const { ensureWorkspaceDB } = await import('../handlers');
|
||||
await Promise.all(ids.map(id => ensureWorkspaceDB(id)));
|
||||
const list = await dispatch('workspace:list');
|
||||
expect(list).toEqual(ids);
|
||||
});
|
||||
|
||||
test('delete workspace', async () => {
|
||||
const ids = ['test-workspace-id', 'test-workspace-id-2'];
|
||||
const { ensureWorkspaceDB } = await import('../handlers');
|
||||
await Promise.all(ids.map(id => ensureWorkspaceDB(id)));
|
||||
await dispatch('workspace:delete', 'test-workspace-id-2');
|
||||
const list = await dispatch('workspace:list');
|
||||
expect(list).toEqual(['test-workspace-id']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UI handlers', () => {
|
||||
test('theme-change', async () => {
|
||||
await dispatch('ui:theme-change', 'dark');
|
||||
expect(nativeTheme.themeSource).toBe('dark');
|
||||
await dispatch('ui:theme-change', 'light');
|
||||
expect(nativeTheme.themeSource).toBe('light');
|
||||
});
|
||||
|
||||
test('sidebar-visibility-change (macOS)', async () => {
|
||||
vi.stubGlobal('process', { platform: 'darwin' });
|
||||
const setWindowButtonVisibility = vi.fn();
|
||||
browserWindow.setWindowButtonVisibility = setWindowButtonVisibility;
|
||||
await dispatch('ui:sidebar-visibility-change', true);
|
||||
expect(setWindowButtonVisibility).toBeCalledWith(true);
|
||||
await dispatch('ui:sidebar-visibility-change', false);
|
||||
expect(setWindowButtonVisibility).toBeCalledWith(false);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test('sidebar-visibility-change (non-macOS)', async () => {
|
||||
vi.stubGlobal('process', { platform: 'linux' });
|
||||
const setWindowButtonVisibility = vi.fn();
|
||||
browserWindow.setWindowButtonVisibility = setWindowButtonVisibility;
|
||||
await dispatch('ui:sidebar-visibility-change', true);
|
||||
expect(setWindowButtonVisibility).not.toBeCalled();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
describe('db handlers', () => {
|
||||
test('will reconnect on activate', async () => {
|
||||
const { ensureWorkspaceDB } = await import('../handlers');
|
||||
const workspaceDB = await ensureWorkspaceDB('test-workspace-id');
|
||||
const instance = vi.spyOn(workspaceDB, 'reconnectDB');
|
||||
await dispatch('activate');
|
||||
expect(instance).toBeCalled();
|
||||
});
|
||||
|
||||
test('apply doc and get doc updates', async () => {
|
||||
const workspaceId = 'test-workspace-id';
|
||||
const bin = await dispatch('db:get-doc', workspaceId);
|
||||
// ? is this a good test?
|
||||
expect(bin.every((byte: number) => byte === 0)).toBe(true);
|
||||
|
||||
const ydoc = new Y.Doc();
|
||||
const ytext = ydoc.getText('test');
|
||||
ytext.insert(0, 'hello world');
|
||||
const bin2 = Y.encodeStateAsUpdate(ydoc);
|
||||
|
||||
await dispatch('db:apply-doc-update', workspaceId, bin2);
|
||||
|
||||
const bin3 = await dispatch('db:get-doc', workspaceId);
|
||||
const ydoc2 = new Y.Doc();
|
||||
Y.applyUpdate(ydoc2, bin3);
|
||||
const ytext2 = ydoc2.getText('test');
|
||||
expect(ytext2.toString()).toBe('hello world');
|
||||
});
|
||||
|
||||
test('get non existent doc', async () => {
|
||||
const workspaceId = 'test-workspace-id';
|
||||
const bin = await dispatch('db:get-blob', workspaceId, 'non-existent-id');
|
||||
expect(bin).toBeNull();
|
||||
});
|
||||
|
||||
test('list blobs (empty)', async () => {
|
||||
const workspaceId = 'test-workspace-id';
|
||||
const list = await dispatch('db:get-persisted-blobs', workspaceId);
|
||||
expect(list).toEqual([]);
|
||||
});
|
||||
|
||||
test('CRUD blobs', async () => {
|
||||
const testBin = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
const testBin2 = new Uint8Array([6, 7, 8, 9, 10]);
|
||||
const workspaceId = 'test-workspace-id';
|
||||
|
||||
// add blob
|
||||
await dispatch('db:add-blob', workspaceId, 'testBin', testBin);
|
||||
|
||||
// get blob
|
||||
expect(
|
||||
compareBuffer(
|
||||
await dispatch('db:get-blob', workspaceId, 'testBin'),
|
||||
testBin
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
// add another blob
|
||||
await dispatch('db:add-blob', workspaceId, 'testBin2', testBin2);
|
||||
expect(
|
||||
compareBuffer(
|
||||
await dispatch('db:get-blob', workspaceId, 'testBin2'),
|
||||
testBin2
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
// list blobs
|
||||
let lists = await dispatch('db:get-persisted-blobs', workspaceId);
|
||||
expect(lists).toHaveLength(2);
|
||||
expect(lists).toContain('testBin');
|
||||
expect(lists).toContain('testBin2');
|
||||
|
||||
// delete blob
|
||||
await dispatch('db:delete-blob', workspaceId, 'testBin');
|
||||
lists = await dispatch('db:get-persisted-blobs', workspaceId);
|
||||
expect(lists).toEqual(['testBin2']);
|
||||
});
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
import * as os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { app, shell } from 'electron';
|
||||
import { BrowserWindow, ipcMain, nativeTheme } from 'electron';
|
||||
import fs from 'fs-extra';
|
||||
import { parse } from 'url';
|
||||
|
||||
import { isMacOS } from '../../../utils';
|
||||
import { getExchangeTokenParams, oauthEndpoint } from './google-auth';
|
||||
|
||||
const AFFINE_ROOT = path.join(os.homedir(), '.affine');
|
||||
|
||||
fs.ensureDirSync(AFFINE_ROOT);
|
||||
|
||||
const logger = console;
|
||||
|
||||
export const registerHandlers = () => {
|
||||
ipcMain.handle('ui:theme-change', async (_, theme) => {
|
||||
nativeTheme.themeSource = theme;
|
||||
logger.info('theme change', theme);
|
||||
});
|
||||
|
||||
ipcMain.handle('ui:sidebar-visibility-change', async (_, visible) => {
|
||||
// todo
|
||||
// detect if os is macos
|
||||
if (isMacOS()) {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
windows.forEach(w => {
|
||||
// hide window buttons when sidebar is not visible
|
||||
w.setWindowButtonVisibility(visible);
|
||||
});
|
||||
logger.info('sidebar visibility change', visible);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('ui:get-google-oauth-code', async () => {
|
||||
logger.info('starting google sign in ...');
|
||||
shell.openExternal(oauthEndpoint);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const handleOpenUrl = async (_: any, url: string) => {
|
||||
const mainWindow = BrowserWindow.getAllWindows().find(
|
||||
w => !w.isDestroyed()
|
||||
);
|
||||
const urlObj = parse(url.replace('??', '?'), true);
|
||||
if (!mainWindow || !url.startsWith('affine://auth-callback')) return;
|
||||
const code = urlObj.query['code'] as string;
|
||||
if (!code) return;
|
||||
|
||||
logger.info('google sign in code received from callback', code);
|
||||
|
||||
app.removeListener('open-url', handleOpenUrl);
|
||||
resolve(getExchangeTokenParams(code));
|
||||
};
|
||||
|
||||
app.on('open-url', handleOpenUrl);
|
||||
|
||||
setTimeout(() => {
|
||||
reject(new Error('Timed out'));
|
||||
app.removeListener('open-url', handleOpenUrl);
|
||||
}, 30000);
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle('main:env-update', async (_, env, value) => {
|
||||
process.env[env] = value;
|
||||
});
|
||||
};
|
||||
9
apps/electron/layers/main/src/context.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { app } from 'electron';
|
||||
import path from 'path';
|
||||
|
||||
export const appContext = {
|
||||
appName: app.name,
|
||||
appDataPath: path.join(app.getPath('appData'), app.name),
|
||||
};
|
||||
|
||||
export type AppContext = typeof appContext;
|
||||
34
apps/electron/layers/main/src/data/export.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import { logger } from '../../../logger';
|
||||
import type { WorkspaceDatabase } from './sqlite';
|
||||
|
||||
/**
|
||||
* Start a backup of the database to the given destination.
|
||||
*/
|
||||
export async function exportDatabase(db: WorkspaceDatabase, dest: string) {
|
||||
await fs.copyFile(db.path, dest);
|
||||
logger.log('export: ', dest);
|
||||
}
|
||||
|
||||
// export async function startBackup(db: WorkspaceDatabase, dest: string) {
|
||||
// let timeout: NodeJS.Timeout | null;
|
||||
// async function backup() {
|
||||
// await fs.copyFile(db.path, dest);
|
||||
// logger.log('backup: ', dest);
|
||||
// }
|
||||
|
||||
// backup();
|
||||
|
||||
// const _db = await db.sqliteDB$;
|
||||
|
||||
// _db.on('change', () => {
|
||||
// if (timeout) {
|
||||
// clearTimeout(timeout);
|
||||
// }
|
||||
// timeout = setTimeout(async () => {
|
||||
// await backup();
|
||||
// timeout = null;
|
||||
// }, 1000);
|
||||
// });
|
||||
// }
|
||||
7
apps/electron/layers/main/src/data/fs-watch.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { WatchListener } from 'fs-extra';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
export function watchFile(path: string, callback: WatchListener<string>) {
|
||||
const watcher = fs.watch(path, callback);
|
||||
return () => watcher.close();
|
||||
}
|
||||
174
apps/electron/layers/main/src/data/sqlite.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import type { Database } from 'better-sqlite3';
|
||||
import sqlite from 'better-sqlite3';
|
||||
import fs from 'fs-extra';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { logger } from '../../../logger';
|
||||
import type { AppContext } from '../context';
|
||||
|
||||
const schemas = [
|
||||
`CREATE TABLE IF NOT EXISTS "updates" (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
data BLOB NOT NULL,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS "blobs" (
|
||||
key TEXT PRIMARY KEY NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
)`,
|
||||
];
|
||||
|
||||
interface UpdateRow {
|
||||
id: number;
|
||||
data: Buffer;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface BlobRow {
|
||||
key: string;
|
||||
data: Buffer;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export class WorkspaceDatabase {
|
||||
sqliteDB: Database;
|
||||
ydoc = new Y.Doc();
|
||||
firstConnect = false;
|
||||
|
||||
constructor(public path: string) {
|
||||
this.sqliteDB = this.reconnectDB();
|
||||
}
|
||||
|
||||
// release resources
|
||||
destroy = () => {
|
||||
this.sqliteDB?.close();
|
||||
this.ydoc.destroy();
|
||||
};
|
||||
|
||||
reconnectDB = () => {
|
||||
logger.log('open db', this.path);
|
||||
if (this.sqliteDB) {
|
||||
this.sqliteDB.close();
|
||||
}
|
||||
|
||||
// use cached version?
|
||||
const db = (this.sqliteDB = sqlite(this.path));
|
||||
db.exec(schemas.join(';'));
|
||||
|
||||
if (!this.firstConnect) {
|
||||
this.ydoc.on('update', this.addUpdateToSQLite);
|
||||
}
|
||||
|
||||
const updates = this.getUpdates();
|
||||
updates.forEach(update => {
|
||||
Y.applyUpdate(this.ydoc, update.data);
|
||||
});
|
||||
|
||||
this.firstConnect = true;
|
||||
|
||||
return db;
|
||||
};
|
||||
|
||||
getEncodedDocUpdates = () => {
|
||||
return Y.encodeStateAsUpdate(this.ydoc);
|
||||
};
|
||||
|
||||
// non-blocking and use yDoc to validate the update
|
||||
// after that, the update is added to the db
|
||||
applyUpdate = (data: Uint8Array) => {
|
||||
Y.applyUpdate(this.ydoc, data);
|
||||
// todo: trim the updates when the number of records is too large
|
||||
// 1. store the current ydoc state in the db
|
||||
// 2. then delete the old updates
|
||||
// yjs-idb will always trim the db for the first time after DB is loaded
|
||||
};
|
||||
|
||||
addBlob = (key: string, data: Uint8Array) => {
|
||||
try {
|
||||
const statement = this.sqliteDB.prepare(
|
||||
'INSERT INTO blobs (key, data) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET data = ?'
|
||||
);
|
||||
statement.run(key, data, data);
|
||||
} catch (error) {
|
||||
logger.error('addBlob', error);
|
||||
}
|
||||
};
|
||||
|
||||
getBlob = (key: string) => {
|
||||
try {
|
||||
const statement = this.sqliteDB.prepare(
|
||||
'SELECT data FROM blobs WHERE key = ?'
|
||||
);
|
||||
const row = statement.get(key) as BlobRow;
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return row.data;
|
||||
} catch (error) {
|
||||
logger.error('getBlob', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
deleteBlob = (key: string) => {
|
||||
try {
|
||||
const statement = this.sqliteDB.prepare(
|
||||
'DELETE FROM blobs WHERE key = ?'
|
||||
);
|
||||
statement.run(key);
|
||||
} catch (error) {
|
||||
logger.error('deleteBlob', error);
|
||||
}
|
||||
};
|
||||
|
||||
getPersistentBlobKeys = () => {
|
||||
try {
|
||||
const statement = this.sqliteDB.prepare('SELECT key FROM blobs');
|
||||
const rows = statement.all() as BlobRow[];
|
||||
return rows.map(row => row.key);
|
||||
} catch (error) {
|
||||
logger.error('getPersistentBlobKeys', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
private getUpdates = () => {
|
||||
try {
|
||||
const statement = this.sqliteDB.prepare('SELECT * FROM updates');
|
||||
const rows = statement.all() as UpdateRow[];
|
||||
return rows;
|
||||
} catch (error) {
|
||||
logger.error('getUpdates', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// batch write instead write per key stroke?
|
||||
private addUpdateToSQLite = (data: Uint8Array) => {
|
||||
try {
|
||||
const start = performance.now();
|
||||
const statement = this.sqliteDB.prepare(
|
||||
'INSERT INTO updates (data) VALUES (?)'
|
||||
);
|
||||
statement.run(data);
|
||||
logger.debug('addUpdateToSQLite', performance.now() - start, 'ms');
|
||||
} catch (error) {
|
||||
logger.error('addUpdateToSQLite', error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function openWorkspaceDatabase(
|
||||
context: AppContext,
|
||||
workspaceId: string
|
||||
) {
|
||||
const basePath = path.join(context.appDataPath, 'workspaces', workspaceId);
|
||||
// hmmm.... blocking api but it should be fine, right?
|
||||
await fs.ensureDir(basePath);
|
||||
const dbPath = path.join(basePath, 'storage.db');
|
||||
|
||||
return new WorkspaceDatabase(dbPath);
|
||||
}
|
||||
30
apps/electron/layers/main/src/data/workspace.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import { logger } from '../../../logger';
|
||||
import type { AppContext } from '../context';
|
||||
|
||||
export async function listWorkspaces(context: AppContext) {
|
||||
const basePath = path.join(context.appDataPath, 'workspaces');
|
||||
try {
|
||||
return fs.readdir(basePath);
|
||||
} catch (error) {
|
||||
logger.error('listWorkspaces', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteWorkspace(context: AppContext, id: string) {
|
||||
const basePath = path.join(context.appDataPath, 'workspaces', id);
|
||||
const movedPath = path.join(
|
||||
context.appDataPath,
|
||||
'delete-workspaces',
|
||||
`${id}`
|
||||
);
|
||||
try {
|
||||
return fs.move(basePath, movedPath);
|
||||
} catch (error) {
|
||||
logger.error('deleteWorkspace', error);
|
||||
}
|
||||
}
|
||||
227
apps/electron/layers/main/src/handlers.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
dialog,
|
||||
ipcMain,
|
||||
nativeTheme,
|
||||
shell,
|
||||
} from 'electron';
|
||||
import { parse } from 'url';
|
||||
|
||||
import { logger } from '../../logger';
|
||||
import { isMacOS } from '../../utils';
|
||||
import { appContext } from './context';
|
||||
import { exportDatabase } from './data/export';
|
||||
import { watchFile } from './data/fs-watch';
|
||||
import type { WorkspaceDatabase } from './data/sqlite';
|
||||
import { openWorkspaceDatabase } from './data/sqlite';
|
||||
import { deleteWorkspace, listWorkspaces } from './data/workspace';
|
||||
import { getExchangeTokenParams, oauthEndpoint } from './google-auth';
|
||||
import { sendMainEvent } from './send-main-event';
|
||||
|
||||
let currentWorkspaceId = '';
|
||||
|
||||
const dbMapping = new Map<string, WorkspaceDatabase>();
|
||||
const dbWatchers = new Map<string, () => void>();
|
||||
const dBLastUse = new Map<string, number>();
|
||||
|
||||
export async function ensureWorkspaceDB(id: string) {
|
||||
let workspaceDB = dbMapping.get(id);
|
||||
if (!workspaceDB) {
|
||||
// hmm... potential race condition?
|
||||
workspaceDB = await openWorkspaceDatabase(appContext, id);
|
||||
dbMapping.set(id, workspaceDB);
|
||||
|
||||
logger.info('watch db file', workspaceDB.path);
|
||||
|
||||
dbWatchers.set(
|
||||
id,
|
||||
watchFile(workspaceDB.path, (event, filename) => {
|
||||
const minTime = 1000;
|
||||
logger.debug(
|
||||
'db file changed',
|
||||
event,
|
||||
filename,
|
||||
Date.now() - dBLastUse.get(id)!
|
||||
);
|
||||
|
||||
if (Date.now() - dBLastUse.get(id)! < minTime || !filename) {
|
||||
logger.debug('skip db update');
|
||||
return;
|
||||
}
|
||||
|
||||
sendMainEvent('main:on-db-update', id);
|
||||
|
||||
// handle DB file update by other process
|
||||
dbWatchers.get(id)?.();
|
||||
dbMapping.delete(id);
|
||||
dbWatchers.delete(id);
|
||||
ensureWorkspaceDB(id);
|
||||
})
|
||||
);
|
||||
}
|
||||
dBLastUse.set(id, Date.now());
|
||||
return workspaceDB;
|
||||
}
|
||||
|
||||
export async function cleanupWorkspaceDBs() {
|
||||
for (const [id, db] of dbMapping) {
|
||||
logger.info('close db connection', id);
|
||||
db.destroy();
|
||||
dbWatchers.get(id)?.();
|
||||
}
|
||||
dbMapping.clear();
|
||||
dbWatchers.clear();
|
||||
dBLastUse.clear();
|
||||
}
|
||||
|
||||
function registerWorkspaceHandlers() {
|
||||
ipcMain.handle('workspace:list', async _ => {
|
||||
logger.info('list workspaces');
|
||||
return listWorkspaces(appContext);
|
||||
});
|
||||
|
||||
ipcMain.handle('workspace:delete', async (_, id) => {
|
||||
logger.info('delete workspace', id);
|
||||
return deleteWorkspace(appContext, id);
|
||||
});
|
||||
}
|
||||
|
||||
function registerUIHandlers() {
|
||||
ipcMain.handle('ui:theme-change', async (_, theme) => {
|
||||
nativeTheme.themeSource = theme;
|
||||
logger.info('theme change', theme);
|
||||
});
|
||||
|
||||
ipcMain.handle('ui:sidebar-visibility-change', async (_, visible) => {
|
||||
// todo
|
||||
// detect if os is macos
|
||||
if (isMacOS()) {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
windows.forEach(w => {
|
||||
// hide window buttons when sidebar is not visible
|
||||
w.setWindowButtonVisibility(visible);
|
||||
});
|
||||
logger.info('sidebar visibility change', visible);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('ui:workspace-change', async (_, workspaceId) => {
|
||||
logger.info('workspace change', workspaceId);
|
||||
currentWorkspaceId = workspaceId;
|
||||
});
|
||||
|
||||
// @deprecated
|
||||
ipcMain.handle('ui:get-google-oauth-code', async () => {
|
||||
logger.info('starting google sign in ...');
|
||||
shell.openExternal(oauthEndpoint);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const handleOpenUrl = async (_: any, url: string) => {
|
||||
const mainWindow = BrowserWindow.getAllWindows().find(
|
||||
w => !w.isDestroyed()
|
||||
);
|
||||
const urlObj = parse(url.replace('??', '?'), true);
|
||||
if (!mainWindow || !url.startsWith('affine://auth-callback')) return;
|
||||
const code = urlObj.query['code'] as string;
|
||||
if (!code) return;
|
||||
|
||||
logger.info('google sign in code received from callback', code);
|
||||
|
||||
app.removeListener('open-url', handleOpenUrl);
|
||||
resolve(getExchangeTokenParams(code));
|
||||
};
|
||||
|
||||
app.on('open-url', handleOpenUrl);
|
||||
|
||||
setTimeout(() => {
|
||||
reject(new Error('Timed out'));
|
||||
app.removeListener('open-url', handleOpenUrl);
|
||||
}, 30000);
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle('main:env-update', async (_, env, value) => {
|
||||
process.env[env] = value;
|
||||
});
|
||||
}
|
||||
|
||||
function registerDBHandlers() {
|
||||
app.on('activate', () => {
|
||||
for (const [_, workspaceDB] of dbMapping) {
|
||||
workspaceDB.reconnectDB();
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('db:get-doc', async (_, id) => {
|
||||
logger.log('main: get doc', id);
|
||||
const workspaceDB = await ensureWorkspaceDB(id);
|
||||
return workspaceDB.getEncodedDocUpdates();
|
||||
});
|
||||
|
||||
ipcMain.handle('db:apply-doc-update', async (_, id, update) => {
|
||||
logger.log('main: apply doc update', id);
|
||||
const workspaceDB = await ensureWorkspaceDB(id);
|
||||
return workspaceDB.applyUpdate(update);
|
||||
});
|
||||
|
||||
ipcMain.handle('db:add-blob', async (_, workspaceId, key, data) => {
|
||||
logger.log('main: add blob', workspaceId, key);
|
||||
const workspaceDB = await ensureWorkspaceDB(workspaceId);
|
||||
return workspaceDB.addBlob(key, data);
|
||||
});
|
||||
|
||||
ipcMain.handle('db:get-blob', async (_, workspaceId, key) => {
|
||||
logger.log('main: get blob', workspaceId, key);
|
||||
const workspaceDB = await ensureWorkspaceDB(workspaceId);
|
||||
return workspaceDB.getBlob(key);
|
||||
});
|
||||
|
||||
ipcMain.handle('db:get-persisted-blobs', async (_, workspaceId) => {
|
||||
logger.log('main: get persisted blob keys', workspaceId);
|
||||
const workspaceDB = await ensureWorkspaceDB(workspaceId);
|
||||
return workspaceDB.getPersistentBlobKeys();
|
||||
});
|
||||
|
||||
ipcMain.handle('db:delete-blob', async (_, workspaceId, key) => {
|
||||
logger.log('main: delete blob', workspaceId, key);
|
||||
const workspaceDB = await ensureWorkspaceDB(workspaceId);
|
||||
return workspaceDB.deleteBlob(key);
|
||||
});
|
||||
|
||||
ipcMain.handle('ui:open-db-folder', async _ => {
|
||||
const workspaceDB = await ensureWorkspaceDB(currentWorkspaceId);
|
||||
logger.log('main: open db folder', workspaceDB.path);
|
||||
shell.showItemInFolder(workspaceDB.path);
|
||||
});
|
||||
|
||||
ipcMain.handle('ui:open-load-db-file-dialog', async () => {
|
||||
// todo
|
||||
});
|
||||
|
||||
ipcMain.handle('ui:open-save-db-file-dialog', async () => {
|
||||
logger.log('main: open save db file dialog', currentWorkspaceId);
|
||||
const workspaceDB = await ensureWorkspaceDB(currentWorkspaceId);
|
||||
const ret = await dialog.showSaveDialog({
|
||||
properties: ['showOverwriteConfirmation'],
|
||||
title: 'Save Workspace',
|
||||
buttonLabel: 'Save',
|
||||
defaultPath: currentWorkspaceId + '.db',
|
||||
message: 'Save Workspace as SQLite Database',
|
||||
});
|
||||
const filePath = ret.filePath;
|
||||
if (ret.canceled || !filePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await exportDatabase(workspaceDB, filePath);
|
||||
shell.showItemInFolder(filePath);
|
||||
return filePath;
|
||||
});
|
||||
}
|
||||
|
||||
export const registerHandlers = () => {
|
||||
registerWorkspaceHandlers();
|
||||
registerUIHandlers();
|
||||
registerDBHandlers();
|
||||
};
|
||||
@@ -3,7 +3,8 @@ import './security-restrictions';
|
||||
import { app } from 'electron';
|
||||
import path from 'path';
|
||||
|
||||
import { registerHandlers } from './app-state';
|
||||
import { logger } from '../../logger';
|
||||
import { registerHandlers } from './handlers';
|
||||
import { restoreOrCreateWindow } from './main-window';
|
||||
import { registerProtocol } from './protocol';
|
||||
|
||||
@@ -22,6 +23,7 @@ if (process.defaultApp) {
|
||||
*/
|
||||
const isSingleInstance = app.requestSingleInstanceLock();
|
||||
if (!isSingleInstance) {
|
||||
logger.info('Another instance is running, exiting...');
|
||||
app.quit();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,14 @@ import { BrowserWindow, nativeTheme } from 'electron';
|
||||
import electronWindowState from 'electron-window-state';
|
||||
import { join } from 'path';
|
||||
|
||||
import { logger } from '../../logger';
|
||||
import { isMacOS } from '../../utils';
|
||||
|
||||
const IS_DEV = process.env.NODE_ENV === 'development';
|
||||
const IS_DEV: boolean =
|
||||
process.env.NODE_ENV === 'development' && !process.env.CI;
|
||||
|
||||
async function createWindow() {
|
||||
logger.info('create window');
|
||||
const mainWindowState = electronWindowState({
|
||||
defaultWidth: 1000,
|
||||
defaultHeight: 800,
|
||||
@@ -45,7 +48,14 @@ async function createWindow() {
|
||||
* @see https://github.com/electron/electron/issues/25012
|
||||
*/
|
||||
browserWindow.on('ready-to-show', () => {
|
||||
browserWindow.show();
|
||||
if (IS_DEV) {
|
||||
// do not gain focus in dev mode
|
||||
browserWindow.showInactive();
|
||||
} else {
|
||||
browserWindow.show();
|
||||
}
|
||||
|
||||
logger.info('main window is ready to show');
|
||||
|
||||
if (IS_DEV) {
|
||||
browserWindow.webContents.openDevTools();
|
||||
@@ -61,13 +71,12 @@ async function createWindow() {
|
||||
/**
|
||||
* URL for main window.
|
||||
*/
|
||||
const pageUrl =
|
||||
IS_DEV && process.env.DEV_SERVER_URL !== undefined
|
||||
? process.env.DEV_SERVER_URL
|
||||
: 'file://./index.html'; // see protocol.ts
|
||||
const pageUrl = process.env.DEV_SERVER_URL || 'file://./index.html'; // see protocol.ts
|
||||
|
||||
await browserWindow.loadURL(pageUrl);
|
||||
|
||||
logger.info('main window is loaded at' + pageUrl);
|
||||
|
||||
return browserWindow;
|
||||
}
|
||||
|
||||
@@ -85,9 +94,8 @@ export async function restoreOrCreateWindow() {
|
||||
|
||||
if (browserWindow.isMinimized()) {
|
||||
browserWindow.restore();
|
||||
logger.info('restore main window');
|
||||
}
|
||||
|
||||
browserWindow.focus();
|
||||
|
||||
return browserWindow;
|
||||
}
|
||||
|
||||
@@ -30,23 +30,21 @@ function toAbsolutePath(url: string) {
|
||||
}
|
||||
|
||||
export function registerProtocol() {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
protocol.interceptFileProtocol('file', (request, callback) => {
|
||||
const url = request.url.replace(/^file:\/\//, '');
|
||||
const realpath = toAbsolutePath(url);
|
||||
// console.log('realpath', realpath, 'for', url);
|
||||
callback(realpath);
|
||||
return true;
|
||||
});
|
||||
protocol.interceptFileProtocol('file', (request, callback) => {
|
||||
const url = request.url.replace(/^file:\/\//, '');
|
||||
const realpath = toAbsolutePath(url);
|
||||
// console.log('realpath', realpath, 'for', url);
|
||||
callback(realpath);
|
||||
return true;
|
||||
});
|
||||
|
||||
protocol.registerFileProtocol('assets', (request, callback) => {
|
||||
const url = request.url.replace(/^assets:\/\//, '');
|
||||
const realpath = toAbsolutePath(url);
|
||||
// console.log('realpath', realpath, 'for', url);
|
||||
callback(realpath);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
protocol.registerFileProtocol('assets', (request, callback) => {
|
||||
const url = request.url.replace(/^assets:\/\//, '');
|
||||
const realpath = toAbsolutePath(url);
|
||||
// console.log('realpath', realpath, 'for', url);
|
||||
callback(realpath);
|
||||
return true;
|
||||
});
|
||||
|
||||
session.defaultSession.webRequest.onHeadersReceived(
|
||||
(responseDetails, callback) => {
|
||||
|
||||
14
apps/electron/layers/main/src/send-main-event.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { BrowserWindow } from 'electron';
|
||||
|
||||
import type { MainEventMap } from '../../main-events';
|
||||
|
||||
function getActiveWindows() {
|
||||
return BrowserWindow.getAllWindows().filter(win => !win.isDestroyed());
|
||||
}
|
||||
|
||||
export function sendMainEvent<T extends keyof MainEventMap>(
|
||||
type: T,
|
||||
...args: Parameters<MainEventMap[T]>
|
||||
) {
|
||||
getActiveWindows().forEach(win => win.webContents.send(type, ...args));
|
||||
}
|
||||
14
apps/electron/layers/preload/preload.d.ts
vendored
@@ -1,12 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/consistent-type-imports */
|
||||
|
||||
interface Window {
|
||||
/**
|
||||
* After analyzing the `exposeInMainWorld` calls,
|
||||
* `packages/preload/exposedInMainWorld.d.ts` file will be generated.
|
||||
* It contains all interfaces.
|
||||
* `packages/preload/exposedInMainWorld.d.ts` file is required for TS is `renderer`
|
||||
*
|
||||
* @see https://github.com/cawa-93/dts-for-context-bridge
|
||||
*/
|
||||
readonly apis: { workspaceSync: (id: string) => Promise<any>; onThemeChange: (theme: string) => Promise<any>; onSidebarVisibilityChange: (visible: boolean) => Promise<any>; getGoogleOauthCode: () => Promise<{ requestInit: RequestInit; url: string; }>; updateEnv: (env: string, value: string) => void; };
|
||||
readonly appInfo: { electron: boolean; isMacOS: boolean; };
|
||||
apis: typeof import('./src/affine-apis').apis;
|
||||
appInfo: typeof import('./src/affine-apis').appInfo;
|
||||
}
|
||||
|
||||
81
apps/electron/layers/preload/src/affine-apis.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
// NOTE: we will generate preload types from this file
|
||||
|
||||
import { ipcRenderer } from 'electron';
|
||||
|
||||
import type { MainEventMap } from '../../main-events';
|
||||
|
||||
// main -> renderer
|
||||
function onMainEvent<T extends keyof MainEventMap>(
|
||||
eventName: T,
|
||||
callback: MainEventMap[T]
|
||||
): () => void {
|
||||
// @ts-expect-error fix me later
|
||||
const fn = (_, ...args) => callback(...args);
|
||||
ipcRenderer.on(eventName, fn);
|
||||
return () => ipcRenderer.off(eventName, fn);
|
||||
}
|
||||
|
||||
const apis = {
|
||||
db: {
|
||||
// workspace providers
|
||||
getDoc: (id: string): Promise<Uint8Array | null> =>
|
||||
ipcRenderer.invoke('db:get-doc', id),
|
||||
applyDocUpdate: (id: string, update: Uint8Array) =>
|
||||
ipcRenderer.invoke('db:apply-doc-update', id, update),
|
||||
addBlob: (workspaceId: string, key: string, data: Uint8Array) =>
|
||||
ipcRenderer.invoke('db:add-blob', workspaceId, key, data),
|
||||
getBlob: (workspaceId: string, key: string): Promise<Uint8Array | null> =>
|
||||
ipcRenderer.invoke('db:get-blob', workspaceId, key),
|
||||
deleteBlob: (workspaceId: string, key: string) =>
|
||||
ipcRenderer.invoke('db:delete-blob', workspaceId, key),
|
||||
getPersistedBlobs: (workspaceId: string): Promise<string[]> =>
|
||||
ipcRenderer.invoke('db:get-persisted-blobs', workspaceId),
|
||||
|
||||
// listeners
|
||||
onDBUpdate: (callback: (workspaceId: string) => void) => {
|
||||
return onMainEvent('main:on-db-update', callback);
|
||||
},
|
||||
},
|
||||
|
||||
workspace: {
|
||||
list: (): Promise<string[]> => ipcRenderer.invoke('workspace:list'),
|
||||
delete: (id: string): Promise<void> =>
|
||||
ipcRenderer.invoke('workspace:delete', id),
|
||||
// create will be implicitly called by db functions
|
||||
},
|
||||
|
||||
openLoadDBFileDialog: () => ipcRenderer.invoke('ui:open-load-db-file-dialog'),
|
||||
openSaveDBFileDialog: () => ipcRenderer.invoke('ui:open-save-db-file-dialog'),
|
||||
|
||||
// ui
|
||||
onThemeChange: (theme: string) =>
|
||||
ipcRenderer.invoke('ui:theme-change', theme),
|
||||
|
||||
onSidebarVisibilityChange: (visible: boolean) =>
|
||||
ipcRenderer.invoke('ui:sidebar-visibility-change', visible),
|
||||
|
||||
onWorkspaceChange: (workspaceId: string) =>
|
||||
ipcRenderer.invoke('ui:workspace-change', workspaceId),
|
||||
|
||||
openDBFolder: () => ipcRenderer.invoke('ui:open-db-folder'),
|
||||
|
||||
/**
|
||||
* Try sign in using Google and return a request object to exchange the code for a token
|
||||
* Not exchange in Node side because it is easier to do it in the renderer with VPN
|
||||
*/
|
||||
getGoogleOauthCode: (): Promise<{ requestInit: RequestInit; url: string }> =>
|
||||
ipcRenderer.invoke('ui:get-google-oauth-code'),
|
||||
|
||||
/**
|
||||
* Secret backdoor to update environment variables in main process
|
||||
*/
|
||||
updateEnv: (env: string, value: string) => {
|
||||
ipcRenderer.invoke('main:env-update', env, value);
|
||||
},
|
||||
};
|
||||
|
||||
const appInfo = {
|
||||
electron: true,
|
||||
};
|
||||
|
||||
export { apis, appInfo };
|
||||
@@ -2,9 +2,9 @@
|
||||
* @module preload
|
||||
*/
|
||||
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
import { contextBridge } from 'electron';
|
||||
|
||||
import { isMacOS } from '../../utils';
|
||||
import * as affineApis from './affine-apis';
|
||||
|
||||
/**
|
||||
* The "Main World" is the JavaScript context that your main renderer code runs in.
|
||||
@@ -13,40 +13,5 @@ import { isMacOS } from '../../utils';
|
||||
* @see https://www.electronjs.org/docs/api/context-bridge
|
||||
*/
|
||||
|
||||
/**
|
||||
* After analyzing the `exposeInMainWorld` calls,
|
||||
* `packages/preload/exposedInMainWorld.d.ts` file will be generated.
|
||||
* It contains all interfaces.
|
||||
* `packages/preload/exposedInMainWorld.d.ts` file is required for TS is `renderer`
|
||||
*
|
||||
* @see https://github.com/cawa-93/dts-for-context-bridge
|
||||
*/
|
||||
contextBridge.exposeInMainWorld('apis', {
|
||||
workspaceSync: (id: string) => ipcRenderer.invoke('octo:workspace-sync', id),
|
||||
|
||||
// ui
|
||||
onThemeChange: (theme: string) =>
|
||||
ipcRenderer.invoke('ui:theme-change', theme),
|
||||
|
||||
onSidebarVisibilityChange: (visible: boolean) =>
|
||||
ipcRenderer.invoke('ui:sidebar-visibility-change', visible),
|
||||
|
||||
/**
|
||||
* Try sign in using Google and return a request object to exchange the code for a token
|
||||
* Not exchange in Node side because it is easier to do it in the renderer with VPN
|
||||
*/
|
||||
getGoogleOauthCode: (): Promise<{ requestInit: RequestInit; url: string }> =>
|
||||
ipcRenderer.invoke('ui:get-google-oauth-code'),
|
||||
|
||||
/**
|
||||
* Secret backdoor to update environment variables in main process
|
||||
*/
|
||||
updateEnv: (env: string, value: string) => {
|
||||
ipcRenderer.invoke('main:env-update', env, value);
|
||||
},
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld('appInfo', {
|
||||
electron: true,
|
||||
isMacOS: isMacOS(),
|
||||
});
|
||||
contextBridge.exposeInMainWorld('apis', affineApis.apis);
|
||||
contextBridge.exposeInMainWorld('appInfo', affineApis.appInfo);
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
{
|
||||
"name": "@affine/electron",
|
||||
"private": true,
|
||||
"version": "0.5.3",
|
||||
"version": "0.5.4-canary.15",
|
||||
"author": "affine",
|
||||
"description": "AFFiNE App",
|
||||
"homepage": "https://github.com/toeverything/AFFiNE",
|
||||
"scripts": {
|
||||
"dev": "cross-env NODE_ENV=development node scripts/dev.mjs",
|
||||
"prod": "cross-env NODE_ENV=production node scripts/dev.mjs",
|
||||
"dev": "yarn electron-rebuild && yarn cross-env DEV_SERVER_URL=http://localhost:8080 node scripts/dev.mjs",
|
||||
"prod": "yarn electron-rebuild && yarn node scripts/dev.mjs",
|
||||
"generate-assets": "zx scripts/generate-assets.mjs",
|
||||
"package": "electron-forge package",
|
||||
"make": "electron-forge make",
|
||||
"make-macos-arm64": "electron-forge make --platform=darwin --arch=arm64",
|
||||
"make-macos-x64": "electron-forge make --platform=darwin --arch=x64",
|
||||
"make-windows-x64": "electron-forge make --platform=win32 --arch=x64",
|
||||
"make-linux-x64": "electron-forge make --platform=linux --arch=x64"
|
||||
"make-linux-x64": "electron-forge make --platform=linux --arch=x64",
|
||||
"rebuild:for-test": "yarn rebuild better-sqlite3",
|
||||
"rebuild:for-electron": "yarn electron-rebuild",
|
||||
"test": "playwright test"
|
||||
},
|
||||
"config": {
|
||||
"forge": "./forge.config.js"
|
||||
},
|
||||
"main": "./dist/layers/main/index.js",
|
||||
"devDependencies": {
|
||||
"@affine-test/kit": "workspace:*",
|
||||
"@electron-forge/cli": "^6.1.1",
|
||||
"@electron-forge/core": "^6.1.1",
|
||||
"@electron-forge/core-utils": "^6.1.1",
|
||||
@@ -29,20 +33,25 @@
|
||||
"@electron-forge/maker-squirrel": "^6.1.1",
|
||||
"@electron-forge/maker-zip": "^6.1.1",
|
||||
"@electron-forge/shared-types": "^6.1.1",
|
||||
"@electron/rebuild": "^3.2.12",
|
||||
"@electron/rebuild": "^3.2.13",
|
||||
"@electron/remote": "2.0.9",
|
||||
"dts-for-context-bridge": "^0.7.1",
|
||||
"electron": "24.1.2",
|
||||
"@types/better-sqlite3": "^7.6.4",
|
||||
"@types/fs-extra": "^11.0.1",
|
||||
"cross-env": "7.0.3",
|
||||
"electron": "24.1.3",
|
||||
"electron-log": "^5.0.0-beta.23",
|
||||
"electron-squirrel-startup": "1.0.0",
|
||||
"esbuild": "^0.17.17",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"esbuild": "^0.17.18",
|
||||
"fs-extra": "^11.1.1",
|
||||
"playwright": "^1.33.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"undici": "^5.22.0",
|
||||
"zx": "^7.2.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"cross-env": "7.0.3",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"firebase": "^9.19.1",
|
||||
"fs-extra": "^11.1.1",
|
||||
"undici": "^5.21.2"
|
||||
"better-sqlite3": "^8.3.0",
|
||||
"yjs": "^13.6.0"
|
||||
},
|
||||
"build": {
|
||||
"protocols": [
|
||||
@@ -54,5 +63,12 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"packageManager": "yarn@3.5.0"
|
||||
"stableVersion": "0.5.3",
|
||||
"installConfig": {
|
||||
"hoistingLimits": "workspaces"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"playwright": "*",
|
||||
"ts-node": "*"
|
||||
}
|
||||
}
|
||||
|
||||
27
apps/electron/playwright.config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { PlaywrightTestConfig } from '@playwright/test';
|
||||
// import { devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
const config: PlaywrightTestConfig = {
|
||||
testDir: './tests',
|
||||
fullyParallel: true,
|
||||
timeout: process.env.CI ? 50_000 : 30_000,
|
||||
use: {
|
||||
viewport: { width: 1440, height: 800 },
|
||||
},
|
||||
};
|
||||
|
||||
if (process.env.CI) {
|
||||
config.retries = 3;
|
||||
config.workers = '50%';
|
||||
}
|
||||
|
||||
export default config;
|
||||
BIN
apps/electron/resources/icons/dmg-background.png
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
apps/electron/resources/icons/dmg-background@2x.png
Normal file
|
After Width: | Height: | Size: 342 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 1.0 MiB |
BIN
apps/electron/resources/icons/icon_beta.icns
Normal file
BIN
apps/electron/resources/icons/icon_beta.ico
Normal file
|
After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 88 KiB |
17
apps/electron/scripts/build-ci.mts
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env ts-node-esm
|
||||
import * as esbuild from 'esbuild';
|
||||
|
||||
import { config } from './common.mjs';
|
||||
|
||||
const common = config();
|
||||
await esbuild.build(common.preload);
|
||||
|
||||
await esbuild.build({
|
||||
...common.main,
|
||||
define: {
|
||||
...common.main.define,
|
||||
'process.env.NODE_ENV': `"production"`,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Compiled successfully.');
|
||||
@@ -1,5 +1,9 @@
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const NODE_MAJOR_VERSION = 18;
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
export const root = fileURLToPath(new URL('..', import.meta.url));
|
||||
export const NODE_MAJOR_VERSION = 18;
|
||||
|
||||
const nativeNodeModulesPlugin = {
|
||||
name: 'native-node-modules',
|
||||
@@ -15,7 +19,7 @@ const nativeNodeModulesPlugin = {
|
||||
const ENV_MACROS = ['AFFINE_GOOGLE_CLIENT_ID', 'AFFINE_GOOGLE_CLIENT_SECRET'];
|
||||
|
||||
/** @return {{main: import('esbuild').BuildOptions, preload: import('esbuild').BuildOptions}} */
|
||||
export default () => {
|
||||
export const config = () => {
|
||||
const define = Object.fromEntries(
|
||||
ENV_MACROS.map(key => [
|
||||
'process.env.' + key,
|
||||
@@ -24,18 +28,18 @@ export default () => {
|
||||
);
|
||||
return {
|
||||
main: {
|
||||
entryPoints: ['layers/main/src/index.ts'],
|
||||
outdir: 'dist/layers/main',
|
||||
entryPoints: [resolve(root, './layers/main/src/index.ts')],
|
||||
outdir: resolve(root, './dist/layers/main'),
|
||||
bundle: true,
|
||||
target: `node${NODE_MAJOR_VERSION}`,
|
||||
platform: 'node',
|
||||
external: ['electron'],
|
||||
external: ['electron', 'yjs', 'better-sqlite3'],
|
||||
plugins: [nativeNodeModulesPlugin],
|
||||
define: define,
|
||||
},
|
||||
preload: {
|
||||
entryPoints: ['layers/preload/src/index.ts'],
|
||||
outdir: 'dist/layers/preload',
|
||||
entryPoints: [resolve(root, './layers/preload/src/index.ts')],
|
||||
outdir: resolve(root, './dist/layers/preload'),
|
||||
bundle: true,
|
||||
target: `node${NODE_MAJOR_VERSION}`,
|
||||
platform: 'node',
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { generateAsync } from 'dts-for-context-bridge';
|
||||
import electronPath from 'electron';
|
||||
import * as esbuild from 'esbuild';
|
||||
|
||||
import commonFn from './common.mjs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
import { config, root } from './common.mjs';
|
||||
|
||||
/** @type 'production' | 'development'' */
|
||||
const mode = (process.env.NODE_ENV = process.env.NODE_ENV || 'development');
|
||||
@@ -23,9 +18,9 @@ const stderrFilterPatterns = [
|
||||
/ExtensionLoadWarning/,
|
||||
];
|
||||
|
||||
// these are set before calling commonFn so we have a chance to override them
|
||||
// these are set before calling `config`, so we have a chance to override them
|
||||
try {
|
||||
const devJson = readFileSync(path.resolve(__dirname, '../dev.json'), 'utf-8');
|
||||
const devJson = readFileSync(path.resolve(root, './dev.json'), 'utf-8');
|
||||
const devEnv = JSON.parse(devJson);
|
||||
Object.assign(process.env, devEnv);
|
||||
} catch (err) {
|
||||
@@ -35,8 +30,8 @@ try {
|
||||
}
|
||||
|
||||
// hard-coded for now:
|
||||
// fixme(xp): report error if app is not running on port 8080
|
||||
process.env.DEV_SERVER_URL = `http://localhost:8080`;
|
||||
// fixme(xp): report error if app is not running on DEV_SERVER_URL
|
||||
const DEV_SERVER_URL = process.env.DEV_SERVER_URL;
|
||||
|
||||
/** @type {ChildProcessWithoutNullStreams | null} */
|
||||
let spawnProcess = null;
|
||||
@@ -50,10 +45,12 @@ function spawnOrReloadElectron() {
|
||||
|
||||
spawnProcess = spawn(String(electronPath), ['.']);
|
||||
|
||||
spawnProcess.stdout.on(
|
||||
'data',
|
||||
d => d.toString().trim() && console.warn(d.toString())
|
||||
);
|
||||
spawnProcess.stdout.on('data', d => {
|
||||
let str = d.toString().trim();
|
||||
if (str) {
|
||||
console.log(str);
|
||||
}
|
||||
});
|
||||
spawnProcess.stderr.on('data', d => {
|
||||
const data = d.toString().trim();
|
||||
if (!data) return;
|
||||
@@ -66,7 +63,7 @@ function spawnOrReloadElectron() {
|
||||
spawnProcess.on('exit', process.exit);
|
||||
}
|
||||
|
||||
const common = commonFn();
|
||||
const common = config();
|
||||
|
||||
async function main() {
|
||||
async function watchPreload(onInitialBuild) {
|
||||
@@ -79,10 +76,6 @@ async function main() {
|
||||
setup(build) {
|
||||
let initialBuild = false;
|
||||
build.onEnd(() => {
|
||||
generateAsync({
|
||||
input: 'layers/preload/src/**/*.ts',
|
||||
output: 'layers/preload/preload.d.ts',
|
||||
});
|
||||
if (initialBuild) {
|
||||
console.log(`[preload] has changed`);
|
||||
spawnOrReloadElectron();
|
||||
@@ -99,13 +92,18 @@ async function main() {
|
||||
}
|
||||
|
||||
async function watchMain() {
|
||||
const define = {
|
||||
...common.main.define,
|
||||
'process.env.NODE_ENV': `"${mode}"`,
|
||||
};
|
||||
|
||||
if (DEV_SERVER_URL) {
|
||||
define['process.env.DEV_SERVER_URL'] = `"${DEV_SERVER_URL}"`;
|
||||
}
|
||||
|
||||
const mainBuild = await esbuild.context({
|
||||
...common.main,
|
||||
define: {
|
||||
...common.main.define,
|
||||
'process.env.NODE_ENV': `"${mode}"`,
|
||||
'process.env.DEV_SERVER_URL': `"${process.env.DEV_SERVER_URL}"`,
|
||||
},
|
||||
define: define,
|
||||
plugins: [
|
||||
...(common.main.plugins ?? []),
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@ import path from 'node:path';
|
||||
|
||||
import * as esbuild from 'esbuild';
|
||||
|
||||
import commonFn from './common.mjs';
|
||||
import { config } from './common.mjs';
|
||||
|
||||
const repoRootDir = path.join(__dirname, '..', '..', '..');
|
||||
const electronRootDir = path.join(__dirname, '..');
|
||||
@@ -33,46 +33,51 @@ if (process.platform === 'win32') {
|
||||
$.shell = 'powershell.exe';
|
||||
$.prefix = '';
|
||||
}
|
||||
// step 1: build web (nextjs) dist
|
||||
process.env.ENABLE_LEGACY_PROVIDER = 'false';
|
||||
|
||||
cd(repoRootDir);
|
||||
await $`yarn add`;
|
||||
await $`yarn build`;
|
||||
await $`yarn export`;
|
||||
|
||||
// step 1.5: amend sourceMappingURL to allow debugging in devtools
|
||||
await glob('**/*.{js,css}', { cwd: affineWebOutDir }).then(files => {
|
||||
return files.map(async file => {
|
||||
const dir = path.dirname(file);
|
||||
const fullpath = path.join(affineWebOutDir, file);
|
||||
let content = await fs.readFile(fullpath, 'utf-8');
|
||||
// replace # sourceMappingURL=76-6370cd185962bc89.js.map
|
||||
// to # sourceMappingURL=assets://./{dir}/76-6370cd185962bc89.js.map
|
||||
content = content.replace(/# sourceMappingURL=(.*)\.map/g, (_, p1) => {
|
||||
return `# sourceMappingURL=assets://./${dir}/${p1}.map`;
|
||||
});
|
||||
await fs.writeFile(fullpath, content);
|
||||
});
|
||||
});
|
||||
|
||||
await fs.move(affineWebOutDir, publicAffineOutDir, { overwrite: true });
|
||||
|
||||
// step 2: build electron resources
|
||||
// step 1: build electron resources
|
||||
await buildLayers();
|
||||
echo('Build layers done');
|
||||
|
||||
// step 2: build web (nextjs) dist
|
||||
if (!process.env.SKIP_WEB_BUILD) {
|
||||
process.env.ENABLE_LEGACY_PROVIDER = 'false';
|
||||
await $`yarn build`;
|
||||
await $`yarn export`;
|
||||
|
||||
// step 1.5: amend sourceMappingURL to allow debugging in devtools
|
||||
await glob('**/*.{js,css}', { cwd: affineWebOutDir }).then(files => {
|
||||
return files.map(async file => {
|
||||
const dir = path.dirname(file);
|
||||
const fullpath = path.join(affineWebOutDir, file);
|
||||
let content = await fs.readFile(fullpath, 'utf-8');
|
||||
// replace # sourceMappingURL=76-6370cd185962bc89.js.map
|
||||
// to # sourceMappingURL=assets://./{dir}/76-6370cd185962bc89.js.map
|
||||
content = content.replace(/# sourceMappingURL=(.*)\.map/g, (_, p1) => {
|
||||
return `# sourceMappingURL=assets://./${dir}/${p1}.map`;
|
||||
});
|
||||
await fs.writeFile(fullpath, content);
|
||||
});
|
||||
});
|
||||
|
||||
await fs.move(affineWebOutDir, publicAffineOutDir, { overwrite: true });
|
||||
}
|
||||
|
||||
/// --------
|
||||
/// --------
|
||||
/// --------
|
||||
async function cleanup() {
|
||||
await fs.emptyDir(publicAffineOutDir);
|
||||
if (!process.env.SKIP_WEB_BUILD) {
|
||||
await fs.emptyDir(publicAffineOutDir);
|
||||
}
|
||||
await fs.emptyDir(path.join(electronRootDir, 'layers', 'main', 'dist'));
|
||||
await fs.emptyDir(path.join(electronRootDir, 'layers', 'preload', 'dist'));
|
||||
await fs.remove(path.join(electronRootDir, 'out'));
|
||||
}
|
||||
|
||||
async function buildLayers() {
|
||||
const common = commonFn();
|
||||
const common = config();
|
||||
await esbuild.build(common.preload);
|
||||
|
||||
await esbuild.build({
|
||||
|
||||
23
apps/electron/tests/basic.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { test } from '@affine-test/kit/playwright';
|
||||
import { expect } from '@playwright/test';
|
||||
import { _electron as electron } from 'playwright';
|
||||
|
||||
test('new page', async () => {
|
||||
const electronApp = await electron.launch({
|
||||
args: [resolve(__dirname, '..')],
|
||||
executablePath: resolve(__dirname, '../node_modules/.bin/electron'),
|
||||
});
|
||||
const page = await electronApp.firstWindow();
|
||||
await page.getByTestId('new-page-button').click({
|
||||
delay: 100,
|
||||
});
|
||||
await page.waitForSelector('v-line');
|
||||
const flavour = await page.evaluate(
|
||||
// @ts-expect-error
|
||||
() => globalThis.currentWorkspace.flavour
|
||||
);
|
||||
expect(flavour).toBe('local');
|
||||
await electronApp.close();
|
||||
});
|
||||
8
apps/electron/tests/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["**.spec.ts", "**.test.ts"]
|
||||
}
|
||||
27
apps/electron/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": false,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node"],
|
||||
"outDir": "dist",
|
||||
"noEmit": false
|
||||
},
|
||||
"include": ["layers", "types", "package.json"],
|
||||
"exclude": ["out", "dist", "node_modules"],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
],
|
||||
"ts-node": {
|
||||
"esm": true,
|
||||
"experimentalSpecifierResolution": "node"
|
||||
}
|
||||
}
|
||||
11
apps/electron/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"resolveJsonModule": true,
|
||||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["./scripts", "package.json"]
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
SECRET_KEY="secret"
|
||||
DATABASE_URL="postgresql://affine@localhost:5432/affine"
|
||||
|
||||
59
apps/server/migrations/20230425035217_init/migration.sql
Normal file
@@ -0,0 +1,59 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "users" (
|
||||
"id" VARCHAR NOT NULL,
|
||||
"name" VARCHAR NOT NULL,
|
||||
"email" VARCHAR NOT NULL,
|
||||
"token_nonce" SMALLINT NOT NULL DEFAULT 0,
|
||||
"avatar_url" VARCHAR,
|
||||
"password" VARCHAR,
|
||||
"fulfilled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "workspaces" (
|
||||
"id" VARCHAR NOT NULL,
|
||||
"public" BOOLEAN NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "workspaces_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "connected_accounts" (
|
||||
"id" VARCHAR NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"provider" VARCHAR NOT NULL,
|
||||
"provider_user_id" VARCHAR NOT NULL,
|
||||
|
||||
CONSTRAINT "connected_accounts_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_workspace_permissions" (
|
||||
"id" VARCHAR NOT NULL,
|
||||
"workspace_id" VARCHAR NOT NULL,
|
||||
"entity_id" VARCHAR NOT NULL,
|
||||
"type" SMALLINT NOT NULL,
|
||||
"accepted" BOOLEAN NOT NULL DEFAULT false,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "user_workspace_permissions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "connected_accounts_provider_user_id_key" ON "connected_accounts"("provider_user_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "connected_accounts" ADD CONSTRAINT "connected_accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_workspace_permissions" ADD CONSTRAINT "user_workspace_permissions_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_workspace_permissions" ADD CONSTRAINT "user_workspace_permissions_entity_id_fkey" FOREIGN KEY ("entity_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
3
apps/server/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
@@ -1,36 +1,48 @@
|
||||
{
|
||||
"name": "@affine/server",
|
||||
"private": true,
|
||||
"version": "0.5.3",
|
||||
"version": "0.5.4-canary.15",
|
||||
"description": "Affine Node.js server",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"run-test": "./scripts/run-test.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "nodemon ./src/index.ts",
|
||||
"test": "NODE_ENV=test node --loader ts-node/esm.mjs --es-module-specifier-resolution node --test ./src/tests/*",
|
||||
"test:coverage": "NODE_ENV=test c8 node --loader ts-node/esm.mjs --es-module-specifier-resolution node --experimental-test-coverage ./src/tests/*"
|
||||
"test": "yarn exec ts-node-esm ./scripts/run-test.ts all",
|
||||
"test:coverage": "c8 yarn exec ts-node-esm ./scripts/run-test.ts all",
|
||||
"postinstall": "prisma generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/server": "^4.6.0",
|
||||
"@apollo/server": "^4.7.0",
|
||||
"@nestjs/apollo": "^11.0.5",
|
||||
"@nestjs/common": "^9.4.0",
|
||||
"@nestjs/core": "^9.4.0",
|
||||
"@nestjs/graphql": "^11.0.5",
|
||||
"@nestjs/platform-express": "^9.4.0",
|
||||
"@prisma/client": "^4.12.0",
|
||||
"@prisma/client": "^4.13.0",
|
||||
"bcrypt": "^5.1.0",
|
||||
"dotenv": "^16.0.3",
|
||||
"express": "^4.18.2",
|
||||
"graphql": "^16.6.0",
|
||||
"graphql-type-json": "^0.3.2",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"prisma": "^4.12.0",
|
||||
"prisma": "^4.13.0",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rxjs": "^7.8.0"
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/testing": "^9.4.0",
|
||||
"@types/lodash-es": "^4.14.194",
|
||||
"@types/node": "^18.15.11",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/jsonwebtoken": "^9.0.1",
|
||||
"@types/lodash-es": "^4.17.7",
|
||||
"@types/node": "^18.16.2",
|
||||
"@types/supertest": "^2.0.12",
|
||||
"c8": "^7.13.0",
|
||||
"nodemon": "^2.0.22",
|
||||
"supertest": "^6.3.3",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.0.4",
|
||||
"vitest": "^0.30.1"
|
||||
@@ -49,6 +61,7 @@
|
||||
"**/dist/**"
|
||||
],
|
||||
"env": {
|
||||
"TS_NODE_TRANSPILE_ONLY": true,
|
||||
"TS_NODE_PROJECT": "./tsconfig.json",
|
||||
"NODE_ENV": "development",
|
||||
"DEBUG": "affine:*",
|
||||
@@ -64,8 +77,10 @@
|
||||
],
|
||||
"report-dir": ".coverage",
|
||||
"exclude": [
|
||||
"scripts",
|
||||
"node_modules",
|
||||
"**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"stableVersion": "0.5.3"
|
||||
}
|
||||
|
||||
@@ -7,46 +7,57 @@ generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
model google_users {
|
||||
id String @id @db.VarChar
|
||||
user_id String @db.VarChar
|
||||
google_id String @unique @db.VarChar
|
||||
users users @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||
model User {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
name String @db.VarChar
|
||||
email String @unique @db.VarChar
|
||||
tokenNonce Int @default(0) @map("token_nonce") @db.SmallInt
|
||||
avatarUrl String? @map("avatar_url") @db.VarChar
|
||||
/// Available if user signed up through OAuth providers
|
||||
password String? @db.VarChar
|
||||
/// User may created by email collobration invitation before signup.
|
||||
/// We will precreate a user entity in such senarios but leave fulfilled as false until they signed up
|
||||
/// This implementation is convenient for handing unregistered user permissoin
|
||||
fulfilled Boolean @default(true)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
connectedAccounts ConnectedAccount[]
|
||||
workspaces UserWorkspacePermission[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model permissions {
|
||||
id String @id @db.VarChar
|
||||
workspace_id String @db.VarChar
|
||||
user_id String? @db.VarChar
|
||||
user_email String?
|
||||
type Int @db.SmallInt
|
||||
accepted Boolean @default(false)
|
||||
created_at DateTime? @default(now()) @db.Timestamptz(6)
|
||||
users users? @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||
workspaces workspaces @relation(fields: [workspace_id], references: [id], onDelete: Cascade)
|
||||
model Workspace {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
public Boolean
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
users UserWorkspacePermission[]
|
||||
|
||||
@@map("workspaces")
|
||||
}
|
||||
|
||||
model seaql_migrations {
|
||||
version String @id @db.VarChar
|
||||
applied_at BigInt
|
||||
model ConnectedAccount {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
userId String @map("user_id")
|
||||
/// the general provider name, e.g. google, github, facebook
|
||||
provider String @db.VarChar
|
||||
/// the user id provided by OAuth providers, or other user identitive credential like `username` provided by GitHub
|
||||
providerUserId String @unique @map("provider_user_id") @db.VarChar
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("connected_accounts")
|
||||
}
|
||||
|
||||
model users {
|
||||
id String @id @db.VarChar
|
||||
name String @db.VarChar
|
||||
email String @unique @db.VarChar
|
||||
avatar_url String? @db.VarChar
|
||||
token_nonce Int? @default(0) @db.SmallInt
|
||||
password String? @db.VarChar
|
||||
created_at DateTime? @default(now()) @db.Timestamptz(6)
|
||||
google_users google_users[]
|
||||
permissions permissions[]
|
||||
}
|
||||
model UserWorkspacePermission {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
workspaceId String @map("workspace_id") @db.VarChar
|
||||
userId String @map("entity_id") @db.VarChar
|
||||
/// Read/Write/Admin/Owner
|
||||
type Int @db.SmallInt
|
||||
/// Whether the permission invitation is accepted by the user
|
||||
accepted Boolean @default(false)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
model workspaces {
|
||||
id String @id @db.VarChar
|
||||
public Boolean
|
||||
type Int @db.SmallInt
|
||||
created_at DateTime? @default(now()) @db.Timestamptz(6)
|
||||
permissions permissions[]
|
||||
@@map("user_workspace_permissions")
|
||||
}
|
||||
|
||||
19
apps/server/scripts/gen-auth-key.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import { genSalt } from 'bcrypt';
|
||||
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', {
|
||||
namedCurve: 'prime256v1',
|
||||
publicKeyEncoding: {
|
||||
type: 'spki',
|
||||
format: 'pem',
|
||||
},
|
||||
privateKeyEncoding: {
|
||||
type: 'pkcs8',
|
||||
format: 'pem',
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Salt:\n', await genSalt(10));
|
||||
console.log('ECDSA Public Key:\n', publicKey);
|
||||
console.log('ECDSA Private Key:\n', privateKey);
|
||||
19
apps/server/scripts/init-db.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import userA from '@affine-test/fixtures/userA.json' assert { type: 'json' };
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
await prisma.user.create({
|
||||
data: userA,
|
||||
});
|
||||
}
|
||||
|
||||
main()
|
||||
.then(async () => {
|
||||
await prisma.$disconnect();
|
||||
})
|
||||
.catch(async e => {
|
||||
console.error(e);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
65
apps/server/scripts/run-test.ts
Executable file
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env ts-node-esm
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import { spawn } from 'child_process';
|
||||
import { readdir } from 'fs/promises';
|
||||
import * as process from 'process';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import pkg from '../package.json' assert { type: 'json' };
|
||||
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||
const testDir = resolve(root, 'src', 'tests');
|
||||
const files = await readdir(testDir);
|
||||
|
||||
const sharedArgs = [...pkg.nodemonConfig.nodeArgs, '--test'];
|
||||
|
||||
const env = {
|
||||
PATH: process.env.PATH,
|
||||
NODE_ENV: 'test',
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
};
|
||||
|
||||
if (process.argv[2] === 'all') {
|
||||
const cp = spawn('node', [...sharedArgs, resolve(testDir, '*')], {
|
||||
cwd: root,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
shell: true,
|
||||
});
|
||||
cp.on('exit', code => {
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
} else {
|
||||
const result = await p.group({
|
||||
file: () =>
|
||||
p.select({
|
||||
message: 'Select a file to run',
|
||||
options: files.map(file => ({
|
||||
label: file,
|
||||
value: file as any,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
|
||||
const target = resolve(testDir, result.file);
|
||||
|
||||
const cp = spawn(
|
||||
'node',
|
||||
[
|
||||
...sharedArgs,
|
||||
'--test-reporter=spec',
|
||||
'--test-reporter-destination=stdout',
|
||||
target,
|
||||
],
|
||||
{
|
||||
cwd: root,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
shell: true,
|
||||
}
|
||||
);
|
||||
cp.on('exit', code => {
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
/// <reference types="./global.d.ts" />
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ConfigModule } from './config';
|
||||
|
||||
@@ -69,6 +69,10 @@ export function parseEnvValue(value: string | undefined, type?: EnvConfigType) {
|
||||
*/
|
||||
export interface AFFiNEConfig {
|
||||
ENV_MAP: Record<string, ConfigPaths | [ConfigPaths, EnvConfigType?]>;
|
||||
/**
|
||||
* Server Identity
|
||||
*/
|
||||
readonly serverId: string;
|
||||
/**
|
||||
* System version
|
||||
*/
|
||||
@@ -165,6 +169,28 @@ export interface AFFiNEConfig {
|
||||
* authentication config
|
||||
*/
|
||||
auth: {
|
||||
/**
|
||||
* Application sign key secret
|
||||
*/
|
||||
readonly salt: string;
|
||||
/**
|
||||
* Application access token expiration time
|
||||
*/
|
||||
readonly accessTokenExpiresIn: string;
|
||||
/**
|
||||
* Application refresh token expiration time
|
||||
*/
|
||||
readonly refreshTokenExpiresIn: string;
|
||||
/**
|
||||
* Application public key
|
||||
*
|
||||
*/
|
||||
readonly publicKey: string;
|
||||
/**
|
||||
* Application private key
|
||||
*
|
||||
*/
|
||||
readonly privateKey: string;
|
||||
/**
|
||||
* whether allow user to signup with email directly
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
/// <reference types="../global.d.ts" />
|
||||
|
||||
import pkg from '../../package.json' assert { type: 'json' };
|
||||
import type { AFFiNEConfig } from './def';
|
||||
|
||||
// Don't use this in production
|
||||
export const examplePublicKey = `-----BEGIN PUBLIC KEY-----
|
||||
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEnxM+GhB6eNKPmTP6uH5Gpr+bmQ87
|
||||
hHGeOiCsay0w/aPwMqzAOKkZGqX+HZ9BNGy/yiXmnscey5b2vOTzxtRvxA==
|
||||
-----END PUBLIC KEY-----`;
|
||||
|
||||
// Don't use this in production
|
||||
export const examplePrivateKey = `-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgWOog5SFXs1Vjh/WP
|
||||
QCYPQKgf/jsNmWsvD+jYSn6mi3yhRANCAASfEz4aEHp40o+ZM/q4fkamv5uZDzuE
|
||||
cZ46IKxrLTD9o/AyrMA4qRkapf4dn0E0bL/KJeaexx7Llva85PPG1G/E
|
||||
-----END PRIVATE KEY-----`;
|
||||
|
||||
export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => ({
|
||||
serverId: 'affine-nestjs-server',
|
||||
version: pkg.version,
|
||||
ENV_MAP: {},
|
||||
env: process.env.NODE_ENV ?? 'development',
|
||||
@@ -19,7 +35,7 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => ({
|
||||
},
|
||||
https: false,
|
||||
host: 'localhost',
|
||||
port: 3000,
|
||||
port: 3010,
|
||||
path: '',
|
||||
get origin() {
|
||||
return this.dev
|
||||
@@ -40,6 +56,11 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => ({
|
||||
debug: true,
|
||||
},
|
||||
auth: {
|
||||
salt: '$2b$10$x4VDo2nmlo74yB5jflNhlu',
|
||||
accessTokenExpiresIn: '1h',
|
||||
refreshTokenExpiresIn: '7d',
|
||||
publicKey: examplePublicKey,
|
||||
privateKey: examplePrivateKey,
|
||||
enableSignup: true,
|
||||
enableOauth: false,
|
||||
oauthProviders: {},
|
||||
|
||||
@@ -66,4 +66,4 @@ export class ConfigModule {
|
||||
};
|
||||
}
|
||||
|
||||
export { AFFiNEConfig } from './def';
|
||||
export type { AFFiNEConfig } from './def';
|
||||
|
||||
5
apps/server/src/global.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
declare namespace Express {
|
||||
interface Request {
|
||||
user?: import('@prisma/client').User | null;
|
||||
}
|
||||
}
|
||||
@@ -17,4 +17,9 @@ const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
bodyParser: true,
|
||||
});
|
||||
|
||||
await app.listen(process.env.PORT ?? 3010);
|
||||
const host = process.env.HOST ?? 'localhost';
|
||||
const port = process.env.PORT ?? 3010;
|
||||
|
||||
await app.listen(port, host);
|
||||
|
||||
console.log(`Listening on http://${host}:${port}`);
|
||||
|
||||
82
apps/server/src/modules/auth/guard.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
CanActivate,
|
||||
createParamDecorator,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { PrismaService } from '../../prisma';
|
||||
import { getRequestResponseFromContext } from '../../utils/nestjs';
|
||||
import { AuthService } from './service';
|
||||
|
||||
export function getUserFromContext(context: ExecutionContext) {
|
||||
const req = getRequestResponseFromContext(context).req;
|
||||
return req.user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to fetch current user from the request context.
|
||||
*
|
||||
* > The user may be undefined if authorization token is not provided.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```typescript
|
||||
* // Graphql Query
|
||||
* \@Query(() => UserType)
|
||||
* user(@CurrentUser() user?: User) {
|
||||
* return user;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ```typescript
|
||||
* // HTTP Controller
|
||||
* \@Get('/user)
|
||||
* user(@CurrentUser() user?: User) {
|
||||
* return user;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_: unknown, context: ExecutionContext) => {
|
||||
return getUserFromContext(context);
|
||||
}
|
||||
);
|
||||
|
||||
@Injectable()
|
||||
class AuthGuard implements CanActivate {
|
||||
constructor(private auth: AuthService, private prisma: PrismaService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const { req } = getRequestResponseFromContext(context);
|
||||
const token = req.headers.authorization;
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const claims = this.auth.verify(token);
|
||||
req.user = await this.prisma.user.findUnique({ where: { id: claims.id } });
|
||||
return !!req.user;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This guard is used to protect routes/queries/mutations that require a user to be logged in.
|
||||
*
|
||||
* The `@CurrentUser()` parameter decorator used in a `Auth` guarded queries would always give us the user because the `Auth` guard will
|
||||
* fast throw if user is not logged in.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```typescript
|
||||
* \@Auth()
|
||||
* \@Query(() => UserType)
|
||||
* user(@CurrentUser() user: User) {
|
||||
* return user;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const Auth = () => {
|
||||
return UseGuards(AuthGuard);
|
||||
};
|
||||
12
apps/server/src/modules/auth/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { AuthResolver } from './resolver';
|
||||
import { AuthService } from './service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [AuthService, AuthResolver],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
export * from './guard';
|
||||
53
apps/server/src/modules/auth/resolver.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Context,
|
||||
Field,
|
||||
Mutation,
|
||||
ObjectType,
|
||||
Parent,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import { Request } from 'express';
|
||||
|
||||
import { UserType } from '../users/resolver';
|
||||
import { CurrentUser } from './guard';
|
||||
import { AuthService } from './service';
|
||||
|
||||
@ObjectType()
|
||||
export class TokenType {
|
||||
@Field()
|
||||
token!: string;
|
||||
|
||||
@Field()
|
||||
refresh!: string;
|
||||
}
|
||||
|
||||
@Resolver(() => UserType)
|
||||
export class AuthResolver {
|
||||
constructor(private auth: AuthService) {}
|
||||
|
||||
@ResolveField(() => TokenType)
|
||||
token(@CurrentUser() currentUser: UserType, @Parent() user: UserType) {
|
||||
if (user !== currentUser) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return {
|
||||
token: this.auth.sign(user),
|
||||
refresh: this.auth.refresh(user),
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation(() => UserType)
|
||||
async signIn(
|
||||
@Context() ctx: { req: Request },
|
||||
@Args('email') email: string,
|
||||
@Args('password') password: string
|
||||
) {
|
||||
const user = await this.auth.signIn(email, password);
|
||||
ctx.req.user = user;
|
||||
return user;
|
||||
}
|
||||
}
|
||||
92
apps/server/src/modules/auth/service.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { User } from '@prisma/client';
|
||||
import { compare, hash } from 'bcrypt';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
import { Config } from '../../config';
|
||||
import { PrismaService } from '../../prisma';
|
||||
|
||||
type UserClaim = Pick<User, 'id' | 'name' | 'email'>;
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(private config: Config, private prisma: PrismaService) {}
|
||||
|
||||
sign(user: UserClaim) {
|
||||
return jwt.sign(user, this.config.auth.privateKey, {
|
||||
algorithm: 'ES256',
|
||||
subject: user.id,
|
||||
issuer: this.config.serverId,
|
||||
expiresIn: this.config.auth.accessTokenExpiresIn,
|
||||
});
|
||||
}
|
||||
|
||||
refresh(user: UserClaim) {
|
||||
return jwt.sign(user, this.config.auth.privateKey, {
|
||||
algorithm: 'ES256',
|
||||
subject: user.id,
|
||||
issuer: this.config.serverId,
|
||||
expiresIn: this.config.auth.refreshTokenExpiresIn,
|
||||
});
|
||||
}
|
||||
|
||||
verify(token: string) {
|
||||
try {
|
||||
return jwt.verify(token, this.config.auth.publicKey, {
|
||||
algorithms: ['ES256'],
|
||||
}) as UserClaim;
|
||||
} catch (e) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
}
|
||||
|
||||
async signIn(email: string, password: string): Promise<User> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException('Invalid email');
|
||||
}
|
||||
|
||||
if (!user.password) {
|
||||
throw new BadRequestException('User has no password');
|
||||
}
|
||||
|
||||
const equal = await compare(password, user.password);
|
||||
|
||||
if (!equal) {
|
||||
throw new UnauthorizedException('Invalid password');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async register(name: string, email: string, password: string): Promise<User> {
|
||||
const hashedPassword = await hash(password, this.config.auth.salt);
|
||||
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
if (user) {
|
||||
throw new BadRequestException('Email already exists');
|
||||
}
|
||||
|
||||
return this.prisma.user.create({
|
||||
data: {
|
||||
name,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { AuthModule } from './auth';
|
||||
import { UsersModule } from './users';
|
||||
import { WorkspaceModule } from './workspaces';
|
||||
|
||||
export const BusinessModules = [WorkspaceModule];
|
||||
export const BusinessModules = [AuthModule, WorkspaceModule, UsersModule];
|
||||
|
||||
8
apps/server/src/modules/users/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { UserResolver } from './resolver';
|
||||
|
||||
@Module({
|
||||
providers: [UserResolver],
|
||||
})
|
||||
export class UsersModule {}
|
||||
37
apps/server/src/modules/users/resolver.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Args, Field, ID, ObjectType, Query, Resolver } from '@nestjs/graphql';
|
||||
import type { User } from '@prisma/client';
|
||||
|
||||
import { PrismaService } from '../../prisma/service';
|
||||
|
||||
@ObjectType()
|
||||
export class UserType implements Partial<User> {
|
||||
@Field(() => ID)
|
||||
id!: string;
|
||||
|
||||
@Field({ description: 'User name' })
|
||||
name!: string;
|
||||
|
||||
@Field({ description: 'User email' })
|
||||
email!: string;
|
||||
|
||||
@Field({ description: 'User avatar url', nullable: true })
|
||||
avatarUrl!: string;
|
||||
|
||||
@Field({ description: 'User created date', nullable: true })
|
||||
createdAt!: Date;
|
||||
}
|
||||
|
||||
@Resolver(() => UserType)
|
||||
export class UserResolver {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@Query(() => UserType, {
|
||||
name: 'user',
|
||||
description: 'Get user by email',
|
||||
})
|
||||
async user(@Args('email') email: string) {
|
||||
return this.prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { PermissionService } from './permission';
|
||||
import { WorkspaceResolver } from './resolver';
|
||||
|
||||
@Module({
|
||||
providers: [WorkspaceResolver],
|
||||
providers: [WorkspaceResolver, PermissionService],
|
||||
exports: [PermissionService],
|
||||
})
|
||||
export class WorkspaceModule {}
|
||||
export { WorkspaceType } from './resolver';
|
||||
|
||||
134
apps/server/src/modules/workspaces/permission.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { PrismaService } from '../../prisma';
|
||||
import { Permission } from './types';
|
||||
|
||||
@Injectable()
|
||||
export class PermissionService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async get(ws: string, user: string) {
|
||||
const data = await this.prisma.userWorkspacePermission.findFirst({
|
||||
where: {
|
||||
workspaceId: ws,
|
||||
userId: user,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
return data?.type as Permission;
|
||||
}
|
||||
|
||||
async check(
|
||||
ws: string,
|
||||
user: string,
|
||||
permission: Permission = Permission.Read
|
||||
) {
|
||||
if (!(await this.tryCheck(ws, user, permission))) {
|
||||
throw new ForbiddenException('Permission denied');
|
||||
}
|
||||
}
|
||||
|
||||
async tryCheck(
|
||||
ws: string,
|
||||
user: string,
|
||||
permission: Permission = Permission.Read
|
||||
) {
|
||||
const data = await this.prisma.userWorkspacePermission.count({
|
||||
where: {
|
||||
workspaceId: ws,
|
||||
userId: user,
|
||||
accepted: true,
|
||||
type: {
|
||||
gte: permission,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (data > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If the permission is read, we should check if the workspace is public
|
||||
if (permission === Permission.Read) {
|
||||
const data = await this.prisma.workspace.count({
|
||||
where: { id: ws, public: true },
|
||||
});
|
||||
|
||||
return data > 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async grant(
|
||||
ws: string,
|
||||
user: string,
|
||||
permission: Permission = Permission.Read
|
||||
) {
|
||||
const data = await this.prisma.userWorkspacePermission.findFirst({
|
||||
where: {
|
||||
workspaceId: ws,
|
||||
userId: user,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (data) {
|
||||
const [p] = await this.prisma.$transaction(
|
||||
[
|
||||
this.prisma.userWorkspacePermission.update({
|
||||
where: {
|
||||
id: data.id,
|
||||
},
|
||||
data: {
|
||||
type: permission,
|
||||
},
|
||||
}),
|
||||
|
||||
// If the new permission is owner, we need to revoke old owner
|
||||
permission === Permission.Owner
|
||||
? this.prisma.userWorkspacePermission.updateMany({
|
||||
where: {
|
||||
workspaceId: ws,
|
||||
type: Permission.Owner,
|
||||
userId: {
|
||||
not: user,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
type: Permission.Admin,
|
||||
},
|
||||
})
|
||||
: null,
|
||||
].filter(Boolean) as Prisma.PrismaPromise<any>[]
|
||||
);
|
||||
|
||||
return p;
|
||||
}
|
||||
|
||||
return this.prisma.userWorkspacePermission.create({
|
||||
data: {
|
||||
workspaceId: ws,
|
||||
userId: user,
|
||||
type: permission,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async revoke(ws: string, user: string) {
|
||||
const result = await this.prisma.userWorkspacePermission.deleteMany({
|
||||
where: {
|
||||
workspaceId: ws,
|
||||
userId: user,
|
||||
type: {
|
||||
// We shouldn't revoke owner permission, should auto deleted by workspace/user delete cascading
|
||||
not: Permission.Owner,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return result.count > 0;
|
||||
}
|
||||
}
|
||||
@@ -1,56 +1,206 @@
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Field,
|
||||
ID,
|
||||
InputType,
|
||||
Int,
|
||||
Mutation,
|
||||
ObjectType,
|
||||
Parent,
|
||||
PartialType,
|
||||
PickType,
|
||||
Query,
|
||||
registerEnumType,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import type { workspaces } from '@prisma/client';
|
||||
import type { User, Workspace } from '@prisma/client';
|
||||
|
||||
import { PrismaService } from '../../prisma/service';
|
||||
import { PrismaService } from '../../prisma';
|
||||
import { Auth, CurrentUser } from '../auth';
|
||||
import { UserType } from '../users/resolver';
|
||||
import { PermissionService } from './permission';
|
||||
import { Permission } from './types';
|
||||
|
||||
export enum WorkspaceType {
|
||||
Private = 0,
|
||||
Normal = 1,
|
||||
}
|
||||
|
||||
registerEnumType(WorkspaceType, {
|
||||
name: 'WorkspaceType',
|
||||
registerEnumType(Permission, {
|
||||
name: 'Permission',
|
||||
description: 'User permission in workspace',
|
||||
});
|
||||
|
||||
@ObjectType()
|
||||
export class Workspace implements workspaces {
|
||||
@Field()
|
||||
export class WorkspaceType implements Partial<Workspace> {
|
||||
@Field(() => ID)
|
||||
id!: string;
|
||||
|
||||
@Field({ description: 'is Public workspace' })
|
||||
public!: boolean;
|
||||
@Field(() => WorkspaceType, { description: 'Workspace type' })
|
||||
type!: WorkspaceType;
|
||||
|
||||
@Field({ description: 'Workspace created date' })
|
||||
created_at!: Date;
|
||||
createdAt!: Date;
|
||||
}
|
||||
|
||||
@Resolver(() => Workspace)
|
||||
export class WorkspaceResolver {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@InputType()
|
||||
export class UpdateWorkspaceInput extends PickType(
|
||||
PartialType(WorkspaceType),
|
||||
['public'],
|
||||
InputType
|
||||
) {
|
||||
@Field(() => ID)
|
||||
id!: string;
|
||||
}
|
||||
|
||||
// debug only query should be removed
|
||||
@Query(() => [Workspace], {
|
||||
name: 'workspaces',
|
||||
description: 'Get all workspaces',
|
||||
@Auth()
|
||||
@Resolver(() => WorkspaceType)
|
||||
export class WorkspaceResolver {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissionProvider: PermissionService
|
||||
) {}
|
||||
|
||||
@ResolveField(() => Permission, {
|
||||
description: 'Permission of current signed in user in workspace',
|
||||
complexity: 2,
|
||||
})
|
||||
async workspaces() {
|
||||
return this.prisma.workspaces.findMany();
|
||||
async permission(
|
||||
@CurrentUser() user: User,
|
||||
@Parent() workspace: WorkspaceType
|
||||
) {
|
||||
// may applied in workspaces query
|
||||
if ('permission' in workspace) {
|
||||
return workspace.permission;
|
||||
}
|
||||
|
||||
const permission = this.permissionProvider.get(workspace.id, user.id);
|
||||
|
||||
if (!permission) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return permission;
|
||||
}
|
||||
|
||||
@Query(() => Workspace, {
|
||||
name: 'workspace',
|
||||
description: 'Get workspace by id',
|
||||
@ResolveField(() => Int, {
|
||||
description: 'member count of workspace',
|
||||
complexity: 2,
|
||||
})
|
||||
async workspace(@Args('id') id: string) {
|
||||
return this.prisma.workspaces.findUnique({
|
||||
where: { id },
|
||||
memberCount(@Parent() workspace: WorkspaceType) {
|
||||
return this.prisma.userWorkspacePermission.count({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ResolveField(() => UserType, {
|
||||
description: 'Owner of workspace',
|
||||
complexity: 2,
|
||||
})
|
||||
async owner(@Parent() workspace: WorkspaceType) {
|
||||
const data = await this.prisma.userWorkspacePermission.findFirstOrThrow({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
type: Permission.Owner,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
return data.user;
|
||||
}
|
||||
|
||||
@Query(() => [WorkspaceType], {
|
||||
description: 'Get all accessible workspaces for current user',
|
||||
complexity: 2,
|
||||
})
|
||||
async workspaces(@CurrentUser() user: User) {
|
||||
const data = await this.prisma.userWorkspacePermission.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
accepted: true,
|
||||
},
|
||||
include: {
|
||||
workspace: true,
|
||||
},
|
||||
});
|
||||
|
||||
return data.map(({ workspace, type }) => {
|
||||
return {
|
||||
...workspace,
|
||||
permission: type,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@Query(() => WorkspaceType, {
|
||||
description: 'Get workspace by id',
|
||||
})
|
||||
async workspace(@CurrentUser() user: UserType, @Args('id') id: string) {
|
||||
await this.permissionProvider.check(id, user.id);
|
||||
const workspace = await this.prisma.workspace.findUnique({ where: { id } });
|
||||
|
||||
if (!workspace) {
|
||||
throw new NotFoundException("Workspace doesn't exist");
|
||||
}
|
||||
|
||||
return workspace;
|
||||
}
|
||||
|
||||
@Mutation(() => WorkspaceType, {
|
||||
description: 'Create a new workspace',
|
||||
})
|
||||
async createWorkspace(@CurrentUser() user: User) {
|
||||
return this.prisma.workspace.create({
|
||||
data: {
|
||||
public: false,
|
||||
users: {
|
||||
create: {
|
||||
type: Permission.Owner,
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => WorkspaceType, {
|
||||
description: 'Update workspace',
|
||||
})
|
||||
async updateWorkspace(
|
||||
@CurrentUser() user: User,
|
||||
@Args({ name: 'input', type: () => UpdateWorkspaceInput })
|
||||
{ id, ...updates }: UpdateWorkspaceInput
|
||||
) {
|
||||
await this.permissionProvider.check('id', user.id, Permission.Admin);
|
||||
|
||||
return this.prisma.workspace.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: updates,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async deleteWorkspace(@CurrentUser() user: User, @Args('id') id: string) {
|
||||
await this.permissionProvider.check(id, user.id, Permission.Owner);
|
||||
|
||||
await this.prisma.workspace.delete({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
// TODO:
|
||||
// delete all related data, like websocket connections, blobs, etc.
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
6
apps/server/src/modules/workspaces/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export enum Permission {
|
||||
Read = 0,
|
||||
Write = 1,
|
||||
Admin = 10,
|
||||
Owner = 99,
|
||||
}
|
||||
@@ -8,3 +8,4 @@ import { PrismaService } from './service';
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
export { PrismaService } from './service';
|
||||
|
||||
136
apps/server/src/tests/app.e2e.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { equal, ok } from 'node:assert';
|
||||
import { afterEach, beforeEach, describe, test } from 'node:test';
|
||||
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { hash } from 'bcrypt';
|
||||
import request from 'supertest';
|
||||
|
||||
import { AppModule } from '../app';
|
||||
import { getDefaultAFFiNEConfig } from '../config/default';
|
||||
|
||||
const gql = '/graphql';
|
||||
|
||||
globalThis.AFFiNE = getDefaultAFFiNEConfig();
|
||||
|
||||
describe('AppModule', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
// cleanup database before each test
|
||||
beforeEach(async () => {
|
||||
const client = new PrismaClient();
|
||||
await client.$connect();
|
||||
await client.user.deleteMany({});
|
||||
await client.user.create({
|
||||
data: {
|
||||
id: '1',
|
||||
name: 'Alex Yang',
|
||||
email: 'alex.yang@example.org',
|
||||
password: await hash('123456', globalThis.AFFiNE.auth.salt),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
app = module.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('should init app', async () => {
|
||||
ok(typeof app === 'object');
|
||||
await request(app.getHttpServer())
|
||||
.post(gql)
|
||||
.send({
|
||||
query: `
|
||||
query {
|
||||
error
|
||||
}
|
||||
`,
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
let token;
|
||||
await request(app.getHttpServer())
|
||||
.post(gql)
|
||||
.send({
|
||||
query: `
|
||||
mutation {
|
||||
signIn(email: "alex.yang@example.org", password: "123456") {
|
||||
token {
|
||||
token
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
.expect(200)
|
||||
.expect(res => {
|
||||
ok(
|
||||
typeof res.body.data.signIn.token.token === 'string',
|
||||
'res.body.data.signIn.token.token is not a string'
|
||||
);
|
||||
token = res.body.data.signIn.token.token;
|
||||
});
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(gql)
|
||||
.set({ Authorization: token })
|
||||
.send({
|
||||
query: `
|
||||
mutation {
|
||||
createWorkspace {
|
||||
id
|
||||
public
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
.expect(200)
|
||||
.expect(res => {
|
||||
ok(
|
||||
typeof res.body.data.createWorkspace === 'object',
|
||||
'res.body.data.createWorkspace is not an object'
|
||||
);
|
||||
ok(
|
||||
typeof res.body.data.createWorkspace.id === 'string',
|
||||
'res.body.data.createWorkspace.id is not a string'
|
||||
);
|
||||
ok(
|
||||
typeof res.body.data.createWorkspace.public === 'boolean',
|
||||
'res.body.data.createWorkspace.public is not a boolean'
|
||||
);
|
||||
ok(
|
||||
typeof res.body.data.createWorkspace.createdAt === 'string',
|
||||
'res.body.data.createWorkspace.created_at is not a string'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('should find default user', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post(gql)
|
||||
.send({
|
||||
query: `
|
||||
query {
|
||||
user(email: "alex.yang@example.org") {
|
||||
email
|
||||
avatarUrl
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
.expect(200)
|
||||
.expect(res => {
|
||||
equal(res.body.data.user.email, 'alex.yang@example.org');
|
||||
});
|
||||
});
|
||||
});
|
||||
82
apps/server/src/tests/auth.spec.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { ok, throws } from 'node:assert';
|
||||
import { beforeEach, test } from 'node:test';
|
||||
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { Config, ConfigModule } from '../config';
|
||||
import { getDefaultAFFiNEConfig } from '../config/default';
|
||||
import { GqlModule } from '../graphql.module';
|
||||
import { AuthModule } from '../modules/auth';
|
||||
import { AuthService } from '../modules/auth/service';
|
||||
import { PrismaModule } from '../prisma';
|
||||
|
||||
globalThis.AFFiNE = getDefaultAFFiNEConfig();
|
||||
|
||||
let auth: AuthService;
|
||||
let config: Config;
|
||||
|
||||
// cleanup database before each test
|
||||
beforeEach(async () => {
|
||||
const client = new PrismaClient();
|
||||
await client.$connect();
|
||||
await client.user.deleteMany({});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
auth: {
|
||||
accessTokenExpiresIn: '1s',
|
||||
refreshTokenExpiresIn: '3s',
|
||||
},
|
||||
}),
|
||||
PrismaModule,
|
||||
GqlModule,
|
||||
AuthModule,
|
||||
],
|
||||
}).compile();
|
||||
config = module.get(Config);
|
||||
auth = module.get(AuthService);
|
||||
});
|
||||
|
||||
async function sleep(ms: number) {
|
||||
return new Promise<void>(resolve => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
test('should be able to register and signIn', async () => {
|
||||
await auth.register('Alex Yang', 'alexyang@example.org', '123456');
|
||||
await auth.signIn('alexyang@example.org', '123456');
|
||||
});
|
||||
|
||||
test('should be able to verify', async () => {
|
||||
await auth.register('Alex Yang', 'alexyang@example.org', '123456');
|
||||
await auth.signIn('alexyang@example.org', '123456');
|
||||
const user = {
|
||||
id: '1',
|
||||
name: 'Alex Yang',
|
||||
email: 'alexyang@example.org',
|
||||
};
|
||||
{
|
||||
const token = auth.sign(user);
|
||||
const clain = auth.verify(token);
|
||||
ok(clain.id === '1');
|
||||
ok(clain.name === 'Alex Yang');
|
||||
ok(clain.email === 'alexyang@example.org');
|
||||
await sleep(1050);
|
||||
throws(() => auth.verify(token), UnauthorizedException, 'Invalid token');
|
||||
}
|
||||
{
|
||||
const token = auth.refresh(user);
|
||||
const clain = auth.verify(token);
|
||||
ok(clain.id === '1');
|
||||
ok(clain.name === 'Alex Yang');
|
||||
ok(clain.email === 'alexyang@example.org');
|
||||
await sleep(3050);
|
||||
throws(() => auth.verify(token), UnauthorizedException, 'Invalid token');
|
||||
}
|
||||
});
|
||||
57
apps/server/src/utils/nestjs.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { ArgumentsHost, ExecutionContext } from '@nestjs/common';
|
||||
import {
|
||||
GqlArgumentsHost,
|
||||
GqlContextType,
|
||||
GqlExecutionContext,
|
||||
} from '@nestjs/graphql';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
export function getRequestResponseFromContext(context: ExecutionContext) {
|
||||
switch (context.getType<GqlContextType>()) {
|
||||
case 'graphql': {
|
||||
const gqlContext = GqlExecutionContext.create(context).getContext<{
|
||||
req: Request;
|
||||
}>();
|
||||
return {
|
||||
req: gqlContext.req,
|
||||
res: gqlContext.req.res!,
|
||||
};
|
||||
}
|
||||
case 'http': {
|
||||
const http = context.switchToHttp();
|
||||
return {
|
||||
req: http.getRequest<Request>(),
|
||||
res: http.getResponse<Response>(),
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new Error('Unknown context type for getting request and response');
|
||||
}
|
||||
}
|
||||
|
||||
export function getRequestResponseFromHost(host: ArgumentsHost) {
|
||||
switch (host.getType<GqlContextType>()) {
|
||||
case 'graphql': {
|
||||
const gqlContext = GqlArgumentsHost.create(host).getContext<{
|
||||
req: Request;
|
||||
}>();
|
||||
return {
|
||||
req: gqlContext.req,
|
||||
res: gqlContext.req.res!,
|
||||
};
|
||||
}
|
||||
case 'http': {
|
||||
const http = host.switchToHttp();
|
||||
return {
|
||||
req: http.getRequest<Request>(),
|
||||
res: http.getResponse<Response>(),
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new Error('Unknown host type for getting request and response');
|
||||
}
|
||||
}
|
||||
|
||||
export function getRequestFromHost(host: ArgumentsHost) {
|
||||
return getRequestResponseFromHost(host).req;
|
||||
}
|
||||
@@ -15,6 +15,11 @@
|
||||
},
|
||||
"include": ["src", "package.json"],
|
||||
"exclude": ["dist", "node_modules"],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
],
|
||||
"ts-node": {
|
||||
"esm": true,
|
||||
"experimentalSpecifierResolution": "node"
|
||||
|
||||
11
apps/server/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"resolveJsonModule": true,
|
||||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["scripts", "package.json"]
|
||||
}
|
||||
@@ -7,14 +7,15 @@ import { withSentryConfig } from '@sentry/nextjs';
|
||||
import SentryWebpackPlugin from '@sentry/webpack-plugin';
|
||||
import debugLocal from 'next-debug-local';
|
||||
|
||||
import preset from './preset.config.mjs';
|
||||
import { blockSuiteFeatureFlags, buildFlags } from './preset.config.mjs';
|
||||
import { getCommitHash, getGitVersion } from './scripts/gitInfo.mjs';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { createVanillaExtractPlugin } = require('@vanilla-extract/next-plugin');
|
||||
const withVanillaExtract = createVanillaExtractPlugin();
|
||||
|
||||
console.info('Runtime Preset', preset);
|
||||
console.info('Build Flags', buildFlags);
|
||||
console.info('Editor Flags', blockSuiteFeatureFlags);
|
||||
|
||||
const enableDebugLocal = path.isAbsolute(process.env.LOCAL_BLOCK_SUITE ?? '');
|
||||
|
||||
@@ -84,10 +85,11 @@ const nextConfig = {
|
||||
'@affine/debug',
|
||||
'@affine/env',
|
||||
'@affine/templates',
|
||||
'@toeverything/hooks',
|
||||
'@affine/workspace',
|
||||
'@affine/jotai',
|
||||
'@toeverything/hooks',
|
||||
'@toeverything/y-indexeddb',
|
||||
'@toeverything/theme',
|
||||
],
|
||||
publicRuntimeConfig: {
|
||||
PROJECT_NAME: process.env.npm_package_name ?? 'AFFiNE',
|
||||
@@ -98,7 +100,8 @@ const nextConfig = {
|
||||
profileTarget[process.env.API_SERVER_PROFILE || 'dev'] ??
|
||||
profileTarget.dev,
|
||||
editorVersion: require('./package.json').dependencies['@blocksuite/editor'],
|
||||
...preset,
|
||||
editorFlags: blockSuiteFeatureFlags,
|
||||
...buildFlags,
|
||||
},
|
||||
webpack: (config, { dev, isServer }) => {
|
||||
config.experiments = { ...config.experiments, topLevelAwait: true };
|
||||
@@ -144,7 +147,9 @@ const nextConfig = {
|
||||
},
|
||||
basePath: process.env.NEXT_BASE_PATH,
|
||||
assetPrefix: process.env.NEXT_ASSET_PREFIX,
|
||||
pageExtensions: [...(preset.enableDebugPage ? ['tsx', 'dev.tsx'] : ['tsx'])],
|
||||
pageExtensions: [
|
||||
...(buildFlags.enableDebugPage ? ['tsx', 'dev.tsx'] : ['tsx']),
|
||||
],
|
||||
};
|
||||
|
||||
const baseDir = process.env.LOCAL_BLOCK_SUITE ?? '/';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "@affine/web",
|
||||
"private": true,
|
||||
"version": "0.5.4-canary.15",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
@@ -13,56 +14,58 @@
|
||||
"@affine/component": "workspace:*",
|
||||
"@affine/debug": "workspace:*",
|
||||
"@affine/env": "workspace:*",
|
||||
"@affine/graphql": "workspace:*",
|
||||
"@affine/i18n": "workspace:*",
|
||||
"@affine/jotai": "workspace:*",
|
||||
"@affine/templates": "workspace:*",
|
||||
"@affine/workspace": "workspace:*",
|
||||
"@blocksuite/blocks": "0.0.0-20230416194015-c6ae6f0f-nightly",
|
||||
"@blocksuite/editor": "0.0.0-20230416194015-c6ae6f0f-nightly",
|
||||
"@blocksuite/global": "0.0.0-20230416194015-c6ae6f0f-nightly",
|
||||
"@blocksuite/icons": "^2.1.10",
|
||||
"@blocksuite/store": "0.0.0-20230416194015-c6ae6f0f-nightly",
|
||||
"@blocksuite/blocks": "0.0.0-20230427041825-7fff957d-nightly",
|
||||
"@blocksuite/editor": "0.0.0-20230427041825-7fff957d-nightly",
|
||||
"@blocksuite/global": "0.0.0-20230427041825-7fff957d-nightly",
|
||||
"@blocksuite/icons": "^2.1.13",
|
||||
"@blocksuite/store": "0.0.0-20230427041825-7fff957d-nightly",
|
||||
"@dnd-kit/core": "^6.0.8",
|
||||
"@dnd-kit/sortable": "^7.0.2",
|
||||
"@emotion/cache": "^11.10.7",
|
||||
"@emotion/react": "^11.10.6",
|
||||
"@emotion/server": "^11.10.0",
|
||||
"@emotion/styled": "^11.10.6",
|
||||
"@mui/material": "^5.12.0",
|
||||
"@mui/material": "^5.12.2",
|
||||
"@react-hookz/web": "^23.0.0",
|
||||
"@sentry/nextjs": "^7.48.0",
|
||||
"@sentry/nextjs": "^7.50.0",
|
||||
"@toeverything/hooks": "workspace:*",
|
||||
"cmdk": "^0.2.0",
|
||||
"css-spring": "^4.1.0",
|
||||
"dayjs": "^1.11.7",
|
||||
"graphql": "^16.6.0",
|
||||
"jotai": "^2.0.4",
|
||||
"jotai-devtools": "^0.4.0",
|
||||
"lit": "^2.7.2",
|
||||
"lit": "^2.7.3",
|
||||
"lottie-web": "^5.11.0",
|
||||
"next-themes": "^0.2.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-is": "^18.2.0",
|
||||
"swr": "^2.1.3",
|
||||
"swr": "^2.1.5",
|
||||
"y-protocols": "^1.0.5",
|
||||
"yjs": "^13.5.52",
|
||||
"yjs": "^13.6.0",
|
||||
"zod": "^3.21.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@perfsee/webpack": "^1.5.0",
|
||||
"@perfsee/webpack": "^1.6.0",
|
||||
"@redux-devtools/extension": "^3.2.5",
|
||||
"@rich-data/viewer": "^2.15.6",
|
||||
"@sentry/webpack-plugin": "^1.20.0",
|
||||
"@sentry/webpack-plugin": "^1.20.1",
|
||||
"@swc-jotai/debug-label": "^0.0.9",
|
||||
"@swc-jotai/react-refresh": "^0.0.7",
|
||||
"@types/react": "^18.0.35",
|
||||
"@types/react-dom": "^18.0.11",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.1",
|
||||
"@types/webpack-env": "^1.18.0",
|
||||
"@vanilla-extract/css": "^1.11.0",
|
||||
"@vanilla-extract/next-plugin": "^2.1.2",
|
||||
"dotenv": "^16.0.3",
|
||||
"eslint": "^8.38.0",
|
||||
"eslint-config-next": "^13.3.0",
|
||||
"eslint": "^8.39.0",
|
||||
"eslint-config-next": "^13.3.1",
|
||||
"next": "=13.2.3",
|
||||
"next-debug-local": "^0.1.5",
|
||||
"next-router-mock": "^0.9.3",
|
||||
@@ -70,6 +73,7 @@
|
||||
"redux": "^4.2.1",
|
||||
"swc-plugin-coverage-instrument": "=0.0.14",
|
||||
"typescript": "^5.0.4",
|
||||
"webpack": "^5.79.0"
|
||||
}
|
||||
"webpack": "^5.81.0"
|
||||
},
|
||||
"stableVersion": "0.0.0"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
// @ts-check
|
||||
import 'dotenv/config';
|
||||
|
||||
const config = {
|
||||
/**
|
||||
* @type {import('@affine/env').BlockSuiteFeatureFlags}
|
||||
*/
|
||||
export const blockSuiteFeatureFlags = {
|
||||
enable_database: true,
|
||||
enable_slash_menu: true,
|
||||
enable_edgeless_toolbar: true,
|
||||
enable_block_hub: true,
|
||||
enable_drag_handle: true,
|
||||
enable_surface: true,
|
||||
enable_linked_page: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* @type {import('@affine/env').BuildFlags}
|
||||
*/
|
||||
export const buildFlags = {
|
||||
enableLegacyCloud: process.env.ENABLE_LEGACY_PROVIDER
|
||||
? process.env.ENABLE_LEGACY_PROVIDER === 'true'
|
||||
: true,
|
||||
@@ -11,4 +28,3 @@ const config = {
|
||||
process.env.ENABLE_DEBUG_PAGE ?? process.env.NODE_ENV === 'development'
|
||||
),
|
||||
};
|
||||
export default config;
|
||||
|
||||
@@ -4,8 +4,8 @@ const SENTRY_DSN = process.env.NEXT_PUBLIC_SENTRY_DSN;
|
||||
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
tracesSampleRate: 1.0,
|
||||
tracesSampleRate: 0.1,
|
||||
replaysSessionSampleRate: 0.1,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
replaysOnErrorSampleRate: 0.1,
|
||||
integrations: [new Sentry.Replay()],
|
||||
});
|
||||
|
||||
@@ -4,5 +4,5 @@ const SENTRY_DSN = process.env.NEXT_PUBLIC_SENTRY_DSN;
|
||||
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
tracesSampleRate: 1.0,
|
||||
tracesSampleRate: 0.1,
|
||||
});
|
||||
|
||||
@@ -4,5 +4,5 @@ const SENTRY_DSN = process.env.NEXT_PUBLIC_SENTRY_DSN;
|
||||
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
tracesSampleRate: 1.0,
|
||||
tracesSampleRate: 0.1,
|
||||
});
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
import { atomWithSyncStorage } from '@affine/jotai';
|
||||
import { atomWithStorage } from 'jotai/utils';
|
||||
|
||||
export type Visibility = Record<string, boolean>;
|
||||
|
||||
const DEFAULT_VALUE = '0.0.0';
|
||||
|
||||
export const lastVersionAtom = atomWithSyncStorage(
|
||||
'lastVersion',
|
||||
DEFAULT_VALUE
|
||||
);
|
||||
export const guideHiddenAtom = atomWithSyncStorage<Visibility>(
|
||||
'guideHidden',
|
||||
{}
|
||||
);
|
||||
export const lastVersionAtom = atomWithStorage('lastVersion', DEFAULT_VALUE);
|
||||
|
||||
export const guideHiddenUntilNextUpdateAtom = atomWithSyncStorage<Visibility>(
|
||||
export const guideHiddenAtom = atomWithStorage<Visibility>('guideHidden', {});
|
||||
|
||||
export const guideHiddenUntilNextUpdateAtom = atomWithStorage<Visibility>(
|
||||
'guideHiddenUntilNextUpdate',
|
||||
{}
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
rootCurrentWorkspaceIdAtom,
|
||||
rootWorkspacesMetadataAtom,
|
||||
} from '@affine/workspace/atom';
|
||||
import { WorkspaceFlavour } from '@affine/workspace/type';
|
||||
import type { Page } from '@blocksuite/store';
|
||||
import { atom } from 'jotai';
|
||||
|
||||
@@ -46,6 +47,21 @@ rootWorkspacesMetadataAtom.onMount = setAtom => {
|
||||
}
|
||||
return metadata;
|
||||
});
|
||||
|
||||
if (environment.isDesktop) {
|
||||
window.apis.workspace.list().then(workspaceIDs => {
|
||||
const newMetadata = workspaceIDs.map(w => ({
|
||||
id: w,
|
||||
flavour: WorkspaceFlavour.LOCAL,
|
||||
}));
|
||||
setAtom(metadata => {
|
||||
return [
|
||||
...metadata,
|
||||
...newMetadata.filter(m => !metadata.find(m2 => m2.id === m.id)),
|
||||
];
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getLoginStorage } from '@affine/workspace/affine/login';
|
||||
import type { BlockSuiteFeatureFlags } from '@affine/env';
|
||||
import { config } from '@affine/env';
|
||||
import type { AffinePublicWorkspace } from '@affine/workspace/type';
|
||||
import { WorkspaceFlavour } from '@affine/workspace/type';
|
||||
import { createEmptyBlockSuiteWorkspace } from '@affine/workspace/utils';
|
||||
@@ -14,10 +15,9 @@ function createPublicWorkspace(
|
||||
): AffinePublicWorkspace {
|
||||
const blockSuiteWorkspace = createEmptyBlockSuiteWorkspace(
|
||||
workspaceId,
|
||||
(k: string) =>
|
||||
// fixme: token could be expired
|
||||
({ api: `api/workspace`, token: getLoginStorage()?.token }[k]),
|
||||
WorkspaceFlavour.AFFINE,
|
||||
{
|
||||
workspaceApis: affineApis,
|
||||
cachePrefix: WorkspaceFlavour.PUBLIC + (singlePage ? '-single-page' : ''),
|
||||
}
|
||||
);
|
||||
@@ -25,11 +25,14 @@ function createPublicWorkspace(
|
||||
blockSuiteWorkspace.doc,
|
||||
new Uint8Array(binary)
|
||||
);
|
||||
Object.entries(config.editorFlags).forEach(([key, value]) => {
|
||||
blockSuiteWorkspace.awarenessStore.setFlag(
|
||||
key as keyof BlockSuiteFeatureFlags,
|
||||
value
|
||||
);
|
||||
});
|
||||
// force disable some features
|
||||
blockSuiteWorkspace.awarenessStore.setFlag('enable_block_hub', false);
|
||||
blockSuiteWorkspace.awarenessStore.setFlag('enable_set_remote_flag', false);
|
||||
blockSuiteWorkspace.awarenessStore.setFlag('enable_database', true);
|
||||
blockSuiteWorkspace.awarenessStore.setFlag('enable_edgeless_toolbar', false);
|
||||
blockSuiteWorkspace.awarenessStore.setFlag('enable_slash_menu', false);
|
||||
blockSuiteWorkspace.awarenessStore.setFlag('enable_drag_handle', false);
|
||||
return {
|
||||
flavour: WorkspaceFlavour.PUBLIC,
|
||||
|
||||
@@ -25,14 +25,19 @@ export const createAffineDownloadProvider = (
|
||||
);
|
||||
return;
|
||||
}
|
||||
affineApis.downloadWorkspace(id, false).then(binary => {
|
||||
hashMap.set(id, binary);
|
||||
providerLogger.debug('applyUpdate');
|
||||
BlockSuiteWorkspace.Y.applyUpdate(
|
||||
blockSuiteWorkspace.doc,
|
||||
new Uint8Array(binary)
|
||||
);
|
||||
});
|
||||
affineApis
|
||||
.downloadWorkspace(id, false)
|
||||
.then(binary => {
|
||||
hashMap.set(id, binary);
|
||||
providerLogger.debug('applyUpdate');
|
||||
BlockSuiteWorkspace.Y.applyUpdate(
|
||||
blockSuiteWorkspace.doc,
|
||||
new Uint8Array(binary)
|
||||
);
|
||||
})
|
||||
.catch(e => {
|
||||
providerLogger.error('downloadWorkspace', e);
|
||||
});
|
||||
},
|
||||
disconnect: () => {
|
||||
providerLogger.info('disconnect download provider', id);
|
||||
|
||||
@@ -26,9 +26,9 @@ const createAffineWebSocketProvider = (
|
||||
blockSuiteWorkspace.doc,
|
||||
{
|
||||
params: { token: getLoginStorage()?.token ?? '' },
|
||||
// @ts-expect-error ignore the type
|
||||
awareness: blockSuiteWorkspace.awarenessStore.awareness,
|
||||
// we maintain broadcast channel by ourselves
|
||||
// @ts-expect-error
|
||||
disableBc: true,
|
||||
connect: false,
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { WorkspaceFlavour } from '@affine/workspace/type';
|
||||
import { createEmptyBlockSuiteWorkspace } from '@affine/workspace/utils';
|
||||
import type { EditorContainer } from '@blocksuite/editor';
|
||||
import type { Page } from '@blocksuite/store';
|
||||
@@ -9,7 +10,7 @@ import { BlockSuiteEditor } from '../../blocksuite/block-suite-editor';
|
||||
|
||||
const blockSuiteWorkspace = createEmptyBlockSuiteWorkspace(
|
||||
'test',
|
||||
_ => undefined,
|
||||
WorkspaceFlavour.LOCAL,
|
||||
{
|
||||
idGenerator: Generator.AutoIncrement,
|
||||
}
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import 'fake-indexeddb/auto';
|
||||
|
||||
import { rootCurrentWorkspaceIdAtom } from '@affine/workspace/atom';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import matchers from '@testing-library/jest-dom/matchers';
|
||||
import type { RenderResult } from '@testing-library/react';
|
||||
import { render, renderHook } from '@testing-library/react';
|
||||
import { createStore, getDefaultStore, Provider } from 'jotai';
|
||||
import type { FC, PropsWithChildren } from 'react';
|
||||
import { beforeEach, describe, expect, test } from 'vitest';
|
||||
|
||||
import { workspacesAtom } from '../../atoms';
|
||||
import { rootCurrentWorkspaceAtom } from '../../atoms/root';
|
||||
import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace';
|
||||
import { useAppHelper } from '../../hooks/use-workspaces';
|
||||
import { ThemeProvider } from '../../providers/ThemeProvider';
|
||||
import type { BlockSuiteWorkspace } from '../../shared';
|
||||
import type { PinboardProps } from '../pure/workspace-slider-bar/Pinboard';
|
||||
import Pinboard from '../pure/workspace-slider-bar/Pinboard';
|
||||
|
||||
expect.extend(matchers);
|
||||
|
||||
let store = getDefaultStore();
|
||||
beforeEach(async () => {
|
||||
store = createStore();
|
||||
await store.get(workspacesAtom);
|
||||
});
|
||||
|
||||
const ProviderWrapper: FC<PropsWithChildren> = ({ children }) => {
|
||||
return <Provider store={store}>{children}</Provider>;
|
||||
};
|
||||
|
||||
const initPinBoard = async () => {
|
||||
// create one workspace with 2 root pages and 2 pinboard pages
|
||||
// - hasPinboardPage
|
||||
// - hasPinboardPage
|
||||
// - pinboard1
|
||||
// - pinboard2
|
||||
// - noPinboardPage
|
||||
|
||||
const mutationHook = renderHook(() => useAppHelper(), {
|
||||
wrapper: ProviderWrapper,
|
||||
});
|
||||
const rootPageIds = ['hasPinboardPage', 'noPinboardPage'];
|
||||
const pinboardPageIds = ['pinboard1', 'pinboard2'];
|
||||
const id = await mutationHook.result.current.createLocalWorkspace('test0');
|
||||
|
||||
store.set(rootCurrentWorkspaceIdAtom, id);
|
||||
await store.get(workspacesAtom);
|
||||
|
||||
await store.get(rootCurrentWorkspaceAtom);
|
||||
const currentWorkspaceHook = renderHook(() => useCurrentWorkspace(), {
|
||||
wrapper: ProviderWrapper,
|
||||
});
|
||||
currentWorkspaceHook.result.current[1](id);
|
||||
const currentWorkspace = await store.get(rootCurrentWorkspaceAtom);
|
||||
const blockSuiteWorkspace =
|
||||
currentWorkspace?.blockSuiteWorkspace as BlockSuiteWorkspace;
|
||||
|
||||
mutationHook.rerender();
|
||||
// create root pinboard
|
||||
mutationHook.result.current.createWorkspacePage(id, 'rootPinboard');
|
||||
blockSuiteWorkspace.meta.setPageMeta('rootPinboard', {
|
||||
isRootPinboard: true,
|
||||
subpageIds: rootPageIds,
|
||||
});
|
||||
// create parent
|
||||
rootPageIds.forEach(rootPageId => {
|
||||
mutationHook.result.current.createWorkspacePage(id, rootPageId);
|
||||
blockSuiteWorkspace.meta.setPageMeta(rootPageId, {
|
||||
subpageIds: rootPageId === rootPageIds[0] ? pinboardPageIds : [],
|
||||
});
|
||||
});
|
||||
// create children to first parent
|
||||
pinboardPageIds.forEach(pinboardId => {
|
||||
mutationHook.result.current.createWorkspacePage(id, pinboardId);
|
||||
blockSuiteWorkspace.meta.setPageMeta(pinboardId, {
|
||||
title: pinboardId,
|
||||
});
|
||||
});
|
||||
|
||||
const App = (props: PinboardProps) => {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<ProviderWrapper>
|
||||
<Pinboard {...props} />
|
||||
</ProviderWrapper>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const app = render(
|
||||
<App
|
||||
blockSuiteWorkspace={blockSuiteWorkspace as BlockSuiteWorkspace}
|
||||
allMetas={blockSuiteWorkspace.meta.pageMetas as PageMeta[]}
|
||||
openPage={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
return {
|
||||
rootPageIds,
|
||||
pinboardPageIds,
|
||||
app,
|
||||
blockSuiteWorkspace,
|
||||
};
|
||||
};
|
||||
const openOperationMenu = async (app: RenderResult, pageId: string) => {
|
||||
const rootPinboard = await app.findByTestId(`pinboard-${pageId}`);
|
||||
const operationBtn = (await rootPinboard.querySelector(
|
||||
'[data-testid="pinboard-operation-button"]'
|
||||
)) as HTMLElement;
|
||||
await operationBtn.click();
|
||||
const menu = await app.findByTestId('pinboard-operation-menu');
|
||||
expect(menu).toBeInTheDocument();
|
||||
};
|
||||
describe('PinBoard', () => {
|
||||
test('add pinboard', async () => {
|
||||
const { app, blockSuiteWorkspace, rootPageIds } = await initPinBoard();
|
||||
const [hasChildrenPageId] = rootPageIds;
|
||||
await openOperationMenu(app, hasChildrenPageId);
|
||||
|
||||
const addBtn = await app.findByTestId('pinboard-operation-add');
|
||||
await addBtn.click();
|
||||
|
||||
const hasChildrenPageMeta =
|
||||
blockSuiteWorkspace.meta.getPageMeta(hasChildrenPageId);
|
||||
|
||||
// Page meta have been added
|
||||
expect(blockSuiteWorkspace.meta.pageMetas.length).toBe(6);
|
||||
// New page meta is added in initial page meta
|
||||
|
||||
expect(hasChildrenPageMeta?.subpageIds.length).toBe(3);
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
test('delete pinboard', async () => {
|
||||
const {
|
||||
app,
|
||||
blockSuiteWorkspace,
|
||||
rootPageIds: [hasChildrenPageId],
|
||||
} = await initPinBoard();
|
||||
await openOperationMenu(app, hasChildrenPageId);
|
||||
|
||||
const deleteBtn = await app.findByTestId(
|
||||
'pinboard-operation-move-to-trash'
|
||||
);
|
||||
await deleteBtn.click();
|
||||
|
||||
const confirmBtn = await app.findByTestId('move-to-trash-confirm');
|
||||
expect(confirmBtn).toBeInTheDocument();
|
||||
await confirmBtn.click();
|
||||
|
||||
// Every page should be tagged as trash
|
||||
expect(blockSuiteWorkspace.meta.pageMetas.filter(m => m.trash).length).toBe(
|
||||
3
|
||||
);
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
test('rename pinboard', async () => {
|
||||
const {
|
||||
app,
|
||||
rootPageIds: [hasChildrenPageId],
|
||||
} = await initPinBoard();
|
||||
await openOperationMenu(app, hasChildrenPageId);
|
||||
|
||||
const renameBtn = await app.findByTestId('pinboard-operation-rename');
|
||||
await renameBtn.click();
|
||||
|
||||
const input = await app.findByTestId(`pinboard-input-${hasChildrenPageId}`);
|
||||
expect(input).toBeInTheDocument();
|
||||
|
||||
// TODO: Fix this test
|
||||
// fireEvent.change(input, { target: { value: 'tteesstt' } });
|
||||
// expect(
|
||||
// blockSuiteWorkspace.meta.getPageMeta(hasChildrenPageId)?.name
|
||||
// ).toBe('tteesstt');
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
test('move pinboard', async () => {
|
||||
const {
|
||||
app,
|
||||
blockSuiteWorkspace,
|
||||
rootPageIds: [hasChildrenPageId],
|
||||
pinboardPageIds: [pinboardId1, pinboardId2],
|
||||
} = await initPinBoard();
|
||||
await openOperationMenu(app, pinboardId1);
|
||||
|
||||
const moveToBtn = await app.findByTestId('pinboard-operation-move-to');
|
||||
await moveToBtn.click();
|
||||
|
||||
const pinboardMenu = await app.findByTestId('pinboard-menu');
|
||||
expect(pinboardMenu).toBeInTheDocument();
|
||||
|
||||
await (
|
||||
pinboardMenu.querySelector(
|
||||
`[data-testid="pinboard-${pinboardId2}"]`
|
||||
) as HTMLElement
|
||||
).click();
|
||||
|
||||
const hasChildrenPageMeta =
|
||||
blockSuiteWorkspace.meta.getPageMeta(hasChildrenPageId);
|
||||
|
||||
expect(hasChildrenPageMeta?.subpageIds.includes(pinboardId1)).toBe(false);
|
||||
expect(hasChildrenPageMeta?.subpageIds.includes(pinboardId2)).toBe(true);
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
test('remove from pinboard', async () => {
|
||||
const {
|
||||
app,
|
||||
blockSuiteWorkspace,
|
||||
rootPageIds: [hasChildrenPageId],
|
||||
pinboardPageIds: [pinboardId1],
|
||||
} = await initPinBoard();
|
||||
await openOperationMenu(app, pinboardId1);
|
||||
|
||||
const moveToBtn = await app.findByTestId('pinboard-operation-move-to');
|
||||
await moveToBtn.click();
|
||||
|
||||
const removeFromPinboardBtn = await app.findByTestId(
|
||||
'remove-from-pinboard-button'
|
||||
);
|
||||
removeFromPinboardBtn.click();
|
||||
|
||||
const hasPinboardPageMeta =
|
||||
blockSuiteWorkspace.meta.getPageMeta(hasChildrenPageId);
|
||||
|
||||
expect(hasPinboardPageMeta?.subpageIds.length).toBe(1);
|
||||
expect(hasPinboardPageMeta?.subpageIds.includes(pinboardId1)).toBe(false);
|
||||
app.unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import 'fake-indexeddb/auto';
|
||||
|
||||
import {
|
||||
rootCurrentPageIdAtom,
|
||||
rootCurrentWorkspaceIdAtom,
|
||||
} from '@affine/workspace/atom';
|
||||
import { WorkspaceFlavour } from '@affine/workspace/type';
|
||||
import { assertExists } from '@blocksuite/store';
|
||||
import { render, renderHook } from '@testing-library/react';
|
||||
import { useBlockSuiteWorkspaceHelper } from '@toeverything/hooks/use-block-suite-workspace-helper';
|
||||
import { createStore, getDefaultStore, Provider, useAtomValue } from 'jotai';
|
||||
import { useRouter } from 'next/router';
|
||||
import type React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { workspacesAtom } from '../../atoms';
|
||||
import { rootCurrentWorkspaceAtom } from '../../atoms/root';
|
||||
import {
|
||||
currentWorkspaceAtom,
|
||||
useCurrentWorkspace,
|
||||
} from '../../hooks/current/use-current-workspace';
|
||||
import { useAppHelper } from '../../hooks/use-workspaces';
|
||||
import { ThemeProvider } from '../../providers/ThemeProvider';
|
||||
import { pathGenerator } from '../../shared';
|
||||
import { WorkSpaceSliderBar } from '../pure/workspace-slider-bar';
|
||||
|
||||
vi.mock('../blocksuite/header/editor-mode-switch/CustomLottie', () => ({
|
||||
default: (props: React.PropsWithChildren) => <>{props.children}</>,
|
||||
}));
|
||||
|
||||
// fetchMocker.enableMocks();
|
||||
let store = getDefaultStore();
|
||||
beforeEach(async () => {
|
||||
store = createStore();
|
||||
await store.get(workspacesAtom);
|
||||
});
|
||||
|
||||
const ProviderWrapper: React.FC<React.PropsWithChildren> = ({ children }) => {
|
||||
return <Provider store={store}>{children}</Provider>;
|
||||
};
|
||||
|
||||
describe('WorkSpaceSliderBar', () => {
|
||||
test('basic', async () => {
|
||||
// fetchMocker.mock
|
||||
|
||||
const onOpenWorkspaceListModalFn = vi.fn();
|
||||
const onOpenQuickSearchModalFn = vi.fn();
|
||||
const mutationHook = renderHook(() => useAppHelper(), {
|
||||
wrapper: ProviderWrapper,
|
||||
});
|
||||
const id = await mutationHook.result.current.createLocalWorkspace('test0');
|
||||
await store.get(workspacesAtom);
|
||||
mutationHook.rerender();
|
||||
mutationHook.result.current.createWorkspacePage(id, 'test1');
|
||||
store.set(rootCurrentWorkspaceIdAtom, id);
|
||||
await store.get(rootCurrentWorkspaceAtom);
|
||||
const currentWorkspaceHook = renderHook(() => useCurrentWorkspace(), {
|
||||
wrapper: ProviderWrapper,
|
||||
});
|
||||
let i = 0;
|
||||
const Component = () => {
|
||||
const [currentWorkspace] = useCurrentWorkspace();
|
||||
const currentPageId = useAtomValue(rootCurrentPageIdAtom);
|
||||
assertExists(currentWorkspace);
|
||||
const helper = useBlockSuiteWorkspaceHelper(
|
||||
currentWorkspace.blockSuiteWorkspace
|
||||
);
|
||||
return (
|
||||
<WorkSpaceSliderBar
|
||||
currentWorkspace={currentWorkspace}
|
||||
currentPageId={currentPageId}
|
||||
onOpenQuickSearchModal={onOpenQuickSearchModalFn}
|
||||
onOpenWorkspaceListModal={onOpenWorkspaceListModalFn}
|
||||
openPage={useCallback(() => {}, [])}
|
||||
createPage={() => {
|
||||
i++;
|
||||
return helper.createPage('page-test-' + i);
|
||||
}}
|
||||
currentPath={useRouter().asPath}
|
||||
paths={pathGenerator}
|
||||
isPublicWorkspace={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const App = () => {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<ProviderWrapper>
|
||||
<Component />
|
||||
</ProviderWrapper>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
currentWorkspaceHook.result.current[1](id);
|
||||
const currentWorkspace = await store.get(currentWorkspaceAtom);
|
||||
expect(currentWorkspace).toBeDefined();
|
||||
expect(currentWorkspace?.flavour).toBe(WorkspaceFlavour.LOCAL);
|
||||
expect(currentWorkspace?.id).toBe(id);
|
||||
const app = render(<App />);
|
||||
const card = await app.findByTestId('current-workspace');
|
||||
expect(onOpenWorkspaceListModalFn).toBeCalledTimes(0);
|
||||
card.click();
|
||||
expect(onOpenWorkspaceListModalFn).toBeCalledTimes(1);
|
||||
const newPageButton = await app.findByTestId('new-page-button');
|
||||
newPageButton.click();
|
||||
expect(
|
||||
currentWorkspaceHook.result.current[0]?.blockSuiteWorkspace.meta
|
||||
.pageMetas[1].id
|
||||
).toBe('page-test-1');
|
||||
expect(onOpenQuickSearchModalFn).toBeCalledTimes(0);
|
||||
const quickSearchButton = await app.findByTestId(
|
||||
'slider-bar-quick-search-button'
|
||||
);
|
||||
quickSearchButton.click();
|
||||
expect(onOpenQuickSearchModalFn).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,7 @@ export const Export = ({
|
||||
return (
|
||||
<Menu
|
||||
width={248}
|
||||
placement="left-start"
|
||||
// placement="left-start"
|
||||
trigger="click"
|
||||
content={
|
||||
<>
|
||||
|
||||