Merge pull request #935 from toeverything/feat/client-app

feat: tauri desktop client app
This commit is contained in:
DarkSky
2023-02-10 19:38:44 +08:00
committed by GitHub
78 changed files with 14027 additions and 90 deletions
+74
View File
@@ -0,0 +1,74 @@
name: Release App
on:
push:
tags:
- 'v*.*.*'
paths-ignore:
- 'README.md'
- 'docs/**'
- '.vscode'
pull_request:
branches:
- master
paths-ignore:
- 'docs/**'
- 'README.md'
- '.vscode'
concurrency:
group: release-ci-group
cancel-in-progress: true
jobs:
MacOS:
runs-on: macos-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
submodules: 'true'
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: 18.x
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: latest
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- name: Get npm cache directory
uses: actions/cache@v3
id: cache
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm i -r
- name: Make macOS (x64)
run: pnpm build:app
working-directory: client-app
env:
CI: true
CI_PULL_REQUEST: ${{ github.event_name == 'pull_request' }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create Release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
draft: true
generate_release_notes: true
files: client-app/src-tauri/target/release/bundle/dmg/*.dmg
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+4
View File
@@ -58,3 +58,7 @@ module-resolve.cjs
# Cache
.eslintcache
# generated assets
client-app/public/affine-out
client-app/public/preload
+3
View File
@@ -0,0 +1,3 @@
[submodule "client-app/src-OctoBase"]
path = client-app/src-OctoBase
url = https://github.com/toeverything/OctoBase
+13 -2
View File
@@ -8,13 +8,24 @@
"datacenter",
"livedemo",
"pnpm",
"jwst",
"testid",
"octobase",
"selfhosted",
"testid"
"testid",
"schemars"
],
"explorer.fileNesting.patterns": {
"*.js": "${capture}.js.map, ${capture}.min.js, ${capture}.d.ts, ${capture}.d.ts.map",
"package.json": ".browserslist*, .circleci*, .codecov, .commitlint*, .cz-config.js, .czrc, .dlint.json, .dprint.json, .editorconfig, .eslint*, .firebase*, .flowconfig, .github*, .gitlab*, .gitpod*, .huskyrc*, .jslint*, .lighthouserc.*, .lintstagedrc*, .markdownlint*, .mocha*, .node-version, .nodemon*, .npm*, .nvmrc, .pm2*, .pnp.*, .pnpm*, .prettier*, .releaserc*, .sentry*, .stackblitz*, .styleci*, .stylelint*, .tazerc*, .textlint*, .tool-versions, .travis*, .versionrc*, .vscode*, .watchman*, .xo-config*, .yamllint*, .yarnrc*, Procfile, api-extractor.json, apollo.config.*, appveyor*, ava.config.*, azure-pipelines*, bower.json, build.config.*, commitlint*, crowdin*, cypress.*, dangerfile*, dlint.json, dprint.json, firebase.json, grunt*, gulp*, histoire.config.*, jasmine.*, jenkins*, jest.config.*, jsconfig.*, karma*, lerna*, lighthouserc.*, lint-staged*, nest-cli.*, netlify*, nodemon*, nx.*, package-lock.json, package.nls*.json, phpcs.xml, playwright.config.*, pm2.*, pnpm*, prettier*, pullapprove*, puppeteer.config.*, pyrightconfig.json, release-tasks.sh, renovate*, rollup.config.*, stylelint*, tsconfig.*, tsdoc.*, tslint*, tsup.config.*, turbo*, typedoc*, unlighthouse*, vercel*, vetur.config.*, vitest.config.*, webpack*, workspace.json, xo.config.*, yarn*, babel.*, .babelrc, project.json",
"Cargo.toml": "Cargo.lock",
"README.md": "LICENSE, CHANGELOG.md, CODE_OF_CONDUCT.md, CONTRIBUTING.md"
}
},
"[rust]": {
"editor.defaultFormatter": "rust-lang.rust-analyzer"
},
"[toml]": {
"editor.defaultFormatter": "tamasfe.even-better-toml"
},
"rust-analyzer.linkedProjects": ["client-app/src-tauri/Cargo.toml"]
}
+32
View File
@@ -0,0 +1,32 @@
# Client App
AFFiNE App client powered by Tauri.
## Quick Start
Please follow the Tauri [getting started guide](https://tauri.app/v1/guides/getting-started/setup/) for environment setup.
After the environment is ready, start development build:
```sh
pnpm tauri dev
```
## Development
Currently client-app depends on a rapidly developing rust library "Octobase", we use git-submodule to link it currently.
We will provide its binary binding soon, to replace the git-submodule, before Octobase become opensource.
### Scripts
On this folder:
- `pnpm dev:app` will start a vite server
- `pnpm build:prerequisite` will link the Octobase and prepare affine dist html and tauri preload script, also will generate ts type from rs. You should run this before start your first development time.
On project root folder:
### Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="stylesheet" href="/src/style.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AFFiNE</title>
</head>
<body>
<div id="react-root" />
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+70
View File
@@ -0,0 +1,70 @@
{
"name": "@affine/client-app",
"private": true,
"version": "0.0.0",
"type": "module",
"license": "MPL-2.0",
"module": "true",
"scripts": {
"dev:app": "pnpm build:preload && cross-env NODE_ENV=development tauri dev",
"dev:prerequisite": "concurrently \"cd ../packages/data-center && pnpm dev\" \"cd ../packages/app && pnpm dev\"",
"build:prerequisite": "pnpm build:submodules && pnpm build:rs-types && pnpm build:affine && pnpm build:preload",
"build:rs-types": "zx scripts/generateTsTypingsFromJsonSchema.mjs",
"build:submodules": "zx scripts/buildSubModules.mjs",
"build:affine": "zx scripts/buildAffine.mjs",
"build:preload": "esbuild src/preload/index.ts --outdir=public/preload",
"build:app": "tauri build"
},
"dependencies": {
"@blocksuite/store": "^0.3.1",
"@emotion/react": "^11.10.5",
"@emotion/styled": "^11.10.5",
"@tauri-apps/api": "^1.2.0",
"json-schema-to-typescript": "^11.0.2",
"lib0": "^0.2.58",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-is": "^18.2.0",
"react-router": "^6.5.0",
"react-router-dom": "^6.5.0",
"y-protocols": "^1.0.5",
"yjs": "^13.5.43"
},
"devDependencies": {
"@tauri-apps/cli": "^1.2.3",
"@types/node": "^18.11.17",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.9",
"@typescript-eslint/eslint-plugin": "5.47.0",
"@typescript-eslint/parser": "5.47.0",
"concurrently": "^7.6.0",
"cross-env": "^7.0.3",
"esbuild": "^0.16.10",
"eslint": "8.30.0",
"eslint-config-prettier": "8.5.0",
"eslint-config-standard": "^17.0.0",
"eslint-config-standard-with-typescript": "24.0.0",
"eslint-import-resolver-alias": "1.1.2",
"eslint-import-resolver-typescript": "3.5.2",
"eslint-plugin-autofix": "1.1.0",
"eslint-plugin-html": "7.1.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-n": "^15.6.0",
"eslint-plugin-node": "11.1.0",
"eslint-plugin-prettier": "4.2.1",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-react": "7.31.11",
"eslint-plugin-react-hooks": "4.6.0",
"eslint-plugin-security": "1.5.0",
"eslint-plugin-security-node": "1.1.1",
"eslint-plugin-typescript-sort-keys": "2.1.0",
"eslint-plugin-unicorn": "45.0.2",
"eslint-plugin-unused-imports": "2.0.0",
"prettier": "2.8.1",
"rimraf": "^3.0.2",
"typescript": "^4.9.4",
"typesync": "^0.9.2",
"vite": "^4.0.2",
"zx": "^7.1.1"
}
}
+4041
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/restrict-template-expressions */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
const repoDirectory = path.join(__dirname, '..', '..');
const clientAppDirectory = path.join(__dirname, '..');
const publicDistributionDirectory = path.join(clientAppDirectory, 'public');
const affineSrcDirectory = path.join(repoDirectory, 'packages', 'app');
const affineSrcOutDirectory = path.join(affineSrcDirectory, 'out');
const publicAffineOutDirectory = path.join(
publicDistributionDirectory,
'affine-out'
);
/**
* Build affine dist html
*/
cd(repoDirectory);
await $`pnpm i -r`;
await $`pnpm build`;
cd(affineSrcDirectory);
$.env.BASE_PATH = '/affine-out';
await $`pnpm build`;
await $`pnpm export`;
await fs.remove(publicAffineOutDirectory);
await fs.move(affineSrcOutDirectory, publicAffineOutDirectory);
+18
View File
@@ -0,0 +1,18 @@
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/restrict-template-expressions */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
const repoDirectory = path.join(__dirname, '..');
const publicDistributionDirectory = path.join(repoDirectory, 'public');
const octoBaseBranchName = 'master';
/**
* 1. Until OctoBase become public, we link it using submodule too.
*/
cd(`${path.join(repoDirectory, 'src-OctoBase')}`);
await $`git checkout ${octoBaseBranchName}`;
await $`git submodule update --recursive && git submodule update --remote`;
await $`git pull origin ${octoBaseBranchName}`;
await $`git reset --hard origin/${octoBaseBranchName}`;
@@ -0,0 +1,53 @@
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/restrict-template-expressions */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import fs from 'fs';
import path from 'path';
// TODO: use https://github.com/quicktype/quicktype#installation instead
import { compileFromFile } from 'json-schema-to-typescript';
import { cd } from 'zx/core';
const projectRoot = path.join(__dirname, '..', '..');
const tsTypingsFolder = path.join(
projectRoot,
'packages/data-center/src/provider/tauri-ipc/ipc/types'
);
/**
* 1. generate JSONSchema using rs crate `schemars`, this happened on rs side script `src-tauri/examples/generate-jsonschema.rs`
*/
cd('./src-tauri');
try {
fs.mkdirSync(tsTypingsFolder);
} catch {}
await $`cargo run --example generate-jsonschema`;
/**
* 2. generate TS from JSON schema, this is efficient on NodeJS side.
*/
const fileNames = fs.readdirSync(tsTypingsFolder);
const jsonSchemaFilePaths = fileNames
.filter(fileName => fileName.endsWith('.json'))
.map(fileName => path.join(tsTypingsFolder, fileName));
await Promise.all(
jsonSchemaFilePaths.map(
async fileName =>
await compileFromFile(fileName).then(tsContent =>
fs.writeFileSync(fileName.replace('.json', '.ts'), tsContent)
)
)
);
/**
* 3. fix eslint error on generated ts files
*/
cd(path.join(projectRoot, 'packages/data-center'));
await $`eslint ${tsTypingsFolder} --ext ts --fix`;
/**
* 4. // TODO: parse all #[tauri::command] and generate ts method code
*/
+4
View File
@@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
+5781
View File
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
[package]
name = "AFFiNE"
version = "0.0.0"
description = "Development Tool for BlockSuite"
authors = ["you"]
license = ""
repository = ""
edition = "2021"
rust-version = "1.57"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "1.2", features = [] }
[dependencies]
bytes = "1.3.0"
ipc_types = { path = "./types" }
futures = "^0.3.25"
js-sys = "0.3.60"
jwst = { path = "../src-OctoBase/libs/jwst" }
jwst-storage = { path = "../src-OctoBase/libs/jwst-storage", features = [
"sqlite",
] }
cloud-database = { path = "../src-OctoBase/libs/cloud-database", features = [
"sqlite",
] }
project-root = "0.2.2"
schemars = "0.8.3"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
dotenvy = "0.15.6"
tauri = { version = "1.2", features = ["api-all", "devtools"] }
tokio = { version = "1.23.0", features = ["rt", "macros"] }
lib0 = "0.12.0"
moka = { version = "0.9.6", features = ["future"] }
yrs = { path = "../src-OctoBase/libs/vendors/y-crdt/yrs" }
y-sync = { path = "../src-OctoBase/libs/vendors/y-sync" }
[features]
# by default Tauri runs in production mode
# when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL
default = ["custom-protocol"]
# this feature is used used for production builds where `devPath` points to the filesystem
# DO NOT remove this
custom-protocol = ["tauri/custom-protocol"]
[profile.release.package.wry]
debug = true
debug-assertions = true
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,37 @@
use ipc_types::{
blob::IBlobParameters, document::IDocumentParameters, user::IUserParameters,
workspace::IWorkspaceParameters,
};
/**
* convert serde to jsonschema: https://imfeld.dev/writing/generating_typescript_types_from_rust
* with way to optimize
* convert jsonschema to ts: https://github.com/bcherny/json-schema-to-typescript
*/
use project_root::get_project_root;
use schemars::{schema_for, JsonSchema};
use std::{
fs::write,
path::{Path, PathBuf},
};
fn generate<T>(path: PathBuf)
where
T: ?Sized + JsonSchema, // Sized or ?Sized are both ok, click https://zhuanlan.zhihu.com/p/21820917 to learn why
{
let schema = schema_for!(T);
let output = serde_json::to_string_pretty(&schema).unwrap();
write(path, output).expect("can not write json-schema file")
}
fn main() {
let project_root = &get_project_root().unwrap();
let mono_repo_root = Path::join(project_root, "../..");
let data_center_ipc_type_folder = Path::join(
&mono_repo_root,
"packages/data-center/src/provider/tauri-ipc/ipc/types",
);
generate::<IDocumentParameters>(Path::join(&data_center_ipc_type_folder, "document.json"));
generate::<IWorkspaceParameters>(Path::join(&data_center_ipc_type_folder, "workspace.json"));
generate::<IBlobParameters>(Path::join(&data_center_ipc_type_folder, "blob.json"));
generate::<IUserParameters>(Path::join(&data_center_ipc_type_folder, "user.json"));
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 947 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

+1
View File
@@ -0,0 +1 @@
tab_spaces = 2
+25
View File
@@ -0,0 +1,25 @@
pub mod blob;
pub mod workspace;
pub mod document;
pub mod user;
use blob::*;
use workspace::*;
use document::*;
use user::*;
pub fn invoke_handler() -> impl Fn(tauri::Invoke) + Send + Sync + 'static {
tauri::generate_handler![
update_y_document,
create_workspace,
update_workspace,
get_workspaces,
get_workspace,
create_user,
get_user,
create_doc,
get_doc,
put_blob,
get_blob
]
}
+68
View File
@@ -0,0 +1,68 @@
use bytes::Bytes;
use futures::{
stream::{self},
StreamExt,
};
use ipc_types::blob::{GetBlob, PutBlob};
use jwst::BlobStorage;
use crate::state::AppState;
#[tauri::command]
pub async fn put_blob<'s>(
state: tauri::State<'s, AppState>,
parameters: PutBlob,
) -> Result<String, String> {
let blob_storage = &state.0.lock().await.blob_storage;
if let Ok(path) = blob_storage
.put_blob(
// TODO: ask octobase to accept blob directly or wrap/await tauri command to create a real stream, so we don't need to construct stream manually
parameters.workspace_id,
stream::iter::<Vec<Bytes>>(vec![Bytes::from(parameters.blob)]),
)
.await
{
Ok(path)
} else {
Err("Failed to create".to_string())
}
}
#[tauri::command]
pub async fn get_blob<'s>(
state: tauri::State<'s, AppState>,
parameters: GetBlob,
) -> Result<Vec<u8>, String> {
let GetBlob { workspace_id, id } = parameters;
// TODO: check user permission? Or just assume there will only be one user
let blob_storage = &state.0.lock().await.blob_storage;
if let Ok(mut file_stream) = blob_storage.get_blob(workspace_id.clone(), id.clone()).await {
// Read all of the chunks into a vector.
let mut stream_contents = Vec::new();
let mut error_message = "".to_string();
while let Some(chunk) = file_stream.next().await {
match chunk {
Ok(chunk_bytes) => stream_contents.extend_from_slice(&chunk_bytes),
Err(err) => {
error_message = format!(
"Failed to read blob file {}/{} from stream, error: {}",
workspace_id.clone().unwrap_or_default().to_string(),
id,
err
);
}
}
}
if error_message.len() > 0 {
return Err(error_message);
}
Ok(stream_contents)
} else {
Err(format!(
"Failed to read blob file {}/{} ",
workspace_id.unwrap_or_default().to_string(),
id
))
}
}
@@ -0,0 +1,81 @@
use ipc_types::document::{
CreateDocumentParameter, GetDocumentParameter, GetDocumentResponse, YDocumentUpdate,
};
use jwst::DocStorage;
use jwst::Workspace as OctoBaseWorkspace;
use lib0::any::Any;
use crate::state::AppState;
#[tauri::command]
/// get yDoc created by create_workspace, using same id
pub async fn create_doc<'s>(
state: tauri::State<'s, AppState>,
parameters: CreateDocumentParameter,
) -> Result<(), String> {
let workspace_doc = OctoBaseWorkspace::new(parameters.workspace_id.clone());
workspace_doc.with_trx(|mut workspace_doc_transaction| {
workspace_doc_transaction.set_metadata(
"name",
Any::String(parameters.workspace_name.clone().into_boxed_str()),
);
});
if let Err(error_message) = &state
.0
.lock()
.await
.doc_db
.write_doc(parameters.workspace_id.clone(), workspace_doc.doc())
.await
{
Err(format!(
"Failed to write_doc during create_workspace with error {}",
error_message.to_string()
))
} else {
Ok(())
}
}
#[tauri::command]
/// get yDoc created by create_workspace, using same id
pub async fn get_doc<'s>(
state: tauri::State<'s, AppState>,
parameters: GetDocumentParameter,
) -> Result<GetDocumentResponse, String> {
// TODO: check user permission
let state = &state.0.lock().await;
let doc_db = &state.doc_db;
if let Ok(all_updates_of_workspace) = doc_db.all(&parameters.id).await {
let all_updates = all_updates_of_workspace
.iter()
.map(|model| model.blob.clone())
.collect::<Vec<Vec<u8>>>();
Ok(GetDocumentResponse {
updates: all_updates,
})
} else {
Err(format!(
"Failed to get yDoc from workspace {}",
parameters.id
))
}
}
#[tauri::command]
pub async fn update_y_document<'s>(
state: tauri::State<'s, AppState>,
parameters: YDocumentUpdate,
) -> Result<bool, String> {
let state = &state.0.lock().await;
let doc_db = &state.doc_db;
doc_db
.replace_with(&parameters.id.clone(), parameters.update)
.await
.ok();
Ok(true)
}
+53
View File
@@ -0,0 +1,53 @@
use cloud_database::{CreateUser, User};
use ipc_types::{document::CreateDocumentParameter, user::GetUserParameters};
use crate::state::AppState;
use super::document::create_doc;
#[tauri::command]
/// create new user and a private workspace
pub async fn create_user<'s>(
state: tauri::State<'s, AppState>,
parameters: CreateUser,
) -> Result<User, String> {
let new_user_result = &state
.0
.lock()
.await
.metadata_db
.create_user(parameters.clone())
.await;
match new_user_result {
Ok(new_user_option) => match new_user_option {
Some((new_user, new_workspace)) => {
// a new private workspace is created, we have to create a yDoc for it
create_doc(
state,
CreateDocumentParameter {
workspace_id: new_workspace.id.clone(),
workspace_name: parameters.name.clone(),
},
)
.await
.ok();
Ok(new_user.clone())
}
None => Err("User creation failed".to_string()),
},
Err(error_message) => Err(error_message.to_string()),
}
}
#[tauri::command]
/// get the only one user in local sqlite
pub async fn get_user<'s>(
state: tauri::State<'s, AppState>,
parameters: GetUserParameters,
) -> Result<User, String> {
let db = &state.0.lock().await.metadata_db;
match db.get_user_by_email(&parameters.email).await.ok().unwrap() {
Some(user) => Ok(user),
None => Err("User not found".to_string()),
}
}
@@ -0,0 +1,102 @@
use ipc_types::{
document::CreateDocumentParameter,
workspace::{
CreateWorkspace, CreateWorkspaceResult, GetWorkspace, GetWorkspaceResult, GetWorkspaces,
GetWorkspacesResult, UpdateWorkspace,
},
};
use crate::state::AppState;
use super::document::create_doc;
#[tauri::command]
/// create yDoc for a workspace
pub async fn get_workspaces<'s>(
state: tauri::State<'s, AppState>,
parameters: GetWorkspaces,
) -> Result<GetWorkspacesResult, String> {
match &state
.0
.lock()
.await
.metadata_db
.get_user_workspaces(parameters.user_id.to_string())
.await
{
Ok(user_workspaces) => Ok(GetWorkspacesResult {
workspaces: user_workspaces.clone(),
}),
Err(error_message) => Err(error_message.to_string()),
}
}
#[tauri::command]
/// create yDoc for a workspace
pub async fn get_workspace<'s>(
state: tauri::State<'s, AppState>,
parameters: GetWorkspace,
) -> Result<GetWorkspaceResult, String> {
match &state
.0
.lock()
.await
.metadata_db
.get_workspace_by_id(parameters.id)
.await
{
Ok(user_workspace_option) => match user_workspace_option {
Some(user_workspace) => Ok(GetWorkspaceResult {
workspace: user_workspace.clone(),
}),
None => Err("Get workspace has no result".to_string()),
},
Err(error_message) => Err(error_message.to_string()),
}
}
#[tauri::command]
/// create yDoc for a workspace
pub async fn create_workspace<'s>(
state: tauri::State<'s, AppState>,
parameters: CreateWorkspace,
) -> Result<CreateWorkspaceResult, String> {
let new_workspace_result = &state
.0
.lock()
.await
.metadata_db
.create_normal_workspace(parameters.user_id.to_string())
.await;
match new_workspace_result {
Ok(new_workspace) => {
create_doc(
state,
CreateDocumentParameter {
workspace_id: new_workspace.id.clone(),
workspace_name: parameters.name.clone(),
},
)
.await
.ok();
Ok(CreateWorkspaceResult {
id: new_workspace.id.clone(),
name: parameters.name,
})
}
Err(error_message) => Err(format!(
"Failed to create_workspace with error {}",
error_message.to_string()
)),
}
}
#[tauri::command]
pub async fn update_workspace<'s>(
state: tauri::State<'s, AppState>,
parameters: UpdateWorkspace,
) -> Result<bool, String> {
// TODO: check user permission
// No thing to update now. The avatar is update in YDoc using websocket or yrs.update
Ok(true)
}
+46
View File
@@ -0,0 +1,46 @@
#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
mod commands;
mod state;
use dotenvy::dotenv;
use state::AppState;
use std::env;
use tauri::TitleBarStyle;
use tokio::sync::Mutex;
#[tokio::main]
async fn main() {
tauri::async_runtime::set(tokio::runtime::Handle::current());
dotenv().ok();
let preload = include_str!("../../public/preload/index.js");
let is_dev = env::var("NODE_ENV").unwrap_or_default() == "development";
let initial_path = if is_dev {
"index.html"
} else {
"affine-out/index.html"
};
tauri::Builder::default()
.manage(AppState(Mutex::new(
state::AppStateRaw::new().await.unwrap(),
)))
// manually create window here, instead of in the tauri.conf.json, to add `initialization_script` here
.setup(move |app| {
let _window =
tauri::WindowBuilder::new(app, "label", tauri::WindowUrl::App(initial_path.into()))
.title("AFFiNE")
.inner_size(1000.0, 800.0)
.title_bar_style(TitleBarStyle::Overlay)
.hidden_title(true)
.initialization_script(&preload)
.build()?;
#[cfg(debug_assertions)]
_window.open_devtools();
Ok(())
})
.invoke_handler(commands::invoke_handler())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+48
View File
@@ -0,0 +1,48 @@
use cloud_database::SqliteDBContext;
use jwst::Workspace;
use jwst_storage::{BlobAutoStorage, DocAutoStorage};
use std::{fs, path::Path};
use tauri::api::path::document_dir;
use tokio::sync::Mutex;
pub struct AppStateRaw {
pub doc_db: DocAutoStorage,
pub blob_storage: BlobAutoStorage,
pub metadata_db: SqliteDBContext,
}
impl AppStateRaw {
pub async fn new() -> Option<AppStateRaw> {
let affine_document_path = Path::new(&document_dir()?.into_os_string()).join("affine");
let metadata_db_env = format!(
"sqlite://{}?mode=rwc",
affine_document_path
.join("metadata")
.with_extension("db")
.display()
);
let blob_db_env = format!(
"sqlite://{}?mode=rwc",
affine_document_path
.join("blob")
.with_extension("db")
.display()
);
let doc_db_env = format!(
"sqlite://{}?mode=rwc",
affine_document_path
.join("doc")
.with_extension("db")
.display()
);
fs::create_dir_all(affine_document_path.clone()).unwrap();
Some(Self {
doc_db: DocAutoStorage::init_pool(&doc_db_env).await.unwrap(),
blob_storage: BlobAutoStorage::init_pool(&blob_db_env).await.unwrap(),
metadata_db: SqliteDBContext::new(metadata_db_env).await,
})
}
}
pub struct AppState(pub Mutex<AppStateRaw>); // need pub, otherwise will be "field `0` of struct `types::state::AppState` is private"
+58
View File
@@ -0,0 +1,58 @@
{
"build": {
"beforeDevCommand": "pnpm dev:prerequisite",
"beforeBuildCommand": "pnpm build:preload && pnpm build:affine",
"devPath": "http://localhost:8080",
"distDir": "../public",
"withGlobalTauri": false
},
"package": {
"productName": "AFFiNE",
"version": "0.0.2"
},
"tauri": {
"allowlist": {
"all": true,
"fs": {
"all": true,
"scope": ["$RESOURCE", "$RESOURCE/*", "$APP/*"]
}
},
"bundle": {
"active": true,
"category": "DeveloperTool",
"copyright": "",
"deb": {
"depends": []
},
"externalBin": [],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"identifier": "com.affine.client",
"longDescription": "",
"macOS": {
"entitlements": null,
"exceptionDomain": "",
"frameworks": [],
"providerShortName": null,
"signingIdentity": null
},
"resources": [],
"shortDescription": "",
"targets": "all",
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": ""
}
},
"updater": {
"active": false
}
}
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "ipc_types"
version = "0.1.0"
[dependencies]
jwst-storage = { path = "../../src-OctoBase/libs/jwst-storage", features = [
"sqlite",
] }
cloud-database = { path = "../../src-OctoBase/libs/cloud-database", features = [
"sqlite",
] }
project-root = "0.2.2"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
schemars = "0.8.3"
+20
View File
@@ -0,0 +1,20 @@
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct PutBlob {
pub workspace_id: Option<String>,
pub blob: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct GetBlob {
pub workspace_id: Option<String>,
pub id: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub enum IBlobParameters {
Put(PutBlob),
Get(GetBlob),
}
@@ -0,0 +1,30 @@
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct YDocumentUpdate {
pub update: Vec<u8>,
pub id: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct GetDocumentParameter {
pub id: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct CreateDocumentParameter {
pub workspace_id: String,
pub workspace_name: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct GetDocumentResponse {
pub updates: Vec<Vec<u8>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub enum IDocumentParameters {
YDocumentUpdate(YDocumentUpdate),
CreateDocumentParameter(CreateDocumentParameter),
GetDocumentParameter(GetDocumentParameter),
GetDocumentResponse(GetDocumentResponse),
}
+10
View File
@@ -0,0 +1,10 @@
#[allow(unused_imports)]
extern crate serde;
extern crate schemars;
extern crate jwst_storage;
extern crate cloud_database;
pub mod blob;
pub mod document;
pub mod workspace;
pub mod user;
+15
View File
@@ -0,0 +1,15 @@
use cloud_database::{CreateUser, User};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct GetUserParameters {
pub email: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub enum IUserParameters {
CreateUser(CreateUser),
User(User),
GetUserParameters(GetUserParameters),
}
@@ -0,0 +1,55 @@
use cloud_database::{WorkspaceWithPermission, WorkspaceDetail};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct CreateWorkspace {
pub user_id: String,
/**
* only set name, avatar is update in datacenter to yDoc directly
*/
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct GetWorkspaces {
pub user_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct GetWorkspace {
pub id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct CreateWorkspaceResult {
pub id: String,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct GetWorkspacesResult {
pub workspaces: Vec<WorkspaceWithPermission>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct GetWorkspaceResult {
pub workspace: WorkspaceDetail,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct UpdateWorkspace {
pub id: i64,
pub public: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub enum IWorkspaceParameters {
CreateWorkspace(CreateWorkspace),
GetWorkspace(GetWorkspace),
GetWorkspaces(GetWorkspaces),
GetWorkspaceResult(GetWorkspaceResult),
GetWorkspacesResult(GetWorkspacesResult),
UpdateWorkspace(UpdateWorkspace),
CreateWorkspaceResult(CreateWorkspaceResult),
}
+5
View File
@@ -0,0 +1,5 @@
# Preload Scripts
Here are preload scripts (See [tauri&#39;s doc](https://tauri.app/v1/references/architecture/inter-process-communication/isolation)). This is simillar to Electron's [preload script](https://www.electronjs.org/docs/latest/tutorial/sandbox#preload-scripts).
We pass env variables to AFFiNE side from here.
+18
View File
@@ -0,0 +1,18 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// tauri preload script can't have `export {}`
// @ts-ignore 'index.ts' cannot be compiled under '--isolatedModules' because it is considered a global script file. Add an import, export, or an empty 'export {}' statement to make it a module.ts(1208)
window.__TAURI_ISOLATION_HOOK__ = payload => {
console.log('Tauri isolation hook', payload);
return payload;
};
/**
* Give AFFiNE app code some env to know it is inside a tauri app.
*/
function setEnvironmentVariables() {
window.CLIENT_APP = true;
}
setEnvironmentVariables();
+8
View File
@@ -0,0 +1,8 @@
declare global {
interface Window {
CLIENT_APP?: boolean;
__editoVersion?: string;
}
}
export {};
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"moduleResolution": "Node" /* can't use NodeNext, otherwise can't find styled-components and @emotion/styled 's type, because ts won't follow `type` field in @emotion/styled 's packagejson, will follow `main` instead */,
"strict": true,
"strictNullChecks": true /* Enable strict null checks. */,
"strictFunctionTypes": true /* Enable strict checking of function types. */,
"strictPropertyInitialization": true /* Enable strict checking of property initialization in classes. */,
"noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */,
"alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */,
"sourceMap": true,
"resolveJsonModule": true,
"jsx": "react-jsx" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */,
"allowJs": false /* Allow javascript files to be compiled. */,
"esModuleInterop": true,
"types": ["vite/client"],
"typeRoots": ["types"],
"noEmit": true,
"experimentalDecorators": true,
"isolatedModules": true,
"skipLibCheck": true,
"noImplicitReturns": true
},
"include": ["./src"],
"exclude": ["node_modules"]
}
+27
View File
@@ -0,0 +1,27 @@
import { defineConfig } from 'vite';
// https://vitejs.dev/config/
export default defineConfig({
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
// prevent vite from obscuring rust errors
clearScreen: false,
// tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
},
// to make use of `TAURI_DEBUG` and other env variables
// https://tauri.studio/v1/api/config#buildconfig.beforedevcommand
envPrefix: ['VITE_', 'TAURI_'],
build: {
// Tauri supports es2021
target: ['es2021', 'chrome100', 'safari13'],
// don't minify for debug builds
minify: !process.env.TAURI_DEBUG ? 'esbuild' : false,
// produce sourcemaps for debug builds
sourcemap: !!process.env.TAURI_DEBUG,
},
esbuild: {
jsxInject: `import React from 'react';`,
},
});
+5 -2
View File
@@ -6,10 +6,11 @@
"license": "MPL-2.0",
"type": "module",
"scripts": {
"dev": "pnpm --filter=!@affine/app build && pnpm --filter @affine/app dev",
"dev:ac": "pnpm --filter=!@affine/app build && NODE_API_SERVER=ac pnpm --filter @affine/app dev",
"dev": "cross-env NODE_ENV=development pnpm --filter=!@affine/app build && pnpm --filter @affine/app dev",
"dev:ac": "pnpm --filter=!@affine/app build && cross-env NODE_API_SERVER=ac pnpm --filter @affine/app dev",
"dev:local": "pnpm --filter=!@affine/app build && cross-env NODE_API_SERVER=local pnpm --filter @affine/app dev",
"build": " pnpm --filter=!@affine/app build && pnpm --filter!=@affine/datacenter -r build",
"build:client": " pnpm --filter=@affine/client-app build:app",
"build:storybook": " pnpm -r build-storybook",
"export": "pnpm --filter @affine/app export",
"start": "pnpm --filter @affine/app start",
@@ -32,6 +33,7 @@
"@types/node": "^18.11.17",
"@typescript-eslint/eslint-plugin": "^5.47.0",
"@typescript-eslint/parser": "^5.47.0",
"concurrently": "^7.6.0",
"cross-env": "^7.0.3",
"eslint": "^8.30.0",
"eslint-config-next": "12.3.1",
@@ -80,6 +82,7 @@
},
"pnpm": {
"patchedDependencies": {
"@tauri-apps/api@1.2.0": "patches/@tauri-apps__api@1.2.0.patch",
"next@13.1.0": "patches/next@13.1.0.patch"
}
}
@@ -43,6 +43,7 @@ export const EditorHeader = () => {
>
{title && (
<StyledTitle
data-tauri-drag-region
onMouseEnter={() => {
if (isTrash) return;
@@ -56,7 +56,11 @@ export const Header = ({
setShowWarning(false);
}}
/>
<StyledHeader hasWarning={showWarning} data-testid="editor-header-items">
<StyledHeader
hasWarning={showWarning}
data-testid="editor-header-items"
data-tauri-drag-region
>
{children}
<StyledHeaderRightSide>
{rightItems.map(itemName => {
+1 -1
View File
@@ -79,7 +79,7 @@ const AppDefender = ({ children }: PropsWithChildren) => {
const { synced } = useAppState();
useEffect(() => {
if (router.asPath === '/') {
if (['/index.html', '/'].includes(router.asPath)) {
router.replace('/workspace');
}
}, [router]);
@@ -32,7 +32,9 @@ export const AppStateProvider = ({
if (dataCenter.workspaces.length === 0) {
await createDefaultWorkspace(dataCenter);
}
setUser((await dataCenter.getUserInfo()) || null);
setUser(
(await dataCenter.getUserInfo(dataCenter.providers[0]?.id)) || null
);
setAppState({
dataCenter,
workspaceList: dataCenter.workspaces,
+1
View File
@@ -29,6 +29,7 @@
"dependencies": {
"@blocksuite/blocks": "0.4.0-20230210031655-264744e",
"@blocksuite/store": "0.4.0-20230210031655-264744e",
"@tauri-apps/api": "^1.2.0",
"debug": "^4.3.4",
"encoding": "^0.1.13",
"firebase": "^9.15.0",
+10 -1
View File
@@ -46,7 +46,16 @@ export class DataCenter {
};
};
// TODO: switch different provider
await dc.registerProvider(new LocalProvider(getInitParams()));
if (
typeof window !== 'undefined' &&
window.CLIENT_APP &&
typeof window.__TAURI_IPC__ === 'function'
) {
const { TauriIPCProvider } = await import('./provider/tauri-ipc');
await dc.registerProvider(new TauriIPCProvider(getInitParams()));
} else {
await dc.registerProvider(new LocalProvider(getInitParams()));
}
await dc.registerProvider(new AffineProvider(getInitParams()));
for (const provider of dc.providerMap.values()) {
+8
View File
@@ -0,0 +1,8 @@
declare global {
interface Window {
CLIENT_APP?: boolean;
__editoVersion?: string;
}
}
export {};
@@ -27,7 +27,7 @@ export enum WorkspaceType {
export enum PermissionType {
Read = 0,
Write = 1,
Admin = 2,
Admin = 10,
Owner = 99,
}
@@ -0,0 +1,75 @@
import { PutBlob, GetBlob } from '../ipc/types/blob';
import {
YDocumentUpdate,
GetDocumentParameter,
GetDocumentResponse,
} from '../ipc/types/document';
import { CreateUser, GetUserParameters, User } from '../ipc/types/user';
import {
CreateWorkspace,
CreateWorkspaceResult,
GetWorkspaces,
GetWorkspacesResult,
GetWorkspace,
GetWorkspaceResult,
} from '../ipc/types/workspace';
export const updateYDocument = async (parameters: YDocumentUpdate) =>
await true;
export const getYDocument = async (
parameters: GetDocumentParameter
): Promise<GetDocumentResponse> =>
await {
updates: [],
};
export const createWorkspace = async (
parameters: CreateWorkspace
): Promise<CreateWorkspaceResult> => await { id: 'xxx', name: 'xxx' };
export const getWorkspaces = async (
parameters: GetWorkspaces
): Promise<GetWorkspacesResult> =>
await {
workspaces: [],
};
export const getWorkspace = async (
parameters: GetWorkspace
): Promise<GetWorkspaceResult> =>
await {
workspace: {
created_at: 0,
id: '',
member_count: 1,
public: true,
type: 0,
},
};
export const putBlob = async (parameters: PutBlob): Promise<string> =>
await 'path/xxx';
export const getBlob = async (parameters: GetBlob): Promise<number[]> =>
await [];
/**
* This will create a private workspace too.
* @returns
*/
export const createUser = async (parameters: CreateUser): Promise<User> =>
await {
created_at: 0,
id: '1',
email: 'xxx@xxx.xxx',
name: 'xxx',
};
export const getUser = async (parameters: GetUserParameters): Promise<User> =>
await {
created_at: 0,
id: '1',
email: 'xxx@xxx.xxx',
name: 'xxx',
};
@@ -0,0 +1,54 @@
import { test, expect } from '@playwright/test';
import { WorkspaceUnitCollection } from '../../../workspace-unit-collection.js';
import { TauriIPCProvider } from '../index.js';
import { MessageCenter } from '../../../message/index.js';
import * as ipcMethods from './mock-apis.js';
import 'fake-indexeddb/auto';
test.describe.serial('tauri-ipc provider', async () => {
const workspaceMetaCollection = new WorkspaceUnitCollection();
const provider = new TauriIPCProvider({
workspaces: workspaceMetaCollection.createScope(),
messageCenter: new MessageCenter(),
});
provider.init(ipcMethods);
const workspaceName = 'workspace-test';
let workspaceId: string | undefined;
test('create workspace', async () => {
const workspaceUnit = await provider.createWorkspace({
name: workspaceName,
});
workspaceId = workspaceUnit?.id;
expect(workspaceMetaCollection.workspaces.length).toEqual(1);
expect(workspaceMetaCollection.workspaces[0].name).toEqual(workspaceName);
});
test('workspace list', async () => {
const workspacesMetaCollection1 = new WorkspaceUnitCollection();
const provider1 = new TauriIPCProvider({
workspaces: workspacesMetaCollection1.createScope(),
messageCenter: new MessageCenter(),
});
provider1.init(ipcMethods);
await provider1.getUserInfo();
await provider1.loadWorkspaces();
expect(workspacesMetaCollection1.workspaces.length).toEqual(0);
await provider1.createWorkspace({
name: workspaceName,
});
expect(workspacesMetaCollection1.workspaces[0].name).toEqual(workspaceName);
expect(workspacesMetaCollection1.workspaces[0].id).toEqual(workspaceId);
});
test('update workspace', async () => {
await provider.updateWorkspaceMeta(workspaceId!, {
name: '1111',
});
expect(workspaceMetaCollection.workspaces[0].name).toEqual('1111');
});
});
@@ -0,0 +1,70 @@
import { BlobSyncState } from '@blocksuite/store';
import * as ipcMethods from '../ipc/methods.js';
import { Signal } from '@blocksuite/store';
import type {
BlobProvider,
BlobSyncStateChangeEvent,
BlobId,
BlobURL,
} from '@blocksuite/store/dist/persistence/blob/types.js';
export class IPCBlobProvider implements BlobProvider {
#ipc = ipcMethods;
#workspace: string;
readonly signals = {
onBlobSyncStateChange: new Signal<BlobSyncStateChangeEvent>(),
};
private constructor(workspace: string) {
this.#workspace = workspace;
}
static async init(workspace: string): Promise<IPCBlobProvider> {
const provider = new IPCBlobProvider(workspace);
return provider;
}
get uploading() {
return false;
}
get blobs() {
// TODO: implement blob list in Octobase
return Promise.resolve([]) as Promise<string[]>;
}
async get(id: BlobId): Promise<BlobURL | null> {
const blobArray = await this.#ipc.getBlob({
id,
});
// Make a Blob from the bytes
const blob = new Blob([new Uint8Array(blobArray)], { type: 'image/bmp' });
this.signals.onBlobSyncStateChange.emit({
id,
state: BlobSyncState.Success,
});
return window.URL.createObjectURL(blob);
}
async set(blob: Blob): Promise<BlobId> {
// TODO: skip if already has
const blobID = await this.#ipc.putBlob({
blob: Array.from(new Uint8Array(await blob.arrayBuffer())),
});
this.signals.onBlobSyncStateChange.emit({
id: blobID,
state: BlobSyncState.Success,
});
return blobID;
}
async delete(id: BlobId): Promise<void> {
// TODO: implement blob delete in Octobase
}
async clear(): Promise<void> {
// TODO: implement blob clear in Octobase, use workspace id #workspace
}
}
@@ -0,0 +1,194 @@
import * as Y from 'yjs';
import assert from 'assert';
import { LocalProvider } from '../local/index.js';
import type { IPCMethodsType } from './ipc/methods.js';
import {
CreateWorkspaceInfoParams,
ProviderConstructorParams,
} from '../base.js';
import { Workspace as BlocksuiteWorkspace } from '@blocksuite/store';
import { IPCBlobProvider } from './blocksuite-provider/blob.js';
import type { WorkspaceUnit } from 'src/workspace-unit.js';
import { createWorkspaceUnit, loadWorkspaceUnit } from '../local/utils.js';
import { WorkspaceWithPermission } from './ipc/types/workspace.js';
import { applyUpdate } from '../../utils/index.js';
import { User } from 'src/types/index.js';
/**
* init - createUser - create first workspace and ydoc - loadWorkspace - return the first workspace - wrapWorkspace - #initDocFromIPC - applyUpdate - on('update') - updateYDocument
*
* (init - createUser - error) loadWorkspace - return the first workspace - wrapWorkspace - #initDocFromIPC - applyUpdate - on('update') - updateYDocument
*/
export class TauriIPCProvider extends LocalProvider {
public id = 'tauri-ipc';
static defaultUserEmail = 'xxx@xx.xx';
/**
* // TODO: We only have one user in this version of app client. But may support switch user later.
*/
#userID?: string;
#ipc: IPCMethodsType | undefined;
constructor(params: ProviderConstructorParams) {
super(params);
}
async init(ipc?: IPCMethodsType) {
if (ipc) {
this.#ipc = ipc;
} else {
this.#ipc = await import('./ipc/methods.js');
}
try {
const user = await this.#ipc?.getUser({
email: TauriIPCProvider.defaultUserEmail,
});
this.#userID = user.id;
} catch (error) {
// maybe user not existed, we create a default user if we don't have one.
try {
const user = await this.#ipc?.createUser({
email: TauriIPCProvider.defaultUserEmail,
name: 'xxx',
password: 'xxx',
avatar_url: '',
});
this.#userID = user.id;
} catch (error) {
// maybe user existed, which can be omited
console.error(error);
}
}
}
/**
* get auth user info
* @returns
*/
public async getUserInfo(): Promise<User | undefined> {
const user = await this.#ipc?.getUser({
email: TauriIPCProvider.defaultUserEmail,
});
if (user?.name !== undefined) {
return {
...user,
avatar: user?.avatar_url || '',
};
}
}
async #initDocFromIPC(
workspaceID: string,
blocksuiteWorkspace: BlocksuiteWorkspace
) {
this._logger(`Loading ${workspaceID}...`);
const result = await this.#ipc?.getYDocument({ id: workspaceID });
if (result) {
const updates = result.updates.map(
binaryUpdate => new Uint8Array(binaryUpdate)
);
const mergedUpdate = Y.mergeUpdates(updates);
await applyUpdate(blocksuiteWorkspace, mergedUpdate);
this._logger(`Loaded: ${workspaceID}`);
}
}
async #connectDocToIPC(
workspaceID: string,
blocksuiteWorkspace: BlocksuiteWorkspace
) {
this._logger(`Connecting yDoc for ${workspaceID}...`);
blocksuiteWorkspace.doc.on('update', async (update: Uint8Array) => {
try {
const binary = Y.encodeStateAsUpdate(blocksuiteWorkspace.doc);
const success = await this.#ipc?.updateYDocument({
update: Array.from(binary),
id: workspaceID,
});
if (!success) {
throw new Error(`YDoc update failed, id: ${workspaceID}`);
}
} catch (error) {
// TODO: write error log to disk, and add button to open them in settings panel
console.error("#yDocument.on('update'", error);
}
});
}
async clear() {
await super.clear();
}
override async warpWorkspace(blocksuiteWorkspace: BlocksuiteWorkspace) {
const { room } = blocksuiteWorkspace;
assert(room);
(await blocksuiteWorkspace.blobs)?.setProvider(
await IPCBlobProvider.init(room)
);
await this.#initDocFromIPC(room, blocksuiteWorkspace);
await this.#connectDocToIPC(room, blocksuiteWorkspace);
return blocksuiteWorkspace;
}
public override async createWorkspace(
meta: CreateWorkspaceInfoParams
): Promise<WorkspaceUnit | undefined> {
this._logger('Creating client app workspace');
assert(this.#ipc);
assert(this.#userID);
const { id } = await this.#ipc.createWorkspace({
name: meta.name,
// TODO: get userID here
user_id: this.#userID,
});
const workspaceUnit = await createWorkspaceUnit({
name: meta.name,
id,
published: false,
avatar: '',
owner: undefined,
syncMode: 'core',
memberCount: 1,
provider: this.id,
});
this._workspaces.add(workspaceUnit);
const doc = workspaceUnit?.blocksuiteWorkspace?.doc;
if (doc) {
const update = Y.encodeStateAsUpdate(doc);
const success = await this.#ipc?.updateYDocument({
update: Array.from(update),
id,
});
if (!success) {
throw new Error(`YDoc update failed, id: ${id}`);
}
}
return workspaceUnit;
}
override async loadWorkspaces(): Promise<WorkspaceUnit[]> {
assert(this.#ipc);
assert(this.#userID);
const { workspaces } = await this.#ipc.getWorkspaces({
user_id: this.#userID,
});
const workspaceUnits = await Promise.all(
workspaces.map((meta: WorkspaceWithPermission) => {
return loadWorkspaceUnit({
...meta,
memberCount: 1,
// TODO: load name here
name: '',
provider: this.id,
syncMode: 'all',
});
})
);
this._workspaces.add(workspaceUnits);
return workspaceUnits;
}
}
@@ -0,0 +1,78 @@
import { invoke } from '@tauri-apps/api';
import {
GetDocumentParameter,
GetDocumentResponse,
YDocumentUpdate,
} from './types/document';
import {
CreateWorkspace,
CreateWorkspaceResult,
GetWorkspace,
GetWorkspaceResult,
GetWorkspaces,
GetWorkspacesResult,
User,
} from './types/workspace';
import { GetBlob, PutBlob } from './types/blob';
import { CreateUser, GetUserParameters } from './types/user';
export interface IPCMethodsType {
updateYDocument: typeof updateYDocument;
getYDocument: typeof getYDocument;
createWorkspace: typeof createWorkspace;
getWorkspaces: typeof getWorkspaces;
getWorkspace: typeof getWorkspace;
putBlob: typeof putBlob;
getBlob: typeof getBlob;
createUser: typeof createUser;
getUser: typeof getUser;
}
export const updateYDocument = async (parameters: YDocumentUpdate) =>
await invoke<boolean>('update_y_document', {
parameters,
});
export const getYDocument = async (parameters: GetDocumentParameter) =>
await invoke<GetDocumentResponse>('get_doc', {
parameters,
});
export const createWorkspace = async (parameters: CreateWorkspace) =>
await invoke<CreateWorkspaceResult>('create_workspace', {
parameters,
});
export const getWorkspaces = async (parameters: GetWorkspaces) =>
await invoke<GetWorkspacesResult>('get_workspaces', {
parameters,
});
export const getWorkspace = async (parameters: GetWorkspace) =>
await invoke<GetWorkspaceResult>('get_workspace', {
parameters,
});
export const putBlob = async (parameters: PutBlob) =>
await invoke<string>('put_blob', {
parameters,
});
export const getBlob = async (parameters: GetBlob) =>
await invoke<number[]>('get_blob', {
parameters,
});
/**
* This will create a private workspace too.
* @returns
*/
export const createUser = async (parameters: CreateUser) =>
await invoke<User>('create_user', {
parameters,
});
export const getUser = async (parameters: GetUserParameters) =>
await invoke<User>('get_user', {
parameters,
});
@@ -0,0 +1,57 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "IBlobParameters",
"oneOf": [
{
"type": "object",
"required": ["Put"],
"properties": {
"Put": {
"$ref": "#/definitions/PutBlob"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": ["Get"],
"properties": {
"Get": {
"$ref": "#/definitions/GetBlob"
}
},
"additionalProperties": false
}
],
"definitions": {
"GetBlob": {
"type": "object",
"required": ["id"],
"properties": {
"id": {
"type": "string"
},
"workspace_id": {
"type": ["string", "null"]
}
}
},
"PutBlob": {
"type": "object",
"required": ["blob"],
"properties": {
"blob": {
"type": "array",
"items": {
"type": "integer",
"format": "uint8",
"minimum": 0.0
}
},
"workspace_id": {
"type": ["string", "null"]
}
}
}
}
}
@@ -0,0 +1,24 @@
/**
* This file was automatically generated by json-schema-to-typescript.
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
* and run json-schema-to-typescript to regenerate this file.
*/
export type IBlobParameters =
| {
Put: PutBlob;
}
| {
Get: GetBlob;
};
export interface PutBlob {
blob: number[];
workspace_id?: string | null;
[k: string]: unknown;
}
export interface GetBlob {
id: string;
workspace_id?: string | null;
[k: string]: unknown;
}
@@ -0,0 +1,103 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "IDocumentParameters",
"oneOf": [
{
"type": "object",
"required": ["YDocumentUpdate"],
"properties": {
"YDocumentUpdate": {
"$ref": "#/definitions/YDocumentUpdate"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": ["CreateDocumentParameter"],
"properties": {
"CreateDocumentParameter": {
"$ref": "#/definitions/CreateDocumentParameter"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": ["GetDocumentParameter"],
"properties": {
"GetDocumentParameter": {
"$ref": "#/definitions/GetDocumentParameter"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": ["GetDocumentResponse"],
"properties": {
"GetDocumentResponse": {
"$ref": "#/definitions/GetDocumentResponse"
}
},
"additionalProperties": false
}
],
"definitions": {
"CreateDocumentParameter": {
"type": "object",
"required": ["workspace_id", "workspace_name"],
"properties": {
"workspace_id": {
"type": "string"
},
"workspace_name": {
"type": "string"
}
}
},
"GetDocumentParameter": {
"type": "object",
"required": ["id"],
"properties": {
"id": {
"type": "string"
}
}
},
"GetDocumentResponse": {
"type": "object",
"required": ["updates"],
"properties": {
"updates": {
"type": "array",
"items": {
"type": "array",
"items": {
"type": "integer",
"format": "uint8",
"minimum": 0.0
}
}
}
}
},
"YDocumentUpdate": {
"type": "object",
"required": ["id", "update"],
"properties": {
"id": {
"type": "string"
},
"update": {
"type": "array",
"items": {
"type": "integer",
"format": "uint8",
"minimum": 0.0
}
}
}
}
}
}
@@ -0,0 +1,38 @@
/**
* This file was automatically generated by json-schema-to-typescript.
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
* and run json-schema-to-typescript to regenerate this file.
*/
export type IDocumentParameters =
| {
YDocumentUpdate: YDocumentUpdate;
}
| {
CreateDocumentParameter: CreateDocumentParameter;
}
| {
GetDocumentParameter: GetDocumentParameter;
}
| {
GetDocumentResponse: GetDocumentResponse;
};
export interface YDocumentUpdate {
id: string;
update: number[];
[k: string]: unknown;
}
export interface CreateDocumentParameter {
workspace_id: string;
workspace_name: string;
[k: string]: unknown;
}
export interface GetDocumentParameter {
id: string;
[k: string]: unknown;
}
export interface GetDocumentResponse {
updates: number[][];
[k: string]: unknown;
}
@@ -0,0 +1,87 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "IUserParameters",
"oneOf": [
{
"type": "object",
"required": ["CreateUser"],
"properties": {
"CreateUser": {
"$ref": "#/definitions/CreateUser"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": ["User"],
"properties": {
"User": {
"$ref": "#/definitions/User"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": ["GetUserParameters"],
"properties": {
"GetUserParameters": {
"$ref": "#/definitions/GetUserParameters"
}
},
"additionalProperties": false
}
],
"definitions": {
"CreateUser": {
"type": "object",
"required": ["email", "name", "password"],
"properties": {
"avatar_url": {
"type": ["string", "null"]
},
"email": {
"type": "string"
},
"name": {
"type": "string"
},
"password": {
"type": "string"
}
}
},
"GetUserParameters": {
"type": "object",
"required": ["email"],
"properties": {
"email": {
"type": "string"
}
}
},
"User": {
"type": "object",
"required": ["created_at", "email", "id", "name"],
"properties": {
"avatar_url": {
"type": ["string", "null"]
},
"created_at": {
"type": "integer",
"format": "int64"
},
"email": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
}
}
}
}
}
@@ -0,0 +1,36 @@
/**
* This file was automatically generated by json-schema-to-typescript.
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
* and run json-schema-to-typescript to regenerate this file.
*/
export type IUserParameters =
| {
CreateUser: CreateUser;
}
| {
User: User;
}
| {
GetUserParameters: GetUserParameters;
};
export interface CreateUser {
avatar_url?: string | null;
email: string;
name: string;
password: string;
[k: string]: unknown;
}
export interface User {
avatar_url?: string | null;
created_at: number;
email: string;
id: string;
name: string;
[k: string]: unknown;
}
export interface GetUserParameters {
email: string;
[k: string]: unknown;
}
@@ -0,0 +1,240 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "IWorkspaceParameters",
"oneOf": [
{
"type": "object",
"required": ["CreateWorkspace"],
"properties": {
"CreateWorkspace": {
"$ref": "#/definitions/CreateWorkspace"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": ["GetWorkspace"],
"properties": {
"GetWorkspace": {
"$ref": "#/definitions/GetWorkspace"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": ["GetWorkspaces"],
"properties": {
"GetWorkspaces": {
"$ref": "#/definitions/GetWorkspaces"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": ["GetWorkspaceResult"],
"properties": {
"GetWorkspaceResult": {
"$ref": "#/definitions/GetWorkspaceResult"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": ["GetWorkspacesResult"],
"properties": {
"GetWorkspacesResult": {
"$ref": "#/definitions/GetWorkspacesResult"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": ["UpdateWorkspace"],
"properties": {
"UpdateWorkspace": {
"$ref": "#/definitions/UpdateWorkspace"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": ["CreateWorkspaceResult"],
"properties": {
"CreateWorkspaceResult": {
"$ref": "#/definitions/CreateWorkspaceResult"
}
},
"additionalProperties": false
}
],
"definitions": {
"CreateWorkspace": {
"type": "object",
"required": ["name", "user_id"],
"properties": {
"name": {
"description": "only set name, avatar is update in datacenter to yDoc directly",
"type": "string"
},
"user_id": {
"type": "string"
}
}
},
"CreateWorkspaceResult": {
"type": "object",
"required": ["id", "name"],
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"GetWorkspace": {
"type": "object",
"required": ["id"],
"properties": {
"id": {
"type": "string"
}
}
},
"GetWorkspaceResult": {
"type": "object",
"required": ["workspace"],
"properties": {
"workspace": {
"$ref": "#/definitions/WorkspaceDetail"
}
}
},
"GetWorkspaces": {
"type": "object",
"required": ["user_id"],
"properties": {
"user_id": {
"type": "string"
}
}
},
"GetWorkspacesResult": {
"type": "object",
"required": ["workspaces"],
"properties": {
"workspaces": {
"type": "array",
"items": {
"$ref": "#/definitions/WorkspaceWithPermission"
}
}
}
},
"PermissionType": {
"type": "integer",
"enum": [0, 1, 10, 99]
},
"UpdateWorkspace": {
"type": "object",
"required": ["id", "public"],
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"public": {
"type": "boolean"
}
}
},
"User": {
"type": "object",
"required": ["created_at", "email", "id", "name"],
"properties": {
"avatar_url": {
"type": ["string", "null"]
},
"created_at": {
"type": "integer",
"format": "int64"
},
"email": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"WorkspaceDetail": {
"type": "object",
"required": ["created_at", "id", "member_count", "public", "type"],
"properties": {
"created_at": {
"type": "integer",
"format": "int64"
},
"id": {
"type": "string"
},
"member_count": {
"type": "integer",
"format": "int64"
},
"owner": {
"anyOf": [
{
"$ref": "#/definitions/User"
},
{
"type": "null"
}
]
},
"public": {
"type": "boolean"
},
"type": {
"$ref": "#/definitions/WorkspaceType"
}
}
},
"WorkspaceType": {
"type": "integer",
"enum": [0, 1]
},
"WorkspaceWithPermission": {
"type": "object",
"required": ["created_at", "id", "permission", "public", "type"],
"properties": {
"created_at": {
"type": "integer",
"format": "int64"
},
"id": {
"type": "string"
},
"permission": {
"$ref": "#/definitions/PermissionType"
},
"public": {
"type": "boolean"
},
"type": {
"$ref": "#/definitions/WorkspaceType"
}
}
}
}
}
@@ -0,0 +1,90 @@
/**
* This file was automatically generated by json-schema-to-typescript.
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
* and run json-schema-to-typescript to regenerate this file.
*/
export type IWorkspaceParameters =
| {
CreateWorkspace: CreateWorkspace;
}
| {
GetWorkspace: GetWorkspace;
}
| {
GetWorkspaces: GetWorkspaces;
}
| {
GetWorkspaceResult: GetWorkspaceResult;
}
| {
GetWorkspacesResult: GetWorkspacesResult;
}
| {
UpdateWorkspace: UpdateWorkspace;
}
| {
CreateWorkspaceResult: CreateWorkspaceResult;
};
export type WorkspaceType = 0 | 1;
export type PermissionType = 0 | 1 | 10 | 99;
export interface CreateWorkspace {
/**
* only set name, avatar is update in datacenter to yDoc directly
*/
name: string;
user_id: string;
[k: string]: unknown;
}
export interface GetWorkspace {
id: string;
[k: string]: unknown;
}
export interface GetWorkspaces {
user_id: string;
[k: string]: unknown;
}
export interface GetWorkspaceResult {
workspace: WorkspaceDetail;
[k: string]: unknown;
}
export interface WorkspaceDetail {
created_at: number;
id: string;
member_count: number;
owner?: User | null;
public: boolean;
type: WorkspaceType;
[k: string]: unknown;
}
export interface User {
avatar_url?: string | null;
created_at: number;
email: string;
id: string;
name: string;
[k: string]: unknown;
}
export interface GetWorkspacesResult {
workspaces: WorkspaceWithPermission[];
[k: string]: unknown;
}
export interface WorkspaceWithPermission {
created_at: number;
id: string;
permission: PermissionType;
public: boolean;
type: WorkspaceType;
[k: string]: unknown;
}
export interface UpdateWorkspace {
id: number;
public: boolean;
[k: string]: unknown;
}
export interface CreateWorkspaceResult {
id: string;
name: string;
[k: string]: unknown;
}
+7
View File
@@ -11,6 +11,13 @@ import { getLogger } from '../logger';
// provider: string;
// };
declare global {
interface Window {
// eslint-disable-next-line @typescript-eslint/ban-types
__TAURI_IPC__: Function;
}
}
export type User = {
name: string;
id: string;
File diff suppressed because one or more lines are too long
+1845 -80
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -1,3 +1,5 @@
packages:
# all packages in direct subdirs of packages/
- 'packages/*'
- 'packages/*'
# app folder is on top level because of its importance
- 'client-app'