Compare commits
74 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 |
@@ -12,6 +12,7 @@
|
|||||||
"component",
|
"component",
|
||||||
"workspace",
|
"workspace",
|
||||||
"env",
|
"env",
|
||||||
|
"graphql",
|
||||||
"cli",
|
"cli",
|
||||||
"hooks",
|
"hooks",
|
||||||
"i18n",
|
"i18n",
|
||||||
@@ -19,7 +20,8 @@
|
|||||||
"octobase-node",
|
"octobase-node",
|
||||||
"templates",
|
"templates",
|
||||||
"y-indexeddb",
|
"y-indexeddb",
|
||||||
"debug"
|
"debug",
|
||||||
|
"theme"
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,12 @@ const config = {
|
|||||||
'@typescript-eslint/consistent-type-imports': 0,
|
'@typescript-eslint/consistent-type-imports': 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
files: '*.cjs',
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-var-requires': 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
2
.github/CLA.md
vendored
@@ -56,3 +56,5 @@ Example:
|
|||||||
- Skye Sun, @skyesun, 2023/04/14
|
- Skye Sun, @skyesun, 2023/04/14
|
||||||
- Jordy Delgado, @Jdelgad8, 2023/04/17
|
- Jordy Delgado, @Jdelgad8, 2023/04/17
|
||||||
- Howard Do, @howarddo2208, 2023/04/20
|
- 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:workspace: 'packages/workspace/**/*'
|
||||||
|
|
||||||
|
mod:theme: 'packages/theme/**/*'
|
||||||
|
|
||||||
mod:i18n: 'packages/i18n/**/*'
|
mod:i18n: 'packages/i18n/**/*'
|
||||||
|
|
||||||
mod:env: 'packages/env/**/*'
|
mod:env: 'packages/env/**/*'
|
||||||
|
|||||||
354
.github/workflows/build-master.yml
vendored
@@ -1,354 +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
|
|
||||||
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:
|
|
||||||
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 }}
|
|
||||||
92
.github/workflows/build.yml
vendored
@@ -1,6 +1,9 @@
|
|||||||
name: Build & Test
|
name: Build & Test
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
@@ -17,16 +20,6 @@ jobs:
|
|||||||
uses: ./.github/actions/setup-node
|
uses: ./.github/actions/setup-node
|
||||||
- run: yarn lint --max-warnings=0
|
- 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:
|
build-storybook:
|
||||||
name: Build Storybook
|
name: Build Storybook
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -44,6 +37,23 @@ jobs:
|
|||||||
path: ./packages/component/storybook-static
|
path: ./packages/component/storybook-static
|
||||||
if-no-files-found: error
|
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:
|
build:
|
||||||
name: Build @affine/web
|
name: Build @affine/web
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -73,6 +83,7 @@ jobs:
|
|||||||
NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID }}
|
NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID }}
|
||||||
API_SERVER_PROFILE: local
|
API_SERVER_PROFILE: local
|
||||||
ENABLE_DEBUG_PAGE: true
|
ENABLE_DEBUG_PAGE: true
|
||||||
|
ENABLE_LEGACY_PROVIDER: true
|
||||||
COVERAGE: true
|
COVERAGE: true
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
@@ -82,6 +93,32 @@ jobs:
|
|||||||
path: ./apps/web/.next
|
path: ./apps/web/.next
|
||||||
if-no-files-found: error
|
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:
|
server-test:
|
||||||
name: Server Test
|
name: Server Test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -230,6 +267,41 @@ jobs:
|
|||||||
path: ./test-results
|
path: ./test-results
|
||||||
if-no-files-found: ignore
|
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:
|
unit-test:
|
||||||
name: Unit Test
|
name: Unit Test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
10
.github/workflows/release-desktop-app.yml
vendored
@@ -17,11 +17,11 @@ on:
|
|||||||
type: boolean
|
type: boolean
|
||||||
required: true
|
required: true
|
||||||
default: true
|
default: true
|
||||||
is-canary:
|
build-type:
|
||||||
description: 'Canary Release? The app will be named as "AFFiNE Canary"'
|
description: 'Build Type (canary, beta or stable)'
|
||||||
type: boolean
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: true
|
default: canary
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
actions: write
|
actions: write
|
||||||
@@ -35,7 +35,7 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
BUILD_TYPE: ${{ github.event.inputs.is-canary == 'true' && 'canary' || 'stable' }}
|
BUILD_TYPE: ${{ github.event.inputs.build-type }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
before-make:
|
before-make:
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
apps/electron/layers/preload/preload.d.ts
|
|
||||||
|
|||||||
16
README.md
@@ -25,9 +25,10 @@ See https://github.com/all-?/all-contributors/issues/361#issuecomment-637166066
|
|||||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
<!-- 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)
|
[?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://affine.pro/download)
|
||||||
[](https://github.com/toeverything/AFFiNE/releases/latest)
|
[](https://affine.pro/download)
|
||||||
[](https://github.com/toeverything/AFFiNE/releases/latest)
|
[](https://affine.pro/download)
|
||||||
|
[](https://affine.pro/download)
|
||||||
|
|
||||||
[![stars-icon]](https://github.com/toeverything/AFFiNE)
|
[![stars-icon]](https://github.com/toeverything/AFFiNE)
|
||||||
[![All Contributors][all-contributors-badge]](#contributors)
|
[![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.
|
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
|
## Thanks
|
||||||
|
|
||||||
We would also like to give thanks to open-source projects that make AFFiNE possible:
|
We would also like to give thanks to open-source projects that make AFFiNE possible:
|
||||||
@@ -134,7 +143,6 @@ We would like to express our gratitude to all the individuals who have already c
|
|||||||
<img src="https://user-images.githubusercontent.com/5910926/233382206-312428ca-094a-4579-ae06-213961ed7eab.svg" />
|
<img src="https://user-images.githubusercontent.com/5910926/233382206-312428ca-094a-4579-ae06-213961ed7eab.svg" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|
||||||
## Self-Host
|
## Self-Host
|
||||||
|
|
||||||
Get started with Docker and deploy your own feature-rich, restriction-free deployment of AFFiNE - check the [latest packages].
|
Get started with Docker and deploy your own feature-rich, restriction-free deployment of AFFiNE - check the [latest packages].
|
||||||
|
|||||||
@@ -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
|
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
|
## Credits
|
||||||
|
|
||||||
Most of the boilerplate code is generously borrowed from the following
|
Most of the boilerplate code is generously borrowed from the following
|
||||||
|
|||||||
@@ -3,25 +3,33 @@ const {
|
|||||||
utils: { fromBuildIdentifier },
|
utils: { fromBuildIdentifier },
|
||||||
} = require('@electron-forge/core');
|
} = require('@electron-forge/core');
|
||||||
|
|
||||||
const isCanary = process.env.BUILD_TYPE === 'canary';
|
const path = require('node:path');
|
||||||
|
|
||||||
const productName = isCanary ? 'AFFiNE-Canary' : 'AFFiNE';
|
const buildType = (process.env.BUILD_TYPE || 'stable').trim().toLowerCase();
|
||||||
const icoPath = isCanary
|
const stableBuild = buildType === 'stable';
|
||||||
? './resources/icons/icon_canary.ico'
|
const productName = !stableBuild ? `AFFiNE-${buildType}` : 'AFFiNE';
|
||||||
|
const icoPath = !stableBuild
|
||||||
|
? `./resources/icons/icon_${buildType}.ico`
|
||||||
: './resources/icons/icon.ico';
|
: './resources/icons/icon.ico';
|
||||||
const icnsPath = isCanary
|
const icnsPath = !stableBuild
|
||||||
? './resources/icons/icon_canary.icns'
|
? `./resources/icons/icon_${buildType}.icns`
|
||||||
: './resources/icons/icon.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}
|
* @type {import('@electron-forge/shared-types').ForgeConfig}
|
||||||
*/
|
*/
|
||||||
module.exports = {
|
module.exports = {
|
||||||
buildIdentifier: isCanary ? 'canary' : 'stable',
|
buildIdentifier: buildType,
|
||||||
packagerConfig: {
|
packagerConfig: {
|
||||||
name: productName,
|
name: productName,
|
||||||
appBundleId: fromBuildIdentifier({
|
appBundleId: fromBuildIdentifier({
|
||||||
canary: 'pro.affine.canary',
|
canary: 'pro.affine.canary',
|
||||||
|
beta: 'pro.affine.beta',
|
||||||
stable: 'pro.affine.app',
|
stable: 'pro.affine.app',
|
||||||
}),
|
}),
|
||||||
icon: icnsPath,
|
icon: icnsPath,
|
||||||
@@ -45,6 +53,23 @@ module.exports = {
|
|||||||
format: 'ULFO',
|
format: 'ULFO',
|
||||||
icon: icnsPath,
|
icon: icnsPath,
|
||||||
name: 'AFFiNE',
|
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 { app } from 'electron';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import { registerHandlers } from './app-state';
|
import { logger } from '../../logger';
|
||||||
|
import { registerHandlers } from './handlers';
|
||||||
import { restoreOrCreateWindow } from './main-window';
|
import { restoreOrCreateWindow } from './main-window';
|
||||||
import { registerProtocol } from './protocol';
|
import { registerProtocol } from './protocol';
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ if (process.defaultApp) {
|
|||||||
*/
|
*/
|
||||||
const isSingleInstance = app.requestSingleInstanceLock();
|
const isSingleInstance = app.requestSingleInstanceLock();
|
||||||
if (!isSingleInstance) {
|
if (!isSingleInstance) {
|
||||||
|
logger.info('Another instance is running, exiting...');
|
||||||
app.quit();
|
app.quit();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ import { BrowserWindow, nativeTheme } from 'electron';
|
|||||||
import electronWindowState from 'electron-window-state';
|
import electronWindowState from 'electron-window-state';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
|
||||||
|
import { logger } from '../../logger';
|
||||||
import { isMacOS } from '../../utils';
|
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() {
|
async function createWindow() {
|
||||||
|
logger.info('create window');
|
||||||
const mainWindowState = electronWindowState({
|
const mainWindowState = electronWindowState({
|
||||||
defaultWidth: 1000,
|
defaultWidth: 1000,
|
||||||
defaultHeight: 800,
|
defaultHeight: 800,
|
||||||
@@ -45,7 +48,14 @@ async function createWindow() {
|
|||||||
* @see https://github.com/electron/electron/issues/25012
|
* @see https://github.com/electron/electron/issues/25012
|
||||||
*/
|
*/
|
||||||
browserWindow.on('ready-to-show', () => {
|
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) {
|
if (IS_DEV) {
|
||||||
browserWindow.webContents.openDevTools();
|
browserWindow.webContents.openDevTools();
|
||||||
@@ -61,13 +71,12 @@ async function createWindow() {
|
|||||||
/**
|
/**
|
||||||
* URL for main window.
|
* URL for main window.
|
||||||
*/
|
*/
|
||||||
const pageUrl =
|
const pageUrl = process.env.DEV_SERVER_URL || 'file://./index.html'; // see protocol.ts
|
||||||
IS_DEV && process.env.DEV_SERVER_URL !== undefined
|
|
||||||
? process.env.DEV_SERVER_URL
|
|
||||||
: 'file://./index.html'; // see protocol.ts
|
|
||||||
|
|
||||||
await browserWindow.loadURL(pageUrl);
|
await browserWindow.loadURL(pageUrl);
|
||||||
|
|
||||||
|
logger.info('main window is loaded at' + pageUrl);
|
||||||
|
|
||||||
return browserWindow;
|
return browserWindow;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,9 +94,8 @@ export async function restoreOrCreateWindow() {
|
|||||||
|
|
||||||
if (browserWindow.isMinimized()) {
|
if (browserWindow.isMinimized()) {
|
||||||
browserWindow.restore();
|
browserWindow.restore();
|
||||||
|
logger.info('restore main window');
|
||||||
}
|
}
|
||||||
|
|
||||||
browserWindow.focus();
|
|
||||||
|
|
||||||
return browserWindow;
|
return browserWindow;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,23 +30,21 @@ function toAbsolutePath(url: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function registerProtocol() {
|
export function registerProtocol() {
|
||||||
if (process.env.NODE_ENV === 'production') {
|
protocol.interceptFileProtocol('file', (request, callback) => {
|
||||||
protocol.interceptFileProtocol('file', (request, callback) => {
|
const url = request.url.replace(/^file:\/\//, '');
|
||||||
const url = request.url.replace(/^file:\/\//, '');
|
const realpath = toAbsolutePath(url);
|
||||||
const realpath = toAbsolutePath(url);
|
// console.log('realpath', realpath, 'for', url);
|
||||||
// console.log('realpath', realpath, 'for', url);
|
callback(realpath);
|
||||||
callback(realpath);
|
return true;
|
||||||
return true;
|
});
|
||||||
});
|
|
||||||
|
|
||||||
protocol.registerFileProtocol('assets', (request, callback) => {
|
protocol.registerFileProtocol('assets', (request, callback) => {
|
||||||
const url = request.url.replace(/^assets:\/\//, '');
|
const url = request.url.replace(/^assets:\/\//, '');
|
||||||
const realpath = toAbsolutePath(url);
|
const realpath = toAbsolutePath(url);
|
||||||
// console.log('realpath', realpath, 'for', url);
|
// console.log('realpath', realpath, 'for', url);
|
||||||
callback(realpath);
|
callback(realpath);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
session.defaultSession.webRequest.onHeadersReceived(
|
session.defaultSession.webRequest.onHeadersReceived(
|
||||||
(responseDetails, callback) => {
|
(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 {
|
interface Window {
|
||||||
/**
|
apis: typeof import('./src/affine-apis').apis;
|
||||||
* After analyzing the `exposeInMainWorld` calls,
|
appInfo: typeof import('./src/affine-apis').appInfo;
|
||||||
* `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; };
|
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
* @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.
|
* 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
|
* @see https://www.electronjs.org/docs/api/context-bridge
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
contextBridge.exposeInMainWorld('apis', affineApis.apis);
|
||||||
* After analyzing the `exposeInMainWorld` calls,
|
contextBridge.exposeInMainWorld('appInfo', affineApis.appInfo);
|
||||||
* `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(),
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,26 +1,30 @@
|
|||||||
{
|
{
|
||||||
"name": "@affine/electron",
|
"name": "@affine/electron",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.5.4-canary.7",
|
"version": "0.5.4-canary.15",
|
||||||
"author": "affine",
|
"author": "affine",
|
||||||
"description": "AFFiNE App",
|
"description": "AFFiNE App",
|
||||||
"homepage": "https://github.com/toeverything/AFFiNE",
|
"homepage": "https://github.com/toeverything/AFFiNE",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "cross-env NODE_ENV=development node scripts/dev.mjs",
|
"dev": "yarn electron-rebuild && yarn cross-env DEV_SERVER_URL=http://localhost:8080 node scripts/dev.mjs",
|
||||||
"prod": "cross-env NODE_ENV=production node scripts/dev.mjs",
|
"prod": "yarn electron-rebuild && yarn node scripts/dev.mjs",
|
||||||
"generate-assets": "zx scripts/generate-assets.mjs",
|
"generate-assets": "zx scripts/generate-assets.mjs",
|
||||||
"package": "electron-forge package",
|
"package": "electron-forge package",
|
||||||
"make": "electron-forge make",
|
"make": "electron-forge make",
|
||||||
"make-macos-arm64": "electron-forge make --platform=darwin --arch=arm64",
|
"make-macos-arm64": "electron-forge make --platform=darwin --arch=arm64",
|
||||||
"make-macos-x64": "electron-forge make --platform=darwin --arch=x64",
|
"make-macos-x64": "electron-forge make --platform=darwin --arch=x64",
|
||||||
"make-windows-x64": "electron-forge make --platform=win32 --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": {
|
"config": {
|
||||||
"forge": "./forge.config.js"
|
"forge": "./forge.config.js"
|
||||||
},
|
},
|
||||||
"main": "./dist/layers/main/index.js",
|
"main": "./dist/layers/main/index.js",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@affine-test/kit": "workspace:*",
|
||||||
"@electron-forge/cli": "^6.1.1",
|
"@electron-forge/cli": "^6.1.1",
|
||||||
"@electron-forge/core": "^6.1.1",
|
"@electron-forge/core": "^6.1.1",
|
||||||
"@electron-forge/core-utils": "^6.1.1",
|
"@electron-forge/core-utils": "^6.1.1",
|
||||||
@@ -29,20 +33,25 @@
|
|||||||
"@electron-forge/maker-squirrel": "^6.1.1",
|
"@electron-forge/maker-squirrel": "^6.1.1",
|
||||||
"@electron-forge/maker-zip": "^6.1.1",
|
"@electron-forge/maker-zip": "^6.1.1",
|
||||||
"@electron-forge/shared-types": "^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",
|
"@electron/remote": "2.0.9",
|
||||||
"dts-for-context-bridge": "^0.7.1",
|
"@types/better-sqlite3": "^7.6.4",
|
||||||
"electron": "24.1.2",
|
"@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",
|
"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"
|
"zx": "^7.2.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-env": "7.0.3",
|
"better-sqlite3": "^8.3.0",
|
||||||
"electron-window-state": "^5.0.3",
|
"yjs": "^13.6.0"
|
||||||
"firebase": "^9.19.1",
|
|
||||||
"fs-extra": "^11.1.1",
|
|
||||||
"undici": "^5.21.2"
|
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"protocols": [
|
"protocols": [
|
||||||
@@ -54,6 +63,12 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"packageManager": "yarn@3.5.0",
|
"stableVersion": "0.5.3",
|
||||||
"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 = {
|
const nativeNodeModulesPlugin = {
|
||||||
name: 'native-node-modules',
|
name: 'native-node-modules',
|
||||||
@@ -15,7 +19,7 @@ const nativeNodeModulesPlugin = {
|
|||||||
const ENV_MACROS = ['AFFINE_GOOGLE_CLIENT_ID', 'AFFINE_GOOGLE_CLIENT_SECRET'];
|
const ENV_MACROS = ['AFFINE_GOOGLE_CLIENT_ID', 'AFFINE_GOOGLE_CLIENT_SECRET'];
|
||||||
|
|
||||||
/** @return {{main: import('esbuild').BuildOptions, preload: import('esbuild').BuildOptions}} */
|
/** @return {{main: import('esbuild').BuildOptions, preload: import('esbuild').BuildOptions}} */
|
||||||
export default () => {
|
export const config = () => {
|
||||||
const define = Object.fromEntries(
|
const define = Object.fromEntries(
|
||||||
ENV_MACROS.map(key => [
|
ENV_MACROS.map(key => [
|
||||||
'process.env.' + key,
|
'process.env.' + key,
|
||||||
@@ -24,18 +28,18 @@ export default () => {
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
main: {
|
main: {
|
||||||
entryPoints: ['layers/main/src/index.ts'],
|
entryPoints: [resolve(root, './layers/main/src/index.ts')],
|
||||||
outdir: 'dist/layers/main',
|
outdir: resolve(root, './dist/layers/main'),
|
||||||
bundle: true,
|
bundle: true,
|
||||||
target: `node${NODE_MAJOR_VERSION}`,
|
target: `node${NODE_MAJOR_VERSION}`,
|
||||||
platform: 'node',
|
platform: 'node',
|
||||||
external: ['electron'],
|
external: ['electron', 'yjs', 'better-sqlite3'],
|
||||||
plugins: [nativeNodeModulesPlugin],
|
plugins: [nativeNodeModulesPlugin],
|
||||||
define: define,
|
define: define,
|
||||||
},
|
},
|
||||||
preload: {
|
preload: {
|
||||||
entryPoints: ['layers/preload/src/index.ts'],
|
entryPoints: [resolve(root, './layers/preload/src/index.ts')],
|
||||||
outdir: 'dist/layers/preload',
|
outdir: resolve(root, './dist/layers/preload'),
|
||||||
bundle: true,
|
bundle: true,
|
||||||
target: `node${NODE_MAJOR_VERSION}`,
|
target: `node${NODE_MAJOR_VERSION}`,
|
||||||
platform: 'node',
|
platform: 'node',
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
import { generateAsync } from 'dts-for-context-bridge';
|
|
||||||
import electronPath from 'electron';
|
import electronPath from 'electron';
|
||||||
import * as esbuild from 'esbuild';
|
import * as esbuild from 'esbuild';
|
||||||
|
|
||||||
import commonFn from './common.mjs';
|
import { config, root } from './common.mjs';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = path.dirname(__filename);
|
|
||||||
|
|
||||||
/** @type 'production' | 'development'' */
|
/** @type 'production' | 'development'' */
|
||||||
const mode = (process.env.NODE_ENV = process.env.NODE_ENV || 'development');
|
const mode = (process.env.NODE_ENV = process.env.NODE_ENV || 'development');
|
||||||
@@ -23,9 +18,9 @@ const stderrFilterPatterns = [
|
|||||||
/ExtensionLoadWarning/,
|
/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 {
|
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);
|
const devEnv = JSON.parse(devJson);
|
||||||
Object.assign(process.env, devEnv);
|
Object.assign(process.env, devEnv);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -35,8 +30,8 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// hard-coded for now:
|
// hard-coded for now:
|
||||||
// fixme(xp): report error if app is not running on port 8080
|
// fixme(xp): report error if app is not running on DEV_SERVER_URL
|
||||||
process.env.DEV_SERVER_URL = `http://localhost:8080`;
|
const DEV_SERVER_URL = process.env.DEV_SERVER_URL;
|
||||||
|
|
||||||
/** @type {ChildProcessWithoutNullStreams | null} */
|
/** @type {ChildProcessWithoutNullStreams | null} */
|
||||||
let spawnProcess = null;
|
let spawnProcess = null;
|
||||||
@@ -50,10 +45,12 @@ function spawnOrReloadElectron() {
|
|||||||
|
|
||||||
spawnProcess = spawn(String(electronPath), ['.']);
|
spawnProcess = spawn(String(electronPath), ['.']);
|
||||||
|
|
||||||
spawnProcess.stdout.on(
|
spawnProcess.stdout.on('data', d => {
|
||||||
'data',
|
let str = d.toString().trim();
|
||||||
d => d.toString().trim() && console.warn(d.toString())
|
if (str) {
|
||||||
);
|
console.log(str);
|
||||||
|
}
|
||||||
|
});
|
||||||
spawnProcess.stderr.on('data', d => {
|
spawnProcess.stderr.on('data', d => {
|
||||||
const data = d.toString().trim();
|
const data = d.toString().trim();
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
@@ -66,7 +63,7 @@ function spawnOrReloadElectron() {
|
|||||||
spawnProcess.on('exit', process.exit);
|
spawnProcess.on('exit', process.exit);
|
||||||
}
|
}
|
||||||
|
|
||||||
const common = commonFn();
|
const common = config();
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
async function watchPreload(onInitialBuild) {
|
async function watchPreload(onInitialBuild) {
|
||||||
@@ -79,10 +76,6 @@ async function main() {
|
|||||||
setup(build) {
|
setup(build) {
|
||||||
let initialBuild = false;
|
let initialBuild = false;
|
||||||
build.onEnd(() => {
|
build.onEnd(() => {
|
||||||
generateAsync({
|
|
||||||
input: 'layers/preload/src/**/*.ts',
|
|
||||||
output: 'layers/preload/preload.d.ts',
|
|
||||||
});
|
|
||||||
if (initialBuild) {
|
if (initialBuild) {
|
||||||
console.log(`[preload] has changed`);
|
console.log(`[preload] has changed`);
|
||||||
spawnOrReloadElectron();
|
spawnOrReloadElectron();
|
||||||
@@ -99,13 +92,18 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function watchMain() {
|
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({
|
const mainBuild = await esbuild.context({
|
||||||
...common.main,
|
...common.main,
|
||||||
define: {
|
define: define,
|
||||||
...common.main.define,
|
|
||||||
'process.env.NODE_ENV': `"${mode}"`,
|
|
||||||
'process.env.DEV_SERVER_URL': `"${process.env.DEV_SERVER_URL}"`,
|
|
||||||
},
|
|
||||||
plugins: [
|
plugins: [
|
||||||
...(common.main.plugins ?? []),
|
...(common.main.plugins ?? []),
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import path from 'node:path';
|
|||||||
|
|
||||||
import * as esbuild from 'esbuild';
|
import * as esbuild from 'esbuild';
|
||||||
|
|
||||||
import commonFn from './common.mjs';
|
import { config } from './common.mjs';
|
||||||
|
|
||||||
const repoRootDir = path.join(__dirname, '..', '..', '..');
|
const repoRootDir = path.join(__dirname, '..', '..', '..');
|
||||||
const electronRootDir = path.join(__dirname, '..');
|
const electronRootDir = path.join(__dirname, '..');
|
||||||
@@ -33,46 +33,51 @@ if (process.platform === 'win32') {
|
|||||||
$.shell = 'powershell.exe';
|
$.shell = 'powershell.exe';
|
||||||
$.prefix = '';
|
$.prefix = '';
|
||||||
}
|
}
|
||||||
// step 1: build web (nextjs) dist
|
|
||||||
process.env.ENABLE_LEGACY_PROVIDER = 'false';
|
|
||||||
cd(repoRootDir);
|
cd(repoRootDir);
|
||||||
await $`yarn add`;
|
|
||||||
await $`yarn build`;
|
|
||||||
await $`yarn export`;
|
|
||||||
|
|
||||||
// step 1.5: amend sourceMappingURL to allow debugging in devtools
|
// step 1: build electron resources
|
||||||
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
|
|
||||||
await buildLayers();
|
await buildLayers();
|
||||||
echo('Build layers done');
|
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() {
|
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', 'main', 'dist'));
|
||||||
await fs.emptyDir(path.join(electronRootDir, 'layers', 'preload', 'dist'));
|
await fs.emptyDir(path.join(electronRootDir, 'layers', 'preload', 'dist'));
|
||||||
await fs.remove(path.join(electronRootDir, 'out'));
|
await fs.remove(path.join(electronRootDir, 'out'));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildLayers() {
|
async function buildLayers() {
|
||||||
const common = commonFn();
|
const common = config();
|
||||||
await esbuild.build(common.preload);
|
await esbuild.build(common.preload);
|
||||||
|
|
||||||
await esbuild.build({
|
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"
|
DATABASE_URL="postgresql://affine@localhost:5432/affine"
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
-- CreateTable
|
|
||||||
CREATE TABLE "google_users" (
|
|
||||||
"id" VARCHAR NOT NULL,
|
|
||||||
"user_id" VARCHAR NOT NULL,
|
|
||||||
"google_id" VARCHAR NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "google_users_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "permissions" (
|
|
||||||
"id" VARCHAR NOT NULL,
|
|
||||||
"workspace_id" VARCHAR NOT NULL,
|
|
||||||
"user_id" VARCHAR,
|
|
||||||
"user_email" TEXT,
|
|
||||||
"type" SMALLINT NOT NULL,
|
|
||||||
"accepted" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "permissions_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "seaql_migrations" (
|
|
||||||
"version" VARCHAR NOT NULL,
|
|
||||||
"applied_at" BIGINT NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "seaql_migrations_pkey" PRIMARY KEY ("version")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "users" (
|
|
||||||
"id" VARCHAR NOT NULL,
|
|
||||||
"name" VARCHAR NOT NULL,
|
|
||||||
"email" VARCHAR NOT NULL,
|
|
||||||
"avatar_url" VARCHAR,
|
|
||||||
"token_nonce" SMALLINT DEFAULT 0,
|
|
||||||
"password" VARCHAR,
|
|
||||||
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "workspaces" (
|
|
||||||
"id" VARCHAR NOT NULL,
|
|
||||||
"public" BOOLEAN NOT NULL,
|
|
||||||
"type" SMALLINT NOT NULL,
|
|
||||||
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "workspaces_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "google_users_google_id_key" ON "google_users"("google_id");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "google_users" ADD CONSTRAINT "google_users_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "permissions" ADD CONSTRAINT "permissions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "permissions" ADD CONSTRAINT "permissions_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
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;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@affine/server",
|
"name": "@affine/server",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.5.4-canary.7",
|
"version": "0.5.4-canary.15",
|
||||||
"description": "Affine Node.js server",
|
"description": "Affine Node.js server",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -10,28 +10,35 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nodemon ./src/index.ts",
|
"dev": "nodemon ./src/index.ts",
|
||||||
"test": "yarn exec ts-node-esm ./scripts/run-test.ts all",
|
"test": "yarn exec ts-node-esm ./scripts/run-test.ts all",
|
||||||
"test:coverage": "c8 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": {
|
"dependencies": {
|
||||||
"@apollo/server": "^4.6.0",
|
"@apollo/server": "^4.7.0",
|
||||||
"@nestjs/apollo": "^11.0.5",
|
"@nestjs/apollo": "^11.0.5",
|
||||||
"@nestjs/common": "^9.4.0",
|
"@nestjs/common": "^9.4.0",
|
||||||
"@nestjs/core": "^9.4.0",
|
"@nestjs/core": "^9.4.0",
|
||||||
"@nestjs/graphql": "^11.0.5",
|
"@nestjs/graphql": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^9.4.0",
|
"@nestjs/platform-express": "^9.4.0",
|
||||||
"@prisma/client": "^4.12.0",
|
"@prisma/client": "^4.13.0",
|
||||||
|
"bcrypt": "^5.1.0",
|
||||||
"dotenv": "^16.0.3",
|
"dotenv": "^16.0.3",
|
||||||
|
"express": "^4.18.2",
|
||||||
"graphql": "^16.6.0",
|
"graphql": "^16.6.0",
|
||||||
"graphql-type-json": "^0.3.2",
|
"graphql-type-json": "^0.3.2",
|
||||||
|
"jsonwebtoken": "^9.0.0",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"prisma": "^4.12.0",
|
"prisma": "^4.13.0",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rxjs": "^7.8.0"
|
"rxjs": "^7.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/testing": "^9.4.0",
|
"@nestjs/testing": "^9.4.0",
|
||||||
"@types/lodash-es": "^4.14.194",
|
"@types/bcrypt": "^5.0.0",
|
||||||
"@types/node": "^18.15.11",
|
"@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",
|
"@types/supertest": "^2.0.12",
|
||||||
"c8": "^7.13.0",
|
"c8": "^7.13.0",
|
||||||
"nodemon": "^2.0.22",
|
"nodemon": "^2.0.22",
|
||||||
@@ -54,6 +61,7 @@
|
|||||||
"**/dist/**"
|
"**/dist/**"
|
||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
|
"TS_NODE_TRANSPILE_ONLY": true,
|
||||||
"TS_NODE_PROJECT": "./tsconfig.json",
|
"TS_NODE_PROJECT": "./tsconfig.json",
|
||||||
"NODE_ENV": "development",
|
"NODE_ENV": "development",
|
||||||
"DEBUG": "affine:*",
|
"DEBUG": "affine:*",
|
||||||
|
|||||||
@@ -7,46 +7,57 @@ generator client {
|
|||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
}
|
}
|
||||||
|
|
||||||
model google_users {
|
model User {
|
||||||
id String @id @db.VarChar
|
id String @id @default(uuid()) @db.VarChar
|
||||||
user_id String @db.VarChar
|
name String @db.VarChar
|
||||||
google_id String @unique @db.VarChar
|
email String @unique @db.VarChar
|
||||||
users users @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
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 {
|
model Workspace {
|
||||||
id String @id @db.VarChar
|
id String @id @default(uuid()) @db.VarChar
|
||||||
workspace_id String @db.VarChar
|
public Boolean
|
||||||
user_id String? @db.VarChar
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
user_email String?
|
users UserWorkspacePermission[]
|
||||||
type Int @db.SmallInt
|
|
||||||
accepted Boolean @default(false)
|
@@map("workspaces")
|
||||||
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 seaql_migrations {
|
model ConnectedAccount {
|
||||||
version String @id @db.VarChar
|
id String @id @default(uuid()) @db.VarChar
|
||||||
applied_at BigInt
|
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 {
|
model UserWorkspacePermission {
|
||||||
id String @id @db.VarChar
|
id String @id @default(uuid()) @db.VarChar
|
||||||
name String @db.VarChar
|
workspaceId String @map("workspace_id") @db.VarChar
|
||||||
email String @unique @db.VarChar
|
userId String @map("entity_id") @db.VarChar
|
||||||
avatar_url String? @db.VarChar
|
/// Read/Write/Admin/Owner
|
||||||
token_nonce Int? @default(0) @db.SmallInt
|
type Int @db.SmallInt
|
||||||
password String? @db.VarChar
|
/// Whether the permission invitation is accepted by the user
|
||||||
created_at DateTime? @default(now()) @db.Timestamptz(6)
|
accepted Boolean @default(false)
|
||||||
google_users google_users[]
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
permissions permissions[]
|
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||||
}
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
model workspaces {
|
@@map("user_workspace_permissions")
|
||||||
id String @id @db.VarChar
|
|
||||||
public Boolean
|
|
||||||
type Int @db.SmallInt
|
|
||||||
created_at DateTime? @default(now()) @db.Timestamptz(6)
|
|
||||||
permissions 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);
|
||||||
@@ -1,15 +1,10 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
|
|
||||||
import userA from '@affine-test/fixtures/userA.json' assert { type: 'json' };
|
import userA from '@affine-test/fixtures/userA.json' assert { type: 'json' };
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
await prisma.users.create({
|
await prisma.user.create({
|
||||||
data: {
|
data: userA,
|
||||||
id: randomUUID(),
|
|
||||||
...userA,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const root = fileURLToPath(new URL('..', import.meta.url));
|
|||||||
const testDir = resolve(root, 'src', 'tests');
|
const testDir = resolve(root, 'src', 'tests');
|
||||||
const files = await readdir(testDir);
|
const files = await readdir(testDir);
|
||||||
|
|
||||||
const args = [...pkg.nodemonConfig.nodeArgs, '--test'];
|
const sharedArgs = [...pkg.nodemonConfig.nodeArgs, '--test'];
|
||||||
|
|
||||||
const env = {
|
const env = {
|
||||||
PATH: process.env.PATH,
|
PATH: process.env.PATH,
|
||||||
@@ -21,7 +21,7 @@ const env = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (process.argv[2] === 'all') {
|
if (process.argv[2] === 'all') {
|
||||||
const cp = spawn('node', [...args, resolve(testDir, '*')], {
|
const cp = spawn('node', [...sharedArgs, resolve(testDir, '*')], {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
env,
|
env,
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
@@ -44,12 +44,21 @@ if (process.argv[2] === 'all') {
|
|||||||
|
|
||||||
const target = resolve(testDir, result.file);
|
const target = resolve(testDir, result.file);
|
||||||
|
|
||||||
const cp = spawn('node', [...args, target], {
|
const cp = spawn(
|
||||||
cwd: root,
|
'node',
|
||||||
env,
|
[
|
||||||
stdio: 'inherit',
|
...sharedArgs,
|
||||||
shell: true,
|
'--test-reporter=spec',
|
||||||
});
|
'--test-reporter-destination=stdout',
|
||||||
|
target,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: root,
|
||||||
|
env,
|
||||||
|
stdio: 'inherit',
|
||||||
|
shell: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
cp.on('exit', code => {
|
cp.on('exit', code => {
|
||||||
process.exit(code ?? 0);
|
process.exit(code ?? 0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/// <reference types="./global.d.ts" />
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { ConfigModule } from './config';
|
import { ConfigModule } from './config';
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ export function parseEnvValue(value: string | undefined, type?: EnvConfigType) {
|
|||||||
*/
|
*/
|
||||||
export interface AFFiNEConfig {
|
export interface AFFiNEConfig {
|
||||||
ENV_MAP: Record<string, ConfigPaths | [ConfigPaths, EnvConfigType?]>;
|
ENV_MAP: Record<string, ConfigPaths | [ConfigPaths, EnvConfigType?]>;
|
||||||
|
/**
|
||||||
|
* Server Identity
|
||||||
|
*/
|
||||||
|
readonly serverId: string;
|
||||||
/**
|
/**
|
||||||
* System version
|
* System version
|
||||||
*/
|
*/
|
||||||
@@ -165,6 +169,28 @@ export interface AFFiNEConfig {
|
|||||||
* authentication config
|
* authentication config
|
||||||
*/
|
*/
|
||||||
auth: {
|
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
|
* 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 pkg from '../../package.json' assert { type: 'json' };
|
||||||
import type { AFFiNEConfig } from './def';
|
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 = () => ({
|
export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => ({
|
||||||
|
serverId: 'affine-nestjs-server',
|
||||||
version: pkg.version,
|
version: pkg.version,
|
||||||
ENV_MAP: {},
|
ENV_MAP: {},
|
||||||
env: process.env.NODE_ENV ?? 'development',
|
env: process.env.NODE_ENV ?? 'development',
|
||||||
@@ -40,6 +56,11 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => ({
|
|||||||
debug: true,
|
debug: true,
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
|
salt: '$2b$10$x4VDo2nmlo74yB5jflNhlu',
|
||||||
|
accessTokenExpiresIn: '1h',
|
||||||
|
refreshTokenExpiresIn: '7d',
|
||||||
|
publicKey: examplePublicKey,
|
||||||
|
privateKey: examplePrivateKey,
|
||||||
enableSignup: true,
|
enableSignup: true,
|
||||||
enableOauth: false,
|
enableOauth: false,
|
||||||
oauthProviders: {},
|
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,
|
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,4 +1,5 @@
|
|||||||
|
import { AuthModule } from './auth';
|
||||||
import { UsersModule } from './users';
|
import { UsersModule } from './users';
|
||||||
import { WorkspaceModule } from './workspaces';
|
import { WorkspaceModule } from './workspaces';
|
||||||
|
|
||||||
export const BusinessModules = [WorkspaceModule, UsersModule];
|
export const BusinessModules = [AuthModule, WorkspaceModule, UsersModule];
|
||||||
|
|||||||
@@ -1,36 +1,36 @@
|
|||||||
import { Args, Field, ID, ObjectType, Query, Resolver } from '@nestjs/graphql';
|
import { Args, Field, ID, ObjectType, Query, Resolver } from '@nestjs/graphql';
|
||||||
import type { users } from '@prisma/client';
|
import type { User } from '@prisma/client';
|
||||||
|
|
||||||
import { PrismaService } from '../../prisma/service';
|
import { PrismaService } from '../../prisma/service';
|
||||||
|
|
||||||
@ObjectType()
|
@ObjectType()
|
||||||
export class User implements users {
|
export class UserType implements Partial<User> {
|
||||||
@Field(() => ID)
|
@Field(() => ID)
|
||||||
id!: string;
|
id!: string;
|
||||||
|
|
||||||
@Field({ description: 'User name' })
|
@Field({ description: 'User name' })
|
||||||
name!: string;
|
name!: string;
|
||||||
|
|
||||||
@Field({ description: 'User email' })
|
@Field({ description: 'User email' })
|
||||||
email!: string;
|
email!: string;
|
||||||
@Field({ description: 'User password', nullable: true })
|
|
||||||
password!: string;
|
|
||||||
@Field({ description: 'User avatar url', nullable: true })
|
@Field({ description: 'User avatar url', nullable: true })
|
||||||
avatar_url!: string;
|
avatarUrl!: string;
|
||||||
@Field({ description: 'User token nonce', nullable: true })
|
|
||||||
token_nonce!: number;
|
|
||||||
@Field({ description: 'User created date', nullable: true })
|
@Field({ description: 'User created date', nullable: true })
|
||||||
created_at!: Date;
|
createdAt!: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Resolver(() => User)
|
@Resolver(() => UserType)
|
||||||
export class UserResolver {
|
export class UserResolver {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
@Query(() => User, {
|
@Query(() => UserType, {
|
||||||
name: 'user',
|
name: 'user',
|
||||||
description: 'Get user by email',
|
description: 'Get user by email',
|
||||||
})
|
})
|
||||||
async user(@Args('email') email: string) {
|
async user(@Args('email') email: string) {
|
||||||
return this.prisma.users.findUnique({
|
return this.prisma.user.findUnique({
|
||||||
where: { email },
|
where: { email },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { PermissionService } from './permission';
|
||||||
import { WorkspaceResolver } from './resolver';
|
import { WorkspaceResolver } from './resolver';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [WorkspaceResolver],
|
providers: [WorkspaceResolver, PermissionService],
|
||||||
|
exports: [PermissionService],
|
||||||
})
|
})
|
||||||
export class WorkspaceModule {}
|
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,85 +1,206 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Args,
|
Args,
|
||||||
Field,
|
Field,
|
||||||
ID,
|
ID,
|
||||||
|
InputType,
|
||||||
|
Int,
|
||||||
Mutation,
|
Mutation,
|
||||||
ObjectType,
|
ObjectType,
|
||||||
|
Parent,
|
||||||
|
PartialType,
|
||||||
|
PickType,
|
||||||
Query,
|
Query,
|
||||||
registerEnumType,
|
registerEnumType,
|
||||||
|
ResolveField,
|
||||||
Resolver,
|
Resolver,
|
||||||
} from '@nestjs/graphql';
|
} 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 {
|
registerEnumType(Permission, {
|
||||||
Private = 0,
|
name: 'Permission',
|
||||||
Normal = 1,
|
description: 'User permission in workspace',
|
||||||
}
|
|
||||||
|
|
||||||
registerEnumType(WorkspaceType, {
|
|
||||||
name: 'WorkspaceType',
|
|
||||||
description: 'Workspace type',
|
|
||||||
valuesMap: {
|
|
||||||
Normal: {
|
|
||||||
description: 'Normal workspace',
|
|
||||||
},
|
|
||||||
Private: {
|
|
||||||
description: 'Private workspace',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@ObjectType()
|
@ObjectType()
|
||||||
export class Workspace implements workspaces {
|
export class WorkspaceType implements Partial<Workspace> {
|
||||||
@Field(() => ID)
|
@Field(() => ID)
|
||||||
id!: string;
|
id!: string;
|
||||||
|
|
||||||
@Field({ description: 'is Public workspace' })
|
@Field({ description: 'is Public workspace' })
|
||||||
public!: boolean;
|
public!: boolean;
|
||||||
@Field(() => WorkspaceType, { description: 'Workspace type' })
|
|
||||||
type!: WorkspaceType;
|
|
||||||
@Field({ description: 'Workspace created date' })
|
@Field({ description: 'Workspace created date' })
|
||||||
created_at!: Date;
|
createdAt!: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Resolver(() => Workspace)
|
@InputType()
|
||||||
|
export class UpdateWorkspaceInput extends PickType(
|
||||||
|
PartialType(WorkspaceType),
|
||||||
|
['public'],
|
||||||
|
InputType
|
||||||
|
) {
|
||||||
|
@Field(() => ID)
|
||||||
|
id!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Auth()
|
||||||
|
@Resolver(() => WorkspaceType)
|
||||||
export class WorkspaceResolver {
|
export class WorkspaceResolver {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly permissionProvider: PermissionService
|
||||||
|
) {}
|
||||||
|
|
||||||
// debug only query should be removed
|
@ResolveField(() => Permission, {
|
||||||
@Query(() => [Workspace], {
|
description: 'Permission of current signed in user in workspace',
|
||||||
name: 'workspaces',
|
complexity: 2,
|
||||||
description: 'Get all workspaces',
|
|
||||||
})
|
})
|
||||||
async workspaces() {
|
async permission(
|
||||||
return this.prisma.workspaces.findMany();
|
@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, {
|
@ResolveField(() => Int, {
|
||||||
name: 'workspace',
|
description: 'member count of workspace',
|
||||||
description: 'Get workspace by id',
|
complexity: 2,
|
||||||
})
|
})
|
||||||
async workspace(@Args('id') id: string) {
|
memberCount(@Parent() workspace: WorkspaceType) {
|
||||||
return this.prisma.workspaces.findUnique({
|
return this.prisma.userWorkspacePermission.count({
|
||||||
where: { id },
|
where: {
|
||||||
});
|
workspaceId: workspace.id,
|
||||||
}
|
accepted: true,
|
||||||
|
|
||||||
// create workspace
|
|
||||||
@Mutation(() => Workspace, {
|
|
||||||
name: 'createWorkspace',
|
|
||||||
description: 'Create workspace',
|
|
||||||
})
|
|
||||||
async createWorkspace() {
|
|
||||||
return this.prisma.workspaces.create({
|
|
||||||
data: {
|
|
||||||
id: randomUUID(),
|
|
||||||
type: WorkspaceType.Private,
|
|
||||||
public: false,
|
|
||||||
created_at: new Date(),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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],
|
exports: [PrismaService],
|
||||||
})
|
})
|
||||||
export class PrismaModule {}
|
export class PrismaModule {}
|
||||||
|
export { PrismaService } from './service';
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { afterEach, beforeEach, describe, test } from 'node:test';
|
|||||||
|
|
||||||
import { INestApplication } from '@nestjs/common';
|
import { INestApplication } from '@nestjs/common';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { hash } from 'bcrypt';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
|
|
||||||
import { AppModule } from '../app';
|
import { AppModule } from '../app';
|
||||||
@@ -12,10 +14,24 @@ const gql = '/graphql';
|
|||||||
|
|
||||||
globalThis.AFFiNE = getDefaultAFFiNEConfig();
|
globalThis.AFFiNE = getDefaultAFFiNEConfig();
|
||||||
|
|
||||||
// please run `ts-node-esm ./scripts/init-db.ts` before running this test
|
|
||||||
describe('AppModule', () => {
|
describe('AppModule', () => {
|
||||||
let app: INestApplication;
|
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 () => {
|
beforeEach(async () => {
|
||||||
const module = await Test.createTestingModule({
|
const module = await Test.createTestingModule({
|
||||||
imports: [AppModule],
|
imports: [AppModule],
|
||||||
@@ -34,22 +50,46 @@ describe('AppModule', () => {
|
|||||||
.post(gql)
|
.post(gql)
|
||||||
.send({
|
.send({
|
||||||
query: `
|
query: `
|
||||||
query {
|
query {
|
||||||
error
|
error
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
.expect(400);
|
.expect(400);
|
||||||
|
|
||||||
|
let token;
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.post(gql)
|
.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({
|
.send({
|
||||||
query: `
|
query: `
|
||||||
mutation {
|
mutation {
|
||||||
createWorkspace {
|
createWorkspace {
|
||||||
id
|
id
|
||||||
type
|
|
||||||
public
|
public
|
||||||
created_at
|
createdAt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
@@ -64,16 +104,12 @@ describe('AppModule', () => {
|
|||||||
typeof res.body.data.createWorkspace.id === 'string',
|
typeof res.body.data.createWorkspace.id === 'string',
|
||||||
'res.body.data.createWorkspace.id is not a string'
|
'res.body.data.createWorkspace.id is not a string'
|
||||||
);
|
);
|
||||||
ok(
|
|
||||||
typeof res.body.data.createWorkspace.type === 'string',
|
|
||||||
'res.body.data.createWorkspace.type is not a string'
|
|
||||||
);
|
|
||||||
ok(
|
ok(
|
||||||
typeof res.body.data.createWorkspace.public === 'boolean',
|
typeof res.body.data.createWorkspace.public === 'boolean',
|
||||||
'res.body.data.createWorkspace.public is not a boolean'
|
'res.body.data.createWorkspace.public is not a boolean'
|
||||||
);
|
);
|
||||||
ok(
|
ok(
|
||||||
typeof res.body.data.createWorkspace.created_at === 'string',
|
typeof res.body.data.createWorkspace.createdAt === 'string',
|
||||||
'res.body.data.createWorkspace.created_at is not a string'
|
'res.body.data.createWorkspace.created_at is not a string'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -87,7 +123,7 @@ describe('AppModule', () => {
|
|||||||
query {
|
query {
|
||||||
user(email: "alex.yang@example.org") {
|
user(email: "alex.yang@example.org") {
|
||||||
email
|
email
|
||||||
avatar_url
|
avatarUrl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -7,5 +7,5 @@
|
|||||||
"moduleResolution": "Node",
|
"moduleResolution": "Node",
|
||||||
"allowSyntheticDefaultImports": true
|
"allowSyntheticDefaultImports": true
|
||||||
},
|
},
|
||||||
"include": ["./scripts", "package.json"]
|
"include": ["scripts", "package.json"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,15 @@ import { withSentryConfig } from '@sentry/nextjs';
|
|||||||
import SentryWebpackPlugin from '@sentry/webpack-plugin';
|
import SentryWebpackPlugin from '@sentry/webpack-plugin';
|
||||||
import debugLocal from 'next-debug-local';
|
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';
|
import { getCommitHash, getGitVersion } from './scripts/gitInfo.mjs';
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const { createVanillaExtractPlugin } = require('@vanilla-extract/next-plugin');
|
const { createVanillaExtractPlugin } = require('@vanilla-extract/next-plugin');
|
||||||
const withVanillaExtract = createVanillaExtractPlugin();
|
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 ?? '');
|
const enableDebugLocal = path.isAbsolute(process.env.LOCAL_BLOCK_SUITE ?? '');
|
||||||
|
|
||||||
@@ -84,10 +85,11 @@ const nextConfig = {
|
|||||||
'@affine/debug',
|
'@affine/debug',
|
||||||
'@affine/env',
|
'@affine/env',
|
||||||
'@affine/templates',
|
'@affine/templates',
|
||||||
'@toeverything/hooks',
|
|
||||||
'@affine/workspace',
|
'@affine/workspace',
|
||||||
'@affine/jotai',
|
'@affine/jotai',
|
||||||
|
'@toeverything/hooks',
|
||||||
'@toeverything/y-indexeddb',
|
'@toeverything/y-indexeddb',
|
||||||
|
'@toeverything/theme',
|
||||||
],
|
],
|
||||||
publicRuntimeConfig: {
|
publicRuntimeConfig: {
|
||||||
PROJECT_NAME: process.env.npm_package_name ?? 'AFFiNE',
|
PROJECT_NAME: process.env.npm_package_name ?? 'AFFiNE',
|
||||||
@@ -98,7 +100,8 @@ const nextConfig = {
|
|||||||
profileTarget[process.env.API_SERVER_PROFILE || 'dev'] ??
|
profileTarget[process.env.API_SERVER_PROFILE || 'dev'] ??
|
||||||
profileTarget.dev,
|
profileTarget.dev,
|
||||||
editorVersion: require('./package.json').dependencies['@blocksuite/editor'],
|
editorVersion: require('./package.json').dependencies['@blocksuite/editor'],
|
||||||
...preset,
|
editorFlags: blockSuiteFeatureFlags,
|
||||||
|
...buildFlags,
|
||||||
},
|
},
|
||||||
webpack: (config, { dev, isServer }) => {
|
webpack: (config, { dev, isServer }) => {
|
||||||
config.experiments = { ...config.experiments, topLevelAwait: true };
|
config.experiments = { ...config.experiments, topLevelAwait: true };
|
||||||
@@ -144,7 +147,9 @@ const nextConfig = {
|
|||||||
},
|
},
|
||||||
basePath: process.env.NEXT_BASE_PATH,
|
basePath: process.env.NEXT_BASE_PATH,
|
||||||
assetPrefix: process.env.NEXT_ASSET_PREFIX,
|
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 ?? '/';
|
const baseDir = process.env.LOCAL_BLOCK_SUITE ?? '/';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@affine/web",
|
"name": "@affine/web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.5.4-canary.7",
|
"version": "0.5.4-canary.15",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
@@ -14,56 +14,58 @@
|
|||||||
"@affine/component": "workspace:*",
|
"@affine/component": "workspace:*",
|
||||||
"@affine/debug": "workspace:*",
|
"@affine/debug": "workspace:*",
|
||||||
"@affine/env": "workspace:*",
|
"@affine/env": "workspace:*",
|
||||||
|
"@affine/graphql": "workspace:*",
|
||||||
"@affine/i18n": "workspace:*",
|
"@affine/i18n": "workspace:*",
|
||||||
"@affine/jotai": "workspace:*",
|
"@affine/jotai": "workspace:*",
|
||||||
"@affine/templates": "workspace:*",
|
"@affine/templates": "workspace:*",
|
||||||
"@affine/workspace": "workspace:*",
|
"@affine/workspace": "workspace:*",
|
||||||
"@blocksuite/blocks": "0.0.0-20230420160324-857b396c-nightly",
|
"@blocksuite/blocks": "0.0.0-20230427041825-7fff957d-nightly",
|
||||||
"@blocksuite/editor": "0.0.0-20230420160324-857b396c-nightly",
|
"@blocksuite/editor": "0.0.0-20230427041825-7fff957d-nightly",
|
||||||
"@blocksuite/global": "0.0.0-20230420160324-857b396c-nightly",
|
"@blocksuite/global": "0.0.0-20230427041825-7fff957d-nightly",
|
||||||
"@blocksuite/icons": "^2.1.10",
|
"@blocksuite/icons": "^2.1.13",
|
||||||
"@blocksuite/store": "0.0.0-20230420160324-857b396c-nightly",
|
"@blocksuite/store": "0.0.0-20230427041825-7fff957d-nightly",
|
||||||
"@dnd-kit/core": "^6.0.8",
|
"@dnd-kit/core": "^6.0.8",
|
||||||
"@dnd-kit/sortable": "^7.0.2",
|
"@dnd-kit/sortable": "^7.0.2",
|
||||||
"@emotion/cache": "^11.10.7",
|
"@emotion/cache": "^11.10.7",
|
||||||
"@emotion/react": "^11.10.6",
|
"@emotion/react": "^11.10.6",
|
||||||
"@emotion/server": "^11.10.0",
|
"@emotion/server": "^11.10.0",
|
||||||
"@emotion/styled": "^11.10.6",
|
"@emotion/styled": "^11.10.6",
|
||||||
"@mui/material": "^5.12.0",
|
"@mui/material": "^5.12.2",
|
||||||
"@react-hookz/web": "^23.0.0",
|
"@react-hookz/web": "^23.0.0",
|
||||||
"@sentry/nextjs": "^7.48.0",
|
"@sentry/nextjs": "^7.50.0",
|
||||||
"@toeverything/hooks": "workspace:*",
|
"@toeverything/hooks": "workspace:*",
|
||||||
"cmdk": "^0.2.0",
|
"cmdk": "^0.2.0",
|
||||||
"css-spring": "^4.1.0",
|
"css-spring": "^4.1.0",
|
||||||
"dayjs": "^1.11.7",
|
"dayjs": "^1.11.7",
|
||||||
|
"graphql": "^16.6.0",
|
||||||
"jotai": "^2.0.4",
|
"jotai": "^2.0.4",
|
||||||
"jotai-devtools": "^0.4.0",
|
"jotai-devtools": "^0.4.0",
|
||||||
"lit": "^2.7.2",
|
"lit": "^2.7.3",
|
||||||
"lottie-web": "^5.11.0",
|
"lottie-web": "^5.11.0",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-is": "^18.2.0",
|
"react-is": "^18.2.0",
|
||||||
"swr": "^2.1.3",
|
"swr": "^2.1.5",
|
||||||
"y-protocols": "^1.0.5",
|
"y-protocols": "^1.0.5",
|
||||||
"yjs": "^13.5.52",
|
"yjs": "^13.6.0",
|
||||||
"zod": "^3.21.4"
|
"zod": "^3.21.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@perfsee/webpack": "^1.5.0",
|
"@perfsee/webpack": "^1.6.0",
|
||||||
"@redux-devtools/extension": "^3.2.5",
|
"@redux-devtools/extension": "^3.2.5",
|
||||||
"@rich-data/viewer": "^2.15.6",
|
"@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/debug-label": "^0.0.9",
|
||||||
"@swc-jotai/react-refresh": "^0.0.7",
|
"@swc-jotai/react-refresh": "^0.0.7",
|
||||||
"@types/react": "^18.0.35",
|
"@types/react": "^18.2.0",
|
||||||
"@types/react-dom": "^18.0.11",
|
"@types/react-dom": "^18.2.1",
|
||||||
"@types/webpack-env": "^1.18.0",
|
"@types/webpack-env": "^1.18.0",
|
||||||
"@vanilla-extract/css": "^1.11.0",
|
"@vanilla-extract/css": "^1.11.0",
|
||||||
"@vanilla-extract/next-plugin": "^2.1.2",
|
"@vanilla-extract/next-plugin": "^2.1.2",
|
||||||
"dotenv": "^16.0.3",
|
"dotenv": "^16.0.3",
|
||||||
"eslint": "^8.38.0",
|
"eslint": "^8.39.0",
|
||||||
"eslint-config-next": "^13.3.0",
|
"eslint-config-next": "^13.3.1",
|
||||||
"next": "=13.2.3",
|
"next": "=13.2.3",
|
||||||
"next-debug-local": "^0.1.5",
|
"next-debug-local": "^0.1.5",
|
||||||
"next-router-mock": "^0.9.3",
|
"next-router-mock": "^0.9.3",
|
||||||
@@ -71,7 +73,7 @@
|
|||||||
"redux": "^4.2.1",
|
"redux": "^4.2.1",
|
||||||
"swc-plugin-coverage-instrument": "=0.0.14",
|
"swc-plugin-coverage-instrument": "=0.0.14",
|
||||||
"typescript": "^5.0.4",
|
"typescript": "^5.0.4",
|
||||||
"webpack": "^5.79.0"
|
"webpack": "^5.81.0"
|
||||||
},
|
},
|
||||||
"stableVersion": "0.0.0"
|
"stableVersion": "0.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,23 @@
|
|||||||
|
// @ts-check
|
||||||
import 'dotenv/config';
|
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
|
enableLegacyCloud: process.env.ENABLE_LEGACY_PROVIDER
|
||||||
? process.env.ENABLE_LEGACY_PROVIDER === 'true'
|
? process.env.ENABLE_LEGACY_PROVIDER === 'true'
|
||||||
: true,
|
: true,
|
||||||
@@ -11,4 +28,3 @@ const config = {
|
|||||||
process.env.ENABLE_DEBUG_PAGE ?? process.env.NODE_ENV === 'development'
|
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({
|
Sentry.init({
|
||||||
dsn: SENTRY_DSN,
|
dsn: SENTRY_DSN,
|
||||||
tracesSampleRate: 1.0,
|
tracesSampleRate: 0.1,
|
||||||
replaysSessionSampleRate: 0.1,
|
replaysSessionSampleRate: 0.1,
|
||||||
replaysOnErrorSampleRate: 1.0,
|
replaysOnErrorSampleRate: 0.1,
|
||||||
integrations: [new Sentry.Replay()],
|
integrations: [new Sentry.Replay()],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,5 +4,5 @@ const SENTRY_DSN = process.env.NEXT_PUBLIC_SENTRY_DSN;
|
|||||||
|
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn: SENTRY_DSN,
|
dsn: SENTRY_DSN,
|
||||||
tracesSampleRate: 1.0,
|
tracesSampleRate: 0.1,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,5 +4,5 @@ const SENTRY_DSN = process.env.NEXT_PUBLIC_SENTRY_DSN;
|
|||||||
|
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn: SENTRY_DSN,
|
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>;
|
export type Visibility = Record<string, boolean>;
|
||||||
|
|
||||||
const DEFAULT_VALUE = '0.0.0';
|
const DEFAULT_VALUE = '0.0.0';
|
||||||
|
|
||||||
export const lastVersionAtom = atomWithSyncStorage(
|
export const lastVersionAtom = atomWithStorage('lastVersion', DEFAULT_VALUE);
|
||||||
'lastVersion',
|
|
||||||
DEFAULT_VALUE
|
|
||||||
);
|
|
||||||
export const guideHiddenAtom = atomWithSyncStorage<Visibility>(
|
|
||||||
'guideHidden',
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
|
|
||||||
export const guideHiddenUntilNextUpdateAtom = atomWithSyncStorage<Visibility>(
|
export const guideHiddenAtom = atomWithStorage<Visibility>('guideHidden', {});
|
||||||
|
|
||||||
|
export const guideHiddenUntilNextUpdateAtom = atomWithStorage<Visibility>(
|
||||||
'guideHiddenUntilNextUpdate',
|
'guideHiddenUntilNextUpdate',
|
||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
rootCurrentWorkspaceIdAtom,
|
rootCurrentWorkspaceIdAtom,
|
||||||
rootWorkspacesMetadataAtom,
|
rootWorkspacesMetadataAtom,
|
||||||
} from '@affine/workspace/atom';
|
} from '@affine/workspace/atom';
|
||||||
|
import { WorkspaceFlavour } from '@affine/workspace/type';
|
||||||
import type { Page } from '@blocksuite/store';
|
import type { Page } from '@blocksuite/store';
|
||||||
import { atom } from 'jotai';
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
@@ -46,6 +47,21 @@ rootWorkspacesMetadataAtom.onMount = setAtom => {
|
|||||||
}
|
}
|
||||||
return metadata;
|
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,3 +1,5 @@
|
|||||||
|
import type { BlockSuiteFeatureFlags } from '@affine/env';
|
||||||
|
import { config } from '@affine/env';
|
||||||
import type { AffinePublicWorkspace } from '@affine/workspace/type';
|
import type { AffinePublicWorkspace } from '@affine/workspace/type';
|
||||||
import { WorkspaceFlavour } from '@affine/workspace/type';
|
import { WorkspaceFlavour } from '@affine/workspace/type';
|
||||||
import { createEmptyBlockSuiteWorkspace } from '@affine/workspace/utils';
|
import { createEmptyBlockSuiteWorkspace } from '@affine/workspace/utils';
|
||||||
@@ -23,11 +25,14 @@ function createPublicWorkspace(
|
|||||||
blockSuiteWorkspace.doc,
|
blockSuiteWorkspace.doc,
|
||||||
new Uint8Array(binary)
|
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_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);
|
blockSuiteWorkspace.awarenessStore.setFlag('enable_drag_handle', false);
|
||||||
return {
|
return {
|
||||||
flavour: WorkspaceFlavour.PUBLIC,
|
flavour: WorkspaceFlavour.PUBLIC,
|
||||||
|
|||||||
@@ -25,14 +25,19 @@ export const createAffineDownloadProvider = (
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
affineApis.downloadWorkspace(id, false).then(binary => {
|
affineApis
|
||||||
hashMap.set(id, binary);
|
.downloadWorkspace(id, false)
|
||||||
providerLogger.debug('applyUpdate');
|
.then(binary => {
|
||||||
BlockSuiteWorkspace.Y.applyUpdate(
|
hashMap.set(id, binary);
|
||||||
blockSuiteWorkspace.doc,
|
providerLogger.debug('applyUpdate');
|
||||||
new Uint8Array(binary)
|
BlockSuiteWorkspace.Y.applyUpdate(
|
||||||
);
|
blockSuiteWorkspace.doc,
|
||||||
});
|
new Uint8Array(binary)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
providerLogger.error('downloadWorkspace', e);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
disconnect: () => {
|
disconnect: () => {
|
||||||
providerLogger.info('disconnect download provider', id);
|
providerLogger.info('disconnect download provider', id);
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ const createAffineWebSocketProvider = (
|
|||||||
blockSuiteWorkspace.doc,
|
blockSuiteWorkspace.doc,
|
||||||
{
|
{
|
||||||
params: { token: getLoginStorage()?.token ?? '' },
|
params: { token: getLoginStorage()?.token ?? '' },
|
||||||
// @ts-expect-error ignore the type
|
|
||||||
awareness: blockSuiteWorkspace.awarenessStore.awareness,
|
awareness: blockSuiteWorkspace.awarenessStore.awareness,
|
||||||
// we maintain broadcast channel by ourselves
|
// we maintain broadcast channel by ourselves
|
||||||
|
// @ts-expect-error
|
||||||
disableBc: true,
|
disableBc: true,
|
||||||
connect: false,
|
connect: false,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
return (
|
||||||
<Menu
|
<Menu
|
||||||
width={248}
|
width={248}
|
||||||
placement="left-start"
|
// placement="left-start"
|
||||||
trigger="click"
|
trigger="click"
|
||||||
content={
|
content={
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export type MoveToProps = CommonMenuItemProps<{
|
|||||||
blockSuiteWorkspace: BlockSuiteWorkspace;
|
blockSuiteWorkspace: BlockSuiteWorkspace;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
export const MoveTo = ({
|
export const MoveTo = ({
|
||||||
metas,
|
metas,
|
||||||
currentMeta,
|
currentMeta,
|
||||||
@@ -46,7 +49,7 @@ export const MoveTo = ({
|
|||||||
<PinboardMenu
|
<PinboardMenu
|
||||||
anchorEl={anchorEl}
|
anchorEl={anchorEl}
|
||||||
open={open}
|
open={open}
|
||||||
placement="left-start"
|
// placement="left-start"
|
||||||
metas={metas}
|
metas={metas}
|
||||||
currentMeta={currentMeta}
|
currentMeta={currentMeta}
|
||||||
blockSuiteWorkspace={blockSuiteWorkspace}
|
blockSuiteWorkspace={blockSuiteWorkspace}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { Input, PureMenu, TreeView } from '@affine/component';
|
|||||||
import { useTranslation } from '@affine/i18n';
|
import { useTranslation } from '@affine/i18n';
|
||||||
import { RemoveIcon, SearchIcon } from '@blocksuite/icons';
|
import { RemoveIcon, SearchIcon } from '@blocksuite/icons';
|
||||||
import type { PageMeta } from '@blocksuite/store';
|
import type { PageMeta } from '@blocksuite/store';
|
||||||
import { usePageMetaHelper } from '@toeverything/hooks/use-block-suite-page-meta';
|
|
||||||
import React, { useCallback, useMemo, useState } from 'react';
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { useReferenceLinkHelper } from '../../../../hooks/affine/use-reference-link-helper';
|
||||||
import { usePinboardData } from '../../../../hooks/use-pinboard-data';
|
import { usePinboardData } from '../../../../hooks/use-pinboard-data';
|
||||||
import { usePinboardHandler } from '../../../../hooks/use-pinboard-handler';
|
import { usePinboardHandler } from '../../../../hooks/use-pinboard-handler';
|
||||||
import type { BlockSuiteWorkspace } from '../../../../shared';
|
import type { BlockSuiteWorkspace } from '../../../../shared';
|
||||||
@@ -20,13 +20,13 @@ import {
|
|||||||
} from '../styles';
|
} from '../styles';
|
||||||
import { SearchContent } from './SearchContent';
|
import { SearchContent } from './SearchContent';
|
||||||
|
|
||||||
export type PinboardMenuProps = {
|
export interface PinboardMenuProps extends PureMenuProps {
|
||||||
metas: PageMeta[];
|
metas: PageMeta[];
|
||||||
currentMeta: PageMeta;
|
currentMeta: PageMeta;
|
||||||
blockSuiteWorkspace: BlockSuiteWorkspace;
|
blockSuiteWorkspace: BlockSuiteWorkspace;
|
||||||
showRemovePinboard?: boolean;
|
showRemovePinboard?: boolean;
|
||||||
onPinboardClick?: (p: { dragId: string; dropId: string }) => void;
|
onPinboardClick?: (p: { dragId: string; dropId: string }) => void;
|
||||||
} & PureMenuProps;
|
}
|
||||||
|
|
||||||
export const PinboardMenu = ({
|
export const PinboardMenu = ({
|
||||||
metas: propsMetas,
|
metas: propsMetas,
|
||||||
@@ -41,13 +41,13 @@ export const PinboardMenu = ({
|
|||||||
[currentMeta.id, propsMetas]
|
[currentMeta.id, propsMetas]
|
||||||
);
|
);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { setPageMeta } = usePageMetaHelper(blockSuiteWorkspace);
|
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const isSearching = query.length > 0;
|
const isSearching = query.length > 0;
|
||||||
|
|
||||||
const searchResult = metas.filter(
|
const searchResult = metas.filter(
|
||||||
meta => !meta.trash && meta.title.includes(query)
|
meta => !meta.trash && meta.title.includes(query)
|
||||||
);
|
);
|
||||||
|
const { removeReferenceLink } = useReferenceLinkHelper(blockSuiteWorkspace);
|
||||||
|
|
||||||
const { dropPin } = usePinboardHandler({
|
const { dropPin } = usePinboardHandler({
|
||||||
blockSuiteWorkspace,
|
blockSuiteWorkspace,
|
||||||
@@ -117,16 +117,7 @@ export const PinboardMenu = ({
|
|||||||
<StyledPinboard
|
<StyledPinboard
|
||||||
data-testid={'remove-from-pinboard-button'}
|
data-testid={'remove-from-pinboard-button'}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const parentMeta = metas.find(m =>
|
removeReferenceLink(currentMeta.id);
|
||||||
m.subpageIds.includes(currentMeta.id)
|
|
||||||
);
|
|
||||||
if (!parentMeta) return;
|
|
||||||
const newSubpageIds = [...parentMeta.subpageIds];
|
|
||||||
const deleteIndex = newSubpageIds.findIndex(
|
|
||||||
id => id === currentMeta.id
|
|
||||||
);
|
|
||||||
newSubpageIds.splice(deleteIndex, 1);
|
|
||||||
setPageMeta(parentMeta.id, { subpageIds: newSubpageIds });
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RemoveIcon />
|
<RemoveIcon />
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { PlusIcon } from '@blocksuite/icons';
|
||||||
|
|
||||||
|
import { StyledOperationButton } from '../styles';
|
||||||
|
import type { OperationButtonProps } from './OperationButton';
|
||||||
|
|
||||||
|
export const AddButton = ({
|
||||||
|
onAdd,
|
||||||
|
visible,
|
||||||
|
}: Pick<OperationButtonProps, 'onAdd' | 'visible'>) => {
|
||||||
|
return (
|
||||||
|
<StyledOperationButton
|
||||||
|
visible={visible}
|
||||||
|
size="small"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAdd();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
</StyledOperationButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,9 +1,4 @@
|
|||||||
import {
|
import { MenuItem, MuiClickAwayListener, PureMenu } from '@affine/component';
|
||||||
baseTheme,
|
|
||||||
MenuItem,
|
|
||||||
MuiClickAwayListener,
|
|
||||||
PureMenu,
|
|
||||||
} from '@affine/component';
|
|
||||||
import { useTranslation } from '@affine/i18n';
|
import { useTranslation } from '@affine/i18n';
|
||||||
import {
|
import {
|
||||||
MoreVerticalIcon,
|
MoreVerticalIcon,
|
||||||
@@ -12,6 +7,7 @@ import {
|
|||||||
PlusIcon,
|
PlusIcon,
|
||||||
} from '@blocksuite/icons';
|
} from '@blocksuite/icons';
|
||||||
import type { PageMeta } from '@blocksuite/store';
|
import type { PageMeta } from '@blocksuite/store';
|
||||||
|
import { baseTheme } from '@toeverything/theme';
|
||||||
import { useMemo, useRef, useState } from 'react';
|
import { useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { useBlockSuiteMetaHelper } from '../../../../hooks/affine/use-block-suite-meta-helper';
|
import { useBlockSuiteMetaHelper } from '../../../../hooks/affine/use-block-suite-meta-helper';
|
||||||
@@ -28,7 +24,7 @@ export type OperationButtonProps = {
|
|||||||
metas: PageMeta[];
|
metas: PageMeta[];
|
||||||
currentMeta: PageMeta;
|
currentMeta: PageMeta;
|
||||||
blockSuiteWorkspace: BlockSuiteWorkspace;
|
blockSuiteWorkspace: BlockSuiteWorkspace;
|
||||||
isHover: boolean;
|
visible: boolean;
|
||||||
onRename?: () => void;
|
onRename?: () => void;
|
||||||
onMenuClose?: () => void;
|
onMenuClose?: () => void;
|
||||||
};
|
};
|
||||||
@@ -39,7 +35,7 @@ export const OperationButton = ({
|
|||||||
metas,
|
metas,
|
||||||
currentMeta,
|
currentMeta,
|
||||||
blockSuiteWorkspace,
|
blockSuiteWorkspace,
|
||||||
isHover,
|
visible,
|
||||||
onMenuClose,
|
onMenuClose,
|
||||||
onRename,
|
onRename,
|
||||||
}: OperationButtonProps) => {
|
}: OperationButtonProps) => {
|
||||||
@@ -61,6 +57,7 @@ export const OperationButton = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
style={{ display: 'flex' }}
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
@@ -81,7 +78,7 @@ export const OperationButton = ({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOperationMenuOpen(!operationMenuOpen);
|
setOperationMenuOpen(!operationMenuOpen);
|
||||||
}}
|
}}
|
||||||
visible={isHover}
|
visible={visible}
|
||||||
>
|
>
|
||||||
<MoreVerticalIcon />
|
<MoreVerticalIcon />
|
||||||
</StyledOperationButton>
|
</StyledOperationButton>
|
||||||
@@ -91,7 +88,7 @@ export const OperationButton = ({
|
|||||||
width={256}
|
width={256}
|
||||||
anchorEl={anchorEl}
|
anchorEl={anchorEl}
|
||||||
open={operationMenuOpen}
|
open={operationMenuOpen}
|
||||||
placement="bottom-start"
|
// placement="bottom-start"
|
||||||
zIndex={menuIndex}
|
zIndex={menuIndex}
|
||||||
>
|
>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
@@ -146,7 +143,7 @@ export const OperationButton = ({
|
|||||||
<PinboardMenu
|
<PinboardMenu
|
||||||
anchorEl={anchorEl}
|
anchorEl={anchorEl}
|
||||||
open={pinboardMenuOpen}
|
open={pinboardMenuOpen}
|
||||||
placement="bottom-start"
|
// placement="bottom-start"
|
||||||
zIndex={menuIndex}
|
zIndex={menuIndex}
|
||||||
metas={metas}
|
metas={metas}
|
||||||
currentMeta={currentMeta}
|
currentMeta={currentMeta}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
EdgelessIcon,
|
EdgelessIcon,
|
||||||
LevelIcon,
|
LevelIcon,
|
||||||
PageIcon,
|
PageIcon,
|
||||||
PivotsIcon,
|
PinboardIcon,
|
||||||
} from '@blocksuite/icons';
|
} from '@blocksuite/icons';
|
||||||
import { usePageMetaHelper } from '@toeverything/hooks/use-block-suite-page-meta';
|
import { usePageMetaHelper } from '@toeverything/hooks/use-block-suite-page-meta';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
@@ -14,13 +14,14 @@ import { useMemo, useState } from 'react';
|
|||||||
import { workspacePreferredModeAtom } from '../../../../atoms';
|
import { workspacePreferredModeAtom } from '../../../../atoms';
|
||||||
import type { PinboardNode } from '../../../../hooks/use-pinboard-data';
|
import type { PinboardNode } from '../../../../hooks/use-pinboard-data';
|
||||||
import { StyledCollapsedButton, StyledPinboard } from '../styles';
|
import { StyledCollapsedButton, StyledPinboard } from '../styles';
|
||||||
|
import { AddButton } from './AddButton';
|
||||||
import EmptyItem from './EmptyItem';
|
import EmptyItem from './EmptyItem';
|
||||||
import { OperationButton } from './OperationButton';
|
import { OperationButton } from './OperationButton';
|
||||||
|
|
||||||
const getIcon = (type: 'root' | 'edgeless' | 'page') => {
|
const getIcon = (type: 'root' | 'edgeless' | 'page') => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'root':
|
case 'root':
|
||||||
return <PivotsIcon className="mode-icon" />;
|
return <PinboardIcon className="mode-icon" />;
|
||||||
case 'edgeless':
|
case 'edgeless':
|
||||||
return <EdgelessIcon className="mode-icon" />;
|
return <EdgelessIcon className="mode-icon" />;
|
||||||
default:
|
default:
|
||||||
@@ -84,10 +85,8 @@ export const PinboardRender: PinboardNode['render'] = (
|
|||||||
<ArrowDownSmallIcon />
|
<ArrowDownSmallIcon />
|
||||||
</StyledCollapsedButton>
|
</StyledCollapsedButton>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{asPath && !isRoot ? <LevelIcon className="path-icon" /> : null}
|
{asPath && !isRoot ? <LevelIcon className="path-icon" /> : null}
|
||||||
{getIcon(isRoot ? 'root' : record[node.id])}
|
{getIcon(isRoot ? 'root' : record[node.id])}
|
||||||
|
|
||||||
{showRename ? (
|
{showRename ? (
|
||||||
<Input
|
<Input
|
||||||
data-testid={`pinboard-input-${node.id}`}
|
data-testid={`pinboard-input-${node.id}`}
|
||||||
@@ -106,6 +105,7 @@ export const PinboardRender: PinboardNode['render'] = (
|
|||||||
) : (
|
) : (
|
||||||
<span>{isRoot ? 'Pinboard' : currentMeta.title || 'Untitled'}</span>
|
<span>{isRoot ? 'Pinboard' : currentMeta.title || 'Untitled'}</span>
|
||||||
)}
|
)}
|
||||||
|
{showOperationButton && <AddButton onAdd={onAdd} visible={isHover} />}
|
||||||
|
|
||||||
{showOperationButton && (
|
{showOperationButton && (
|
||||||
<OperationButton
|
<OperationButton
|
||||||
@@ -115,7 +115,7 @@ export const PinboardRender: PinboardNode['render'] = (
|
|||||||
metas={metas}
|
metas={metas}
|
||||||
currentMeta={currentMeta!}
|
currentMeta={currentMeta!}
|
||||||
blockSuiteWorkspace={blockSuiteWorkspace!}
|
blockSuiteWorkspace={blockSuiteWorkspace!}
|
||||||
isHover={isHover}
|
visible={isHover}
|
||||||
onMenuClose={() => setIsHover(false)}
|
onMenuClose={() => setIsHover(false)}
|
||||||
onRename={() => {
|
onRename={() => {
|
||||||
setShowRename(true);
|
setShowRename(true);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
export const StyledCollapsedButton = styled('button')<{
|
export const StyledCollapsedButton = styled('button')<{
|
||||||
collapse: boolean;
|
collapse: boolean;
|
||||||
show?: boolean;
|
show?: boolean;
|
||||||
}>(({ collapse, show = true, theme }) => {
|
}>(({ collapse, show = true }) => {
|
||||||
return {
|
return {
|
||||||
width: '16px',
|
width: '16px',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
@@ -43,7 +43,6 @@ export const StyledPinboard = styled('div')<{
|
|||||||
disableCollapse,
|
disableCollapse,
|
||||||
disable = false,
|
disable = false,
|
||||||
active = false,
|
active = false,
|
||||||
theme,
|
|
||||||
isOver,
|
isOver,
|
||||||
textWrap = false,
|
textWrap = false,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -66,7 +65,7 @@ export const StyledPinboard = styled('div')<{
|
|||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
...(textWrap
|
...(textWrap
|
||||||
? {
|
? {
|
||||||
wordBreak: 'break-all',
|
wordBreak: 'break-word',
|
||||||
whiteSpace: 'pre-wrap',
|
whiteSpace: 'pre-wrap',
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
@@ -106,7 +105,7 @@ export const StyledOperationButton = styled(IconButton, {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export const StyledSearchContainer = styled('div')(({ theme }) => {
|
export const StyledSearchContainer = styled('div')(() => {
|
||||||
return {
|
return {
|
||||||
width: 'calc(100% - 24px)',
|
width: 'calc(100% - 24px)',
|
||||||
margin: '0 auto',
|
margin: '0 auto',
|
||||||
@@ -125,7 +124,7 @@ export const StyledMenuContent = styled('div')(() => {
|
|||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
export const StyledMenuSubTitle = styled('div')(({ theme }) => {
|
export const StyledMenuSubTitle = styled('div')(() => {
|
||||||
return {
|
return {
|
||||||
color: 'var(--affine-text-secondary-color)',
|
color: 'var(--affine-text-secondary-color)',
|
||||||
lineHeight: '36px',
|
lineHeight: '36px',
|
||||||
@@ -133,7 +132,7 @@ export const StyledMenuSubTitle = styled('div')(({ theme }) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export const StyledMenuFooter = styled('div')(({ theme }) => {
|
export const StyledMenuFooter = styled('div')(() => {
|
||||||
return {
|
return {
|
||||||
width: 'calc(100% - 24px)',
|
width: 'calc(100% - 24px)',
|
||||||
margin: '0 auto',
|
margin: '0 auto',
|
||||||
|
|||||||