mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-05 19:40:30 +08:00
refactor(infra): directory structure (#4615)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
tmp
|
||||
@@ -0,0 +1,131 @@
|
||||
import path from 'node:path';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
|
||||
import { removeWithRetry } from '@affine-test/kit/utils/utils';
|
||||
import { v4 } from 'uuid';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
const tmpDir = path.join(__dirname, 'tmp');
|
||||
const appDataPath = path.join(tmpDir, 'app-data');
|
||||
|
||||
vi.doMock('../../main-rpc', () => ({
|
||||
mainRPC: {
|
||||
getPath: async () => appDataPath,
|
||||
},
|
||||
}));
|
||||
|
||||
const constructorStub = vi.fn();
|
||||
const destroyStub = vi.fn();
|
||||
destroyStub.mockReturnValue(Promise.resolve());
|
||||
|
||||
function existProcess() {
|
||||
process.emit('beforeExit', 0);
|
||||
}
|
||||
|
||||
vi.doMock('../secondary-db', () => {
|
||||
return {
|
||||
SecondaryWorkspaceSQLiteDB: class {
|
||||
constructor(...args: any[]) {
|
||||
constructorStub(...args);
|
||||
}
|
||||
|
||||
connectIfNeeded = () => Promise.resolve();
|
||||
|
||||
pull = () => Promise.resolve();
|
||||
|
||||
destroy = destroyStub;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
existProcess();
|
||||
await removeWithRetry(tmpDir);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('can get a valid WorkspaceSQLiteDB', async () => {
|
||||
const { ensureSQLiteDB } = await import('../ensure-db');
|
||||
const workspaceId = v4();
|
||||
const db0 = await ensureSQLiteDB(workspaceId);
|
||||
expect(db0).toBeDefined();
|
||||
expect(db0.workspaceId).toBe(workspaceId);
|
||||
|
||||
const db1 = await ensureSQLiteDB(v4());
|
||||
expect(db1).not.toBe(db0);
|
||||
expect(db1.workspaceId).not.toBe(db0.workspaceId);
|
||||
|
||||
// ensure that the db is cached
|
||||
expect(await ensureSQLiteDB(workspaceId)).toBe(db0);
|
||||
});
|
||||
|
||||
test('db should be destroyed when app quits', async () => {
|
||||
const { ensureSQLiteDB } = await import('../ensure-db');
|
||||
const workspaceId = v4();
|
||||
const db0 = await ensureSQLiteDB(workspaceId);
|
||||
const db1 = await ensureSQLiteDB(v4());
|
||||
|
||||
expect(db0.db).not.toBeNull();
|
||||
expect(db1.db).not.toBeNull();
|
||||
|
||||
existProcess();
|
||||
|
||||
// wait the async `db.destroy()` to be called
|
||||
await setTimeout(100);
|
||||
|
||||
expect(db0.db).toBeNull();
|
||||
expect(db1.db).toBeNull();
|
||||
});
|
||||
|
||||
test('db should be removed in db$Map after destroyed', async () => {
|
||||
const { ensureSQLiteDB, db$Map } = await import('../ensure-db');
|
||||
const workspaceId = v4();
|
||||
const db = await ensureSQLiteDB(workspaceId);
|
||||
await db.destroy();
|
||||
await setTimeout(100);
|
||||
expect(db$Map.has(workspaceId)).toBe(false);
|
||||
});
|
||||
|
||||
// we have removed secondary db feature
|
||||
test.skip('if db has a secondary db path, we should also poll that', async () => {
|
||||
const { ensureSQLiteDB } = await import('../ensure-db');
|
||||
const { storeWorkspaceMeta } = await import('../../workspace');
|
||||
const workspaceId = v4();
|
||||
await storeWorkspaceMeta(workspaceId, {
|
||||
secondaryDBPath: path.join(tmpDir, 'secondary.db'),
|
||||
});
|
||||
|
||||
const db = await ensureSQLiteDB(workspaceId);
|
||||
|
||||
await setTimeout(10);
|
||||
|
||||
expect(constructorStub).toBeCalledTimes(1);
|
||||
expect(constructorStub).toBeCalledWith(path.join(tmpDir, 'secondary.db'), db);
|
||||
|
||||
// if secondary meta is changed
|
||||
await storeWorkspaceMeta(workspaceId, {
|
||||
secondaryDBPath: path.join(tmpDir, 'secondary2.db'),
|
||||
});
|
||||
|
||||
// wait the async `db.destroy()` to be called
|
||||
await setTimeout(100);
|
||||
expect(constructorStub).toBeCalledTimes(2);
|
||||
expect(destroyStub).toBeCalledTimes(1);
|
||||
|
||||
// if secondary meta is changed (but another workspace)
|
||||
await storeWorkspaceMeta(v4(), {
|
||||
secondaryDBPath: path.join(tmpDir, 'secondary3.db'),
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(1500);
|
||||
expect(constructorStub).toBeCalledTimes(2);
|
||||
expect(destroyStub).toBeCalledTimes(1);
|
||||
|
||||
// if primary is destroyed, secondary should also be destroyed
|
||||
await db.destroy();
|
||||
await setTimeout(100);
|
||||
expect(destroyStub).toBeCalledTimes(2);
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { SqliteConnection } from '@affine/native';
|
||||
import { removeWithRetry } from '@affine-test/kit/utils/utils';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { applyUpdate, Doc as YDoc } from 'yjs';
|
||||
|
||||
import { copyToTemp, migrateToSubdocAndReplaceDatabase } from '../migration';
|
||||
|
||||
const tmpDir = path.join(__dirname, 'tmp');
|
||||
const testDBFilePath = path.resolve(__dirname, 'old-db.affine');
|
||||
|
||||
const appDataPath = path.join(tmpDir, 'app-data');
|
||||
|
||||
vi.mock('../../main-rpc', () => ({
|
||||
mainRPC: {
|
||||
getPath: async () => appDataPath,
|
||||
},
|
||||
}));
|
||||
|
||||
afterEach(async () => {
|
||||
await removeWithRetry(tmpDir);
|
||||
});
|
||||
|
||||
describe('migrateToSubdocAndReplaceDatabase', () => {
|
||||
it('should migrate and replace the database', async () => {
|
||||
const copiedDbFilePath = await copyToTemp(testDBFilePath);
|
||||
await migrateToSubdocAndReplaceDatabase(copiedDbFilePath);
|
||||
|
||||
const db = new SqliteConnection(copiedDbFilePath);
|
||||
await db.connect();
|
||||
|
||||
// check if db has two rows, one for root doc and one for subdoc
|
||||
const rows = await db.getAllUpdates();
|
||||
expect(rows.length).toBe(2);
|
||||
|
||||
const rootUpdate = rows.find(row => row.docId === undefined)!.data;
|
||||
const subdocUpdate = rows.find(row => row.docId !== undefined)!.data;
|
||||
|
||||
expect(rootUpdate).toBeDefined();
|
||||
expect(subdocUpdate).toBeDefined();
|
||||
|
||||
// apply updates
|
||||
const rootDoc = new YDoc();
|
||||
applyUpdate(rootDoc, rootUpdate);
|
||||
|
||||
// check if root doc has one subdoc
|
||||
expect(rootDoc.subdocs.size).toBe(1);
|
||||
|
||||
// populates subdoc
|
||||
applyUpdate(rootDoc.subdocs.values().next().value, subdocUpdate);
|
||||
|
||||
// check if root doc's meta is correct
|
||||
const meta = rootDoc.getMap('meta').toJSON();
|
||||
expect(meta.workspaceVersion).toBe(1);
|
||||
expect(meta.name).toBe('hiw');
|
||||
expect(meta.pages.length).toBe(1);
|
||||
const pageMeta = meta.pages[0];
|
||||
expect(pageMeta.title).toBe('Welcome to AFFiNEd');
|
||||
|
||||
// get the subdoc through id
|
||||
const subDoc = rootDoc.getMap('spaces').get(pageMeta.id) as YDoc;
|
||||
expect(subDoc).toEqual(rootDoc.subdocs.values().next().value);
|
||||
|
||||
await db.close();
|
||||
});
|
||||
});
|
||||
Binary file not shown.
@@ -0,0 +1,142 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { removeWithRetry } from '@affine-test/kit/utils/utils';
|
||||
import fs from 'fs-extra';
|
||||
import { v4 } from 'uuid';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { Doc as YDoc, encodeStateAsUpdate } from 'yjs';
|
||||
|
||||
import { dbSubjects } from '../subjects';
|
||||
|
||||
const tmpDir = path.join(__dirname, 'tmp');
|
||||
const appDataPath = path.join(tmpDir, 'app-data');
|
||||
|
||||
vi.doMock('../../main-rpc', () => ({
|
||||
mainRPC: {
|
||||
getPath: async () => appDataPath,
|
||||
},
|
||||
}));
|
||||
|
||||
afterEach(async () => {
|
||||
await removeWithRetry(tmpDir);
|
||||
});
|
||||
|
||||
let testYDoc: YDoc;
|
||||
let testYSubDoc: YDoc;
|
||||
|
||||
function getTestUpdates() {
|
||||
testYDoc = new YDoc();
|
||||
const yText = testYDoc.getText('test');
|
||||
yText.insert(0, 'hello');
|
||||
|
||||
testYSubDoc = new YDoc();
|
||||
testYDoc.getMap('subdocs').set('test-subdoc', testYSubDoc);
|
||||
|
||||
const updates = encodeStateAsUpdate(testYDoc);
|
||||
|
||||
return updates;
|
||||
}
|
||||
|
||||
function getTestSubDocUpdates() {
|
||||
const yText = testYSubDoc.getText('test');
|
||||
yText.insert(0, 'hello');
|
||||
|
||||
const updates = encodeStateAsUpdate(testYSubDoc);
|
||||
|
||||
return updates;
|
||||
}
|
||||
|
||||
test('can create new db file if not exists', async () => {
|
||||
const { openWorkspaceDatabase } = await import('../workspace-db-adapter');
|
||||
const workspaceId = v4();
|
||||
const db = await openWorkspaceDatabase(workspaceId);
|
||||
const dbPath = path.join(
|
||||
appDataPath,
|
||||
`workspaces/${workspaceId}`,
|
||||
`storage.db`
|
||||
);
|
||||
expect(await fs.exists(dbPath)).toBe(true);
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
test('on applyUpdate (from self), will not trigger update', async () => {
|
||||
const { openWorkspaceDatabase } = await import('../workspace-db-adapter');
|
||||
const workspaceId = v4();
|
||||
const onUpdate = vi.fn();
|
||||
|
||||
const db = await openWorkspaceDatabase(workspaceId);
|
||||
db.update$.subscribe(onUpdate);
|
||||
db.applyUpdate(getTestUpdates(), 'self');
|
||||
expect(onUpdate).not.toHaveBeenCalled();
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
test('on applyUpdate (from renderer), will trigger update', async () => {
|
||||
const { openWorkspaceDatabase } = await import('../workspace-db-adapter');
|
||||
const workspaceId = v4();
|
||||
const onUpdate = vi.fn();
|
||||
const onExternalUpdate = vi.fn();
|
||||
|
||||
const db = await openWorkspaceDatabase(workspaceId);
|
||||
db.update$.subscribe(onUpdate);
|
||||
const sub = dbSubjects.externalUpdate.subscribe(onExternalUpdate);
|
||||
db.applyUpdate(getTestUpdates(), 'renderer');
|
||||
expect(onUpdate).toHaveBeenCalled();
|
||||
sub.unsubscribe();
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
test('on applyUpdate (from renderer, subdoc), will trigger update', async () => {
|
||||
const { openWorkspaceDatabase } = await import('../workspace-db-adapter');
|
||||
const workspaceId = v4();
|
||||
const onUpdate = vi.fn();
|
||||
const insertUpdates = vi.fn();
|
||||
|
||||
const db = await openWorkspaceDatabase(workspaceId);
|
||||
db.applyUpdate(getTestUpdates(), 'renderer');
|
||||
|
||||
db.db!.insertUpdates = insertUpdates;
|
||||
db.update$.subscribe(onUpdate);
|
||||
|
||||
const subdocUpdates = getTestSubDocUpdates();
|
||||
db.applyUpdate(subdocUpdates, 'renderer', testYSubDoc.guid);
|
||||
|
||||
expect(onUpdate).toHaveBeenCalled();
|
||||
expect(insertUpdates).toHaveBeenCalledWith([
|
||||
{
|
||||
docId: testYSubDoc.guid,
|
||||
data: subdocUpdates,
|
||||
},
|
||||
]);
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
test('on applyUpdate (from external), will trigger update & send external update event', async () => {
|
||||
const { openWorkspaceDatabase } = await import('../workspace-db-adapter');
|
||||
const workspaceId = v4();
|
||||
const onUpdate = vi.fn();
|
||||
const onExternalUpdate = vi.fn();
|
||||
|
||||
const db = await openWorkspaceDatabase(workspaceId);
|
||||
db.update$.subscribe(onUpdate);
|
||||
const sub = dbSubjects.externalUpdate.subscribe(onExternalUpdate);
|
||||
db.applyUpdate(getTestUpdates(), 'external');
|
||||
expect(onUpdate).toHaveBeenCalled();
|
||||
expect(onExternalUpdate).toHaveBeenCalled();
|
||||
sub.unsubscribe();
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
test('on destroy, check if resources have been released', async () => {
|
||||
const { openWorkspaceDatabase } = await import('../workspace-db-adapter');
|
||||
const workspaceId = v4();
|
||||
const db = await openWorkspaceDatabase(workspaceId);
|
||||
const updateSub = {
|
||||
complete: vi.fn(),
|
||||
next: vi.fn(),
|
||||
};
|
||||
db.update$ = updateSub as any;
|
||||
await db.destroy();
|
||||
expect(db.db).toBe(null);
|
||||
expect(updateSub.complete).toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
type InsertRow,
|
||||
SqliteConnection,
|
||||
ValidationResult,
|
||||
} from '@affine/native';
|
||||
import { WorkspaceVersion } from '@toeverything/infra/blocksuite';
|
||||
|
||||
import { migrateToLatest } from '../db/migration';
|
||||
import { logger } from '../logger';
|
||||
|
||||
/**
|
||||
* A base class for SQLite DB adapter that provides basic methods around updates & blobs
|
||||
*/
|
||||
export abstract class BaseSQLiteAdapter {
|
||||
db: SqliteConnection | null = null;
|
||||
abstract role: string;
|
||||
|
||||
constructor(public readonly path: string) {}
|
||||
|
||||
async connectIfNeeded() {
|
||||
if (!this.db) {
|
||||
const validation = await SqliteConnection.validate(this.path);
|
||||
if (validation === ValidationResult.MissingVersionColumn) {
|
||||
await migrateToLatest(this.path, WorkspaceVersion.SubDoc);
|
||||
}
|
||||
this.db = new SqliteConnection(this.path);
|
||||
await this.db.connect();
|
||||
const maxVersion = await this.db.getMaxVersion();
|
||||
if (maxVersion !== WorkspaceVersion.Surface) {
|
||||
await migrateToLatest(this.path, WorkspaceVersion.Surface);
|
||||
}
|
||||
logger.info(`[SQLiteAdapter:${this.role}]`, 'connected:', this.path);
|
||||
}
|
||||
return this.db;
|
||||
}
|
||||
|
||||
async destroy() {
|
||||
const { db } = this;
|
||||
this.db = null;
|
||||
// log after close will sometimes crash the app when quitting
|
||||
logger.info(`[SQLiteAdapter:${this.role}]`, 'destroyed:', this.path);
|
||||
await db?.close();
|
||||
}
|
||||
|
||||
async addBlob(key: string, data: Uint8Array) {
|
||||
try {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return;
|
||||
}
|
||||
await this.db.addBlob(key, data);
|
||||
} catch (error) {
|
||||
logger.error('addBlob', error);
|
||||
}
|
||||
}
|
||||
|
||||
async getBlob(key: string) {
|
||||
try {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return null;
|
||||
}
|
||||
const blob = await this.db.getBlob(key);
|
||||
return blob?.data ?? null;
|
||||
} catch (error) {
|
||||
logger.error('getBlob', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteBlob(key: string) {
|
||||
try {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return;
|
||||
}
|
||||
await this.db.deleteBlob(key);
|
||||
} catch (error) {
|
||||
logger.error(`${this.path} delete blob failed`, error);
|
||||
}
|
||||
}
|
||||
|
||||
async getBlobKeys() {
|
||||
try {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return [];
|
||||
}
|
||||
return await this.db.getBlobKeys();
|
||||
} catch (error) {
|
||||
logger.error(`getBlobKeys failed`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getUpdates(docId?: string) {
|
||||
try {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return [];
|
||||
}
|
||||
return await this.db.getUpdates(docId);
|
||||
} catch (error) {
|
||||
logger.error('getUpdates', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getAllUpdates() {
|
||||
try {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return [];
|
||||
}
|
||||
return await this.db.getAllUpdates();
|
||||
} catch (error) {
|
||||
logger.error('getAllUpdates', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// add a single update to SQLite
|
||||
async addUpdateToSQLite(updates: InsertRow[]) {
|
||||
// batch write instead write per key stroke?
|
||||
try {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return;
|
||||
}
|
||||
const start = performance.now();
|
||||
await this.db.insertUpdates(updates);
|
||||
logger.debug(
|
||||
`[SQLiteAdapter][${this.role}] addUpdateToSQLite`,
|
||||
'length:',
|
||||
updates.length,
|
||||
'docids',
|
||||
updates.map(u => u.docId),
|
||||
performance.now() - start,
|
||||
'ms'
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('addUpdateToSQLite', this.path, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { Subject } from 'rxjs';
|
||||
import { Observable } from 'rxjs';
|
||||
import {
|
||||
concat,
|
||||
defer,
|
||||
from,
|
||||
fromEvent,
|
||||
interval,
|
||||
lastValueFrom,
|
||||
merge,
|
||||
} from 'rxjs';
|
||||
import {
|
||||
concatMap,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
ignoreElements,
|
||||
last,
|
||||
map,
|
||||
shareReplay,
|
||||
startWith,
|
||||
switchMap,
|
||||
take,
|
||||
takeUntil,
|
||||
tap,
|
||||
} from 'rxjs/operators';
|
||||
|
||||
import { logger } from '../logger';
|
||||
import { getWorkspaceMeta } from '../workspace/meta';
|
||||
import { workspaceSubjects } from '../workspace/subjects';
|
||||
import { SecondaryWorkspaceSQLiteDB } from './secondary-db';
|
||||
import type { WorkspaceSQLiteDB } from './workspace-db-adapter';
|
||||
import { openWorkspaceDatabase } from './workspace-db-adapter';
|
||||
|
||||
// export for testing
|
||||
export const db$Map = new Map<string, Observable<WorkspaceSQLiteDB>>();
|
||||
|
||||
// use defer to prevent `app` is undefined while running tests
|
||||
const beforeQuit$ = defer(() => fromEvent(process, 'beforeExit'));
|
||||
|
||||
// return a stream that emit a single event when the subject completes
|
||||
function completed<T>(subject: Subject<T>) {
|
||||
return new Observable(subscriber => {
|
||||
const sub = subject.subscribe({
|
||||
complete: () => {
|
||||
subscriber.next();
|
||||
subscriber.complete();
|
||||
},
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
});
|
||||
}
|
||||
|
||||
function getWorkspaceDB$(id: string) {
|
||||
if (!db$Map.has(id)) {
|
||||
db$Map.set(
|
||||
id,
|
||||
from(openWorkspaceDatabase(id)).pipe(
|
||||
tap({
|
||||
next: db => {
|
||||
logger.info(
|
||||
'[ensureSQLiteDB] db connection established',
|
||||
db.workspaceId
|
||||
);
|
||||
},
|
||||
}),
|
||||
switchMap(db =>
|
||||
// takeUntil the polling stream, and then destroy the db
|
||||
concat(
|
||||
startPollingSecondaryDB(db).pipe(
|
||||
ignoreElements(),
|
||||
startWith(db),
|
||||
takeUntil(merge(beforeQuit$, completed(db.update$))),
|
||||
last(),
|
||||
tap({
|
||||
next() {
|
||||
logger.info(
|
||||
'[ensureSQLiteDB] polling secondary db complete',
|
||||
db.workspaceId
|
||||
);
|
||||
},
|
||||
})
|
||||
),
|
||||
defer(async () => {
|
||||
try {
|
||||
await db.destroy();
|
||||
db$Map.delete(id);
|
||||
return db;
|
||||
} catch (err) {
|
||||
logger.error('[ensureSQLiteDB] destroy db failed', err);
|
||||
throw err;
|
||||
}
|
||||
})
|
||||
).pipe(startWith(db))
|
||||
),
|
||||
shareReplay(1)
|
||||
)
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return db$Map.get(id)!;
|
||||
}
|
||||
|
||||
function startPollingSecondaryDB(db: WorkspaceSQLiteDB) {
|
||||
return merge(
|
||||
getWorkspaceMeta(db.workspaceId),
|
||||
workspaceSubjects.meta.pipe(
|
||||
map(({ meta }) => meta),
|
||||
filter(meta => meta.id === db.workspaceId)
|
||||
)
|
||||
).pipe(
|
||||
map(meta => meta?.secondaryDBPath),
|
||||
filter((p): p is string => !!p),
|
||||
distinctUntilChanged(),
|
||||
switchMap(path => {
|
||||
// on secondary db path change, destroy the old db and create a new one
|
||||
const secondaryDB = new SecondaryWorkspaceSQLiteDB(path, db);
|
||||
return new Observable<SecondaryWorkspaceSQLiteDB>(subscriber => {
|
||||
subscriber.next(secondaryDB);
|
||||
return () => {
|
||||
secondaryDB.destroy().catch(err => {
|
||||
subscriber.error(err);
|
||||
});
|
||||
};
|
||||
});
|
||||
}),
|
||||
switchMap(secondaryDB => {
|
||||
return interval(300000).pipe(
|
||||
startWith(0),
|
||||
concatMap(() => secondaryDB.pull()),
|
||||
tap({
|
||||
error: err => {
|
||||
logger.error(`[ensureSQLiteDB] polling secondary db error`, err);
|
||||
},
|
||||
complete: () => {
|
||||
logger.info('[ensureSQLiteDB] polling secondary db complete');
|
||||
},
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function ensureSQLiteDB(id: string) {
|
||||
return lastValueFrom(getWorkspaceDB$(id).pipe(take(1)));
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { mainRPC } from '../main-rpc';
|
||||
import type { MainEventRegister } from '../type';
|
||||
import { ensureSQLiteDB } from './ensure-db';
|
||||
import { dbSubjects } from './subjects';
|
||||
|
||||
export * from './ensure-db';
|
||||
export * from './subjects';
|
||||
|
||||
export const dbHandlers = {
|
||||
getDocAsUpdates: async (workspaceId: string, subdocId?: string) => {
|
||||
const workspaceDB = await ensureSQLiteDB(workspaceId);
|
||||
return workspaceDB.getDocAsUpdates(subdocId);
|
||||
},
|
||||
applyDocUpdate: async (
|
||||
workspaceId: string,
|
||||
update: Uint8Array,
|
||||
subdocId?: string
|
||||
) => {
|
||||
const workspaceDB = await ensureSQLiteDB(workspaceId);
|
||||
return workspaceDB.applyUpdate(update, 'renderer', subdocId);
|
||||
},
|
||||
addBlob: async (workspaceId: string, key: string, data: Uint8Array) => {
|
||||
const workspaceDB = await ensureSQLiteDB(workspaceId);
|
||||
return workspaceDB.addBlob(key, data);
|
||||
},
|
||||
getBlob: async (workspaceId: string, key: string) => {
|
||||
const workspaceDB = await ensureSQLiteDB(workspaceId);
|
||||
return workspaceDB.getBlob(key);
|
||||
},
|
||||
deleteBlob: async (workspaceId: string, key: string) => {
|
||||
const workspaceDB = await ensureSQLiteDB(workspaceId);
|
||||
return workspaceDB.deleteBlob(key);
|
||||
},
|
||||
getBlobKeys: async (workspaceId: string) => {
|
||||
const workspaceDB = await ensureSQLiteDB(workspaceId);
|
||||
return workspaceDB.getBlobKeys();
|
||||
},
|
||||
getDefaultStorageLocation: async () => {
|
||||
return await mainRPC.getPath('sessionData');
|
||||
},
|
||||
};
|
||||
|
||||
export const dbEvents = {
|
||||
onExternalUpdate: (
|
||||
fn: (update: {
|
||||
workspaceId: string;
|
||||
update: Uint8Array;
|
||||
docId?: string;
|
||||
}) => void
|
||||
) => {
|
||||
const sub = dbSubjects.externalUpdate.subscribe(fn);
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
};
|
||||
},
|
||||
} satisfies Record<string, MainEventRegister>;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { applyUpdate, Doc as YDoc, encodeStateAsUpdate, transact } from 'yjs';
|
||||
|
||||
export function mergeUpdate(updates: Uint8Array[]) {
|
||||
const yDoc = new YDoc();
|
||||
transact(yDoc, () => {
|
||||
for (const update of updates) {
|
||||
applyUpdate(yDoc, update);
|
||||
}
|
||||
});
|
||||
return encodeStateAsUpdate(yDoc);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { equal } from 'node:assert';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { SqliteConnection } from '@affine/native';
|
||||
import { __unstableSchemas, AffineSchemas } from '@blocksuite/blocks/models';
|
||||
import { Schema } from '@blocksuite/store';
|
||||
import {
|
||||
forceUpgradePages,
|
||||
migrateToSubdoc,
|
||||
WorkspaceVersion,
|
||||
} from '@toeverything/infra/blocksuite';
|
||||
import fs from 'fs-extra';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { applyUpdate, Doc as YDoc, encodeStateAsUpdate } from 'yjs';
|
||||
|
||||
import { mainRPC } from '../main-rpc';
|
||||
|
||||
export const migrateToSubdocAndReplaceDatabase = async (path: string) => {
|
||||
const db = new SqliteConnection(path);
|
||||
await db.connect();
|
||||
|
||||
const rows = await db.getAllUpdates();
|
||||
const originalDoc = new YDoc();
|
||||
|
||||
// 1. apply all updates to the root doc
|
||||
rows.forEach(row => {
|
||||
applyUpdate(originalDoc, row.data);
|
||||
});
|
||||
|
||||
// 2. migrate using migrateToSubdoc
|
||||
const migratedDoc = migrateToSubdoc(originalDoc);
|
||||
|
||||
// 3. replace db rows with the migrated doc
|
||||
await replaceRows(db, migratedDoc, true);
|
||||
|
||||
// 4. close db
|
||||
await db.close();
|
||||
};
|
||||
|
||||
// v1 v2 -> v3
|
||||
// v3 -> v4
|
||||
export const migrateToLatest = async (
|
||||
path: string,
|
||||
version: WorkspaceVersion
|
||||
) => {
|
||||
const connection = new SqliteConnection(path);
|
||||
await connection.connect();
|
||||
if (version === WorkspaceVersion.SubDoc) {
|
||||
await connection.initVersion();
|
||||
} else {
|
||||
await connection.setVersion(version);
|
||||
}
|
||||
const schema = new Schema();
|
||||
schema.register(AffineSchemas).register(__unstableSchemas);
|
||||
const rootDoc = new YDoc();
|
||||
const downloadBinary = async (doc: YDoc, isRoot: boolean): Promise<void> => {
|
||||
const update = (
|
||||
await connection.getUpdates(isRoot ? undefined : doc.guid)
|
||||
).map(update => update.data);
|
||||
// Buffer[] -> Uint8Array[]
|
||||
const data = update.map(update => new Uint8Array(update));
|
||||
data.forEach(data => {
|
||||
applyUpdate(doc, data);
|
||||
});
|
||||
// trigger data manually
|
||||
if (isRoot) {
|
||||
doc.getMap('meta');
|
||||
doc.getMap('spaces');
|
||||
} else {
|
||||
doc.getMap('blocks');
|
||||
}
|
||||
await Promise.all(
|
||||
[...doc.subdocs].map(subdoc => {
|
||||
return downloadBinary(subdoc, false);
|
||||
})
|
||||
);
|
||||
};
|
||||
await downloadBinary(rootDoc, true);
|
||||
const result = await forceUpgradePages({
|
||||
getSchema: () => schema,
|
||||
getCurrentRootDoc: () => Promise.resolve(rootDoc),
|
||||
});
|
||||
equal(result, true, 'migrateWorkspace should return boolean value');
|
||||
const uploadBinary = async (doc: YDoc, isRoot: boolean) => {
|
||||
await connection.replaceUpdates(doc.guid, [
|
||||
{ docId: isRoot ? undefined : doc.guid, data: encodeStateAsUpdate(doc) },
|
||||
]);
|
||||
// connection..applyUpdate(encodeStateAsUpdate(doc), 'self', doc.guid)
|
||||
await Promise.all(
|
||||
[...doc.subdocs].map(subdoc => {
|
||||
return uploadBinary(subdoc, false);
|
||||
})
|
||||
);
|
||||
};
|
||||
await uploadBinary(rootDoc, true);
|
||||
await connection.close();
|
||||
};
|
||||
|
||||
export const copyToTemp = async (path: string) => {
|
||||
const tmpDirPath = resolve(await mainRPC.getPath('sessionData'), 'tmp');
|
||||
const tmpFilePath = resolve(tmpDirPath, nanoid());
|
||||
await fs.ensureDir(tmpDirPath);
|
||||
await fs.copyFile(path, tmpFilePath);
|
||||
return tmpFilePath;
|
||||
};
|
||||
|
||||
async function replaceRows(
|
||||
db: SqliteConnection,
|
||||
doc: YDoc,
|
||||
isRoot: boolean
|
||||
): Promise<void> {
|
||||
const migratedUpdates = encodeStateAsUpdate(doc);
|
||||
const docId = isRoot ? undefined : doc.guid;
|
||||
const rows = [{ data: migratedUpdates, docId: docId }];
|
||||
await db.replaceUpdates(docId, rows);
|
||||
await Promise.all(
|
||||
[...doc.subdocs].map(async subdoc => {
|
||||
await replaceRows(db, subdoc, false);
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import assert from 'node:assert';
|
||||
|
||||
import type { InsertRow } from '@affine/native';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { applyUpdate, Doc as YDoc } from 'yjs';
|
||||
|
||||
import { logger } from '../logger';
|
||||
import type { YOrigin } from '../type';
|
||||
import { getWorkspaceMeta } from '../workspace/meta';
|
||||
import { BaseSQLiteAdapter } from './base-db-adapter';
|
||||
import type { WorkspaceSQLiteDB } from './workspace-db-adapter';
|
||||
|
||||
const FLUSH_WAIT_TIME = 5000;
|
||||
const FLUSH_MAX_WAIT_TIME = 10000;
|
||||
|
||||
// todo: trim db when it is too big
|
||||
export class SecondaryWorkspaceSQLiteDB extends BaseSQLiteAdapter {
|
||||
role = 'secondary';
|
||||
yDoc = new YDoc();
|
||||
firstConnected = false;
|
||||
destroyed = false;
|
||||
|
||||
updateQueue: { data: Uint8Array; docId?: string }[] = [];
|
||||
|
||||
unsubscribers = new Set<() => void>();
|
||||
|
||||
constructor(
|
||||
public override path: string,
|
||||
public upstream: WorkspaceSQLiteDB
|
||||
) {
|
||||
super(path);
|
||||
this.init();
|
||||
logger.debug('[SecondaryWorkspaceSQLiteDB] created', this.workspaceId);
|
||||
}
|
||||
|
||||
getDoc(docId?: string) {
|
||||
if (!docId) {
|
||||
return this.yDoc;
|
||||
}
|
||||
// this should be pretty fast and we don't need to cache it
|
||||
for (const subdoc of this.yDoc.subdocs) {
|
||||
if (subdoc.guid === docId) {
|
||||
return subdoc;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
override async destroy() {
|
||||
await this.flushUpdateQueue();
|
||||
this.unsubscribers.forEach(unsub => unsub());
|
||||
this.yDoc.destroy();
|
||||
await super.destroy();
|
||||
this.destroyed = true;
|
||||
}
|
||||
|
||||
get workspaceId() {
|
||||
return this.upstream.workspaceId;
|
||||
}
|
||||
|
||||
// do not update db immediately, instead, push to a queue
|
||||
// and flush the queue in a future time
|
||||
async addUpdateToUpdateQueue(update: InsertRow) {
|
||||
this.updateQueue.push(update);
|
||||
await this.debouncedFlush();
|
||||
}
|
||||
|
||||
async flushUpdateQueue() {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
logger.debug(
|
||||
'flushUpdateQueue',
|
||||
this.workspaceId,
|
||||
'queue',
|
||||
this.updateQueue.length
|
||||
);
|
||||
const updates = [...this.updateQueue];
|
||||
this.updateQueue = [];
|
||||
await this.run(async () => {
|
||||
await this.addUpdateToSQLite(updates);
|
||||
});
|
||||
}
|
||||
|
||||
// flush after 5s, but will not wait for more than 10s
|
||||
debouncedFlush = debounce(this.flushUpdateQueue, FLUSH_WAIT_TIME, {
|
||||
maxWait: FLUSH_MAX_WAIT_TIME,
|
||||
});
|
||||
|
||||
runCounter = 0;
|
||||
|
||||
// wrap the fn with connect and close
|
||||
async run<T extends (...args: any[]) => any>(
|
||||
fn: T
|
||||
): Promise<
|
||||
(T extends (...args: any[]) => infer U ? Awaited<U> : unknown) | undefined
|
||||
> {
|
||||
try {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
await this.connectIfNeeded();
|
||||
this.runCounter++;
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
throw err;
|
||||
} finally {
|
||||
this.runCounter--;
|
||||
if (this.runCounter === 0) {
|
||||
// just close db, but not the yDoc
|
||||
await super.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setupListener(docId?: string) {
|
||||
logger.debug(
|
||||
'SecondaryWorkspaceSQLiteDB:setupListener',
|
||||
this.workspaceId,
|
||||
docId
|
||||
);
|
||||
const doc = this.getDoc(docId);
|
||||
const upstreamDoc = this.upstream.getDoc(docId);
|
||||
if (!doc || !upstreamDoc) {
|
||||
logger.warn(
|
||||
'[SecondaryWorkspaceSQLiteDB] setupListener: doc not found',
|
||||
docId
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const onUpstreamUpdate = (update: Uint8Array, origin: YOrigin) => {
|
||||
logger.debug(
|
||||
'SecondaryWorkspaceSQLiteDB:onUpstreamUpdate',
|
||||
origin,
|
||||
this.workspaceId,
|
||||
docId,
|
||||
update.length
|
||||
);
|
||||
if (origin === 'renderer' || origin === 'self') {
|
||||
// update to upstream yDoc should be replicated to self yDoc
|
||||
this.applyUpdate(update, 'upstream', docId);
|
||||
}
|
||||
};
|
||||
|
||||
const onSelfUpdate = async (update: Uint8Array, origin: YOrigin) => {
|
||||
logger.debug(
|
||||
'SecondaryWorkspaceSQLiteDB:onSelfUpdate',
|
||||
origin,
|
||||
this.workspaceId,
|
||||
docId,
|
||||
update.length
|
||||
);
|
||||
// for self update from upstream, we need to push it to external DB
|
||||
if (origin === 'upstream') {
|
||||
await this.addUpdateToUpdateQueue({
|
||||
data: update,
|
||||
docId,
|
||||
});
|
||||
}
|
||||
|
||||
if (origin === 'self') {
|
||||
this.upstream.applyUpdate(update, 'external', docId);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubdocs = ({ added }: { added: Set<YDoc> }) => {
|
||||
added.forEach(subdoc => {
|
||||
this.setupListener(subdoc.guid);
|
||||
});
|
||||
};
|
||||
|
||||
doc.subdocs.forEach(subdoc => {
|
||||
this.setupListener(subdoc.guid);
|
||||
});
|
||||
|
||||
// listen to upstream update
|
||||
this.upstream.yDoc.on('update', onUpstreamUpdate);
|
||||
doc.on('update', onSelfUpdate);
|
||||
doc.on('subdocs', onSubdocs);
|
||||
|
||||
this.unsubscribers.add(() => {
|
||||
this.upstream.yDoc.off('update', onUpstreamUpdate);
|
||||
doc.off('update', onSelfUpdate);
|
||||
doc.off('subdocs', onSubdocs);
|
||||
});
|
||||
}
|
||||
|
||||
init() {
|
||||
if (this.firstConnected) {
|
||||
return;
|
||||
}
|
||||
this.firstConnected = true;
|
||||
this.setupListener();
|
||||
// apply all updates from upstream
|
||||
// we assume here that the upstream ydoc is already sync'ed
|
||||
const syncUpstreamDoc = (docId?: string) => {
|
||||
const update = this.upstream.getDocAsUpdates(docId);
|
||||
if (update) {
|
||||
this.applyUpdate(update, 'upstream');
|
||||
}
|
||||
};
|
||||
syncUpstreamDoc();
|
||||
this.upstream.yDoc.subdocs.forEach(subdoc => {
|
||||
syncUpstreamDoc(subdoc.guid);
|
||||
});
|
||||
}
|
||||
|
||||
applyUpdate = (
|
||||
data: Uint8Array,
|
||||
origin: YOrigin = 'upstream',
|
||||
docId?: string
|
||||
) => {
|
||||
const doc = this.getDoc(docId);
|
||||
if (doc) {
|
||||
applyUpdate(this.yDoc, data, origin);
|
||||
} else {
|
||||
logger.warn(
|
||||
'[SecondaryWorkspaceSQLiteDB] applyUpdate: doc not found',
|
||||
docId
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: have a better solution to handle blobs
|
||||
async syncBlobs() {
|
||||
await this.run(async () => {
|
||||
// skip if upstream db is not connected (maybe it is already closed)
|
||||
const blobsKeys = await this.getBlobKeys();
|
||||
if (!this.upstream.db || this.upstream.db?.isClose) {
|
||||
return;
|
||||
}
|
||||
const upstreamBlobsKeys = await this.upstream.getBlobKeys();
|
||||
// put every missing blob to upstream
|
||||
for (const key of blobsKeys) {
|
||||
if (!upstreamBlobsKeys.includes(key)) {
|
||||
const blob = await this.getBlob(key);
|
||||
if (blob) {
|
||||
await this.upstream.addBlob(key, blob);
|
||||
logger.debug('syncBlobs', this.workspaceId, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* pull from external DB file and apply to embedded yDoc
|
||||
* workflow:
|
||||
* - connect to external db
|
||||
* - get updates
|
||||
* - apply updates to local yDoc
|
||||
* - get blobs and put new blobs to upstream
|
||||
* - disconnect
|
||||
*/
|
||||
async pull() {
|
||||
const start = performance.now();
|
||||
assert(this.upstream.db, 'upstream db should be connected');
|
||||
const rows = await this.run(async () => {
|
||||
// TODO: no need to get all updates, just get the latest ones (using a cursor, etc)?
|
||||
await this.syncBlobs();
|
||||
return await this.getAllUpdates();
|
||||
});
|
||||
|
||||
if (!rows || this.destroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// apply root doc first
|
||||
rows.forEach(row => {
|
||||
if (!row.docId) {
|
||||
this.applyUpdate(row.data, 'self');
|
||||
}
|
||||
});
|
||||
|
||||
rows.forEach(row => {
|
||||
if (row.docId) {
|
||||
this.applyUpdate(row.data, 'self', row.docId);
|
||||
}
|
||||
});
|
||||
|
||||
logger.debug(
|
||||
'pull external updates',
|
||||
this.path,
|
||||
rows.length,
|
||||
(performance.now() - start).toFixed(2),
|
||||
'ms'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSecondaryWorkspaceDBPath(workspaceId: string) {
|
||||
const meta = await getWorkspaceMeta(workspaceId);
|
||||
return meta?.secondaryDBPath;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
export const dbSubjects = {
|
||||
externalUpdate: new Subject<{
|
||||
workspaceId: string;
|
||||
update: Uint8Array;
|
||||
docId?: string;
|
||||
}>(),
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { InsertRow } from '@affine/native';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { Subject } from 'rxjs';
|
||||
import { applyUpdate, Doc as YDoc, encodeStateAsUpdate } from 'yjs';
|
||||
|
||||
import { logger } from '../logger';
|
||||
import type { YOrigin } from '../type';
|
||||
import { getWorkspaceMeta } from '../workspace/meta';
|
||||
import { BaseSQLiteAdapter } from './base-db-adapter';
|
||||
import { dbSubjects } from './subjects';
|
||||
|
||||
const TRIM_SIZE = 500;
|
||||
|
||||
export class WorkspaceSQLiteDB extends BaseSQLiteAdapter {
|
||||
role = 'primary';
|
||||
yDoc = new YDoc();
|
||||
firstConnected = false;
|
||||
|
||||
update$ = new Subject<void>();
|
||||
|
||||
constructor(
|
||||
public override path: string,
|
||||
public workspaceId: string
|
||||
) {
|
||||
super(path);
|
||||
}
|
||||
|
||||
override async destroy() {
|
||||
await super.destroy();
|
||||
this.yDoc.destroy();
|
||||
|
||||
// when db is closed, we can safely remove it from ensure-db list
|
||||
this.update$.complete();
|
||||
this.firstConnected = false;
|
||||
}
|
||||
|
||||
getDoc(docId?: string) {
|
||||
if (!docId) {
|
||||
return this.yDoc;
|
||||
}
|
||||
// this should be pretty fast and we don't need to cache it
|
||||
for (const subdoc of this.yDoc.subdocs) {
|
||||
if (subdoc.guid === docId) {
|
||||
return subdoc;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getWorkspaceName = () => {
|
||||
return this.yDoc.getMap('meta').get('name') as string;
|
||||
};
|
||||
|
||||
setupListener(docId?: string) {
|
||||
logger.debug('WorkspaceSQLiteDB:setupListener', this.workspaceId, docId);
|
||||
const doc = this.getDoc(docId);
|
||||
if (doc) {
|
||||
const onUpdate = async (update: Uint8Array, origin: YOrigin) => {
|
||||
logger.debug(
|
||||
'WorkspaceSQLiteDB:onUpdate',
|
||||
this.workspaceId,
|
||||
docId,
|
||||
update.length
|
||||
);
|
||||
const insertRows = [{ data: update, docId }];
|
||||
if (origin === 'renderer') {
|
||||
await this.addUpdateToSQLite(insertRows);
|
||||
} else if (origin === 'external') {
|
||||
dbSubjects.externalUpdate.next({
|
||||
workspaceId: this.workspaceId,
|
||||
update,
|
||||
docId,
|
||||
});
|
||||
await this.addUpdateToSQLite(insertRows);
|
||||
logger.debug('external update', this.workspaceId);
|
||||
}
|
||||
};
|
||||
doc.subdocs.forEach(subdoc => {
|
||||
this.setupListener(subdoc.guid);
|
||||
});
|
||||
const onSubdocs = ({ added }: { added: Set<YDoc> }) => {
|
||||
logger.info('onSubdocs', this.workspaceId, docId, added);
|
||||
added.forEach(subdoc => {
|
||||
this.setupListener(subdoc.guid);
|
||||
});
|
||||
};
|
||||
|
||||
doc.on('update', onUpdate);
|
||||
doc.on('subdocs', onSubdocs);
|
||||
} else {
|
||||
logger.error('setupListener: doc not found', docId);
|
||||
}
|
||||
}
|
||||
|
||||
async init() {
|
||||
const db = await super.connectIfNeeded();
|
||||
|
||||
if (!this.firstConnected) {
|
||||
this.setupListener();
|
||||
}
|
||||
|
||||
const updates = await this.getAllUpdates();
|
||||
|
||||
// apply root first (without ID).
|
||||
// subdoc will be available after root is applied
|
||||
updates.forEach(update => {
|
||||
if (!update.docId) {
|
||||
this.applyUpdate(update.data, 'self');
|
||||
}
|
||||
});
|
||||
|
||||
// then, for all subdocs, apply the updates
|
||||
updates.forEach(update => {
|
||||
if (update.docId) {
|
||||
this.applyUpdate(update.data, 'self', update.docId);
|
||||
}
|
||||
});
|
||||
|
||||
this.firstConnected = true;
|
||||
this.update$.next();
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
// unlike getUpdates, this will return updates in yDoc
|
||||
getDocAsUpdates = (docId?: string) => {
|
||||
const doc = docId ? this.getDoc(docId) : this.yDoc;
|
||||
if (doc) {
|
||||
return encodeStateAsUpdate(doc);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// non-blocking and use yDoc to validate the update
|
||||
// after that, the update is added to the db
|
||||
applyUpdate = (
|
||||
data: Uint8Array,
|
||||
origin: YOrigin = 'renderer',
|
||||
docId?: string
|
||||
) => {
|
||||
// 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
|
||||
const doc = this.getDoc(docId);
|
||||
if (doc) {
|
||||
applyUpdate(doc, data, origin);
|
||||
} else {
|
||||
logger.warn('[WorkspaceSQLiteDB] applyUpdate: doc not found', docId);
|
||||
}
|
||||
};
|
||||
|
||||
override async addBlob(key: string, value: Uint8Array) {
|
||||
this.update$.next();
|
||||
const res = await super.addBlob(key, value);
|
||||
return res;
|
||||
}
|
||||
|
||||
override async deleteBlob(key: string) {
|
||||
this.update$.next();
|
||||
await super.deleteBlob(key);
|
||||
}
|
||||
|
||||
override async addUpdateToSQLite(data: InsertRow[]) {
|
||||
this.update$.next();
|
||||
data.forEach(row => {
|
||||
this.trimWhenNecessary(row.docId)?.catch(err => {
|
||||
logger.error('trimWhenNecessary failed', err);
|
||||
});
|
||||
});
|
||||
await super.addUpdateToSQLite(data);
|
||||
}
|
||||
|
||||
trimWhenNecessary = debounce(async (docId?: string) => {
|
||||
if (this.firstConnected) {
|
||||
const count = (await this.db?.getUpdatesCount(docId)) ?? 0;
|
||||
if (count > TRIM_SIZE) {
|
||||
logger.debug(`trim ${this.workspaceId}:${docId} ${count}`);
|
||||
const update = this.getDocAsUpdates(docId);
|
||||
if (update) {
|
||||
const insertRows = [{ data: update, docId }];
|
||||
await this.db?.replaceUpdates(docId, insertRows);
|
||||
logger.debug(`trim ${this.workspaceId}:${docId} successfully`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
export async function openWorkspaceDatabase(workspaceId: string) {
|
||||
const meta = await getWorkspaceMeta(workspaceId);
|
||||
const db = new WorkspaceSQLiteDB(meta.mainDBPath, workspaceId);
|
||||
await db.init();
|
||||
logger.info(`openWorkspaceDatabase [${workspaceId}]`);
|
||||
return db;
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { ValidationResult } from '@affine/native';
|
||||
import { WorkspaceVersion } from '@toeverything/infra/blocksuite';
|
||||
import type {
|
||||
FakeDialogResult,
|
||||
LoadDBFileResult,
|
||||
MoveDBFileResult,
|
||||
SaveDBFileResult,
|
||||
SelectDBFileLocationResult,
|
||||
} from '@toeverything/infra/type';
|
||||
import fs from 'fs-extra';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { ensureSQLiteDB } from '../db/ensure-db';
|
||||
import {
|
||||
copyToTemp,
|
||||
migrateToLatest,
|
||||
migrateToSubdocAndReplaceDatabase,
|
||||
} from '../db/migration';
|
||||
import type { WorkspaceSQLiteDB } from '../db/workspace-db-adapter';
|
||||
import { logger } from '../logger';
|
||||
import { mainRPC } from '../main-rpc';
|
||||
import { listWorkspaces, storeWorkspaceMeta } from '../workspace';
|
||||
import {
|
||||
getWorkspaceDBPath,
|
||||
getWorkspaceMeta,
|
||||
getWorkspacesBasePath,
|
||||
} from '../workspace/meta';
|
||||
|
||||
// NOTE:
|
||||
// we are using native dialogs because HTML dialogs do not give full file paths
|
||||
|
||||
export async function revealDBFile(workspaceId: string) {
|
||||
const meta = await getWorkspaceMeta(workspaceId);
|
||||
if (!meta) {
|
||||
return;
|
||||
}
|
||||
await mainRPC.showItemInFolder(meta.secondaryDBPath ?? meta.mainDBPath);
|
||||
}
|
||||
|
||||
// result will be used in the next call to showOpenDialog
|
||||
// if it is being read once, it will be reset to undefined
|
||||
let fakeDialogResult: FakeDialogResult | undefined = undefined;
|
||||
|
||||
function getFakedResult() {
|
||||
const result = fakeDialogResult;
|
||||
fakeDialogResult = undefined;
|
||||
return result;
|
||||
}
|
||||
|
||||
export function setFakeDialogResult(result: FakeDialogResult | undefined) {
|
||||
fakeDialogResult = result;
|
||||
// for convenience, we will fill filePaths with filePath if it is not set
|
||||
if (result?.filePaths === undefined && result?.filePath !== undefined) {
|
||||
result.filePaths = [result.filePath];
|
||||
}
|
||||
}
|
||||
|
||||
const extension = 'affine';
|
||||
|
||||
function getDefaultDBFileName(name: string, id: string) {
|
||||
const fileName = `${name}_${id}.${extension}`;
|
||||
// make sure fileName is a valid file name
|
||||
return fileName.replace(/[/\\?%*:|"<>]/g, '-');
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is called when the user clicks the "Save" button in the "Save Workspace" dialog.
|
||||
*
|
||||
* It will just copy the file to the given path
|
||||
*/
|
||||
export async function saveDBFileAs(
|
||||
workspaceId: string
|
||||
): Promise<SaveDBFileResult> {
|
||||
try {
|
||||
const db = await ensureSQLiteDB(workspaceId);
|
||||
const ret =
|
||||
getFakedResult() ??
|
||||
(await mainRPC.showSaveDialog({
|
||||
properties: ['showOverwriteConfirmation'],
|
||||
title: 'Save Workspace',
|
||||
showsTagField: false,
|
||||
buttonLabel: 'Save',
|
||||
filters: [
|
||||
{
|
||||
extensions: [extension],
|
||||
name: '',
|
||||
},
|
||||
],
|
||||
defaultPath: getDefaultDBFileName(db.getWorkspaceName(), workspaceId),
|
||||
message: 'Save Workspace as a SQLite Database file',
|
||||
}));
|
||||
const filePath = ret.filePath;
|
||||
if (ret.canceled || !filePath) {
|
||||
return {
|
||||
canceled: true,
|
||||
};
|
||||
}
|
||||
|
||||
await fs.copyFile(db.path, filePath);
|
||||
logger.log('saved', filePath);
|
||||
mainRPC.showItemInFolder(filePath).catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
return { filePath };
|
||||
} catch (err) {
|
||||
logger.error('saveDBFileAs', err);
|
||||
return {
|
||||
error: 'UNKNOWN_ERROR',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function selectDBFileLocation(): Promise<SelectDBFileLocationResult> {
|
||||
try {
|
||||
const ret =
|
||||
getFakedResult() ??
|
||||
(await mainRPC.showOpenDialog({
|
||||
properties: ['openDirectory'],
|
||||
title: 'Set Workspace Storage Location',
|
||||
buttonLabel: 'Select',
|
||||
defaultPath: await mainRPC.getPath('documents'),
|
||||
message: "Select a location to store the workspace's database file",
|
||||
}));
|
||||
const dir = ret.filePaths?.[0];
|
||||
if (ret.canceled || !dir) {
|
||||
return {
|
||||
canceled: true,
|
||||
};
|
||||
}
|
||||
return { filePath: dir };
|
||||
} catch (err) {
|
||||
logger.error('selectDBFileLocation', err);
|
||||
return {
|
||||
error: (err as any).message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is called when the user clicks the "Load" button in the "Load Workspace" dialog.
|
||||
*
|
||||
* It will
|
||||
* - symlink the source db file to a new workspace id to app-data
|
||||
* - return the new workspace id
|
||||
*
|
||||
* eg, it will create a new folder in app-data:
|
||||
* <app-data>/<app-name>/workspaces/<workspace-id>/storage.db
|
||||
*
|
||||
* On the renderer side, after the UI got a new workspace id, it will
|
||||
* update the local workspace id list and then connect to it.
|
||||
*
|
||||
*/
|
||||
export async function loadDBFile(): Promise<LoadDBFileResult> {
|
||||
try {
|
||||
const ret =
|
||||
getFakedResult() ??
|
||||
(await mainRPC.showOpenDialog({
|
||||
properties: ['openFile'],
|
||||
title: 'Load Workspace',
|
||||
buttonLabel: 'Load',
|
||||
filters: [
|
||||
{
|
||||
name: 'SQLite Database',
|
||||
// do we want to support other file format?
|
||||
extensions: ['db', 'affine'],
|
||||
},
|
||||
],
|
||||
message: 'Load Workspace from a AFFiNE file',
|
||||
}));
|
||||
let originalPath = ret.filePaths?.[0];
|
||||
if (ret.canceled || !originalPath) {
|
||||
logger.info('loadDBFile canceled');
|
||||
return { canceled: true };
|
||||
}
|
||||
|
||||
// the imported file should not be in app data dir
|
||||
if (originalPath.startsWith(await getWorkspacesBasePath())) {
|
||||
logger.warn('loadDBFile: db file in app data dir');
|
||||
return { error: 'DB_FILE_PATH_INVALID' };
|
||||
}
|
||||
|
||||
if (await dbFileAlreadyLoaded(originalPath)) {
|
||||
logger.warn('loadDBFile: db file already loaded');
|
||||
return { error: 'DB_FILE_ALREADY_LOADED' };
|
||||
}
|
||||
|
||||
const { SqliteConnection } = await import('@affine/native');
|
||||
|
||||
const validationResult = await SqliteConnection.validate(originalPath);
|
||||
|
||||
if (validationResult === ValidationResult.MissingDocIdColumn) {
|
||||
try {
|
||||
const tmpDBPath = await copyToTemp(originalPath);
|
||||
await migrateToSubdocAndReplaceDatabase(tmpDBPath);
|
||||
originalPath = tmpDBPath;
|
||||
} catch (error) {
|
||||
logger.warn(`loadDBFile, migration failed: ${originalPath}`, error);
|
||||
return { error: 'DB_FILE_MIGRATION_FAILED' };
|
||||
}
|
||||
}
|
||||
|
||||
if (validationResult === ValidationResult.MissingVersionColumn) {
|
||||
try {
|
||||
const tmpDBPath = await copyToTemp(originalPath);
|
||||
await migrateToLatest(tmpDBPath, WorkspaceVersion.SubDoc);
|
||||
originalPath = tmpDBPath;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`loadDBFile, migration version column failed: ${originalPath}`,
|
||||
error
|
||||
);
|
||||
return { error: 'DB_FILE_MIGRATION_FAILED' };
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
validationResult !== ValidationResult.MissingVersionColumn &&
|
||||
validationResult !== ValidationResult.MissingDocIdColumn &&
|
||||
validationResult !== ValidationResult.Valid
|
||||
) {
|
||||
return { error: 'DB_FILE_INVALID' }; // invalid db file
|
||||
}
|
||||
|
||||
const db = new SqliteConnection(originalPath);
|
||||
try {
|
||||
await db.connect();
|
||||
if ((await db.getMaxVersion()) === WorkspaceVersion.DatabaseV3) {
|
||||
const tmpDBPath = await copyToTemp(originalPath);
|
||||
await migrateToLatest(tmpDBPath, WorkspaceVersion.SubDoc);
|
||||
originalPath = tmpDBPath;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`loadDBFile, migration version column failed: ${originalPath}`,
|
||||
error
|
||||
);
|
||||
return { error: 'DB_FILE_MIGRATION_FAILED' };
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
|
||||
// copy the db file to a new workspace id
|
||||
const workspaceId = nanoid(10);
|
||||
const internalFilePath = await getWorkspaceDBPath(workspaceId);
|
||||
|
||||
await fs.ensureDir(await getWorkspacesBasePath());
|
||||
await fs.copy(originalPath, internalFilePath);
|
||||
logger.info(`loadDBFile, copy: ${originalPath} -> ${internalFilePath}`);
|
||||
|
||||
await storeWorkspaceMeta(workspaceId, {
|
||||
id: workspaceId,
|
||||
mainDBPath: internalFilePath,
|
||||
});
|
||||
|
||||
return { workspaceId };
|
||||
} catch (err) {
|
||||
logger.error('loadDBFile', err);
|
||||
return {
|
||||
error: 'UNKNOWN_ERROR',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is called when the user clicks the "Move" button in the "Move Workspace Storage" setting.
|
||||
*
|
||||
* It will
|
||||
* - copy the source db file to a new location
|
||||
* - remove the old db external file
|
||||
* - update the external db file path in the workspace meta
|
||||
* - return the new file path
|
||||
*/
|
||||
export async function moveDBFile(
|
||||
workspaceId: string,
|
||||
dbFileDir?: string
|
||||
): Promise<MoveDBFileResult> {
|
||||
let db: WorkspaceSQLiteDB | null = null;
|
||||
try {
|
||||
db = await ensureSQLiteDB(workspaceId);
|
||||
const meta = await getWorkspaceMeta(workspaceId);
|
||||
|
||||
const oldDir = meta.secondaryDBPath
|
||||
? path.dirname(meta.secondaryDBPath)
|
||||
: null;
|
||||
const defaultDir = oldDir ?? (await mainRPC.getPath('documents'));
|
||||
|
||||
const newName = getDefaultDBFileName(db.getWorkspaceName(), workspaceId);
|
||||
|
||||
const newDirPath =
|
||||
dbFileDir ??
|
||||
(
|
||||
getFakedResult() ??
|
||||
(await mainRPC.showOpenDialog({
|
||||
properties: ['openDirectory'],
|
||||
title: 'Move Workspace Storage',
|
||||
buttonLabel: 'Move',
|
||||
defaultPath: defaultDir,
|
||||
message: 'Move Workspace storage file',
|
||||
}))
|
||||
).filePaths?.[0];
|
||||
|
||||
// skips if
|
||||
// - user canceled the dialog
|
||||
// - user selected the same dir
|
||||
if (!newDirPath || newDirPath === oldDir) {
|
||||
return {
|
||||
canceled: true,
|
||||
};
|
||||
}
|
||||
|
||||
const newFilePath = path.join(newDirPath, newName);
|
||||
|
||||
if (await fs.pathExists(newFilePath)) {
|
||||
return {
|
||||
error: 'FILE_ALREADY_EXISTS',
|
||||
};
|
||||
}
|
||||
|
||||
logger.info(`[moveDBFile] copy ${meta.mainDBPath} -> ${newFilePath}`);
|
||||
|
||||
await fs.copy(meta.mainDBPath, newFilePath);
|
||||
|
||||
// remove the old db file, but we don't care if it fails
|
||||
if (meta.secondaryDBPath) {
|
||||
await fs
|
||||
.remove(meta.secondaryDBPath)
|
||||
.then(() => {
|
||||
logger.info(`[moveDBFile] removed ${meta.secondaryDBPath}`);
|
||||
})
|
||||
.catch(err => {
|
||||
logger.error(
|
||||
`[moveDBFile] remove ${meta.secondaryDBPath} failed`,
|
||||
err
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// update meta
|
||||
await storeWorkspaceMeta(workspaceId, {
|
||||
secondaryDBPath: newFilePath,
|
||||
});
|
||||
|
||||
return {
|
||||
filePath: newFilePath,
|
||||
};
|
||||
} catch (err) {
|
||||
await db?.destroy();
|
||||
logger.error('[moveDBFile]', err);
|
||||
return {
|
||||
error: 'UNKNOWN_ERROR',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function dbFileAlreadyLoaded(path: string) {
|
||||
const meta = await listWorkspaces();
|
||||
const paths = meta.map(m => m[1].secondaryDBPath);
|
||||
return paths.includes(path);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
loadDBFile,
|
||||
moveDBFile,
|
||||
revealDBFile,
|
||||
saveDBFileAs,
|
||||
selectDBFileLocation,
|
||||
setFakeDialogResult,
|
||||
} from './dialog';
|
||||
|
||||
export const dialogHandlers = {
|
||||
revealDBFile: async (workspaceId: string) => {
|
||||
return revealDBFile(workspaceId);
|
||||
},
|
||||
loadDBFile: async () => {
|
||||
return loadDBFile();
|
||||
},
|
||||
saveDBFileAs: async (workspaceId: string) => {
|
||||
return saveDBFileAs(workspaceId);
|
||||
},
|
||||
moveDBFile: (workspaceId: string, dbFileLocation?: string) => {
|
||||
return moveDBFile(workspaceId, dbFileLocation);
|
||||
},
|
||||
selectDBFileLocation: async () => {
|
||||
return selectDBFileLocation();
|
||||
},
|
||||
setFakeDialogResult: async (
|
||||
result: Parameters<typeof setFakeDialogResult>[0]
|
||||
) => {
|
||||
return setFakeDialogResult(result);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import type {
|
||||
DBHandlers,
|
||||
DialogHandlers,
|
||||
WorkspaceHandlers,
|
||||
} from '@toeverything/infra/type';
|
||||
|
||||
import { dbEvents, dbHandlers } from './db';
|
||||
import { dialogHandlers } from './dialog';
|
||||
import { provideExposed } from './provide';
|
||||
import { workspaceEvents, workspaceHandlers } from './workspace';
|
||||
|
||||
type AllHandlers = {
|
||||
db: DBHandlers;
|
||||
workspace: WorkspaceHandlers;
|
||||
dialog: DialogHandlers;
|
||||
};
|
||||
|
||||
export const handlers = {
|
||||
db: dbHandlers,
|
||||
workspace: workspaceHandlers,
|
||||
dialog: dialogHandlers,
|
||||
} satisfies AllHandlers;
|
||||
|
||||
export const events = {
|
||||
db: dbEvents,
|
||||
workspace: workspaceEvents,
|
||||
};
|
||||
|
||||
const getExposedMeta = () => {
|
||||
const handlersMeta = Object.entries(handlers).map(
|
||||
([namespace, namespaceHandlers]) => {
|
||||
return [namespace, Object.keys(namespaceHandlers)] as [string, string[]];
|
||||
}
|
||||
);
|
||||
|
||||
const eventsMeta = Object.entries(events).map(
|
||||
([namespace, namespaceHandlers]) => {
|
||||
return [namespace, Object.keys(namespaceHandlers)] as [string, string[]];
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
handlers: handlersMeta,
|
||||
events: eventsMeta,
|
||||
};
|
||||
};
|
||||
|
||||
provideExposed(getExposedMeta());
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { RendererToHelper } from '@toeverything/infra/preload/electron';
|
||||
import { AsyncCall } from 'async-call-rpc';
|
||||
|
||||
import { events, handlers } from './exposed';
|
||||
import { logger } from './logger';
|
||||
|
||||
function setupRendererConnection(rendererPort: Electron.MessagePortMain) {
|
||||
const flattenedHandlers = Object.entries(handlers).flatMap(
|
||||
([namespace, namespaceHandlers]) => {
|
||||
return Object.entries(namespaceHandlers).map(([name, handler]) => {
|
||||
const handlerWithLog = async (...args: any[]) => {
|
||||
try {
|
||||
const start = performance.now();
|
||||
const result = await handler(...args);
|
||||
logger.info(
|
||||
'[async-api]',
|
||||
`${namespace}.${name}`,
|
||||
args.filter(
|
||||
arg => typeof arg !== 'function' && typeof arg !== 'object'
|
||||
),
|
||||
'-',
|
||||
(performance.now() - start).toFixed(2),
|
||||
'ms'
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('[async-api]', `${namespace}.${name}`, error);
|
||||
}
|
||||
};
|
||||
return [`${namespace}:${name}`, handlerWithLog];
|
||||
});
|
||||
}
|
||||
);
|
||||
const rpc = AsyncCall<RendererToHelper>(
|
||||
Object.fromEntries(flattenedHandlers),
|
||||
{
|
||||
channel: {
|
||||
on(listener) {
|
||||
const f = (e: Electron.MessageEvent) => {
|
||||
listener(e.data);
|
||||
};
|
||||
rendererPort.on('message', f);
|
||||
// MUST start the connection to receive messages
|
||||
rendererPort.start();
|
||||
return () => {
|
||||
rendererPort.off('message', f);
|
||||
};
|
||||
},
|
||||
send(data) {
|
||||
rendererPort.postMessage(data);
|
||||
},
|
||||
},
|
||||
log: false,
|
||||
}
|
||||
);
|
||||
|
||||
for (const [namespace, namespaceEvents] of Object.entries(events)) {
|
||||
for (const [key, eventRegister] of Object.entries(namespaceEvents)) {
|
||||
const unsub = eventRegister((...args: any[]) => {
|
||||
const chan = `${namespace}:${key}`;
|
||||
rpc.postEvent(chan, ...args).catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
});
|
||||
process.on('exit', () => {
|
||||
unsub();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
process.parentPort.on('message', e => {
|
||||
if (e.data.channel === 'renderer-connect' && e.ports.length === 1) {
|
||||
const rendererPort = e.ports[0];
|
||||
setupRendererConnection(rendererPort);
|
||||
logger.info('[helper] renderer connected');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,3 @@
|
||||
import log from 'electron-log';
|
||||
|
||||
export const logger = log.scope('helper');
|
||||
@@ -0,0 +1,35 @@
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import type {
|
||||
HelperToMain,
|
||||
MainToHelper,
|
||||
} from '@toeverything/infra/preload/electron';
|
||||
import { AsyncCall } from 'async-call-rpc';
|
||||
|
||||
import { exposed } from './provide';
|
||||
|
||||
const helperToMainServer: HelperToMain = {
|
||||
getMeta: () => {
|
||||
assertExists(exposed);
|
||||
return exposed;
|
||||
},
|
||||
};
|
||||
|
||||
export const mainRPC = AsyncCall<MainToHelper>(helperToMainServer, {
|
||||
strict: {
|
||||
unknownMessage: false,
|
||||
},
|
||||
channel: {
|
||||
on(listener) {
|
||||
const f = (e: Electron.MessageEvent) => {
|
||||
listener(e.data);
|
||||
};
|
||||
process.parentPort.on('message', f);
|
||||
return () => {
|
||||
process.parentPort.off('message', f);
|
||||
};
|
||||
},
|
||||
send(data) {
|
||||
process.parentPort.postMessage(data);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ExposedMeta } from '@toeverything/infra/preload/electron';
|
||||
|
||||
/**
|
||||
* A naive DI implementation to get rid of circular dependency.
|
||||
*/
|
||||
|
||||
export let exposed: ExposedMeta | undefined;
|
||||
|
||||
export const provideExposed = (exposedMeta: ExposedMeta) => {
|
||||
exposed = exposedMeta;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface WorkspaceMeta {
|
||||
id: string;
|
||||
mainDBPath: string;
|
||||
secondaryDBPath?: string; // assume there will be only one
|
||||
}
|
||||
|
||||
export type YOrigin = 'self' | 'external' | 'upstream' | 'renderer';
|
||||
|
||||
export type MainEventRegister = (...args: any[]) => () => void;
|
||||
@@ -0,0 +1 @@
|
||||
tmp
|
||||
@@ -0,0 +1,143 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { removeWithRetry } from '@affine-test/kit/utils/utils';
|
||||
import fs from 'fs-extra';
|
||||
import { v4 } from 'uuid';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const tmpDir = path.join(__dirname, 'tmp');
|
||||
const appDataPath = path.join(tmpDir, 'app-data');
|
||||
|
||||
vi.doMock('../../db/ensure-db', () => ({
|
||||
ensureSQLiteDB: async () => ({
|
||||
destroy: () => {},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.doMock('../../main-rpc', () => ({
|
||||
mainRPC: {
|
||||
getPath: async () => appDataPath,
|
||||
},
|
||||
}));
|
||||
|
||||
afterEach(async () => {
|
||||
await removeWithRetry(tmpDir);
|
||||
});
|
||||
|
||||
describe('list workspaces', () => {
|
||||
test('listWorkspaces (valid)', async () => {
|
||||
const { listWorkspaces } = await import('../handlers');
|
||||
const workspaceId = v4();
|
||||
const workspacePath = path.join(appDataPath, 'workspaces', workspaceId);
|
||||
const meta = {
|
||||
id: workspaceId,
|
||||
};
|
||||
await fs.ensureDir(workspacePath);
|
||||
await fs.writeJSON(path.join(workspacePath, 'meta.json'), meta);
|
||||
const workspaces = await listWorkspaces();
|
||||
expect(workspaces).toEqual([[workspaceId, meta]]);
|
||||
});
|
||||
|
||||
test('listWorkspaces (without meta json file)', async () => {
|
||||
const { listWorkspaces } = await import('../handlers');
|
||||
const workspaceId = v4();
|
||||
const workspacePath = path.join(appDataPath, 'workspaces', workspaceId);
|
||||
await fs.ensureDir(workspacePath);
|
||||
const workspaces = await listWorkspaces();
|
||||
expect(workspaces).toEqual([
|
||||
[
|
||||
workspaceId,
|
||||
// meta file will be created automatically
|
||||
{ id: workspaceId, mainDBPath: path.join(workspacePath, 'storage.db') },
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete workspace', () => {
|
||||
test('deleteWorkspace', async () => {
|
||||
const { deleteWorkspace } = await import('../handlers');
|
||||
const workspaceId = v4();
|
||||
const workspacePath = path.join(appDataPath, 'workspaces', workspaceId);
|
||||
await fs.ensureDir(workspacePath);
|
||||
await deleteWorkspace(workspaceId);
|
||||
expect(await fs.pathExists(workspacePath)).toBe(false);
|
||||
// removed workspace will be moved to deleted-workspaces
|
||||
expect(
|
||||
await fs.pathExists(
|
||||
path.join(appDataPath, 'deleted-workspaces', workspaceId)
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWorkspaceMeta', () => {
|
||||
test('can get meta', async () => {
|
||||
const { getWorkspaceMeta } = await import('../meta');
|
||||
const workspaceId = v4();
|
||||
const workspacePath = path.join(appDataPath, 'workspaces', workspaceId);
|
||||
const meta = {
|
||||
id: workspaceId,
|
||||
};
|
||||
await fs.ensureDir(workspacePath);
|
||||
await fs.writeJSON(path.join(workspacePath, 'meta.json'), meta);
|
||||
expect(await getWorkspaceMeta(workspaceId)).toEqual(meta);
|
||||
});
|
||||
|
||||
test('can create meta if not exists', async () => {
|
||||
const { getWorkspaceMeta } = await import('../meta');
|
||||
const workspaceId = v4();
|
||||
const workspacePath = path.join(appDataPath, 'workspaces', workspaceId);
|
||||
await fs.ensureDir(workspacePath);
|
||||
expect(await getWorkspaceMeta(workspaceId)).toEqual({
|
||||
id: workspaceId,
|
||||
mainDBPath: path.join(workspacePath, 'storage.db'),
|
||||
});
|
||||
expect(
|
||||
await fs.pathExists(path.join(workspacePath, 'meta.json'))
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('can migrate meta if db file is a link', async () => {
|
||||
const { getWorkspaceMeta } = await import('../meta');
|
||||
const workspaceId = v4();
|
||||
const workspacePath = path.join(appDataPath, 'workspaces', workspaceId);
|
||||
await fs.ensureDir(workspacePath);
|
||||
const sourcePath = path.join(tmpDir, 'source.db');
|
||||
await fs.writeFile(sourcePath, 'test');
|
||||
|
||||
await fs.ensureSymlink(sourcePath, path.join(workspacePath, 'storage.db'));
|
||||
|
||||
expect(await getWorkspaceMeta(workspaceId)).toEqual({
|
||||
id: workspaceId,
|
||||
mainDBPath: path.join(workspacePath, 'storage.db'),
|
||||
secondaryDBPath: sourcePath,
|
||||
});
|
||||
|
||||
expect(
|
||||
await fs.pathExists(path.join(workspacePath, 'meta.json'))
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test('storeWorkspaceMeta', async () => {
|
||||
const { storeWorkspaceMeta } = await import('../handlers');
|
||||
const workspaceId = v4();
|
||||
const workspacePath = path.join(appDataPath, 'workspaces', workspaceId);
|
||||
await fs.ensureDir(workspacePath);
|
||||
const meta = {
|
||||
id: workspaceId,
|
||||
mainDBPath: path.join(workspacePath, 'storage.db'),
|
||||
};
|
||||
await storeWorkspaceMeta(workspaceId, meta);
|
||||
expect(await fs.readJSON(path.join(workspacePath, 'meta.json'))).toEqual(
|
||||
meta
|
||||
);
|
||||
await storeWorkspaceMeta(workspaceId, {
|
||||
secondaryDBPath: path.join(tmpDir, 'test.db'),
|
||||
});
|
||||
expect(await fs.readJSON(path.join(workspacePath, 'meta.json'))).toEqual({
|
||||
...meta,
|
||||
secondaryDBPath: path.join(tmpDir, 'test.db'),
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import { ensureSQLiteDB } from '../db/ensure-db';
|
||||
import { logger } from '../logger';
|
||||
import type { WorkspaceMeta } from '../type';
|
||||
import {
|
||||
getDeletedWorkspacesBasePath,
|
||||
getWorkspaceBasePath,
|
||||
getWorkspaceMeta,
|
||||
getWorkspacesBasePath,
|
||||
} from './meta';
|
||||
import { workspaceSubjects } from './subjects';
|
||||
|
||||
export async function listWorkspaces(): Promise<
|
||||
[workspaceId: string, meta: WorkspaceMeta][]
|
||||
> {
|
||||
const basePath = await getWorkspacesBasePath();
|
||||
try {
|
||||
await fs.ensureDir(basePath);
|
||||
const dirs = (
|
||||
await fs.readdir(basePath, {
|
||||
withFileTypes: true,
|
||||
})
|
||||
).filter(d => d.isDirectory());
|
||||
const metaList = (
|
||||
await Promise.all(
|
||||
dirs.map(async dir => {
|
||||
// ? shall we put all meta in a single file instead of one file per workspace?
|
||||
return await getWorkspaceMeta(dir.name);
|
||||
})
|
||||
)
|
||||
).filter((w): w is WorkspaceMeta => !!w);
|
||||
return metaList.map(meta => [meta.id, meta]);
|
||||
} catch (error) {
|
||||
logger.error('listWorkspaces', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteWorkspace(id: string) {
|
||||
const basePath = await getWorkspaceBasePath(id);
|
||||
const movedPath = path.join(await getDeletedWorkspacesBasePath(), `${id}`);
|
||||
try {
|
||||
const db = await ensureSQLiteDB(id);
|
||||
await db.destroy();
|
||||
return await fs.move(basePath, movedPath, {
|
||||
overwrite: true,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('deleteWorkspace', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function storeWorkspaceMeta(
|
||||
workspaceId: string,
|
||||
meta: Partial<WorkspaceMeta>
|
||||
) {
|
||||
try {
|
||||
const basePath = await getWorkspaceBasePath(workspaceId);
|
||||
await fs.ensureDir(basePath);
|
||||
const metaPath = path.join(basePath, 'meta.json');
|
||||
const currentMeta = await getWorkspaceMeta(workspaceId);
|
||||
const newMeta = {
|
||||
...currentMeta,
|
||||
...meta,
|
||||
};
|
||||
await fs.writeJSON(metaPath, newMeta);
|
||||
workspaceSubjects.meta.next({
|
||||
workspaceId,
|
||||
meta: newMeta,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('storeWorkspaceMeta failed', err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { MainEventRegister, WorkspaceMeta } from '../type';
|
||||
import { deleteWorkspace, listWorkspaces } from './handlers';
|
||||
import { getWorkspaceMeta } from './meta';
|
||||
import { workspaceSubjects } from './subjects';
|
||||
|
||||
export * from './handlers';
|
||||
export * from './subjects';
|
||||
|
||||
export const workspaceEvents = {
|
||||
onMetaChange: (
|
||||
fn: (meta: { workspaceId: string; meta: WorkspaceMeta }) => void
|
||||
) => {
|
||||
const sub = workspaceSubjects.meta.subscribe(fn);
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
};
|
||||
},
|
||||
} satisfies Record<string, MainEventRegister>;
|
||||
|
||||
export const workspaceHandlers = {
|
||||
list: async () => listWorkspaces(),
|
||||
delete: async (id: string) => deleteWorkspace(id),
|
||||
getMeta: async (id: string) => {
|
||||
return getWorkspaceMeta(id);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import { logger } from '../logger';
|
||||
import { mainRPC } from '../main-rpc';
|
||||
import type { WorkspaceMeta } from '../type';
|
||||
|
||||
let _appDataPath = '';
|
||||
|
||||
export async function getAppDataPath() {
|
||||
if (_appDataPath) {
|
||||
return _appDataPath;
|
||||
}
|
||||
_appDataPath = await mainRPC.getPath('sessionData');
|
||||
return _appDataPath;
|
||||
}
|
||||
|
||||
export async function getWorkspacesBasePath() {
|
||||
return path.join(await getAppDataPath(), 'workspaces');
|
||||
}
|
||||
|
||||
export async function getWorkspaceBasePath(workspaceId: string) {
|
||||
return path.join(await getAppDataPath(), 'workspaces', workspaceId);
|
||||
}
|
||||
|
||||
export async function getDeletedWorkspacesBasePath() {
|
||||
return path.join(await getAppDataPath(), 'deleted-workspaces');
|
||||
}
|
||||
|
||||
export async function getWorkspaceDBPath(workspaceId: string) {
|
||||
return path.join(await getWorkspaceBasePath(workspaceId), 'storage.db');
|
||||
}
|
||||
|
||||
export async function getWorkspaceMetaPath(workspaceId: string) {
|
||||
return path.join(await getWorkspaceBasePath(workspaceId), 'meta.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workspace meta, create one if not exists
|
||||
* This function will also migrate the workspace if needed
|
||||
*/
|
||||
export async function getWorkspaceMeta(
|
||||
workspaceId: string
|
||||
): Promise<WorkspaceMeta> {
|
||||
try {
|
||||
const basePath = await getWorkspaceBasePath(workspaceId);
|
||||
const metaPath = await getWorkspaceMetaPath(workspaceId);
|
||||
if (
|
||||
!(await fs
|
||||
.access(metaPath)
|
||||
.then(() => true)
|
||||
.catch(() => false))
|
||||
) {
|
||||
// since not meta is found, we will migrate symlinked db file if needed
|
||||
await fs.ensureDir(basePath);
|
||||
const dbPath = await getWorkspaceDBPath(workspaceId);
|
||||
|
||||
// todo: remove this after migration (in stable version)
|
||||
const realDBPath = (await fs
|
||||
.access(dbPath)
|
||||
.then(() => true)
|
||||
.catch(() => false))
|
||||
? await fs.realpath(dbPath)
|
||||
: dbPath;
|
||||
const isLink = realDBPath !== dbPath;
|
||||
if (isLink) {
|
||||
await fs.copy(realDBPath, dbPath);
|
||||
}
|
||||
// create one if not exists
|
||||
const meta = {
|
||||
id: workspaceId,
|
||||
mainDBPath: dbPath,
|
||||
secondaryDBPath: isLink ? realDBPath : undefined,
|
||||
};
|
||||
await fs.writeJSON(metaPath, meta);
|
||||
return meta;
|
||||
} else {
|
||||
const meta = await fs.readJSON(metaPath);
|
||||
return meta;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('getWorkspaceMeta failed', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
import type { WorkspaceMeta } from '../type';
|
||||
|
||||
export const workspaceSubjects = {
|
||||
meta: new Subject<{ workspaceId: string; meta: WorkspaceMeta }>(),
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
tmp
|
||||
@@ -0,0 +1,170 @@
|
||||
import assert from 'node:assert';
|
||||
import path from 'node:path';
|
||||
|
||||
import { removeWithRetry } from '@affine-test/kit/utils/utils';
|
||||
import fs from 'fs-extra';
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { MainIPCHandlerMap } from '../exposed';
|
||||
|
||||
const registeredHandlers = new Map<
|
||||
string,
|
||||
((...args: any[]) => Promise<any>)[]
|
||||
>();
|
||||
|
||||
type WithoutFirstParameter<T> = T extends (_: any, ...args: infer P) => infer R
|
||||
? (...args: P) => R
|
||||
: T;
|
||||
|
||||
// common mock dispatcher for ipcMain.handle AND app.on
|
||||
// alternatively, we can use single parameter for T & F, eg, dispatch('workspace:list'),
|
||||
// however this is too hard to be typed correctly
|
||||
async function dispatch<
|
||||
T extends keyof MainIPCHandlerMap,
|
||||
F extends keyof MainIPCHandlerMap[T],
|
||||
>(
|
||||
namespace: T,
|
||||
functionName: F,
|
||||
...args: Parameters<WithoutFirstParameter<MainIPCHandlerMap[T][F]>>
|
||||
): // @ts-expect-error
|
||||
ReturnType<MainIPCHandlerMap[T][F]> {
|
||||
// @ts-expect-error
|
||||
const handlers = registeredHandlers.get(namespace + ':' + functionName);
|
||||
assert(handlers);
|
||||
|
||||
// we only care about the first handler here
|
||||
return await handlers[0](null, ...args);
|
||||
}
|
||||
|
||||
const SESSION_DATA_PATH = path.join(__dirname, './tmp', 'affine-test');
|
||||
const DOCUMENTS_PATH = path.join(__dirname, './tmp', 'affine-test-documents');
|
||||
|
||||
const browserWindow = {
|
||||
isDestroyed: () => {
|
||||
return false;
|
||||
},
|
||||
setWindowButtonVisibility: (_v: boolean) => {
|
||||
// will be stubbed later
|
||||
},
|
||||
webContents: {
|
||||
send: (_type: string, ..._args: any[]) => {
|
||||
// will be stubbed later
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const ipcMain = {
|
||||
handle: (key: string, callback: (...args: any[]) => Promise<any>) => {
|
||||
const handlers = registeredHandlers.get(key) || [];
|
||||
handlers.push(callback);
|
||||
registeredHandlers.set(key, handlers);
|
||||
},
|
||||
setMaxListeners: (_n: number) => {
|
||||
// noop
|
||||
},
|
||||
};
|
||||
|
||||
const nativeTheme = {
|
||||
themeSource: 'light',
|
||||
};
|
||||
|
||||
const electronModule = {
|
||||
app: {
|
||||
getPath: (name: string) => {
|
||||
if (name === 'sessionData') {
|
||||
return SESSION_DATA_PATH;
|
||||
} else if (name === 'documents') {
|
||||
return DOCUMENTS_PATH;
|
||||
}
|
||||
throw new Error('not implemented');
|
||||
},
|
||||
name: 'affine-test',
|
||||
on: (name: string, callback: (...args: any[]) => any) => {
|
||||
const handlers = registeredHandlers.get(name) || [];
|
||||
handlers.push(callback);
|
||||
registeredHandlers.set(name, handlers);
|
||||
},
|
||||
addListener: (...args: any[]) => {
|
||||
// @ts-expect-error
|
||||
electronModule.app.on(...args);
|
||||
},
|
||||
removeListener: () => {},
|
||||
},
|
||||
BrowserWindow: {
|
||||
getAllWindows: () => {
|
||||
return [browserWindow];
|
||||
},
|
||||
},
|
||||
utilityProcess: {},
|
||||
nativeTheme: nativeTheme,
|
||||
ipcMain,
|
||||
shell: {} as Partial<Electron.Shell>,
|
||||
dialog: {} as Partial<Electron.Dialog>,
|
||||
};
|
||||
|
||||
// dynamically import handlers so that we can inject local variables to mocks
|
||||
vi.doMock('electron', () => {
|
||||
return electronModule;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const { registerHandlers } = await import('../handlers');
|
||||
registerHandlers();
|
||||
|
||||
// should also register events
|
||||
const { registerEvents } = await import('../events');
|
||||
registerEvents();
|
||||
await fs.mkdirp(SESSION_DATA_PATH);
|
||||
|
||||
registeredHandlers.get('ready')?.forEach(fn => fn());
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// reset registered handlers
|
||||
registeredHandlers.get('before-quit')?.forEach(fn => fn());
|
||||
await removeWithRetry(SESSION_DATA_PATH);
|
||||
});
|
||||
|
||||
describe('UI handlers', () => {
|
||||
test('theme-change', async () => {
|
||||
await dispatch('ui', 'handleThemeChange', 'dark');
|
||||
expect(nativeTheme.themeSource).toBe('dark');
|
||||
await dispatch('ui', 'handleThemeChange', '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', 'handleSidebarVisibilityChange', true);
|
||||
expect(setWindowButtonVisibility).toBeCalledWith(true);
|
||||
await dispatch('ui', 'handleSidebarVisibilityChange', 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', 'handleSidebarVisibilityChange', true);
|
||||
expect(setWindowButtonVisibility).not.toBeCalled();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
describe('applicationMenu', () => {
|
||||
// test some basic IPC events
|
||||
test('applicationMenu event', async () => {
|
||||
const { applicationMenuSubjects } = await import('../application-menu');
|
||||
const sendStub = vi.fn();
|
||||
browserWindow.webContents.send = sendStub;
|
||||
applicationMenuSubjects.newPageAction.next();
|
||||
expect(sendStub).toHaveBeenCalledWith(
|
||||
'applicationMenu:onNewPageAction',
|
||||
undefined
|
||||
);
|
||||
browserWindow.webContents.send = () => {};
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { app, Menu } from 'electron';
|
||||
|
||||
import { isMacOS } from '../../shared/utils';
|
||||
import { revealLogFile } from '../logger';
|
||||
import { checkForUpdates } from '../updater';
|
||||
import { applicationMenuSubjects } from './subject';
|
||||
|
||||
// Unique id for menuitems
|
||||
const MENUITEM_NEW_PAGE = 'affine:new-page';
|
||||
|
||||
export function createApplicationMenu() {
|
||||
const isMac = isMacOS();
|
||||
|
||||
// Electron menu cannot be modified
|
||||
// You have to copy the complete default menu template event if you want to add a single custom item
|
||||
// See https://www.electronjs.org/docs/latest/api/menu#examples
|
||||
const template = [
|
||||
// { role: 'appMenu' }
|
||||
...(isMac
|
||||
? [
|
||||
{
|
||||
label: app.name,
|
||||
submenu: [
|
||||
{ role: 'about' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'services' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'hide' },
|
||||
{ role: 'hideOthers' },
|
||||
{ role: 'unhide' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'quit' },
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
// { role: 'fileMenu' }
|
||||
{
|
||||
label: 'File',
|
||||
submenu: [
|
||||
{
|
||||
id: MENUITEM_NEW_PAGE,
|
||||
label: 'New Page',
|
||||
accelerator: isMac ? 'Cmd+N' : 'Ctrl+N',
|
||||
click: () => {
|
||||
applicationMenuSubjects.newPageAction.next();
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
isMac ? { role: 'close' } : { role: 'quit' },
|
||||
],
|
||||
},
|
||||
// { role: 'editMenu' }
|
||||
{
|
||||
label: 'Edit',
|
||||
submenu: [
|
||||
{ role: 'undo' },
|
||||
{ role: 'redo' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'cut' },
|
||||
{ role: 'copy' },
|
||||
{ role: 'paste' },
|
||||
...(isMac
|
||||
? [
|
||||
{ role: 'pasteAndMatchStyle' },
|
||||
{ role: 'delete' },
|
||||
{ role: 'selectAll' },
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Speech',
|
||||
submenu: [{ role: 'startSpeaking' }, { role: 'stopSpeaking' }],
|
||||
},
|
||||
]
|
||||
: [{ role: 'delete' }, { type: 'separator' }, { role: 'selectAll' }]),
|
||||
],
|
||||
},
|
||||
// { role: 'viewMenu' }
|
||||
{
|
||||
label: 'View',
|
||||
submenu: [
|
||||
{ role: 'reload' },
|
||||
{ role: 'forceReload' },
|
||||
{ role: 'toggleDevTools' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'resetZoom' },
|
||||
{ role: 'zoomIn' },
|
||||
{ role: 'zoomOut' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'togglefullscreen' },
|
||||
],
|
||||
},
|
||||
// { role: 'windowMenu' }
|
||||
{
|
||||
label: 'Window',
|
||||
submenu: [
|
||||
{ role: 'minimize' },
|
||||
{ role: 'zoom' },
|
||||
...(isMac
|
||||
? [
|
||||
{ type: 'separator' },
|
||||
{ role: 'front' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'window' },
|
||||
]
|
||||
: [{ role: 'close' }]),
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'help',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Learn More',
|
||||
click: async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { shell } = require('electron');
|
||||
await shell.openExternal('https://affine.pro/');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Open log file',
|
||||
click: async () => {
|
||||
await revealLogFile();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Check for Updates',
|
||||
click: async () => {
|
||||
await checkForUpdates(true);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// @ts-expect-error: The snippet is copied from Electron official docs.
|
||||
// It's working as expected. No idea why it contains type errors.
|
||||
// Just ignore for now.
|
||||
const menu = Menu.buildFromTemplate(template);
|
||||
Menu.setApplicationMenu(menu);
|
||||
|
||||
return menu;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { MainEventRegister } from '../type';
|
||||
import { applicationMenuSubjects } from './subject';
|
||||
|
||||
export * from './create';
|
||||
export * from './subject';
|
||||
|
||||
/**
|
||||
* Events triggered by application menu
|
||||
*/
|
||||
export const applicationMenuEvents = {
|
||||
/**
|
||||
* File -> New Page
|
||||
*/
|
||||
onNewPageAction: (fn: () => void) => {
|
||||
const sub = applicationMenuSubjects.newPageAction.subscribe(fn);
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
};
|
||||
},
|
||||
} satisfies Record<string, MainEventRegister>;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
export const applicationMenuSubjects = {
|
||||
newPageAction: new Subject<void>(),
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { clipboard, type IpcMainInvokeEvent, nativeImage } from 'electron';
|
||||
|
||||
import type { NamespaceHandlers } from '../type';
|
||||
|
||||
export const clipboardHandlers = {
|
||||
copyAsImageFromString: async (_: IpcMainInvokeEvent, dataURL: string) => {
|
||||
clipboard.writeImage(nativeImage.createFromDataURL(dataURL));
|
||||
},
|
||||
} satisfies NamespaceHandlers;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ReleaseTypeSchema = z.enum([
|
||||
'stable',
|
||||
'beta',
|
||||
'canary',
|
||||
'internal',
|
||||
]);
|
||||
|
||||
export const envBuildType = (
|
||||
process.env.BUILD_TYPE_OVERRIDE ||
|
||||
process.env.BUILD_TYPE ||
|
||||
'canary'
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
export const overrideSession = process.env.BUILD_TYPE === 'internal';
|
||||
|
||||
export const buildType = ReleaseTypeSchema.parse(envBuildType);
|
||||
|
||||
export const mode = process.env.NODE_ENV;
|
||||
export const isDev = mode === 'development';
|
||||
|
||||
const API_URL_MAPPING = {
|
||||
stable: `https://app.affine.pro`,
|
||||
beta: `https://insider.affine.pro`,
|
||||
canary: `https://affine.fail`,
|
||||
internal: `https://insider.affine.pro`,
|
||||
};
|
||||
|
||||
export const CLOUD_BASE_URL =
|
||||
process.env.DEV_SERVER_URL || API_URL_MAPPING[buildType];
|
||||
@@ -0,0 +1,116 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { type App, type BrowserWindow, ipcMain } from 'electron';
|
||||
|
||||
import { buildType, CLOUD_BASE_URL, isDev } from './config';
|
||||
import { logger } from './logger';
|
||||
import {
|
||||
handleOpenUrlInHiddenWindow,
|
||||
mainWindowOrigin,
|
||||
removeCookie,
|
||||
restoreOrCreateWindow,
|
||||
setCookie,
|
||||
} from './main-window';
|
||||
|
||||
let protocol = buildType === 'stable' ? 'affine' : `affine-${buildType}`;
|
||||
if (isDev) {
|
||||
protocol = 'affine-dev';
|
||||
}
|
||||
|
||||
export function setupDeepLink(app: App) {
|
||||
if (process.defaultApp) {
|
||||
if (process.argv.length >= 2) {
|
||||
app.setAsDefaultProtocolClient(protocol, process.execPath, [
|
||||
path.resolve(process.argv[1]),
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
app.setAsDefaultProtocolClient(protocol);
|
||||
}
|
||||
|
||||
app.on('open-url', (event, url) => {
|
||||
if (url.startsWith(`${protocol}://`)) {
|
||||
event.preventDefault();
|
||||
handleAffineUrl(url).catch(e => {
|
||||
logger.error('failed to handle affine url', e);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// on windows & linux, we need to listen for the second-instance event
|
||||
app.on('second-instance', (event, commandLine) => {
|
||||
restoreOrCreateWindow()
|
||||
.then(() => {
|
||||
const url = commandLine.pop();
|
||||
if (url?.startsWith(`${protocol}://`)) {
|
||||
event.preventDefault();
|
||||
handleAffineUrl(url).catch(e => {
|
||||
logger.error('failed to handle affine url', e);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(e => console.error('Failed to restore or create window:', e));
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAffineUrl(url: string) {
|
||||
logger.info('open affine url', url);
|
||||
const urlObj = new URL(url);
|
||||
logger.info('handle affine schema action', urlObj.hostname);
|
||||
// handle more actions here
|
||||
// hostname is the action name
|
||||
if (urlObj.hostname === 'signin-redirect') {
|
||||
await handleOauthJwt(url);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOauthJwt(url: string) {
|
||||
if (url) {
|
||||
try {
|
||||
const mainWindow = await restoreOrCreateWindow();
|
||||
mainWindow.show();
|
||||
const urlObj = new URL(url);
|
||||
const token = urlObj.searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
logger.error('no token in url', url);
|
||||
return;
|
||||
}
|
||||
|
||||
const isSecure = CLOUD_BASE_URL.startsWith('https://');
|
||||
|
||||
// set token to cookie
|
||||
await setCookie({
|
||||
url: CLOUD_BASE_URL,
|
||||
httpOnly: true,
|
||||
value: token,
|
||||
secure: true,
|
||||
name: isSecure
|
||||
? '__Secure-next-auth.session-token'
|
||||
: 'next-auth.session-token',
|
||||
expirationDate: Math.floor(Date.now() / 1000 + 3600 * 24 * 7),
|
||||
});
|
||||
|
||||
// force reset next-auth.callback-url
|
||||
// there could be incorrect callback-url in cookie that will cause auth failure
|
||||
// so we need to reset it to empty to mitigate this issue
|
||||
await removeCookie(
|
||||
CLOUD_BASE_URL,
|
||||
isSecure ? '__Secure-next-auth.callback-url' : 'next-auth.callback-url'
|
||||
);
|
||||
|
||||
let hiddenWindow: BrowserWindow | null = null;
|
||||
|
||||
ipcMain.once('affine:login', () => {
|
||||
hiddenWindow?.destroy();
|
||||
});
|
||||
|
||||
// hacks to refresh auth state in the main window
|
||||
hiddenWindow = await handleOpenUrlInHiddenWindow(
|
||||
mainWindowOrigin + '/auth/signIn'
|
||||
);
|
||||
} catch (e) {
|
||||
logger.error('failed to open url in popup', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
|
||||
import { applicationMenuEvents } from './application-menu';
|
||||
import { logger } from './logger';
|
||||
import { uiEvents } from './ui';
|
||||
import { updaterEvents } from './updater/event';
|
||||
|
||||
export const allEvents = {
|
||||
applicationMenu: applicationMenuEvents,
|
||||
updater: updaterEvents,
|
||||
ui: uiEvents,
|
||||
};
|
||||
|
||||
function getActiveWindows() {
|
||||
return BrowserWindow.getAllWindows().filter(win => !win.isDestroyed());
|
||||
}
|
||||
|
||||
export function registerEvents() {
|
||||
// register events
|
||||
for (const [namespace, namespaceEvents] of Object.entries(allEvents)) {
|
||||
for (const [key, eventRegister] of Object.entries(namespaceEvents)) {
|
||||
const subscription = eventRegister((...args: any[]) => {
|
||||
const chan = `${namespace}:${key}`;
|
||||
logger.info(
|
||||
'[ipc-event]',
|
||||
chan,
|
||||
args.filter(
|
||||
a =>
|
||||
a !== undefined &&
|
||||
typeof a !== 'function' &&
|
||||
typeof a !== 'object'
|
||||
)
|
||||
);
|
||||
getActiveWindows().forEach(win => win.webContents.send(chan, ...args));
|
||||
});
|
||||
app.on('before-quit', () => {
|
||||
subscription();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { NamespaceHandlers } from '../type';
|
||||
import { savePDFFileAs } from './pdf';
|
||||
|
||||
export const exportHandlers = {
|
||||
savePDFFileAs: async (_, title: string) => {
|
||||
return savePDFFileAs(title);
|
||||
},
|
||||
} satisfies NamespaceHandlers;
|
||||
|
||||
export * from './pdf';
|
||||
@@ -0,0 +1,90 @@
|
||||
import { BrowserWindow, dialog } from 'electron';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import { logger } from '../logger';
|
||||
import type { ErrorMessage } from './utils';
|
||||
import { getFakedResult } from './utils';
|
||||
|
||||
export interface SavePDFFileResult {
|
||||
filePath?: string;
|
||||
canceled?: boolean;
|
||||
error?: ErrorMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is called when the user clicks the "Export to PDF" button in the electron.
|
||||
*
|
||||
* It will just copy the file to the given path
|
||||
*/
|
||||
export async function savePDFFileAs(
|
||||
pageTitle: string
|
||||
): Promise<SavePDFFileResult> {
|
||||
try {
|
||||
const ret =
|
||||
getFakedResult() ??
|
||||
(await dialog.showSaveDialog({
|
||||
properties: ['showOverwriteConfirmation'],
|
||||
title: 'Save PDF',
|
||||
showsTagField: false,
|
||||
buttonLabel: 'Save',
|
||||
defaultPath: `${pageTitle}.pdf`,
|
||||
message: 'Save Page as a PDF file',
|
||||
filters: [{ name: 'PDF Files', extensions: ['pdf'] }],
|
||||
}));
|
||||
const filePath = ret.filePath;
|
||||
if (ret.canceled || !filePath) {
|
||||
return {
|
||||
canceled: true,
|
||||
};
|
||||
}
|
||||
|
||||
await BrowserWindow.getFocusedWindow()
|
||||
?.webContents.printToPDF({
|
||||
pageSize: 'A4',
|
||||
margins: {
|
||||
bottom: 0.5,
|
||||
},
|
||||
printBackground: true,
|
||||
landscape: false,
|
||||
displayHeaderFooter: true,
|
||||
headerTemplate: '<div></div>',
|
||||
footerTemplate: getFootTemple(),
|
||||
})
|
||||
.then(data => {
|
||||
fs.writeFile(filePath, data, error => {
|
||||
if (error) throw error;
|
||||
logger.log(`Wrote PDF successfully to ${filePath}`);
|
||||
});
|
||||
});
|
||||
return { filePath };
|
||||
} catch (err) {
|
||||
logger.error('savePDFFileAs', err);
|
||||
return {
|
||||
error: 'UNKNOWN_ERROR',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getFootTemple(): string {
|
||||
const logo = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="53" height="12" viewBox="0 0 53 12" fill="none">
|
||||
<path d="M18.9256 0.709372C18.8749 0.504937 18.6912 0.361572 18.4807 0.361572H17.77C17.5595 0.361572 17.3758 0.504937 17.3252 0.709372L14.9153 10.4283C14.8438 10.7172 15.0621 10.9965 15.3601 10.9965H15.6052C15.8183 10.9965 16.0033 10.8497 16.0513 10.6423L16.5646 8.43721C16.6127 8.22974 16.7976 8.08291 17.0107 8.08291H19.2396C19.4527 8.08291 19.6376 8.22974 19.6857 8.43721L20.199 10.6423C20.247 10.8497 20.432 10.9965 20.6451 10.9965H20.8902C21.1878 10.9965 21.4065 10.7172 21.335 10.4283L18.9251 0.709372H18.9256ZM18.7891 7.0629H17.4616C17.1666 7.0629 16.9483 6.7883 17.0155 6.50113L17.9025 2.23181C17.9575 1.99576 18.2936 1.99576 18.3486 2.23181L19.2357 6.50113C19.3024 6.7883 19.0845 7.0629 18.7896 7.0629H18.7891Z" fill="black" fill-opacity="0.1"/>
|
||||
<path d="M36.2654 5.00861H30.766C30.5131 5.00861 30.3078 4.8033 30.3078 4.55036V2.25132C30.3078 1.77055 30.6976 1.38074 31.1783 1.38074H33.8997C34.1526 1.38074 34.3579 1.17544 34.3579 0.922494V0.818977C34.3579 0.566031 34.1526 0.36073 33.8997 0.36073H30.8539C29.8924 0.36073 29.1132 1.14036 29.1132 2.10146V5.00774H24.2171C23.9642 5.00774 23.7589 4.80244 23.7589 4.54949V2.25046C23.7589 1.76969 24.1487 1.37988 24.6295 1.37988H27.3508C27.6038 1.37988 27.8091 1.17457 27.8091 0.921628V0.818111C27.8091 0.565165 27.6038 0.359863 27.3508 0.359863H24.3051C23.3435 0.359863 22.5643 1.13949 22.5643 2.1006V10.5366C22.5643 10.7895 22.7696 10.9948 23.0226 10.9948H23.3011C23.554 10.9948 23.7593 10.7895 23.7593 10.5366V6.48513C23.7593 6.23219 23.9646 6.02689 24.2176 6.02689H29.1136V10.5366C29.1136 10.7895 29.3189 10.9948 29.5719 10.9948H29.8504C30.1033 10.9948 30.3086 10.7895 30.3086 10.5366V6.48513C30.3086 6.23219 30.5139 6.02689 30.7669 6.02689H35.9713C36.4521 6.02689 36.8419 6.4167 36.8419 6.89747V10.5418C36.8419 10.7947 37.0472 11 37.3001 11H37.5492C37.8021 11 38.0074 10.7947 38.0074 10.5418V6.74804C38.0074 5.7865 37.2278 5.00731 36.2667 5.00731L36.2654 5.00861Z" fill="black" fill-opacity="0.1"/>
|
||||
<path d="M45.2871 0.361517H45.0363C44.7838 0.361517 44.5789 0.565953 44.5781 0.818032L44.5504 9.53946L42.0512 0.695024C41.9954 0.497519 41.8156 0.361517 41.6103 0.361517H40.521C40.268 0.361517 40.0627 0.566819 40.0627 0.819765V10.5387C40.0627 10.7916 40.268 10.9969 40.521 10.9969H40.7718C41.0243 10.9969 41.2292 10.7925 41.23 10.5404L41.2577 1.81899L43.7569 10.6634C43.8128 10.8609 43.9925 10.9969 44.1978 10.9969H45.2871C45.5401 10.9969 45.7454 10.7916 45.7454 10.5387V0.819331C45.7454 0.566386 45.5401 0.361084 45.2871 0.361084V0.361517Z" fill="black" fill-opacity="0.1"/>
|
||||
<path d="M49.2307 1.3811H51.8212C52.0741 1.3811 52.2794 1.17579 52.2794 0.922849V0.819331C52.2794 0.566386 52.0741 0.361084 51.8212 0.361084H48.9214C47.9599 0.361084 47.1807 1.14071 47.1807 2.10182V9.25489C47.1807 10.2164 47.9603 10.9956 48.9214 10.9956H51.8212C52.0741 10.9956 52.2794 10.7903 52.2794 10.5374V10.4339C52.2794 10.1809 52.0741 9.97562 51.8212 9.97562H49.2307C48.7499 9.97562 48.3601 9.5858 48.3601 9.10503V6.33996C48.3601 6.08701 48.5654 5.88171 48.8183 5.88171H51.6752C51.9282 5.88171 52.1335 5.67641 52.1335 5.42346V5.31994C52.1335 5.067 51.9282 4.8617 51.6752 4.8617H48.8183C48.5654 4.8617 48.3601 4.65639 48.3601 4.40345V2.24995C48.3601 1.76918 48.7499 1.37936 49.2307 1.37936V1.3811Z" fill="black" fill-opacity="0.1"/>
|
||||
<path d="M37.3088 1.65787C37.1052 1.4543 36.7583 1.54742 36.6838 1.82549L36.3473 3.08199C36.2728 3.35962 36.527 3.61387 36.8051 3.5398L38.0616 3.20326C38.3396 3.12876 38.4323 2.7814 38.2292 2.57826L37.3097 1.65873L37.3088 1.65787Z" fill="black" fill-opacity="0.1"/>
|
||||
<path d="M11.9043 9.92891C11.8624 9.85125 11.3139 8.91339 11.2674 8.82602C9.92288 6.49855 7.98407 3.13718 6.64195 0.814557C6.37775 0.29657 5.6448 0.286862 5.36882 0.795141C3.92685 3.2932 1.71414 7.12436 0.274242 9.6193C0.214607 9.72505 0.118915 9.88037 0.0617076 9.99687C-0.035025 10.2063 -0.0163025 10.4677 0.10574 10.6608C0.238877 10.8858 0.502032 11.0165 0.759985 11.0027H0.8269C2.06986 11.0009 9.86983 11.0048 11.2844 11.0027C11.8329 11.0041 12.1779 10.4022 11.904 9.92926L11.9043 9.92891ZM6.09068 1.66053C6.91793 3.09661 7.8967 4.79099 8.85535 6.45036C8.47016 6.21355 8.05792 6.02875 7.6169 5.91711C7.49763 5.89007 7.04136 5.83529 6.94289 5.83113C6.9207 5.82939 6.89851 5.82835 6.87598 5.82835H5.12474C4.97288 5.82835 4.82622 5.86753 4.69655 5.93757C4.53325 5.14845 4.68199 4.26468 4.8914 3.51683C4.90978 3.45269 4.92954 3.38889 4.9493 3.3251C5.29636 2.7239 5.62366 2.15737 5.91073 1.65984C5.95095 1.5905 6.0508 1.59084 6.09068 1.65984V1.66053ZM6.15412 8.25707C6.15412 8.25707 6.12327 8.30492 6.09692 8.34583C6.07161 8.37079 6.03728 8.38535 5.99984 8.38535C5.94956 8.38535 5.90484 8.35935 5.87988 8.31601L5.03841 6.85809C5.03841 6.85809 5.0124 6.80747 4.98987 6.76413C4.98085 6.72946 4.98571 6.69271 5.00408 6.66046C5.02905 6.61712 5.07412 6.59112 5.12404 6.59112H6.80733C6.80733 6.59112 6.86419 6.59389 6.91308 6.59632C6.9474 6.60568 6.97722 6.62822 6.99559 6.66046C7.02056 6.7038 7.02056 6.75581 6.99559 6.79915L6.15378 8.25707H6.15412ZM1.12681 9.94521C1.30502 9.63733 1.53766 9.23653 1.5914 9.14084C2.1971 8.09169 3.04585 6.62163 3.89252 5.15539C3.88004 5.60715 3.92616 6.05684 4.04993 6.49473C4.08599 6.61158 4.26697 7.03422 4.31274 7.12159C4.32591 7.14967 4.34256 7.17914 4.35851 7.20619L5.21939 8.69739C5.29532 8.8288 5.40245 8.93628 5.52796 9.01359C4.92607 9.54961 4.08634 9.86269 3.33397 10.0551C3.27191 10.0707 3.20916 10.0849 3.14675 10.0988C2.34827 10.0988 1.66837 10.0995 1.21695 10.1009C1.13651 10.1009 1.08659 10.0142 1.12681 9.94486V9.94521ZM10.7834 10.1016C9.65661 10.1026 7.37212 10.1005 5.25475 10.0995C5.65139 9.88453 6.01683 9.62034 6.33337 9.29478C6.41624 9.20498 6.69222 8.83712 6.74492 8.75391C6.7626 8.72825 6.77994 8.69913 6.79519 8.67208L7.65608 7.18088C7.73201 7.04947 7.77153 6.90281 7.77569 6.75546C8.54089 7.00856 9.23188 7.57925 9.77449 8.13468C9.82129 8.18322 9.86706 8.23245 9.91248 8.28203C10.2464 8.86035 10.5695 9.41994 10.8729 9.9459C10.9127 10.0152 10.8632 10.1019 10.7831 10.1019L10.7834 10.1016Z" fill="black" fill-opacity="0.1"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
const footerTemp = `
|
||||
<div style="font-size: 14px; width: 100%; display: flex; justify-content: flex-end; margin-right: 40px;">
|
||||
<a href="https://affine.pro" style="display: flex; text-decoration: none; color: rgba(0, 0, 0, 0.1);">
|
||||
<span>Created with</span>
|
||||
<div style="display: flex; align-items: center;">${logo}</div>
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return footerTemp;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// provide a backdoor to set dialog path for testing in playwright
|
||||
interface FakeDialogResult {
|
||||
canceled?: boolean;
|
||||
filePath?: string;
|
||||
filePaths?: string[];
|
||||
}
|
||||
// result will be used in the next call to showOpenDialog
|
||||
// if it is being read once, it will be reset to undefined
|
||||
let fakeDialogResult: FakeDialogResult | undefined = undefined;
|
||||
export function getFakedResult() {
|
||||
const result = fakeDialogResult;
|
||||
fakeDialogResult = undefined;
|
||||
return result;
|
||||
}
|
||||
|
||||
export function setFakeDialogResult(result: FakeDialogResult | undefined) {
|
||||
fakeDialogResult = result;
|
||||
// for convenience, we will fill filePaths with filePath if it is not set
|
||||
if (result?.filePaths === undefined && result?.filePath !== undefined) {
|
||||
result.filePaths = [result.filePath];
|
||||
}
|
||||
}
|
||||
const ErrorMessages = ['FILE_ALREADY_EXISTS', 'UNKNOWN_ERROR'] as const;
|
||||
export type ErrorMessage = (typeof ErrorMessages)[number];
|
||||
@@ -0,0 +1,29 @@
|
||||
import { allEvents as events } from './events';
|
||||
import { allHandlers as handlers } from './handlers';
|
||||
|
||||
// this will be used by preload script to expose all handlers and events to the renderer process
|
||||
// - register in exposeInMainWorld in preload
|
||||
// - provide type hints
|
||||
export { events, handlers };
|
||||
|
||||
export const getExposedMeta = () => {
|
||||
const handlersMeta = Object.entries(handlers).map(
|
||||
([namespace, namespaceHandlers]) => {
|
||||
return [namespace, Object.keys(namespaceHandlers)];
|
||||
}
|
||||
);
|
||||
|
||||
const eventsMeta = Object.entries(events).map(
|
||||
([namespace, namespaceHandlers]) => {
|
||||
return [namespace, Object.keys(namespaceHandlers)];
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
handlers: handlersMeta,
|
||||
events: eventsMeta,
|
||||
};
|
||||
};
|
||||
|
||||
export type MainIPCHandlerMap = typeof handlers;
|
||||
export type MainIPCEventMap = typeof events;
|
||||
@@ -0,0 +1,85 @@
|
||||
import type {
|
||||
ClipboardHandlerManager,
|
||||
DebugHandlerManager,
|
||||
ExportHandlerManager,
|
||||
UIHandlerManager,
|
||||
UnwrapManagerHandlerToServerSide,
|
||||
UpdaterHandlerManager,
|
||||
} from '@toeverything/infra/index';
|
||||
import { ipcMain } from 'electron';
|
||||
|
||||
import { clipboardHandlers } from './clipboard';
|
||||
import { exportHandlers } from './export';
|
||||
import { getLogFilePath, logger, revealLogFile } from './logger';
|
||||
import { uiHandlers } from './ui';
|
||||
import { updaterHandlers } from './updater';
|
||||
|
||||
export const debugHandlers = {
|
||||
revealLogFile: async () => {
|
||||
return revealLogFile();
|
||||
},
|
||||
logFilePath: async () => {
|
||||
return getLogFilePath();
|
||||
},
|
||||
};
|
||||
|
||||
type AllHandlers = {
|
||||
debug: UnwrapManagerHandlerToServerSide<
|
||||
Electron.IpcMainInvokeEvent,
|
||||
DebugHandlerManager
|
||||
>;
|
||||
clipboard: UnwrapManagerHandlerToServerSide<
|
||||
Electron.IpcMainInvokeEvent,
|
||||
ClipboardHandlerManager
|
||||
>;
|
||||
export: UnwrapManagerHandlerToServerSide<
|
||||
Electron.IpcMainInvokeEvent,
|
||||
ExportHandlerManager
|
||||
>;
|
||||
ui: UnwrapManagerHandlerToServerSide<
|
||||
Electron.IpcMainInvokeEvent,
|
||||
UIHandlerManager
|
||||
>;
|
||||
updater: UnwrapManagerHandlerToServerSide<
|
||||
Electron.IpcMainInvokeEvent,
|
||||
UpdaterHandlerManager
|
||||
>;
|
||||
};
|
||||
|
||||
// Note: all of these handlers will be the single-source-of-truth for the apis exposed to the renderer process
|
||||
export const allHandlers = {
|
||||
debug: debugHandlers,
|
||||
ui: uiHandlers,
|
||||
clipboard: clipboardHandlers,
|
||||
export: exportHandlers,
|
||||
updater: updaterHandlers,
|
||||
} satisfies AllHandlers;
|
||||
|
||||
export const registerHandlers = () => {
|
||||
// TODO: listen to namespace instead of individual event types
|
||||
ipcMain.setMaxListeners(100);
|
||||
for (const [namespace, namespaceHandlers] of Object.entries(allHandlers)) {
|
||||
for (const [key, handler] of Object.entries(namespaceHandlers)) {
|
||||
const chan = `${namespace}:${key}`;
|
||||
ipcMain.handle(chan, async (e, ...args) => {
|
||||
const start = performance.now();
|
||||
try {
|
||||
const result = await handler(e, ...args);
|
||||
logger.info(
|
||||
'[ipc-api]',
|
||||
chan,
|
||||
args.filter(
|
||||
arg => typeof arg !== 'function' && typeof arg !== 'object'
|
||||
),
|
||||
'-',
|
||||
(performance.now() - start).toFixed(2),
|
||||
'ms'
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('[ipc]', chan, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import type {
|
||||
HelperToMain,
|
||||
MainToHelper,
|
||||
} from '@toeverything/infra/preload/electron';
|
||||
import { type _AsyncVersionOf, AsyncCall } from 'async-call-rpc';
|
||||
import {
|
||||
app,
|
||||
dialog,
|
||||
MessageChannelMain,
|
||||
shell,
|
||||
type UtilityProcess,
|
||||
utilityProcess,
|
||||
type WebContents,
|
||||
} from 'electron';
|
||||
|
||||
import { MessageEventChannel } from '../shared/utils';
|
||||
import { logger } from './logger';
|
||||
|
||||
const HELPER_PROCESS_PATH = path.join(__dirname, './helper.js');
|
||||
|
||||
function pickAndBind<T extends object, U extends keyof T>(
|
||||
obj: T,
|
||||
keys: U[]
|
||||
): { [K in U]: T[K] } {
|
||||
return keys.reduce((acc, key) => {
|
||||
const prop = obj[key];
|
||||
acc[key] = typeof prop === 'function' ? prop.bind(obj) : prop;
|
||||
return acc;
|
||||
}, {} as any);
|
||||
}
|
||||
|
||||
class HelperProcessManager {
|
||||
ready: Promise<void>;
|
||||
#process: UtilityProcess;
|
||||
|
||||
// a rpc server for the main process -> helper process
|
||||
rpc?: _AsyncVersionOf<HelperToMain>;
|
||||
|
||||
static _instance: HelperProcessManager | null = null;
|
||||
|
||||
static get instance() {
|
||||
if (!this._instance) {
|
||||
this._instance = new HelperProcessManager();
|
||||
}
|
||||
return this._instance;
|
||||
}
|
||||
|
||||
private constructor() {
|
||||
const helperProcess = utilityProcess.fork(HELPER_PROCESS_PATH);
|
||||
this.#process = helperProcess;
|
||||
this.ready = new Promise((resolve, reject) => {
|
||||
helperProcess.once('spawn', () => {
|
||||
try {
|
||||
this.#connectMain();
|
||||
resolve();
|
||||
} catch (err) {
|
||||
logger.error('[helper] connectMain error', err);
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
this.#process.kill();
|
||||
});
|
||||
}
|
||||
|
||||
// bridge renderer <-> helper process
|
||||
connectRenderer(renderer: WebContents) {
|
||||
// connect to the helper process
|
||||
const { port1: helperPort, port2: rendererPort } = new MessageChannelMain();
|
||||
this.#process.postMessage({ channel: 'renderer-connect' }, [helperPort]);
|
||||
renderer.postMessage('helper-connection', null, [rendererPort]);
|
||||
|
||||
return () => {
|
||||
helperPort.close();
|
||||
rendererPort.close();
|
||||
};
|
||||
}
|
||||
|
||||
// bridge main <-> helper process
|
||||
// also set up the RPC to the helper process
|
||||
#connectMain() {
|
||||
const dialogMethods = pickAndBind(dialog, [
|
||||
'showOpenDialog',
|
||||
'showSaveDialog',
|
||||
]);
|
||||
const shellMethods = pickAndBind(shell, [
|
||||
'openExternal',
|
||||
'showItemInFolder',
|
||||
]);
|
||||
const appMethods = pickAndBind(app, ['getPath']);
|
||||
|
||||
const mainToHelperServer: MainToHelper = {
|
||||
...dialogMethods,
|
||||
...shellMethods,
|
||||
...appMethods,
|
||||
};
|
||||
|
||||
this.rpc = AsyncCall<HelperToMain>(mainToHelperServer, {
|
||||
strict: {
|
||||
// the channel is shared for other purposes as well so that we do not want to
|
||||
// restrict to only JSONRPC messages
|
||||
unknownMessage: false,
|
||||
},
|
||||
channel: new MessageEventChannel(this.#process),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureHelperProcess() {
|
||||
const helperProcessManager = HelperProcessManager.instance;
|
||||
await helperProcessManager.ready;
|
||||
return helperProcessManager;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import './security-restrictions';
|
||||
|
||||
import path from 'node:path';
|
||||
|
||||
import { app } from 'electron';
|
||||
|
||||
import { createApplicationMenu } from './application-menu/create';
|
||||
import { buildType, overrideSession } from './config';
|
||||
import { setupDeepLink } from './deep-link';
|
||||
import { registerEvents } from './events';
|
||||
import { registerHandlers } from './handlers';
|
||||
import { ensureHelperProcess } from './helper-process';
|
||||
import { logger } from './logger';
|
||||
import { restoreOrCreateWindow } from './main-window';
|
||||
import { registerProtocol } from './protocol';
|
||||
import { registerUpdater } from './updater';
|
||||
|
||||
app.enableSandbox();
|
||||
|
||||
// use the same data for internal & beta for testing
|
||||
if (overrideSession) {
|
||||
const appName = buildType === 'stable' ? 'AFFiNE' : `AFFiNE-${buildType}`;
|
||||
const userDataPath = path.join(app.getPath('appData'), appName);
|
||||
app.setPath('userData', userDataPath);
|
||||
app.setPath('sessionData', userDataPath);
|
||||
}
|
||||
|
||||
if (require('electron-squirrel-startup')) app.quit();
|
||||
// allow tests to overwrite app name through passing args
|
||||
if (process.argv.includes('--app-name')) {
|
||||
const appNameIndex = process.argv.indexOf('--app-name');
|
||||
const appName = process.argv[appNameIndex + 1];
|
||||
app.setName(appName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevent multiple instances
|
||||
*/
|
||||
const isSingleInstance = app.requestSingleInstanceLock();
|
||||
if (!isSingleInstance) {
|
||||
logger.info('Another instance is running, exiting...');
|
||||
app.quit();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shout down background process if all windows was closed
|
||||
*/
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @see https://www.electronjs.org/docs/v14-x-y/api/app#event-activate-macos Event: 'activate'
|
||||
*/
|
||||
app.on('activate', () => {
|
||||
restoreOrCreateWindow().catch(e =>
|
||||
console.error('Failed to restore or create window:', e)
|
||||
);
|
||||
});
|
||||
|
||||
setupDeepLink(app);
|
||||
|
||||
/**
|
||||
* Create app window when background process will be ready
|
||||
*/
|
||||
app
|
||||
.whenReady()
|
||||
.then(registerProtocol)
|
||||
.then(registerHandlers)
|
||||
.then(registerEvents)
|
||||
.then(ensureHelperProcess)
|
||||
.then(restoreOrCreateWindow)
|
||||
.then(createApplicationMenu)
|
||||
.then(registerUpdater)
|
||||
.catch(e => console.error('Failed create window:', e));
|
||||
@@ -0,0 +1,15 @@
|
||||
import { shell } from 'electron';
|
||||
import log from 'electron-log';
|
||||
|
||||
export const logger = log.scope('main');
|
||||
export const pluginLogger = log.scope('plugin');
|
||||
log.initialize();
|
||||
|
||||
export function getLogFilePath() {
|
||||
return log.transports.file.getFile().path;
|
||||
}
|
||||
|
||||
export async function revealLogFile() {
|
||||
const filePath = getLogFilePath();
|
||||
return await shell.openPath(filePath);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import assert from 'node:assert';
|
||||
|
||||
import { BrowserWindow, type CookiesSetDetails, nativeTheme } from 'electron';
|
||||
import electronWindowState from 'electron-window-state';
|
||||
import { join } from 'path';
|
||||
|
||||
import { isMacOS, isWindows } from '../shared/utils';
|
||||
import { getExposedMeta } from './exposed';
|
||||
import { ensureHelperProcess } from './helper-process';
|
||||
import { logger } from './logger';
|
||||
import { uiSubjects } from './ui';
|
||||
import { parseCookie } from './utils';
|
||||
|
||||
const IS_DEV: boolean =
|
||||
process.env.NODE_ENV === 'development' && !process.env.CI;
|
||||
|
||||
const DEV_TOOL = process.env.DEV_TOOL === 'true';
|
||||
|
||||
export const mainWindowOrigin = process.env.DEV_SERVER_URL || 'file://.';
|
||||
|
||||
async function createWindow() {
|
||||
logger.info('create window');
|
||||
const mainWindowState = electronWindowState({
|
||||
defaultWidth: 1000,
|
||||
defaultHeight: 800,
|
||||
});
|
||||
|
||||
const helperProcessManager = await ensureHelperProcess();
|
||||
const helperExposedMeta = await helperProcessManager.rpc?.getMeta();
|
||||
|
||||
assert(helperExposedMeta, 'helperExposedMeta should be defined');
|
||||
|
||||
const mainExposedMeta = getExposedMeta();
|
||||
|
||||
const browserWindow = new BrowserWindow({
|
||||
titleBarStyle: isMacOS()
|
||||
? 'hiddenInset'
|
||||
: isWindows()
|
||||
? 'hidden'
|
||||
: 'default',
|
||||
trafficLightPosition: { x: 20, y: 16 },
|
||||
x: mainWindowState.x,
|
||||
y: mainWindowState.y,
|
||||
width: mainWindowState.width,
|
||||
minWidth: 640,
|
||||
minHeight: 480,
|
||||
visualEffectState: 'active',
|
||||
vibrancy: 'under-window',
|
||||
height: mainWindowState.height,
|
||||
show: false, // Use 'ready-to-show' event to show window
|
||||
webPreferences: {
|
||||
webgl: true,
|
||||
contextIsolation: true,
|
||||
sandbox: false,
|
||||
webviewTag: false, // The webview tag is not recommended. Consider alternatives like iframe or Electron's BrowserView. https://www.electronjs.org/docs/latest/api/webview-tag#warning
|
||||
spellcheck: false, // FIXME: enable?
|
||||
preload: join(__dirname, './preload.js'),
|
||||
// serialize exposed meta that to be used in preload
|
||||
additionalArguments: [
|
||||
`--main-exposed-meta=` + JSON.stringify(mainExposedMeta),
|
||||
`--helper-exposed-meta=` + JSON.stringify(helperExposedMeta),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
nativeTheme.themeSource = 'light';
|
||||
|
||||
mainWindowState.manage(browserWindow);
|
||||
|
||||
let helperConnectionUnsub: (() => void) | undefined;
|
||||
|
||||
/**
|
||||
* If you install `show: true` then it can cause issues when trying to close the window.
|
||||
* Use `show: false` and listener events `ready-to-show` to fix these issues.
|
||||
*
|
||||
* @see https://github.com/electron/electron/issues/25012
|
||||
*/
|
||||
browserWindow.on('ready-to-show', () => {
|
||||
if (IS_DEV) {
|
||||
// do not gain focus in dev mode
|
||||
browserWindow.showInactive();
|
||||
} else {
|
||||
browserWindow.show();
|
||||
}
|
||||
helperConnectionUnsub = helperProcessManager.connectRenderer(
|
||||
browserWindow.webContents
|
||||
);
|
||||
|
||||
logger.info('main window is ready to show');
|
||||
|
||||
if (DEV_TOOL) {
|
||||
browserWindow.webContents.openDevTools({
|
||||
mode: 'detach',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
browserWindow.on('close', e => {
|
||||
e.preventDefault();
|
||||
// close and destroy all windows
|
||||
BrowserWindow.getAllWindows().forEach(w => {
|
||||
if (!w.isDestroyed()) {
|
||||
w.destroy();
|
||||
}
|
||||
});
|
||||
helperConnectionUnsub?.();
|
||||
// TODO: gracefully close the app, for example, ask user to save unsaved changes
|
||||
});
|
||||
|
||||
browserWindow.on('leave-full-screen', () => {
|
||||
// FIXME: workaround for theme bug in full screen mode
|
||||
const size = browserWindow.getSize();
|
||||
browserWindow.setSize(size[0] + 1, size[1] + 1);
|
||||
browserWindow.setSize(size[0], size[1]);
|
||||
uiSubjects.onMaximized.next(false);
|
||||
});
|
||||
|
||||
browserWindow.on('maximize', () => {
|
||||
uiSubjects.onMaximized.next(true);
|
||||
});
|
||||
|
||||
// full-screen == maximized in UI on windows
|
||||
browserWindow.on('enter-full-screen', () => {
|
||||
uiSubjects.onMaximized.next(true);
|
||||
});
|
||||
|
||||
browserWindow.on('unmaximize', () => {
|
||||
uiSubjects.onMaximized.next(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* URL for main window.
|
||||
*/
|
||||
const pageUrl = mainWindowOrigin; // see protocol.ts
|
||||
|
||||
logger.info('loading page at', pageUrl);
|
||||
|
||||
await browserWindow.loadURL(pageUrl);
|
||||
|
||||
logger.info('main window is loaded at', pageUrl);
|
||||
|
||||
return browserWindow;
|
||||
}
|
||||
|
||||
// singleton
|
||||
let browserWindow$: Promise<BrowserWindow> | undefined;
|
||||
|
||||
/**
|
||||
* Restore existing BrowserWindow or Create new BrowserWindow
|
||||
*/
|
||||
export async function restoreOrCreateWindow() {
|
||||
if (!browserWindow$ || (await browserWindow$.then(w => w.isDestroyed()))) {
|
||||
browserWindow$ = createWindow();
|
||||
}
|
||||
const mainWindow = await browserWindow$;
|
||||
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore();
|
||||
logger.info('restore main window');
|
||||
}
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
export async function handleOpenUrlInHiddenWindow(url: string) {
|
||||
const win = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 600,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, './preload.js'),
|
||||
},
|
||||
show: false,
|
||||
});
|
||||
win.on('close', e => {
|
||||
e.preventDefault();
|
||||
if (!win.isDestroyed()) {
|
||||
win.destroy();
|
||||
}
|
||||
});
|
||||
logger.info('loading page at', url);
|
||||
await win.loadURL(url);
|
||||
return win;
|
||||
}
|
||||
|
||||
export async function setCookie(cookie: CookiesSetDetails): Promise<void>;
|
||||
export async function setCookie(origin: string, cookie: string): Promise<void>;
|
||||
|
||||
export async function setCookie(
|
||||
arg0: CookiesSetDetails | string,
|
||||
arg1?: string
|
||||
) {
|
||||
const window = await restoreOrCreateWindow();
|
||||
const details =
|
||||
typeof arg1 === 'string' && typeof arg0 === 'string'
|
||||
? parseCookie(arg0, arg1)
|
||||
: arg0;
|
||||
|
||||
logger.info('setting cookie to main window', details);
|
||||
|
||||
if (typeof details !== 'object') {
|
||||
throw new Error('invalid cookie details');
|
||||
}
|
||||
|
||||
await window.webContents.session.cookies.set(details);
|
||||
}
|
||||
|
||||
export async function removeCookie(url: string, name: string): Promise<void> {
|
||||
const window = await restoreOrCreateWindow();
|
||||
await window.webContents.session.cookies.remove(url, name);
|
||||
}
|
||||
|
||||
export async function getCookie(url?: string, name?: string) {
|
||||
const window = await restoreOrCreateWindow();
|
||||
const cookies = await window.webContents.session.cookies.get({
|
||||
url,
|
||||
name,
|
||||
});
|
||||
return cookies;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { net, protocol, session } from 'electron';
|
||||
import { join } from 'path';
|
||||
|
||||
import { CLOUD_BASE_URL } from './config';
|
||||
import { logger } from './logger';
|
||||
import { getCookie } from './main-window';
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: 'assets',
|
||||
privileges: {
|
||||
secure: false,
|
||||
corsEnabled: true,
|
||||
supportFetchAPI: true,
|
||||
standard: true,
|
||||
bypassCSP: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: 'file',
|
||||
privileges: {
|
||||
secure: false,
|
||||
corsEnabled: true,
|
||||
supportFetchAPI: true,
|
||||
standard: true,
|
||||
bypassCSP: true,
|
||||
stream: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const NETWORK_REQUESTS = ['/api', '/ws', '/socket.io', '/graphql'];
|
||||
const webStaticDir = join(__dirname, '../resources/web-static');
|
||||
|
||||
function isNetworkResource(pathname: string) {
|
||||
return NETWORK_REQUESTS.some(opt => pathname.startsWith(opt));
|
||||
}
|
||||
|
||||
async function handleFileRequest(request: Request) {
|
||||
const clonedRequest = Object.assign(request.clone(), {
|
||||
bypassCustomProtocolHandlers: true,
|
||||
});
|
||||
const urlObject = new URL(request.url);
|
||||
if (isNetworkResource(urlObject.pathname)) {
|
||||
// just pass through (proxy)
|
||||
return net.fetch(CLOUD_BASE_URL + urlObject.pathname, clonedRequest);
|
||||
} else {
|
||||
// this will be file types (in the web-static folder)
|
||||
let filepath = '';
|
||||
// if is a file type, load the file in resources
|
||||
if (urlObject.pathname.split('/').at(-1)?.includes('.')) {
|
||||
filepath = join(webStaticDir, decodeURIComponent(urlObject.pathname));
|
||||
} else {
|
||||
// else, fallback to load the index.html instead
|
||||
filepath = join(webStaticDir, 'index.html');
|
||||
}
|
||||
return net.fetch('file://' + filepath, clonedRequest);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerProtocol() {
|
||||
protocol.handle('file', request => {
|
||||
return handleFileRequest(request);
|
||||
});
|
||||
|
||||
protocol.handle('assets', request => {
|
||||
return handleFileRequest(request);
|
||||
});
|
||||
|
||||
// hack for CORS
|
||||
// todo: should use a whitelist
|
||||
session.defaultSession.webRequest.onHeadersReceived(
|
||||
(responseDetails, callback) => {
|
||||
const { responseHeaders } = responseDetails;
|
||||
if (responseHeaders) {
|
||||
delete responseHeaders['access-control-allow-origin'];
|
||||
delete responseHeaders['access-control-allow-methods'];
|
||||
responseHeaders['Access-Control-Allow-Origin'] = ['*'];
|
||||
responseHeaders['Access-Control-Allow-Methods'] = [
|
||||
'GET',
|
||||
'POST',
|
||||
'PUT',
|
||||
'DELETE',
|
||||
'OPTIONS',
|
||||
];
|
||||
// replace SameSite=Lax with SameSite=None
|
||||
const originalCookie =
|
||||
responseHeaders['set-cookie'] || responseHeaders['Set-Cookie'];
|
||||
|
||||
if (originalCookie) {
|
||||
delete responseHeaders['set-cookie'];
|
||||
delete responseHeaders['Set-Cookie'];
|
||||
responseHeaders['Set-Cookie'] = originalCookie.map(cookie => {
|
||||
let newCookie = cookie.replace(/SameSite=Lax/gi, 'SameSite=None');
|
||||
|
||||
// if the cookie is not secure, set it to secure
|
||||
if (!newCookie.includes('Secure')) {
|
||||
newCookie = newCookie + '; Secure';
|
||||
}
|
||||
return newCookie;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
callback({ responseHeaders });
|
||||
}
|
||||
);
|
||||
|
||||
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
|
||||
(async () => {
|
||||
const url = new URL(details.url);
|
||||
const pathname = url.pathname;
|
||||
// if sending request to the cloud, attach the session cookie
|
||||
if (isNetworkResource(pathname)) {
|
||||
const cookie = await getCookie(CLOUD_BASE_URL);
|
||||
const cookieString = cookie.map(c => `${c.name}=${c.value}`).join('; ');
|
||||
details.requestHeaders['cookie'] = cookieString;
|
||||
}
|
||||
callback({
|
||||
cancel: false,
|
||||
requestHeaders: details.requestHeaders,
|
||||
});
|
||||
})().catch(e => {
|
||||
logger.error('failed to attach cookie', e);
|
||||
callback({
|
||||
cancel: false,
|
||||
requestHeaders: details.requestHeaders,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { app, shell } from 'electron';
|
||||
|
||||
app.on('web-contents-created', (_, contents) => {
|
||||
/**
|
||||
* Block navigation to origins not on the allowlist.
|
||||
*
|
||||
* Navigation is a common attack vector. If an attacker can convince the app to navigate away
|
||||
* from its current page, they can possibly force the app to open web sites on the Internet.
|
||||
*
|
||||
* @see https://www.electronjs.org/docs/latest/tutorial/security#13-disable-or-limit-navigation
|
||||
*/
|
||||
contents.on('will-navigate', (event, url) => {
|
||||
if (
|
||||
(process.env.DEV_SERVER_URL &&
|
||||
url.startsWith(process.env.DEV_SERVER_URL)) ||
|
||||
url.startsWith('affine://') ||
|
||||
url.startsWith('file://.')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Prevent navigation
|
||||
event.preventDefault();
|
||||
shell.openExternal(url).catch(console.error);
|
||||
});
|
||||
|
||||
/**
|
||||
* Hyperlinks to allowed sites open in the default browser.
|
||||
*
|
||||
* The creation of new `webContents` is a common attack vector. Attackers attempt to convince the app to create new windows,
|
||||
* frames, or other renderer processes with more privileges than they had before; or with pages opened that they couldn't open before.
|
||||
* You should deny any unexpected window creation.
|
||||
*
|
||||
* @see https://www.electronjs.org/docs/latest/tutorial/security#14-disable-or-limit-creation-of-new-windows
|
||||
* @see https://www.electronjs.org/docs/latest/tutorial/security#15-do-not-use-openexternal-with-untrusted-content
|
||||
*/
|
||||
contents.setWindowOpenHandler(({ url }) => {
|
||||
// Open default browser
|
||||
shell.openExternal(url).catch(console.error);
|
||||
|
||||
// Prevent creating new window in application
|
||||
return { action: 'deny' };
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
export type MainEventRegister = (...args: any[]) => () => void;
|
||||
|
||||
export type IsomorphicHandler = (
|
||||
e: Electron.IpcMainInvokeEvent,
|
||||
...args: any[]
|
||||
) => Promise<any>;
|
||||
|
||||
export type NamespaceHandlers = {
|
||||
[key: string]: IsomorphicHandler;
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { mintChallengeResponse } from '@affine/native';
|
||||
|
||||
export const getChallengeResponse = async (resource: string) => {
|
||||
// 20 bits challenge is a balance between security and user experience
|
||||
// 20 bits challenge cost time is about 1-3s on m2 macbook air
|
||||
return mintChallengeResponse(resource, 20);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { MainEventRegister } from '../type';
|
||||
import { uiSubjects } from './subject';
|
||||
|
||||
/**
|
||||
* Events triggered by application menu
|
||||
*/
|
||||
export const uiEvents = {
|
||||
onMaximized: (fn: (maximized: boolean) => void) => {
|
||||
const sub = uiSubjects.onMaximized.subscribe(fn);
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
};
|
||||
},
|
||||
} satisfies Record<string, MainEventRegister>;
|
||||
@@ -0,0 +1,60 @@
|
||||
import { app, BrowserWindow, shell } from 'electron';
|
||||
import { parse } from 'url';
|
||||
|
||||
import { logger } from '../logger';
|
||||
|
||||
const redirectUri = 'https://affine.pro/client/auth-callback';
|
||||
|
||||
export const oauthEndpoint = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${process.env.AFFINE_GOOGLE_CLIENT_ID}&redirect_uri=${redirectUri}&response_type=code&scope=openid https://www.googleapis.com/auth/userinfo.email profile&access_type=offline&customParameters={"prompt":"select_account"}`;
|
||||
|
||||
const tokenEndpoint = 'https://oauth2.googleapis.com/token';
|
||||
|
||||
export const getExchangeTokenParams = (code: string) => {
|
||||
const postData = {
|
||||
code,
|
||||
client_id: process.env.AFFINE_GOOGLE_CLIENT_ID || '',
|
||||
client_secret: process.env.AFFINE_GOOGLE_CLIENT_SECRET || '',
|
||||
redirect_uri: redirectUri,
|
||||
grant_type: 'authorization_code',
|
||||
};
|
||||
const requestInit: RequestInit = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams(postData).toString(),
|
||||
};
|
||||
return { requestInit, url: tokenEndpoint };
|
||||
};
|
||||
|
||||
export function getGoogleOauthCode() {
|
||||
return new Promise<ReturnType<typeof getExchangeTokenParams>>(
|
||||
(resolve, reject) => {
|
||||
shell.openExternal(oauthEndpoint).catch(e => {
|
||||
logger.error('Failed to open external url', e);
|
||||
reject(e);
|
||||
});
|
||||
const handleOpenUrl = (_: 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);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { app, BrowserWindow, nativeTheme } from 'electron';
|
||||
import { getLinkPreview } from 'link-preview-js';
|
||||
|
||||
import { isMacOS } from '../../shared/utils';
|
||||
import { logger } from '../logger';
|
||||
import type { NamespaceHandlers } from '../type';
|
||||
import { getChallengeResponse } from './challenge';
|
||||
import { getGoogleOauthCode } from './google-auth';
|
||||
|
||||
export const uiHandlers = {
|
||||
handleThemeChange: async (_, theme: (typeof nativeTheme)['themeSource']) => {
|
||||
nativeTheme.themeSource = theme;
|
||||
},
|
||||
handleSidebarVisibilityChange: async (_, visible: boolean) => {
|
||||
if (isMacOS()) {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
windows.forEach(w => {
|
||||
// hide window buttons when sidebar is not visible
|
||||
w.setWindowButtonVisibility(visible);
|
||||
});
|
||||
}
|
||||
},
|
||||
handleMinimizeApp: async () => {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
windows.forEach(w => {
|
||||
w.minimize();
|
||||
});
|
||||
},
|
||||
handleMaximizeApp: async () => {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
windows.forEach(w => {
|
||||
// allow unmaximize when in full screen mode
|
||||
if (w.isFullScreen()) {
|
||||
w.setFullScreen(false);
|
||||
w.unmaximize();
|
||||
} else if (w.isMaximized()) {
|
||||
w.unmaximize();
|
||||
} else {
|
||||
w.maximize();
|
||||
}
|
||||
});
|
||||
},
|
||||
handleCloseApp: async () => {
|
||||
app.quit();
|
||||
},
|
||||
getGoogleOauthCode: async () => {
|
||||
return getGoogleOauthCode();
|
||||
},
|
||||
getChallengeResponse: async (_, challenge: string) => {
|
||||
return getChallengeResponse(challenge);
|
||||
},
|
||||
getBookmarkDataByLink: async (_, link: string) => {
|
||||
if (
|
||||
(link.startsWith('https://x.com/') ||
|
||||
link.startsWith('https://www.x.com/') ||
|
||||
link.startsWith('https://www.twitter.com/') ||
|
||||
link.startsWith('https://twitter.com/')) &&
|
||||
link.includes('/status/')
|
||||
) {
|
||||
// use api.fxtwitter.com
|
||||
link =
|
||||
'https://api.fxtwitter.com/status/' + /\/status\/(.*)/.exec(link)?.[1];
|
||||
try {
|
||||
const { tweet } = await fetch(link).then(res => res.json());
|
||||
return {
|
||||
title: tweet.author.name,
|
||||
icon: tweet.author.avatar_url,
|
||||
description: tweet.text,
|
||||
image: tweet.media?.photos[0].url || tweet.author.banner_url,
|
||||
};
|
||||
} catch (err) {
|
||||
logger.error('getBookmarkDataByLink', err);
|
||||
return {
|
||||
title: undefined,
|
||||
description: undefined,
|
||||
icon: undefined,
|
||||
image: undefined,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const previewData = (await getLinkPreview(link, {
|
||||
timeout: 6000,
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36',
|
||||
},
|
||||
followRedirects: 'follow',
|
||||
}).catch(() => {
|
||||
return {
|
||||
title: '',
|
||||
siteName: '',
|
||||
description: '',
|
||||
images: [],
|
||||
videos: [],
|
||||
contentType: `text/html`,
|
||||
favicons: [],
|
||||
};
|
||||
})) as any;
|
||||
|
||||
return {
|
||||
title: previewData.title,
|
||||
description: previewData.description,
|
||||
icon: previewData.favicons[0],
|
||||
image: previewData.images[0],
|
||||
};
|
||||
}
|
||||
},
|
||||
} satisfies NamespaceHandlers;
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './events';
|
||||
export * from './handlers';
|
||||
export * from './subject';
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
export const uiSubjects = {
|
||||
onMaximized: new Subject<boolean>(),
|
||||
};
|
||||
@@ -0,0 +1,250 @@
|
||||
// credits: migrated from https://github.com/electron-userland/electron-builder/blob/master/packages/electron-updater/src/providers/GitHubProvider.ts
|
||||
import type {
|
||||
CustomPublishOptions,
|
||||
GithubOptions,
|
||||
ReleaseNoteInfo,
|
||||
XElement,
|
||||
} from 'builder-util-runtime';
|
||||
import { HttpError, newError, parseXml } from 'builder-util-runtime';
|
||||
import {
|
||||
type AppUpdater,
|
||||
CancellationToken,
|
||||
type ResolvedUpdateFileInfo,
|
||||
type UpdateInfo,
|
||||
} from 'electron-updater';
|
||||
import { BaseGitHubProvider } from 'electron-updater/out/providers/GitHubProvider';
|
||||
import {
|
||||
parseUpdateInfo,
|
||||
type ProviderRuntimeOptions,
|
||||
resolveFiles,
|
||||
} from 'electron-updater/out/providers/Provider';
|
||||
import * as semver from 'semver';
|
||||
|
||||
interface GithubUpdateInfo extends UpdateInfo {
|
||||
tag: string;
|
||||
}
|
||||
|
||||
const hrefRegExp = /\/tag\/([^/]+)$/;
|
||||
|
||||
export class CustomGitHubProvider extends BaseGitHubProvider<GithubUpdateInfo> {
|
||||
constructor(
|
||||
options: CustomPublishOptions,
|
||||
private updater: AppUpdater,
|
||||
runtimeOptions: ProviderRuntimeOptions
|
||||
) {
|
||||
super(options as unknown as GithubOptions, 'github.com', runtimeOptions);
|
||||
}
|
||||
|
||||
async getLatestVersion(): Promise<GithubUpdateInfo> {
|
||||
const cancellationToken = new CancellationToken();
|
||||
|
||||
const feedXml = await this.httpRequest(
|
||||
newUrlFromBase(`${this.basePath}.atom`, this.baseUrl),
|
||||
{
|
||||
accept: 'application/xml, application/atom+xml, text/xml, */*',
|
||||
},
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
if (!feedXml) {
|
||||
throw new Error(
|
||||
`Cannot find feed in the remote server (${this.baseUrl.href})`
|
||||
);
|
||||
}
|
||||
|
||||
const feed = parseXml(feedXml);
|
||||
// noinspection TypeScriptValidateJSTypes
|
||||
const latestRelease = feed.element(
|
||||
'entry',
|
||||
false,
|
||||
`No published versions on GitHub`
|
||||
);
|
||||
let tag: string | null = null;
|
||||
try {
|
||||
const currentChannel =
|
||||
this.options.channel ||
|
||||
this.updater?.channel ||
|
||||
(semver.prerelease(this.updater.currentVersion)?.[0] as string) ||
|
||||
null;
|
||||
|
||||
if (currentChannel === null) {
|
||||
throw newError(
|
||||
`Cannot parse channel from version: ${this.updater.currentVersion}`,
|
||||
'ERR_UPDATER_INVALID_VERSION'
|
||||
);
|
||||
}
|
||||
|
||||
for (const element of feed.getElements('entry')) {
|
||||
// noinspection TypeScriptValidateJSTypes
|
||||
const hrefElement = hrefRegExp.exec(
|
||||
element.element('link').attribute('href')
|
||||
);
|
||||
|
||||
// If this is null then something is wrong and skip this release
|
||||
if (hrefElement === null) continue;
|
||||
|
||||
// This Release's Tag
|
||||
const hrefTag = hrefElement[1];
|
||||
// Get Channel from this release's tag
|
||||
// If it is null, we believe it is stable version
|
||||
const hrefChannel =
|
||||
(semver.prerelease(hrefTag)?.[0] as string) || 'stable';
|
||||
|
||||
const isNextPreRelease = hrefChannel === currentChannel;
|
||||
if (isNextPreRelease) {
|
||||
tag = hrefTag;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
throw newError(
|
||||
`Cannot parse releases feed: ${
|
||||
e.stack || e.message
|
||||
},\nXML:\n${feedXml}`,
|
||||
'ERR_UPDATER_INVALID_RELEASE_FEED'
|
||||
);
|
||||
}
|
||||
|
||||
if (tag == null) {
|
||||
throw newError(
|
||||
`No published versions on GitHub`,
|
||||
'ERR_UPDATER_NO_PUBLISHED_VERSIONS'
|
||||
);
|
||||
}
|
||||
|
||||
let rawData: string | null = null;
|
||||
let channelFile = '';
|
||||
let channelFileUrl: any = '';
|
||||
const fetchData = async (channelName: string) => {
|
||||
channelFile = getChannelFilename(channelName);
|
||||
channelFileUrl = newUrlFromBase(
|
||||
this.getBaseDownloadPath(String(tag), channelFile),
|
||||
this.baseUrl
|
||||
);
|
||||
const requestOptions = this.createRequestOptions(channelFileUrl);
|
||||
try {
|
||||
return await this.executor.request(requestOptions, cancellationToken);
|
||||
} catch (e: any) {
|
||||
if (e instanceof HttpError && e.statusCode === 404) {
|
||||
throw newError(
|
||||
`Cannot find ${channelFile} in the latest release artifacts (${channelFileUrl}): ${
|
||||
e.stack || e.message
|
||||
}`,
|
||||
'ERR_UPDATER_CHANNEL_FILE_NOT_FOUND'
|
||||
);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const channel = this.updater.allowPrerelease
|
||||
? this.getCustomChannelName(
|
||||
String(semver.prerelease(tag)?.[0] || 'latest')
|
||||
)
|
||||
: this.getDefaultChannelName();
|
||||
rawData = await fetchData(channel);
|
||||
} catch (e: any) {
|
||||
if (this.updater.allowPrerelease) {
|
||||
// Allow fallback to `latest.yml`
|
||||
rawData = await fetchData(this.getDefaultChannelName());
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const result = parseUpdateInfo(rawData, channelFile, channelFileUrl);
|
||||
if (result.releaseName == null) {
|
||||
result.releaseName = latestRelease.elementValueOrEmpty('title');
|
||||
}
|
||||
|
||||
if (result.releaseNotes == null) {
|
||||
result.releaseNotes = computeReleaseNotes(
|
||||
this.updater.currentVersion,
|
||||
this.updater.fullChangelog,
|
||||
feed,
|
||||
latestRelease
|
||||
);
|
||||
}
|
||||
return {
|
||||
tag: tag,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
private get basePath(): string {
|
||||
return `/${this.options.owner}/${this.options.repo}/releases`;
|
||||
}
|
||||
|
||||
resolveFiles(updateInfo: GithubUpdateInfo): Array<ResolvedUpdateFileInfo> {
|
||||
// still replace space to - due to backward compatibility
|
||||
return resolveFiles(updateInfo, this.baseUrl, p =>
|
||||
this.getBaseDownloadPath(updateInfo.tag, p.replace(/ /g, '-'))
|
||||
);
|
||||
}
|
||||
|
||||
private getBaseDownloadPath(tag: string, fileName: string): string {
|
||||
return `${this.basePath}/download/${tag}/${fileName}`;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CustomGitHubOptions {
|
||||
channel: string;
|
||||
repo: string;
|
||||
owner: string;
|
||||
releaseType: 'release' | 'prerelease';
|
||||
}
|
||||
|
||||
function getNoteValue(parent: XElement): string {
|
||||
const result = parent.elementValueOrEmpty('content');
|
||||
// GitHub reports empty notes as <content>No content.</content>
|
||||
return result === 'No content.' ? '' : result;
|
||||
}
|
||||
|
||||
export function computeReleaseNotes(
|
||||
currentVersion: semver.SemVer,
|
||||
isFullChangelog: boolean,
|
||||
feed: XElement,
|
||||
latestRelease: any
|
||||
): string | Array<ReleaseNoteInfo> | null {
|
||||
if (!isFullChangelog) {
|
||||
return getNoteValue(latestRelease);
|
||||
}
|
||||
|
||||
const releaseNotes: Array<ReleaseNoteInfo> = [];
|
||||
for (const release of feed.getElements('entry')) {
|
||||
// noinspection TypeScriptValidateJSTypes
|
||||
const versionRelease = /\/tag\/v?([^/]+)$/.exec(
|
||||
release.element('link').attribute('href')
|
||||
)?.[1];
|
||||
if (versionRelease && semver.lt(currentVersion, versionRelease)) {
|
||||
releaseNotes.push({
|
||||
version: versionRelease,
|
||||
note: getNoteValue(release),
|
||||
});
|
||||
}
|
||||
}
|
||||
return releaseNotes.sort((a, b) => semver.rcompare(a.version, b.version));
|
||||
}
|
||||
|
||||
// addRandomQueryToAvoidCaching is false by default because in most cases URL already contains version number,
|
||||
// so, it makes sense only for Generic Provider for channel files
|
||||
function newUrlFromBase(
|
||||
pathname: string,
|
||||
baseUrl: URL,
|
||||
addRandomQueryToAvoidCaching = false
|
||||
): URL {
|
||||
const result = new URL(pathname, baseUrl);
|
||||
// search is not propagated (search is an empty string if not specified)
|
||||
const search = baseUrl.search;
|
||||
if (search != null && search.length !== 0) {
|
||||
result.search = search;
|
||||
} else if (addRandomQueryToAvoidCaching) {
|
||||
result.search = `noCache=${Date.now().toString(32)}`;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getChannelFilename(channel: string): string {
|
||||
return `${channel}.yml`;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { app } from 'electron';
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
|
||||
import { isMacOS, isWindows } from '../../shared/utils';
|
||||
import { buildType } from '../config';
|
||||
import { logger } from '../logger';
|
||||
import { CustomGitHubProvider } from './custom-github-provider';
|
||||
import { updaterSubjects } from './event';
|
||||
|
||||
const mode = process.env.NODE_ENV;
|
||||
const isDev = mode === 'development';
|
||||
|
||||
export const quitAndInstall = async () => {
|
||||
autoUpdater.quitAndInstall();
|
||||
};
|
||||
|
||||
let lastCheckTime = 0;
|
||||
export const checkForUpdates = async (force = true) => {
|
||||
// check every 30 minutes (1800 seconds) at most
|
||||
if (force || lastCheckTime + 1000 * 1800 < Date.now()) {
|
||||
lastCheckTime = Date.now();
|
||||
return await autoUpdater.checkForUpdates();
|
||||
}
|
||||
return void 0;
|
||||
};
|
||||
|
||||
export const registerUpdater = async () => {
|
||||
// skip auto update in dev mode & internal
|
||||
if (buildType === 'internal' || isDev) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: support auto update on linux
|
||||
const allowAutoUpdate = isMacOS() || isWindows();
|
||||
|
||||
autoUpdater.logger = logger;
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.allowPrerelease = buildType !== 'stable';
|
||||
autoUpdater.autoInstallOnAppQuit = false;
|
||||
autoUpdater.autoRunAppAfterInstall = true;
|
||||
|
||||
const feedUrl: Parameters<typeof autoUpdater.setFeedURL>[0] = {
|
||||
channel: buildType,
|
||||
// hack for custom provider
|
||||
provider: 'custom' as 'github',
|
||||
// @ts-expect-error - just ignore for now
|
||||
repo: buildType !== 'internal' ? 'AFFiNE' : 'AFFiNE-Releases',
|
||||
owner: 'toeverything',
|
||||
releaseType: buildType === 'stable' ? 'release' : 'prerelease',
|
||||
// @ts-expect-error hack for custom provider
|
||||
updateProvider: CustomGitHubProvider,
|
||||
};
|
||||
|
||||
logger.debug('auto-updater feed config', feedUrl);
|
||||
|
||||
autoUpdater.setFeedURL(feedUrl);
|
||||
|
||||
// register events for checkForUpdatesAndNotify
|
||||
autoUpdater.on('checking-for-update', () => {
|
||||
logger.info('Checking for update');
|
||||
});
|
||||
let downloading = false;
|
||||
autoUpdater.on('update-available', info => {
|
||||
logger.info('Update available', info);
|
||||
if (allowAutoUpdate && !downloading) {
|
||||
downloading = true;
|
||||
autoUpdater?.downloadUpdate().catch(e => {
|
||||
downloading = false;
|
||||
logger.error('Failed to download update', e);
|
||||
});
|
||||
logger.info('Update available, downloading...', info);
|
||||
}
|
||||
updaterSubjects.updateAvailable.next({
|
||||
version: info.version,
|
||||
allowAutoUpdate,
|
||||
});
|
||||
});
|
||||
autoUpdater.on('update-not-available', info => {
|
||||
logger.info('Update not available', info);
|
||||
});
|
||||
autoUpdater.on('download-progress', e => {
|
||||
logger.info(`Download progress: ${e.percent}`);
|
||||
updaterSubjects.downloadProgress.next(e.percent);
|
||||
});
|
||||
autoUpdater.on('update-downloaded', e => {
|
||||
downloading = false;
|
||||
updaterSubjects.updateReady.next({
|
||||
version: e.version,
|
||||
allowAutoUpdate,
|
||||
});
|
||||
// I guess we can skip it?
|
||||
// updaterSubjects.clientDownloadProgress.next(100);
|
||||
logger.info('Update downloaded, ready to install');
|
||||
});
|
||||
autoUpdater.on('error', e => {
|
||||
logger.error('Error while updating client', e);
|
||||
});
|
||||
autoUpdater.forceDevUpdateConfig = isDev;
|
||||
|
||||
app.on('activate', () => {
|
||||
checkForUpdates(false).catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { BehaviorSubject, Subject } from 'rxjs';
|
||||
|
||||
import type { MainEventRegister } from '../type';
|
||||
|
||||
export interface UpdateMeta {
|
||||
version: string;
|
||||
allowAutoUpdate: boolean;
|
||||
}
|
||||
|
||||
export const updaterSubjects = {
|
||||
// means it is ready for restart and install the new version
|
||||
updateAvailable: new Subject<UpdateMeta>(),
|
||||
updateReady: new Subject<UpdateMeta>(),
|
||||
downloadProgress: new BehaviorSubject<number>(0),
|
||||
};
|
||||
|
||||
export const updaterEvents = {
|
||||
onUpdateAvailable: (fn: (versionMeta: UpdateMeta) => void) => {
|
||||
const sub = updaterSubjects.updateAvailable.subscribe(fn);
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
};
|
||||
},
|
||||
onUpdateReady: (fn: (versionMeta: UpdateMeta) => void) => {
|
||||
const sub = updaterSubjects.updateReady.subscribe(fn);
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
};
|
||||
},
|
||||
onDownloadProgress: (fn: (progress: number) => void) => {
|
||||
const sub = updaterSubjects.downloadProgress.subscribe(fn);
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
};
|
||||
},
|
||||
} satisfies Record<string, MainEventRegister>;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { app } from 'electron';
|
||||
|
||||
import type { NamespaceHandlers } from '../type';
|
||||
import { checkForUpdates, quitAndInstall } from './electron-updater';
|
||||
|
||||
export const updaterHandlers = {
|
||||
currentVersion: async () => {
|
||||
return app.getVersion();
|
||||
},
|
||||
quitAndInstall: async () => {
|
||||
return quitAndInstall();
|
||||
},
|
||||
checkForUpdatesAndNotify: async () => {
|
||||
const res = await checkForUpdates(true);
|
||||
if (res) {
|
||||
const { updateInfo } = res;
|
||||
return {
|
||||
version: updateInfo.version,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
} satisfies NamespaceHandlers;
|
||||
|
||||
export * from './electron-updater';
|
||||
@@ -0,0 +1,110 @@
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
|
||||
import type { CookiesSetDetails } from 'electron';
|
||||
|
||||
export function parseCookie(
|
||||
cookieString: string,
|
||||
url: string
|
||||
): CookiesSetDetails {
|
||||
const [nameValuePair, ...attributes] = cookieString
|
||||
.split('; ')
|
||||
.map(part => part.trim());
|
||||
|
||||
const [name, value] = nameValuePair.split('=');
|
||||
|
||||
const details: CookiesSetDetails = { url, name, value };
|
||||
|
||||
attributes.forEach(attribute => {
|
||||
const [key, val] = attribute.split('=');
|
||||
|
||||
switch (key.toLowerCase()) {
|
||||
case 'domain':
|
||||
details.domain = val;
|
||||
break;
|
||||
case 'path':
|
||||
details.path = val;
|
||||
break;
|
||||
case 'secure':
|
||||
details.secure = true;
|
||||
break;
|
||||
case 'httponly':
|
||||
details.httpOnly = true;
|
||||
break;
|
||||
case 'expires':
|
||||
details.expirationDate = new Date(val).getTime() / 1000; // Convert to seconds
|
||||
break;
|
||||
case 'samesite':
|
||||
if (
|
||||
['unspecified', 'no_restriction', 'lax', 'strict'].includes(
|
||||
val.toLowerCase()
|
||||
)
|
||||
) {
|
||||
details.sameSite = val.toLowerCase() as
|
||||
| 'unspecified'
|
||||
| 'no_restriction'
|
||||
| 'lax'
|
||||
| 'strict';
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// Handle other cookie attributes if needed
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return details;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a GET request to a specified URL.
|
||||
* This function uses native http/https modules instead of fetch to
|
||||
* bypassing set-cookies headers
|
||||
*/
|
||||
export async function simpleGet(requestUrl: string): Promise<{
|
||||
body: string;
|
||||
headers: [string, string][];
|
||||
statusCode: number;
|
||||
}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsedUrl = new URL(requestUrl);
|
||||
const protocol = parsedUrl.protocol === 'https:' ? https : http;
|
||||
const options = {
|
||||
hostname: parsedUrl.hostname,
|
||||
port: parsedUrl.port,
|
||||
path: parsedUrl.pathname + parsedUrl.search,
|
||||
method: 'GET',
|
||||
};
|
||||
const req = protocol.request(options, res => {
|
||||
let data = '';
|
||||
res.on('data', chunk => {
|
||||
data += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
resolve({
|
||||
body: data,
|
||||
headers: toStandardHeaders(res.headers),
|
||||
statusCode: res.statusCode || 200,
|
||||
});
|
||||
});
|
||||
});
|
||||
req.on('error', error => {
|
||||
reject(error);
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
|
||||
function toStandardHeaders(headers: http.IncomingHttpHeaders) {
|
||||
const result: [string, string][] = [];
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(v => {
|
||||
result.push([key, v]);
|
||||
});
|
||||
} else {
|
||||
result.push([key, value || '']);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
|
||||
(async () => {
|
||||
const { appInfo, getElectronAPIs } = await import(
|
||||
'@toeverything/infra/preload/electron'
|
||||
);
|
||||
const { apis, events } = getElectronAPIs();
|
||||
|
||||
contextBridge.exposeInMainWorld('appInfo', appInfo);
|
||||
contextBridge.exposeInMainWorld('apis', apis);
|
||||
contextBridge.exposeInMainWorld('events', events);
|
||||
contextBridge.exposeInMainWorld('platform', process.platform);
|
||||
|
||||
// Credit to microsoft/vscode
|
||||
const globals = {
|
||||
ipcRenderer: {
|
||||
send(channel: string, ...args: any[]) {
|
||||
ipcRenderer.send(channel, ...args);
|
||||
},
|
||||
|
||||
invoke(channel: string, ...args: any[]) {
|
||||
return ipcRenderer.invoke(channel, ...args);
|
||||
},
|
||||
|
||||
on(
|
||||
channel: string,
|
||||
listener: (event: Electron.IpcRendererEvent, ...args: any[]) => void
|
||||
) {
|
||||
ipcRenderer.on(channel, listener);
|
||||
return this;
|
||||
},
|
||||
|
||||
once(
|
||||
channel: string,
|
||||
listener: (event: Electron.IpcRendererEvent, ...args: any[]) => void
|
||||
) {
|
||||
ipcRenderer.once(channel, listener);
|
||||
return this;
|
||||
},
|
||||
|
||||
removeListener(
|
||||
channel: string,
|
||||
listener: (event: Electron.IpcRendererEvent, ...args: any[]) => void
|
||||
) {
|
||||
ipcRenderer.removeListener(channel, listener);
|
||||
return this;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
contextBridge.exposeInMainWorld('affine', globals);
|
||||
} catch (error) {
|
||||
console.error('Failed to expose affine APIs to window object!', error);
|
||||
}
|
||||
})().catch(err => {
|
||||
console.error('Failed to bootstrap preload script!', err);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
import './bootstrap';
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { EventBasedChannel } from 'async-call-rpc';
|
||||
|
||||
export function getTime() {
|
||||
return new Date().getTime();
|
||||
}
|
||||
|
||||
export const isMacOS = () => {
|
||||
return process.platform === 'darwin';
|
||||
};
|
||||
|
||||
export const isWindows = () => {
|
||||
return process.platform === 'win32';
|
||||
};
|
||||
|
||||
interface MessagePortLike {
|
||||
postMessage: (data: unknown) => void;
|
||||
addListener: (event: 'message', listener: (...args: any[]) => void) => void;
|
||||
removeListener: (
|
||||
event: 'message',
|
||||
listener: (...args: any[]) => void
|
||||
) => void;
|
||||
}
|
||||
|
||||
export class MessageEventChannel implements EventBasedChannel {
|
||||
constructor(private worker: MessagePortLike) {}
|
||||
|
||||
on(listener: (data: unknown) => void) {
|
||||
const f = (data: unknown) => {
|
||||
listener(data);
|
||||
};
|
||||
this.worker.addListener('message', f);
|
||||
return () => {
|
||||
this.worker.removeListener('message', f);
|
||||
};
|
||||
}
|
||||
|
||||
send(data: unknown) {
|
||||
this.worker.postMessage(data);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user