feat: add basic tauri client app

This commit is contained in:
Lin Onetwo
2023-01-04 17:16:26 +08:00
parent 87451a19bb
commit 64ca6a6b35
57 changed files with 12747 additions and 54 deletions
+38
View File
@@ -0,0 +1,38 @@
import { useNavigate } from 'react-router-dom';
import styled from '@emotion/styled';
import { routes } from '../pages/routes.js';
const Container = styled.nav`
height: var(--title-bar-height);
background: #329ea3;
user-select: none;
display: flex;
justify-content: flex-start;
align-items: center;
position: fixed;
top: 0;
left: 0;
right: 0;
`;
const RouteSelect = styled.select`
margin-left: 100px;
`;
export function TitleBar() {
const navigate = useNavigate();
return (
<Container data-tauri-drag-region>
<RouteSelect
onChange={event => {
navigate(event?.target?.value);
}}
>
{routes.map(route => (
<option key={route.path} value={route.path}>
{route.name}
</option>
))}
</RouteSelect>
</Container>
);
}
+54
View File
@@ -0,0 +1,54 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-empty-function */
import * as Y from 'yjs';
import { Observable } from 'lib0/observable';
import { DocProvider } from '@blocksuite/store';
import type { Awareness } from 'y-protocols/awareness';
import { invoke } from '@tauri-apps/api';
import { updateYDocument } from './methods';
export class TauriIPCProvider
extends Observable<string>
implements DocProvider
{
#yDocument: Y.Doc;
constructor(
room: string,
yDocument: Y.Doc,
options?: { awareness?: Awareness }
) {
super();
this.#yDocument = yDocument;
this.#yDocument.on(
'updateV2',
async (
update: Uint8Array,
origin: any,
_yDocument: Y.Doc,
_transaction: Y.Transaction
) => {
try {
// TODO: need handle potential data race when update is frequent?
// TODO: update seems too frequent upon each keydown, why no batching?
const success = await updateYDocument({
update: Array.from(update),
room,
});
} catch (error) {
// TODO: write error log to disk, and add button to open them in settings panel
console.error("#yDocument.on('updateV2'", error);
}
}
);
}
connect() {}
destroy() {}
disconnect() {
// do nothing
}
async clearData() {}
}
+24
View File
@@ -0,0 +1,24 @@
import { invoke } from '@tauri-apps/api';
import { YDocumentUpdate } from '../types/ipc/document';
import { CreateWorkspace } from '../types/ipc/workspace';
import { GetBlob, PutBlob } from '../types/ipc/blob';
export const updateYDocument = async (parameters: YDocumentUpdate) =>
await invoke<boolean>('update_y_document', {
parameters,
});
export const createWorkspace = async (parameters: CreateWorkspace) =>
await invoke<boolean>('create_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,
});
+16
View File
@@ -0,0 +1,16 @@
:root {
--title-bar-height: 30px;
}
body {
margin: 0;
margin-top: var(--title-bar-height);
height: calc(100vh - var(--title-bar-height));
width: 100%;
overflow: hidden;
}
#react-root {
height: 100%;
width: 100%;
}
+15
View File
@@ -0,0 +1,15 @@
import '@emotion/react';
import { StrictMode } from 'react';
import ReactDOM from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import { router } from './pages/routes.js';
import './main.css';
const root = document.querySelector('#react-root');
if (root !== null) {
ReactDOM.createRoot(root).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { redirect } from 'react-router-dom';
import { useEffect } from 'react';
export function AffineBasicPage() {
useEffect(() => {
location.href = '/affine-out/index.html';
redirect('/affine-out/index.html');
}, []);
return null;
}
+3
View File
@@ -0,0 +1,3 @@
export function LandingPage() {
return <div>TBD</div>;
}
+11
View File
@@ -0,0 +1,11 @@
import { Outlet } from 'react-router-dom';
import { TitleBar } from '../components/TitleBar.js';
export function RootLayout() {
return (
<>
<TitleBar />
<Outlet />
</>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { createBrowserRouter } from 'react-router-dom';
import { AffineBasicPage } from './AFFiNE/index.js';
import { LandingPage } from './Landing/index.js';
import { RootLayout } from './Root.js';
export const routes = [
{
path: '/',
name: 'Landing Page',
element: <LandingPage />,
},
{
path: '/affine',
name: 'AFFiNE Basic',
element: <AffineBasicPage />,
},
];
export const router = createBrowserRouter([
{ path: '/', element: <RootLayout />, children: routes },
]);
+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();
+23
View File
@@ -0,0 +1,23 @@
/**
* Copied from packages/data-services/src/sdks/workspace.ts
* // TODO: after it published, use that package
*/
export enum WorkspaceType {
Private = 0,
Normal = 1,
}
export enum PermissionType {
Read = 0,
Write = 1,
Admin = 2,
Owner = 3,
}
export interface Workspace {
avatar: string;
id: number;
create_at: number;
name: string;
permission_type: PermissionType;
public: boolean;
type: WorkspaceType;
}
+11
View File
@@ -0,0 +1,11 @@
// TODO: find official typings if available
type IsolationPayload = unknown;
declare global {
interface Window {
CLIENT_APP?: boolean;
__TAURI_ISOLATION_HOOK__: (payload: IsolationPayload) => IsolationPayload;
}
}
export {};
+61
View File
@@ -0,0 +1,61 @@
{
"$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", "workspace_id"],
"properties": {
"id": {
"type": "string"
},
"workspace_id": {
"type": "integer",
"format": "uint64",
"minimum": 0.0
}
}
},
"PutBlob": {
"type": "object",
"required": ["blob", "workspace_id"],
"properties": {
"blob": {
"type": "array",
"items": {
"type": "integer",
"format": "uint8",
"minimum": 0.0
}
},
"workspace_id": {
"type": "integer",
"format": "uint64",
"minimum": 0.0
}
}
}
}
}
+25
View File
@@ -0,0 +1,25 @@
/* tslint:disable */
/**
* 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 {
[k: string]: unknown;
blob: number[];
workspace_id: number;
}
export interface GetBlob {
[k: string]: unknown;
id: string;
workspace_id: number;
}
+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "YDocumentUpdate",
"type": "object",
"required": ["room", "update"],
"properties": {
"room": {
"type": "string"
},
"update": {
"type": "array",
"items": {
"type": "integer",
"format": "uint8",
"minimum": 0.0
}
}
}
}
+12
View File
@@ -0,0 +1,12 @@
/* tslint:disable */
/**
* 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 interface YDocumentUpdate {
[k: string]: unknown;
room: string;
update: number[];
}
+18
View File
@@ -0,0 +1,18 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "CreateWorkspace",
"type": "object",
"required": ["avatar", "id", "name"],
"properties": {
"avatar": {
"type": "string"
},
"id": {
"type": "integer",
"format": "int64"
},
"name": {
"type": "string"
}
}
}
+13
View File
@@ -0,0 +1,13 @@
/* tslint:disable */
/**
* 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 interface CreateWorkspace {
[k: string]: unknown;
avatar: string;
id: number;
name: string;
}
+38
View File
@@ -0,0 +1,38 @@
const DefaultHeadImgColors = [
['#C6F2F3', '#0C6066'],
['#FFF5AB', '#896406'],
['#FFCCA7', '#8F4500'],
['#FFCECE', '#AF1212'],
['#E3DEFF', '#511AAB'],
];
// TODO: move this back to AFFiNE repo
export async function createDefaultUserAvatar(
workspaceName: string
): Promise<Blob> {
const canvas = document.createElement('canvas');
canvas.height = 100;
canvas.width = 100;
const context = canvas.getContext('2d');
if (context === null) {
throw new Error('Failed to create avatar canvas');
}
const randomNumber = Math.floor(Math.random() * 5);
const randomColor = DefaultHeadImgColors[randomNumber];
context.fillStyle = randomColor[0];
context.fillRect(0, 0, 100, 100);
context.font = "600 50px 'PingFang SC', 'Microsoft Yahei'";
context.fillStyle = randomColor[1];
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillText(workspaceName[0], 50, 50);
return await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(blob => {
if (blob === null) {
reject(new Error('Failed to convert avatar canvas to blob'));
} else {
resolve(blob);
}
}, 'image/png');
});
}