mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-19 11:06:25 +08:00
refactor: new project struct (#8199)
packages/frontend/web -> packages/frontend/apps/web packages/frontend/mobile -> packages/frontend/apps/mobile packages/frontend/electron -> packages/frontend/apps/electron
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
import type { InsertRow } from '@affine/native';
|
||||
import { SqliteConnection } from '@affine/native';
|
||||
import type { ByteKVBehavior } from '@toeverything/infra/storage';
|
||||
|
||||
import { logger } from '../logger';
|
||||
|
||||
/**
|
||||
* A base class for SQLite DB adapter that provides basic methods around updates & blobs
|
||||
*/
|
||||
export class SQLiteAdapter {
|
||||
db: SqliteConnection | null = null;
|
||||
constructor(public readonly path: string) {}
|
||||
|
||||
async connectIfNeeded() {
|
||||
if (!this.db) {
|
||||
this.db = new SqliteConnection(this.path);
|
||||
await this.db.connect();
|
||||
logger.info(`[SQLiteAdapter]`, '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]`, '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] addUpdateToSQLite`,
|
||||
'length:',
|
||||
updates.length,
|
||||
'docids',
|
||||
updates.map(u => u.docId),
|
||||
performance.now() - start,
|
||||
'ms'
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('addUpdateToSQLite', this.path, error);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteUpdates(docId?: string) {
|
||||
try {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return;
|
||||
}
|
||||
await this.db.deleteUpdates(docId);
|
||||
} catch (error) {
|
||||
logger.error('deleteUpdates', error);
|
||||
}
|
||||
}
|
||||
|
||||
async getUpdatesCount(docId?: string) {
|
||||
try {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return 0;
|
||||
}
|
||||
return await this.db.getUpdatesCount(docId);
|
||||
} catch (error) {
|
||||
logger.error('getUpdatesCount', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async replaceUpdates(docId: string | null | undefined, updates: InsertRow[]) {
|
||||
try {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return;
|
||||
}
|
||||
await this.db.replaceUpdates(docId, updates);
|
||||
} catch (error) {
|
||||
logger.error('replaceUpdates', error);
|
||||
}
|
||||
}
|
||||
|
||||
serverClock: ByteKVBehavior = {
|
||||
get: async key => {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return null;
|
||||
}
|
||||
const blob = await this.db.getServerClock(key);
|
||||
return blob?.data ?? null;
|
||||
},
|
||||
set: async (key, data) => {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return;
|
||||
}
|
||||
await this.db.setServerClock(key, data);
|
||||
},
|
||||
keys: async () => {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return [];
|
||||
}
|
||||
return await this.db.getServerClockKeys();
|
||||
},
|
||||
del: async key => {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return;
|
||||
}
|
||||
await this.db.delServerClock(key);
|
||||
},
|
||||
clear: async () => {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return;
|
||||
}
|
||||
await this.db.clearServerClock();
|
||||
},
|
||||
};
|
||||
|
||||
syncMetadata: ByteKVBehavior = {
|
||||
get: async key => {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return null;
|
||||
}
|
||||
const blob = await this.db.getSyncMetadata(key);
|
||||
return blob?.data ?? null;
|
||||
},
|
||||
set: async (key, data) => {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return;
|
||||
}
|
||||
await this.db.setSyncMetadata(key, data);
|
||||
},
|
||||
keys: async () => {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return [];
|
||||
}
|
||||
return await this.db.getSyncMetadataKeys();
|
||||
},
|
||||
del: async key => {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return;
|
||||
}
|
||||
await this.db.delSyncMetadata(key);
|
||||
},
|
||||
clear: async () => {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return;
|
||||
}
|
||||
await this.db.clearSyncMetadata();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { logger } from '../logger';
|
||||
import type { SpaceType } from './types';
|
||||
import type { WorkspaceSQLiteDB } from './workspace-db-adapter';
|
||||
import { openWorkspaceDatabase } from './workspace-db-adapter';
|
||||
|
||||
// export for testing
|
||||
export const db$Map = new Map<
|
||||
`${SpaceType}:${string}`,
|
||||
Promise<WorkspaceSQLiteDB>
|
||||
>();
|
||||
|
||||
async function getWorkspaceDB(spaceType: SpaceType, id: string) {
|
||||
const cacheId = `${spaceType}:${id}` as const;
|
||||
let db = await db$Map.get(cacheId);
|
||||
if (!db) {
|
||||
const promise = openWorkspaceDatabase(spaceType, id);
|
||||
db$Map.set(cacheId, promise);
|
||||
const _db = (db = await promise);
|
||||
const cleanup = () => {
|
||||
db$Map.delete(cacheId);
|
||||
_db
|
||||
.destroy()
|
||||
.then(() => {
|
||||
logger.info('[ensureSQLiteDB] db connection closed', _db.workspaceId);
|
||||
})
|
||||
.catch(err => {
|
||||
logger.error('[ensureSQLiteDB] destroy db failed', err);
|
||||
});
|
||||
};
|
||||
|
||||
db.update$.subscribe({
|
||||
complete: cleanup,
|
||||
});
|
||||
|
||||
process.on('beforeExit', cleanup);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return db!;
|
||||
}
|
||||
|
||||
export function ensureSQLiteDB(spaceType: SpaceType, id: string) {
|
||||
return getWorkspaceDB(spaceType, id);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { mainRPC } from '../main-rpc';
|
||||
import type { MainEventRegister } from '../type';
|
||||
import { ensureSQLiteDB } from './ensure-db';
|
||||
import type { SpaceType } from './types';
|
||||
|
||||
export * from './ensure-db';
|
||||
|
||||
export const dbHandlers = {
|
||||
getDocAsUpdates: async (
|
||||
spaceType: SpaceType,
|
||||
workspaceId: string,
|
||||
subdocId: string
|
||||
) => {
|
||||
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
|
||||
return spaceDB.getDocAsUpdates(subdocId);
|
||||
},
|
||||
applyDocUpdate: async (
|
||||
spaceType: SpaceType,
|
||||
workspaceId: string,
|
||||
update: Uint8Array,
|
||||
subdocId: string
|
||||
) => {
|
||||
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
|
||||
return spaceDB.addUpdateToSQLite(update, subdocId);
|
||||
},
|
||||
deleteDoc: async (
|
||||
spaceType: SpaceType,
|
||||
workspaceId: string,
|
||||
subdocId: string
|
||||
) => {
|
||||
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
|
||||
return spaceDB.deleteUpdate(subdocId);
|
||||
},
|
||||
addBlob: async (
|
||||
spaceType: SpaceType,
|
||||
workspaceId: string,
|
||||
key: string,
|
||||
data: Uint8Array
|
||||
) => {
|
||||
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
|
||||
return spaceDB.addBlob(key, data);
|
||||
},
|
||||
getBlob: async (spaceType: SpaceType, workspaceId: string, key: string) => {
|
||||
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
|
||||
return spaceDB.getBlob(key);
|
||||
},
|
||||
deleteBlob: async (
|
||||
spaceType: SpaceType,
|
||||
workspaceId: string,
|
||||
key: string
|
||||
) => {
|
||||
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
|
||||
return spaceDB.deleteBlob(key);
|
||||
},
|
||||
getBlobKeys: async (spaceType: SpaceType, workspaceId: string) => {
|
||||
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
|
||||
return spaceDB.getBlobKeys();
|
||||
},
|
||||
getDefaultStorageLocation: async () => {
|
||||
return await mainRPC.getPath('sessionData');
|
||||
},
|
||||
getServerClock: async (
|
||||
spaceType: SpaceType,
|
||||
workspaceId: string,
|
||||
key: string
|
||||
) => {
|
||||
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
|
||||
return spaceDB.adapter.serverClock.get(key);
|
||||
},
|
||||
setServerClock: async (
|
||||
spaceType: SpaceType,
|
||||
workspaceId: string,
|
||||
key: string,
|
||||
data: Uint8Array
|
||||
) => {
|
||||
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
|
||||
return spaceDB.adapter.serverClock.set(key, data);
|
||||
},
|
||||
getServerClockKeys: async (spaceType: SpaceType, workspaceId: string) => {
|
||||
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
|
||||
return spaceDB.adapter.serverClock.keys();
|
||||
},
|
||||
clearServerClock: async (spaceType: SpaceType, workspaceId: string) => {
|
||||
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
|
||||
return spaceDB.adapter.serverClock.clear();
|
||||
},
|
||||
delServerClock: async (
|
||||
spaceType: SpaceType,
|
||||
workspaceId: string,
|
||||
key: string
|
||||
) => {
|
||||
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
|
||||
return spaceDB.adapter.serverClock.del(key);
|
||||
},
|
||||
getSyncMetadata: async (
|
||||
spaceType: SpaceType,
|
||||
workspaceId: string,
|
||||
key: string
|
||||
) => {
|
||||
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
|
||||
return spaceDB.adapter.syncMetadata.get(key);
|
||||
},
|
||||
setSyncMetadata: async (
|
||||
spaceType: SpaceType,
|
||||
workspaceId: string,
|
||||
key: string,
|
||||
data: Uint8Array
|
||||
) => {
|
||||
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
|
||||
return spaceDB.adapter.syncMetadata.set(key, data);
|
||||
},
|
||||
getSyncMetadataKeys: async (spaceType: SpaceType, workspaceId: string) => {
|
||||
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
|
||||
return spaceDB.adapter.syncMetadata.keys();
|
||||
},
|
||||
clearSyncMetadata: async (spaceType: SpaceType, workspaceId: string) => {
|
||||
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
|
||||
return spaceDB.adapter.syncMetadata.clear();
|
||||
},
|
||||
delSyncMetadata: async (
|
||||
spaceType: SpaceType,
|
||||
workspaceId: string,
|
||||
key: string
|
||||
) => {
|
||||
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
|
||||
return spaceDB.adapter.syncMetadata.del(key);
|
||||
},
|
||||
};
|
||||
|
||||
export const dbEvents = {} satisfies Record<string, MainEventRegister>;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { applyUpdate, Doc as YDoc, encodeStateAsUpdate, transact } from 'yjs';
|
||||
|
||||
export function mergeUpdate(updates: Uint8Array[]) {
|
||||
if (updates.length === 0) {
|
||||
return new Uint8Array();
|
||||
}
|
||||
if (updates.length === 1) {
|
||||
return updates[0];
|
||||
}
|
||||
const yDoc = new YDoc();
|
||||
transact(yDoc, () => {
|
||||
for (const update of updates) {
|
||||
applyUpdate(yDoc, update);
|
||||
}
|
||||
});
|
||||
return encodeStateAsUpdate(yDoc);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type SpaceType = 'userspace' | 'workspace';
|
||||
@@ -0,0 +1,134 @@
|
||||
import { AsyncLock } from '@toeverything/infra/utils';
|
||||
import { Subject } from 'rxjs';
|
||||
import { applyUpdate, Doc as YDoc } from 'yjs';
|
||||
|
||||
import { logger } from '../logger';
|
||||
import { getWorkspaceMeta } from '../workspace/meta';
|
||||
import { SQLiteAdapter } from './db-adapter';
|
||||
import { mergeUpdate } from './merge-update';
|
||||
import type { SpaceType } from './types';
|
||||
|
||||
const TRIM_SIZE = 1;
|
||||
|
||||
export class WorkspaceSQLiteDB {
|
||||
lock = new AsyncLock();
|
||||
update$ = new Subject<void>();
|
||||
adapter = new SQLiteAdapter(this.path);
|
||||
|
||||
constructor(
|
||||
public path: string,
|
||||
public workspaceId: string
|
||||
) {}
|
||||
|
||||
async transaction<T>(cb: () => Promise<T>): Promise<T> {
|
||||
using _lock = await this.lock.acquire();
|
||||
return await cb();
|
||||
}
|
||||
|
||||
async destroy() {
|
||||
await this.adapter.destroy();
|
||||
|
||||
// when db is closed, we can safely remove it from ensure-db list
|
||||
this.update$.complete();
|
||||
}
|
||||
|
||||
private readonly toDBDocId = (docId: string) => {
|
||||
return this.workspaceId === docId ? undefined : docId;
|
||||
};
|
||||
|
||||
getWorkspaceName = async () => {
|
||||
const ydoc = new YDoc();
|
||||
const updates = await this.adapter.getUpdates();
|
||||
updates.forEach(update => {
|
||||
applyUpdate(ydoc, update.data);
|
||||
});
|
||||
return ydoc.getMap('meta').get('name') as string;
|
||||
};
|
||||
|
||||
async init() {
|
||||
const db = await this.adapter.connectIfNeeded();
|
||||
await this.tryTrim();
|
||||
return db;
|
||||
}
|
||||
|
||||
async get(docId: string) {
|
||||
return this.adapter.getUpdates(docId);
|
||||
}
|
||||
|
||||
// getUpdates then encode
|
||||
getDocAsUpdates = async (docId: string) => {
|
||||
const dbID = this.toDBDocId(docId);
|
||||
const update = await this.tryTrim(dbID);
|
||||
if (update) {
|
||||
return update;
|
||||
} else {
|
||||
const updates = await this.adapter.getUpdates(dbID);
|
||||
return mergeUpdate(updates.map(row => row.data));
|
||||
}
|
||||
};
|
||||
|
||||
async addBlob(key: string, value: Uint8Array) {
|
||||
this.update$.next();
|
||||
const res = await this.adapter.addBlob(key, value);
|
||||
return res;
|
||||
}
|
||||
|
||||
async getBlob(key: string) {
|
||||
return this.adapter.getBlob(key);
|
||||
}
|
||||
|
||||
async getBlobKeys() {
|
||||
return this.adapter.getBlobKeys();
|
||||
}
|
||||
|
||||
async deleteBlob(key: string) {
|
||||
this.update$.next();
|
||||
await this.adapter.deleteBlob(key);
|
||||
}
|
||||
|
||||
async addUpdateToSQLite(update: Uint8Array, subdocId: string) {
|
||||
this.update$.next();
|
||||
await this.transaction(async () => {
|
||||
const dbID = this.toDBDocId(subdocId);
|
||||
const oldUpdate = await this.adapter.getUpdates(dbID);
|
||||
await this.adapter.replaceUpdates(dbID, [
|
||||
{
|
||||
data: mergeUpdate([...oldUpdate.map(u => u.data), update]),
|
||||
docId: dbID,
|
||||
},
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
async deleteUpdate(subdocId: string) {
|
||||
this.update$.next();
|
||||
await this.adapter.deleteUpdates(this.toDBDocId(subdocId));
|
||||
}
|
||||
|
||||
private readonly tryTrim = async (dbID?: string) => {
|
||||
const count = (await this.adapter?.getUpdatesCount(dbID)) ?? 0;
|
||||
if (count > TRIM_SIZE) {
|
||||
return await this.transaction(async () => {
|
||||
logger.debug(`trim ${this.workspaceId}:${dbID} ${count}`);
|
||||
const updates = await this.adapter.getUpdates(dbID);
|
||||
const update = mergeUpdate(updates.map(row => row.data));
|
||||
const insertRows = [{ data: update, docId: dbID }];
|
||||
await this.adapter?.replaceUpdates(dbID, insertRows);
|
||||
logger.debug(`trim ${this.workspaceId}:${dbID} successfully`);
|
||||
return update;
|
||||
});
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
export async function openWorkspaceDatabase(
|
||||
spaceType: SpaceType,
|
||||
spaceId: string
|
||||
) {
|
||||
const meta = await getWorkspaceMeta(spaceType, spaceId);
|
||||
const db = new WorkspaceSQLiteDB(meta.mainDBPath, spaceId);
|
||||
await db.init();
|
||||
logger.info(`openWorkspaceDatabase [${spaceId}]`);
|
||||
return db;
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { ValidationResult } from '@affine/native';
|
||||
import fs from 'fs-extra';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { ensureSQLiteDB } from '../db/ensure-db';
|
||||
import { logger } from '../logger';
|
||||
import { mainRPC } from '../main-rpc';
|
||||
import { storeWorkspaceMeta } from '../workspace';
|
||||
import { getWorkspaceDBPath, getWorkspacesBasePath } from '../workspace/meta';
|
||||
|
||||
export type ErrorMessage =
|
||||
| 'DB_FILE_ALREADY_LOADED'
|
||||
| 'DB_FILE_PATH_INVALID'
|
||||
| 'DB_FILE_INVALID'
|
||||
| 'DB_FILE_MIGRATION_FAILED'
|
||||
| 'FILE_ALREADY_EXISTS'
|
||||
| 'UNKNOWN_ERROR';
|
||||
|
||||
export interface LoadDBFileResult {
|
||||
workspaceId?: string;
|
||||
error?: ErrorMessage;
|
||||
canceled?: boolean;
|
||||
}
|
||||
|
||||
export interface SaveDBFileResult {
|
||||
filePath?: string;
|
||||
canceled?: boolean;
|
||||
error?: ErrorMessage;
|
||||
}
|
||||
|
||||
export interface SelectDBFileLocationResult {
|
||||
filePath?: string;
|
||||
error?: ErrorMessage;
|
||||
canceled?: boolean;
|
||||
}
|
||||
|
||||
// provide a backdoor to set dialog path for testing in playwright
|
||||
export 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;
|
||||
|
||||
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('workspace', workspaceId);
|
||||
const fakedResult = getFakedResult();
|
||||
|
||||
const ret =
|
||||
fakedResult ??
|
||||
(await mainRPC.showSaveDialog({
|
||||
properties: ['showOverwriteConfirmation'],
|
||||
title: 'Save Workspace',
|
||||
showsTagField: false,
|
||||
buttonLabel: 'Save',
|
||||
filters: [
|
||||
{
|
||||
extensions: [extension],
|
||||
name: '',
|
||||
},
|
||||
],
|
||||
defaultPath: getDefaultDBFileName(
|
||||
await 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);
|
||||
if (!fakedResult) {
|
||||
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',
|
||||
}));
|
||||
const 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' };
|
||||
}
|
||||
|
||||
const { SqliteConnection } = await import('@affine/native');
|
||||
|
||||
const validationResult = await SqliteConnection.validate(originalPath);
|
||||
|
||||
if (validationResult !== ValidationResult.Valid) {
|
||||
return { error: 'DB_FILE_INVALID' }; // invalid db file
|
||||
}
|
||||
|
||||
// copy the db file to a new workspace id
|
||||
const workspaceId = nanoid(10);
|
||||
const internalFilePath = await getWorkspaceDBPath('workspace', 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
loadDBFile,
|
||||
saveDBFileAs,
|
||||
selectDBFileLocation,
|
||||
setFakeDialogResult,
|
||||
} from './dialog';
|
||||
|
||||
export const dialogHandlers = {
|
||||
loadDBFile: async () => {
|
||||
return loadDBFile();
|
||||
},
|
||||
saveDBFileAs: async (workspaceId: string) => {
|
||||
return saveDBFileAs(workspaceId);
|
||||
},
|
||||
selectDBFileLocation: async () => {
|
||||
return selectDBFileLocation();
|
||||
},
|
||||
setFakeDialogResult: async (
|
||||
result: Parameters<typeof setFakeDialogResult>[0]
|
||||
) => {
|
||||
return setFakeDialogResult(result);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { dbEvents, dbHandlers } from './db';
|
||||
import { dialogHandlers } from './dialog';
|
||||
import { provideExposed } from './provide';
|
||||
import { workspaceEvents, workspaceHandlers } from './workspace';
|
||||
|
||||
export const handlers = {
|
||||
db: dbHandlers,
|
||||
workspace: workspaceHandlers,
|
||||
dialog: dialogHandlers,
|
||||
};
|
||||
|
||||
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 { AsyncCall } from 'async-call-rpc';
|
||||
|
||||
import type { RendererToHelper } from '../shared/type';
|
||||
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.debug(
|
||||
'[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,5 @@
|
||||
import log from 'electron-log/main';
|
||||
|
||||
export const logger = log.scope('helper');
|
||||
|
||||
log.transports.file.level = 'info';
|
||||
@@ -0,0 +1,32 @@
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import { AsyncCall } from 'async-call-rpc';
|
||||
|
||||
import type { HelperToMain, MainToHelper } from '../shared/type';
|
||||
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 '../shared/type';
|
||||
|
||||
/**
|
||||
* 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,8 @@
|
||||
export interface WorkspaceMeta {
|
||||
id: string;
|
||||
mainDBPath: string;
|
||||
}
|
||||
|
||||
export type YOrigin = 'self' | 'external' | 'upstream' | 'renderer';
|
||||
|
||||
export type MainEventRegister = (...args: any[]) => () => void;
|
||||
@@ -0,0 +1,45 @@
|
||||
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,
|
||||
} from './meta';
|
||||
|
||||
export async function deleteWorkspace(id: string) {
|
||||
const basePath = await getWorkspaceBasePath('workspace', id);
|
||||
const movedPath = path.join(await getDeletedWorkspacesBasePath(), `${id}`);
|
||||
try {
|
||||
const db = await ensureSQLiteDB('workspace', 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('workspace', workspaceId);
|
||||
await fs.ensureDir(basePath);
|
||||
const metaPath = path.join(basePath, 'meta.json');
|
||||
const currentMeta = await getWorkspaceMeta('workspace', workspaceId);
|
||||
const newMeta = {
|
||||
...currentMeta,
|
||||
...meta,
|
||||
};
|
||||
await fs.writeJSON(metaPath, newMeta);
|
||||
} catch (err) {
|
||||
logger.error('storeWorkspaceMeta failed', err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { MainEventRegister } from '../type';
|
||||
import { deleteWorkspace } from './handlers';
|
||||
|
||||
export * from './handlers';
|
||||
export * from './subjects';
|
||||
|
||||
export const workspaceEvents = {} as Record<string, MainEventRegister>;
|
||||
|
||||
export const workspaceHandlers = {
|
||||
delete: async (id: string) => deleteWorkspace(id),
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import type { SpaceType } from '../db/types';
|
||||
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(
|
||||
spaceType: SpaceType,
|
||||
workspaceId: string
|
||||
) {
|
||||
return path.join(
|
||||
await getAppDataPath(),
|
||||
spaceType === 'userspace' ? 'userspaces' : 'workspaces',
|
||||
workspaceId
|
||||
);
|
||||
}
|
||||
|
||||
export async function getDeletedWorkspacesBasePath() {
|
||||
return path.join(await getAppDataPath(), 'deleted-workspaces');
|
||||
}
|
||||
|
||||
export async function getWorkspaceDBPath(
|
||||
spaceType: SpaceType,
|
||||
workspaceId: string
|
||||
) {
|
||||
return path.join(
|
||||
await getWorkspaceBasePath(spaceType, workspaceId),
|
||||
'storage.db'
|
||||
);
|
||||
}
|
||||
|
||||
export async function getWorkspaceMetaPath(
|
||||
spaceType: SpaceType,
|
||||
workspaceId: string
|
||||
) {
|
||||
return path.join(
|
||||
await getWorkspaceBasePath(spaceType, workspaceId),
|
||||
'meta.json'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workspace meta, create one if not exists
|
||||
* This function will also migrate the workspace if needed
|
||||
*/
|
||||
export async function getWorkspaceMeta(
|
||||
spaceType: SpaceType,
|
||||
workspaceId: string
|
||||
): Promise<WorkspaceMeta> {
|
||||
try {
|
||||
const basePath = await getWorkspaceBasePath(spaceType, workspaceId);
|
||||
const metaPath = await getWorkspaceMetaPath(spaceType, workspaceId);
|
||||
if (
|
||||
!(await fs
|
||||
.access(metaPath)
|
||||
.then(() => true)
|
||||
.catch(() => false))
|
||||
) {
|
||||
await fs.ensureDir(basePath);
|
||||
const dbPath = await getWorkspaceDBPath(spaceType, workspaceId);
|
||||
// create one if not exists
|
||||
const meta = {
|
||||
id: workspaceId,
|
||||
mainDBPath: dbPath,
|
||||
type: spaceType,
|
||||
};
|
||||
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,253 @@
|
||||
import { app, Menu } from 'electron';
|
||||
|
||||
import { isMacOS, isWindows } from '../../shared/utils';
|
||||
import { logger, revealLogFile } from '../logger';
|
||||
import { checkForUpdates } from '../updater';
|
||||
import {
|
||||
addTab,
|
||||
closeTab,
|
||||
initAndShowMainWindow,
|
||||
reloadView,
|
||||
showDevTools,
|
||||
showMainWindow,
|
||||
switchTab,
|
||||
switchToNextTab,
|
||||
switchToPreviousTab,
|
||||
undoCloseTab,
|
||||
} from '../windows-manager';
|
||||
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: [
|
||||
{
|
||||
label: `About ${app.getName()}`,
|
||||
click: async () => {
|
||||
await showMainWindow();
|
||||
applicationMenuSubjects.openAboutPageInSettingModal$.next();
|
||||
},
|
||||
},
|
||||
{ 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 Doc',
|
||||
accelerator: isMac ? 'Cmd+N' : 'Ctrl+N',
|
||||
click: async () => {
|
||||
await initAndShowMainWindow();
|
||||
// fixme: if the window is just created, the new page action will not be triggered
|
||||
applicationMenuSubjects.newPageAction$.next();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// { 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: [
|
||||
{
|
||||
label: 'Reload',
|
||||
accelerator: 'CommandOrControl+R',
|
||||
click() {
|
||||
reloadView().catch(console.error);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Open devtools',
|
||||
accelerator: isMac ? 'Cmd+Option+I' : 'Ctrl+Shift+I',
|
||||
click: () => {
|
||||
showDevTools();
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ role: 'resetZoom' },
|
||||
{ role: 'zoomIn' },
|
||||
...(isWindows()
|
||||
? [{ role: 'zoomIn', accelerator: 'Ctrl+=', visible: false }]
|
||||
: []),
|
||||
{ role: 'zoomOut' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'togglefullscreen' },
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'New tab',
|
||||
accelerator: 'CommandOrControl+T',
|
||||
click() {
|
||||
logger.info('New tab with shortcut');
|
||||
addTab().catch(console.error);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Close tab',
|
||||
accelerator: 'CommandOrControl+W',
|
||||
click() {
|
||||
logger.info('Close tab with shortcut');
|
||||
closeTab().catch(console.error);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Undo close tab',
|
||||
accelerator: 'CommandOrControl+Shift+T',
|
||||
click() {
|
||||
logger.info('Undo close tab with shortcut');
|
||||
undoCloseTab().catch(console.error);
|
||||
},
|
||||
},
|
||||
...[1, 2, 3, 4, 5, 6, 7, 8, 9].map(n => {
|
||||
const shortcut = `CommandOrControl+${n}`;
|
||||
const listener = () => {
|
||||
switchTab(n);
|
||||
};
|
||||
return {
|
||||
acceleratorWorksWhenHidden: true,
|
||||
label: `Switch to tab ${n}`,
|
||||
accelerator: shortcut,
|
||||
click: listener,
|
||||
visible: false,
|
||||
};
|
||||
}),
|
||||
{
|
||||
label: 'Switch to next tab',
|
||||
accelerator: 'CommandOrControl+Tab',
|
||||
click: () => {
|
||||
switchToNextTab();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Switch to previous tab',
|
||||
accelerator: 'CommandOrControl+Shift+Tab',
|
||||
click: () => {
|
||||
switchToPreviousTab();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Switch to next tab (mac)',
|
||||
accelerator: 'Command+]',
|
||||
visible: false,
|
||||
click: () => {
|
||||
switchToNextTab();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Switch to previous tab (mac)',
|
||||
accelerator: 'Command+[',
|
||||
visible: false,
|
||||
click: () => {
|
||||
switchToPreviousTab();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Switch to next tab (mac 2)',
|
||||
accelerator: 'Alt+Command+]',
|
||||
visible: false,
|
||||
click: () => {
|
||||
switchToNextTab();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Switch to previous tab (mac 2)',
|
||||
accelerator: 'Alt+Command+[',
|
||||
visible: false,
|
||||
click: () => {
|
||||
switchToPreviousTab();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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 initAndShowMainWindow();
|
||||
await checkForUpdates();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Documentation',
|
||||
click: async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { shell } = require('electron');
|
||||
await shell.openExternal(
|
||||
'https://docs.affine.pro/docs/hello-bonjour-aloha-你好'
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// @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,27 @@
|
||||
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 Doc
|
||||
*/
|
||||
onNewPageAction: (fn: () => void) => {
|
||||
const sub = applicationMenuSubjects.newPageAction$.subscribe(fn);
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
};
|
||||
},
|
||||
openAboutPageInSettingModal: (fn: () => void) => {
|
||||
const sub =
|
||||
applicationMenuSubjects.openAboutPageInSettingModal$.subscribe(fn);
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
};
|
||||
},
|
||||
} satisfies Record<string, MainEventRegister>;
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
export const applicationMenuSubjects = {
|
||||
newPageAction$: new Subject<void>(),
|
||||
openAboutPageInSettingModal$: new Subject<void>(),
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { IpcMainInvokeEvent } from 'electron';
|
||||
import { clipboard, 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,10 @@
|
||||
import type { NamespaceHandlers } from '../type';
|
||||
import { persistentConfig } from './persist';
|
||||
|
||||
/**
|
||||
* @deprecated use shared storage
|
||||
*/
|
||||
export const configStorageHandlers = {
|
||||
get: async () => persistentConfig.get(),
|
||||
set: async (_, v) => persistentConfig.set(v),
|
||||
} satisfies NamespaceHandlers;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './handlers';
|
||||
@@ -0,0 +1,20 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
AppConfigStorage,
|
||||
defaultAppConfig,
|
||||
} from '@toeverything/infra/app-config-storage';
|
||||
import { app } from 'electron';
|
||||
|
||||
const FILENAME = 'config.json';
|
||||
const FILEPATH = path.join(app.getPath('userData'), FILENAME);
|
||||
|
||||
/**
|
||||
* @deprecated use shared storage
|
||||
*/
|
||||
export const persistentConfig = new AppConfigStorage({
|
||||
config: defaultAppConfig,
|
||||
get: () => JSON.parse(fs.readFileSync(FILEPATH, 'utf-8')),
|
||||
set: data => fs.writeFileSync(FILEPATH, JSON.stringify(data, null, 2)),
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ReleaseTypeSchema = z.enum([
|
||||
'stable',
|
||||
'beta',
|
||||
'canary',
|
||||
'internal',
|
||||
]);
|
||||
|
||||
declare global {
|
||||
// THIS variable should be replaced during the build process
|
||||
const REPLACE_ME_BUILD_ENV: string;
|
||||
}
|
||||
|
||||
export const envBuildType = (process.env.BUILD_TYPE || REPLACE_ME_BUILD_ENV)
|
||||
.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,4 @@
|
||||
export const mainWindowOrigin = process.env.DEV_SERVER_URL || 'file://.';
|
||||
export const onboardingViewUrl = `${mainWindowOrigin}${mainWindowOrigin.endsWith('/') ? '' : '/'}onboarding`;
|
||||
export const shellViewUrl = `${mainWindowOrigin}${mainWindowOrigin.endsWith('/') ? '' : '/'}shell.html`;
|
||||
export const customThemeViewUrl = `${mainWindowOrigin}${mainWindowOrigin.endsWith('/') ? '' : '/'}theme-editor`;
|
||||
@@ -0,0 +1,97 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import type { App } from 'electron';
|
||||
|
||||
import { buildType, isDev } from './config';
|
||||
import { logger } from './logger';
|
||||
import { uiSubjects } from './ui';
|
||||
import {
|
||||
getMainWindow,
|
||||
openUrlInHiddenWindow,
|
||||
openUrlInMainWindow,
|
||||
showMainWindow,
|
||||
} from './windows-manager';
|
||||
|
||||
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) => {
|
||||
getMainWindow()
|
||||
.then(window => {
|
||||
if (!window) {
|
||||
logger.error('main window is not ready');
|
||||
return;
|
||||
}
|
||||
window.show();
|
||||
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) {
|
||||
await showMainWindow();
|
||||
|
||||
logger.info('open affine url', url);
|
||||
const urlObj = new URL(url);
|
||||
|
||||
if (urlObj.hostname === 'authentication') {
|
||||
const method = urlObj.searchParams.get('method');
|
||||
const payload = JSON.parse(urlObj.searchParams.get('payload') ?? 'false');
|
||||
|
||||
if (
|
||||
!method ||
|
||||
(method !== 'magic-link' && method !== 'oauth') ||
|
||||
!payload
|
||||
) {
|
||||
logger.error('Invalid authentication url', url);
|
||||
return;
|
||||
}
|
||||
|
||||
uiSubjects.authenticationRequest$.next({
|
||||
method,
|
||||
payload,
|
||||
});
|
||||
} else {
|
||||
const hiddenWindow = urlObj.searchParams.get('hidden')
|
||||
? await openUrlInHiddenWindow(urlObj)
|
||||
: await openUrlInMainWindow(urlObj);
|
||||
|
||||
const main = await getMainWindow();
|
||||
if (main && hiddenWindow) {
|
||||
// when hidden window closed, the main window will be hidden somehow
|
||||
hiddenWindow.on('close', () => {
|
||||
main.show();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { app, BrowserWindow, WebContentsView } from 'electron';
|
||||
|
||||
import { AFFINE_EVENT_CHANNEL_NAME } from '../shared/type';
|
||||
import { applicationMenuEvents } from './application-menu';
|
||||
import { logger } from './logger';
|
||||
import { sharedStorageEvents } from './shared-storage';
|
||||
import { uiEvents } from './ui/events';
|
||||
import { updaterEvents } from './updater/event';
|
||||
|
||||
export const allEvents = {
|
||||
applicationMenu: applicationMenuEvents,
|
||||
updater: updaterEvents,
|
||||
ui: uiEvents,
|
||||
sharedStorage: sharedStorageEvents,
|
||||
};
|
||||
|
||||
function getActiveWindows() {
|
||||
return BrowserWindow.getAllWindows().filter(win => !win.isDestroyed());
|
||||
}
|
||||
|
||||
export function registerEvents() {
|
||||
const unsubs: (() => void)[] = [];
|
||||
// register events
|
||||
for (const [namespace, namespaceEvents] of Object.entries(allEvents)) {
|
||||
for (const [key, eventRegister] of Object.entries(namespaceEvents)) {
|
||||
const unsubscribe = eventRegister((...args: any[]) => {
|
||||
const chan = `${namespace}:${key}`;
|
||||
logger.debug(
|
||||
'[ipc-event]',
|
||||
chan,
|
||||
args.filter(
|
||||
a =>
|
||||
a !== undefined &&
|
||||
typeof a !== 'function' &&
|
||||
typeof a !== 'object'
|
||||
)
|
||||
);
|
||||
// is this efficient?
|
||||
getActiveWindows().forEach(win => {
|
||||
if (win.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
// .webContents could be undefined if the window is destroyed
|
||||
win.webContents?.send(AFFINE_EVENT_CHANNEL_NAME, chan, ...args);
|
||||
win.contentView.children.forEach(child => {
|
||||
if (
|
||||
child instanceof WebContentsView &&
|
||||
child.webContents &&
|
||||
!child.webContents.isDestroyed()
|
||||
) {
|
||||
child.webContents?.send(AFFINE_EVENT_CHANNEL_NAME, chan, ...args);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
unsubs.push(unsubscribe);
|
||||
}
|
||||
}
|
||||
app.on('before-quit', () => {
|
||||
// subscription on quit sometimes crashes the app
|
||||
try {
|
||||
unsubs.forEach(unsub => unsub());
|
||||
} catch (err) {
|
||||
logger.error('unsubscribe error', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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,19 @@
|
||||
import type { NamespaceHandlers } from '../type';
|
||||
|
||||
export const findInPageHandlers = {
|
||||
find: async (event, text: string, options?: Electron.FindInPageOptions) => {
|
||||
const { promise, resolve } =
|
||||
Promise.withResolvers<Electron.Result | null>();
|
||||
const webContents = event.sender;
|
||||
let requestId: number = -1;
|
||||
webContents.once('found-in-page', (_, result) => {
|
||||
resolve(result.requestId === requestId ? result : null);
|
||||
});
|
||||
requestId = webContents.findInPage(text, options);
|
||||
return promise;
|
||||
},
|
||||
clear: async event => {
|
||||
const webContents = event.sender;
|
||||
webContents.stopFindInPage('keepSelection');
|
||||
},
|
||||
} satisfies NamespaceHandlers;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './handlers';
|
||||
@@ -0,0 +1,91 @@
|
||||
import { ipcMain } from 'electron';
|
||||
|
||||
import { AFFINE_API_CHANNEL_NAME } from '../shared/type';
|
||||
import { clipboardHandlers } from './clipboard';
|
||||
import { configStorageHandlers } from './config-storage';
|
||||
import { exportHandlers } from './export';
|
||||
import { findInPageHandlers } from './find-in-page';
|
||||
import { getLogFilePath, logger, revealLogFile } from './logger';
|
||||
import { sharedStorageHandlers } from './shared-storage';
|
||||
import { uiHandlers } from './ui/handlers';
|
||||
import { updaterHandlers } from './updater';
|
||||
|
||||
export const debugHandlers = {
|
||||
revealLogFile: async () => {
|
||||
return revealLogFile();
|
||||
},
|
||||
logFilePath: async () => {
|
||||
return getLogFilePath();
|
||||
},
|
||||
};
|
||||
|
||||
// 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,
|
||||
configStorage: configStorageHandlers,
|
||||
findInPage: findInPageHandlers,
|
||||
sharedStorage: sharedStorageHandlers,
|
||||
};
|
||||
|
||||
export const registerHandlers = () => {
|
||||
const handleIpcMessage = async (
|
||||
e: Electron.IpcMainInvokeEvent,
|
||||
...args: any[]
|
||||
) => {
|
||||
// args[0] is the `{namespace:key}`
|
||||
if (typeof args[0] !== 'string') {
|
||||
logger.error('invalid ipc message', args);
|
||||
return;
|
||||
}
|
||||
const channel = args[0] as string;
|
||||
const [namespace, key] = channel.split(':');
|
||||
|
||||
if (!namespace || !key) {
|
||||
logger.error('invalid ipc message', args);
|
||||
return;
|
||||
}
|
||||
|
||||
// @ts-expect-error - ignore here
|
||||
const handler = allHandlers[namespace]?.[key];
|
||||
|
||||
if (!handler) {
|
||||
logger.error('handler not found for ', args[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
const realArgs = args.slice(1);
|
||||
const result = await handler(e, ...realArgs);
|
||||
|
||||
logger.debug(
|
||||
'[ipc-api]',
|
||||
channel,
|
||||
realArgs.filter(
|
||||
arg => typeof arg !== 'function' && typeof arg !== 'object'
|
||||
),
|
||||
'-',
|
||||
Date.now() - start,
|
||||
'ms'
|
||||
);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
ipcMain.handle(AFFINE_API_CHANNEL_NAME, async (e, ...args: any[]) => {
|
||||
return handleIpcMessage(e, ...args);
|
||||
});
|
||||
|
||||
ipcMain.on(AFFINE_API_CHANNEL_NAME, (e, ...args: any[]) => {
|
||||
handleIpcMessage(e, ...args)
|
||||
.then(ret => {
|
||||
e.returnValue = ret;
|
||||
})
|
||||
.catch(() => {
|
||||
// never throw
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import type { _AsyncVersionOf } from 'async-call-rpc';
|
||||
import { AsyncCall } from 'async-call-rpc';
|
||||
import type { UtilityProcess, WebContents } from 'electron';
|
||||
import {
|
||||
app,
|
||||
dialog,
|
||||
MessageChannelMain,
|
||||
shell,
|
||||
utilityProcess,
|
||||
} from 'electron';
|
||||
|
||||
import type { HelperToMain, MainToHelper } from '../shared/type';
|
||||
import { MessageEventChannel } from '../shared/utils';
|
||||
import { logger } from './logger';
|
||||
|
||||
const HELPER_PROCESS_PATH = path.join(__dirname, './helper.js');
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
|
||||
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>;
|
||||
readonly #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, [], {
|
||||
// todo: port number should not being used
|
||||
execArgv: isDev ? ['--inspect=40894'] : [],
|
||||
});
|
||||
this.#process = helperProcess;
|
||||
this.ready = new Promise((resolve, reject) => {
|
||||
helperProcess.once('spawn', () => {
|
||||
try {
|
||||
this.#connectMain();
|
||||
logger.info('[helper] forked', helperProcess.pid);
|
||||
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,95 @@
|
||||
import './security-restrictions';
|
||||
|
||||
import path from 'node:path';
|
||||
|
||||
import * as Sentry from '@sentry/electron/main';
|
||||
import { IPCMode } from '@sentry/electron/main';
|
||||
import { app } from 'electron';
|
||||
|
||||
import { createApplicationMenu } from './application-menu/create';
|
||||
import { buildType, overrideSession } from './config';
|
||||
import { persistentConfig } from './config-storage/persist';
|
||||
import { setupDeepLink } from './deep-link';
|
||||
import { registerEvents } from './events';
|
||||
import { registerHandlers } from './handlers';
|
||||
import { logger } from './logger';
|
||||
import { registerProtocol } from './protocol';
|
||||
import { isOnline } from './ui';
|
||||
import { registerUpdater } from './updater';
|
||||
import { launch } from './windows-manager/launcher';
|
||||
import { launchStage } from './windows-manager/stage';
|
||||
|
||||
app.enableSandbox();
|
||||
|
||||
app.commandLine.appendSwitch('enable-features', 'CSSTextAutoSpace');
|
||||
|
||||
// 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();
|
||||
|
||||
if (process.env.SKIP_ONBOARDING) {
|
||||
launchStage.value = 'main';
|
||||
persistentConfig.set({
|
||||
onBoarding: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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', () => {
|
||||
app.quit();
|
||||
});
|
||||
|
||||
/**
|
||||
* @see https://www.electronjs.org/docs/latest/api/app#event-activate-macos Event: 'activate'
|
||||
*/
|
||||
app.on('activate', () => {
|
||||
launch().catch(e => console.error('Failed launch:', e));
|
||||
});
|
||||
|
||||
setupDeepLink(app);
|
||||
|
||||
/**
|
||||
* Create app window when background process will be ready
|
||||
*/
|
||||
app
|
||||
.whenReady()
|
||||
.then(registerProtocol)
|
||||
.then(registerHandlers)
|
||||
.then(registerEvents)
|
||||
.then(launch)
|
||||
.then(createApplicationMenu)
|
||||
.then(registerUpdater)
|
||||
.catch(e => console.error('Failed create window:', e));
|
||||
|
||||
if (process.env.SENTRY_RELEASE) {
|
||||
// https://docs.sentry.io/platforms/javascript/guides/electron/
|
||||
Sentry.init({
|
||||
dsn: process.env.SENTRY_DSN,
|
||||
environment: process.env.BUILD_TYPE ?? 'development',
|
||||
ipcMode: IPCMode.Protocol,
|
||||
transportOptions: {
|
||||
maxAgeDays: 30,
|
||||
maxQueueSize: 100,
|
||||
shouldStore: () => !isOnline,
|
||||
shouldSend: () => isOnline,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { app, shell } from 'electron';
|
||||
import log from 'electron-log/main';
|
||||
|
||||
export const logger = log.scope('main');
|
||||
log.initialize({
|
||||
preload: false,
|
||||
});
|
||||
|
||||
log.transports.file.level = 'info';
|
||||
|
||||
export function getLogFilePath() {
|
||||
return log.transports.file.getFile().path;
|
||||
}
|
||||
|
||||
export async function revealLogFile() {
|
||||
const filePath = getLogFilePath();
|
||||
return await shell.openPath(filePath);
|
||||
}
|
||||
|
||||
app.on('before-quit', () => {
|
||||
log.transports.console.level = false;
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { net, protocol, session } from 'electron';
|
||||
|
||||
import { CLOUD_BASE_URL } from './config';
|
||||
import { logger } from './logger';
|
||||
import { isOfflineModeEnabled } from './utils';
|
||||
import { getCookies } from './windows-manager';
|
||||
|
||||
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 + urlObject.search,
|
||||
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) {
|
||||
// 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) => {
|
||||
const url = new URL(details.url);
|
||||
const pathname = url.pathname;
|
||||
const protocol = url.protocol;
|
||||
const origin = url.origin;
|
||||
|
||||
const sameOrigin = origin === CLOUD_BASE_URL || protocol === 'file:';
|
||||
|
||||
// offline whitelist
|
||||
// 1. do not block non-api request for http://localhost || file:// (local dev assets)
|
||||
// 2. do not block devtools
|
||||
// 3. block all other requests
|
||||
const blocked = (() => {
|
||||
if (!isOfflineModeEnabled()) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
(protocol === 'file:' || origin.startsWith('http://localhost')) &&
|
||||
!isNetworkResource(pathname)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if ('devtools:' === protocol) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})();
|
||||
|
||||
if (blocked) {
|
||||
logger.debug('blocked request', details.url);
|
||||
callback({
|
||||
cancel: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// session cookies are set to file:// on production
|
||||
// if sending request to the cloud, attach the session cookie (to affine cloud server)
|
||||
if (isNetworkResource(pathname) && sameOrigin) {
|
||||
const cookie = getCookies();
|
||||
if (cookie) {
|
||||
const cookieString = cookie.map(c => `${c.name}=${c.value}`).join('; ');
|
||||
details.requestHeaders['cookie'] = cookieString;
|
||||
}
|
||||
|
||||
// add the referer and origin headers
|
||||
details.requestHeaders['referer'] ??= CLOUD_BASE_URL;
|
||||
details.requestHeaders['origin'] ??= CLOUD_BASE_URL;
|
||||
}
|
||||
callback({
|
||||
cancel: false,
|
||||
requestHeaders: details.requestHeaders,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { app, shell } from 'electron';
|
||||
|
||||
app.on('web-contents-created', (_, contents) => {
|
||||
const isInternalUrl = (url: string) => {
|
||||
return (
|
||||
(process.env.DEV_SERVER_URL &&
|
||||
url.startsWith(process.env.DEV_SERVER_URL)) ||
|
||||
url.startsWith('affine://') ||
|
||||
url.startsWith('file://.')
|
||||
);
|
||||
};
|
||||
/**
|
||||
* 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 (isInternalUrl(url)) {
|
||||
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 }) => {
|
||||
if (!isInternalUrl(url) || url.includes('/redirect-proxy')) {
|
||||
// Open default browser
|
||||
shell.openExternal(url).catch(console.error);
|
||||
}
|
||||
// Prevent creating new window in application
|
||||
return { action: 'deny' };
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { MainEventRegister } from '../type';
|
||||
import { globalCacheStorage, globalStateStorage } from './storage';
|
||||
|
||||
export const sharedStorageEvents = {
|
||||
onGlobalStateChanged: (
|
||||
fn: (state: Record<string, unknown | undefined>) => void
|
||||
) => {
|
||||
const subscription = globalStateStorage.watchAll().subscribe(updates => {
|
||||
fn(updates);
|
||||
});
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
},
|
||||
onGlobalCacheChanged: (
|
||||
fn: (state: Record<string, unknown | undefined>) => void
|
||||
) => {
|
||||
const subscription = globalCacheStorage.watchAll().subscribe(updates => {
|
||||
fn(updates);
|
||||
});
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
},
|
||||
} satisfies Record<string, MainEventRegister>;
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { NamespaceHandlers } from '../type';
|
||||
import { globalCacheStorage, globalStateStorage } from './storage';
|
||||
|
||||
export const sharedStorageHandlers = {
|
||||
getAllGlobalState: async () => {
|
||||
return globalStateStorage.all();
|
||||
},
|
||||
getAllGlobalCache: async () => {
|
||||
return globalCacheStorage.all();
|
||||
},
|
||||
setGlobalState: async (_, key: string, value: any) => {
|
||||
return globalStateStorage.set(key, value);
|
||||
},
|
||||
delGlobalState: async (_, key: string) => {
|
||||
return globalStateStorage.del(key);
|
||||
},
|
||||
clearGlobalState: async () => {
|
||||
return globalStateStorage.clear();
|
||||
},
|
||||
setGlobalCache: async (_, key: string, value: any) => {
|
||||
return globalCacheStorage.set(key, value);
|
||||
},
|
||||
delGlobalCache: async (_, key: string) => {
|
||||
return globalCacheStorage.del(key);
|
||||
},
|
||||
clearGlobalCache: async () => {
|
||||
return globalCacheStorage.clear();
|
||||
},
|
||||
} satisfies NamespaceHandlers;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { sharedStorageEvents } from './events';
|
||||
export { sharedStorageHandlers } from './handlers';
|
||||
@@ -0,0 +1,139 @@
|
||||
import fs from 'node:fs';
|
||||
|
||||
import type { Memento } from '@toeverything/infra';
|
||||
import {
|
||||
backoffRetry,
|
||||
effect,
|
||||
exhaustMapWithTrailing,
|
||||
fromPromise,
|
||||
} from '@toeverything/infra';
|
||||
import { debounceTime, EMPTY, mergeMap, Observable, timeout } from 'rxjs';
|
||||
|
||||
import { logger } from '../logger';
|
||||
|
||||
export class PersistentJSONFileStorage implements Memento {
|
||||
data: Record<string, any> = {};
|
||||
subscriptions: Map<string, Set<(p: any) => void>> = new Map();
|
||||
subscriptionAll: Set<(p: Record<string, any>) => void> = new Set();
|
||||
|
||||
constructor(readonly filepath: string) {
|
||||
try {
|
||||
this.data = JSON.parse(fs.readFileSync(filepath, 'utf-8'));
|
||||
} catch (err) {
|
||||
// ignore ENOENT error
|
||||
if (
|
||||
!(
|
||||
err &&
|
||||
typeof err === 'object' &&
|
||||
'code' in err &&
|
||||
err.code === 'ENOENT'
|
||||
)
|
||||
) {
|
||||
logger.error('failed to load file', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get<T>(key: string): T | undefined {
|
||||
return this.data[key];
|
||||
}
|
||||
all(): Record<string, any> {
|
||||
return this.data;
|
||||
}
|
||||
watch<T>(key: string): Observable<T | undefined> {
|
||||
const subs = this.subscriptions.get(key) || new Set();
|
||||
this.subscriptions.set(key, subs);
|
||||
return new Observable<T | undefined>(subscriber => {
|
||||
const sub = (p: any) => subscriber.next(p);
|
||||
subs.add(sub);
|
||||
return () => {
|
||||
subs.delete(sub);
|
||||
};
|
||||
});
|
||||
}
|
||||
watchAll(): Observable<Record<string, unknown | undefined>> {
|
||||
return new Observable<Record<string, unknown | undefined>>(subscriber => {
|
||||
const sub = (p: Record<string, unknown | undefined>) =>
|
||||
subscriber.next(p);
|
||||
this.subscriptionAll.add(sub);
|
||||
return () => {
|
||||
this.subscriptionAll.delete(sub);
|
||||
};
|
||||
});
|
||||
}
|
||||
set<T>(key: string, value: T): void {
|
||||
this.data[key] = value;
|
||||
const subs = this.subscriptions.get(key) || new Set();
|
||||
for (const sub of subs) {
|
||||
sub(value);
|
||||
}
|
||||
for (const sub of this.subscriptionAll) {
|
||||
sub({
|
||||
[key]: this.data[key],
|
||||
});
|
||||
}
|
||||
this.save();
|
||||
}
|
||||
|
||||
del(key: string): void {
|
||||
delete this.data[key];
|
||||
const subs = this.subscriptions.get(key) || new Set();
|
||||
for (const sub of subs) {
|
||||
sub(undefined);
|
||||
}
|
||||
for (const sub of this.subscriptionAll) {
|
||||
sub({
|
||||
[key]: undefined,
|
||||
});
|
||||
}
|
||||
this.save();
|
||||
}
|
||||
clear(): void {
|
||||
const oldData = this.data;
|
||||
this.data = {};
|
||||
for (const [_, subs] of this.subscriptions) {
|
||||
for (const sub of subs) {
|
||||
sub(undefined);
|
||||
}
|
||||
}
|
||||
for (const sub of this.subscriptionAll) {
|
||||
sub(
|
||||
Object.fromEntries(
|
||||
Object.entries(oldData).map(([key]) => [key, undefined])
|
||||
)
|
||||
);
|
||||
}
|
||||
this.save();
|
||||
}
|
||||
|
||||
keys(): string[] {
|
||||
return Object.keys(this.data);
|
||||
}
|
||||
|
||||
save = effect(
|
||||
debounceTime(1000),
|
||||
exhaustMapWithTrailing(() => {
|
||||
return fromPromise(async () => {
|
||||
try {
|
||||
await fs.promises.writeFile(
|
||||
this.filepath,
|
||||
JSON.stringify(this.data, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(`failed to save file, ${this.filepath}`, err);
|
||||
}
|
||||
}).pipe(
|
||||
timeout(5000),
|
||||
backoffRetry({
|
||||
count: Infinity,
|
||||
}),
|
||||
mergeMap(() => EMPTY)
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
dispose() {
|
||||
this.save.unsubscribe();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { app } from 'electron';
|
||||
|
||||
import { PersistentJSONFileStorage } from './json-file';
|
||||
|
||||
export const globalStateStorage = new PersistentJSONFileStorage(
|
||||
path.join(app.getPath('userData'), 'global-state.json')
|
||||
);
|
||||
|
||||
export const globalCacheStorage = new PersistentJSONFileStorage(
|
||||
path.join(app.getPath('userData'), 'global-cache.json')
|
||||
);
|
||||
@@ -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,45 @@
|
||||
import type { MainEventRegister } from '../type';
|
||||
import {
|
||||
type AuthenticationRequest,
|
||||
onActiveTabChanged,
|
||||
onTabAction,
|
||||
onTabShellViewActiveChange,
|
||||
onTabsStatusChange,
|
||||
onTabViewsMetaChanged,
|
||||
} from '../windows-manager';
|
||||
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();
|
||||
};
|
||||
},
|
||||
onFullScreen: (fn: (fullScreen: boolean) => void) => {
|
||||
const sub = uiSubjects.onFullScreen$.subscribe(fn);
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
};
|
||||
},
|
||||
onTabViewsMetaChanged,
|
||||
onTabAction,
|
||||
onToggleRightSidebar: (fn: (tabId: string) => void) => {
|
||||
const sub = uiSubjects.onToggleRightSidebar$.subscribe(fn);
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
};
|
||||
},
|
||||
onTabsStatusChange,
|
||||
onActiveTabChanged,
|
||||
onTabShellViewActiveChange,
|
||||
onAuthenticationRequest: (fn: (state: AuthenticationRequest) => void) => {
|
||||
const sub = uiSubjects.authenticationRequest$.subscribe(fn);
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
};
|
||||
},
|
||||
} satisfies Record<string, MainEventRegister>;
|
||||
@@ -0,0 +1,214 @@
|
||||
import { app, nativeTheme, shell } from 'electron';
|
||||
import { getLinkPreview } from 'link-preview-js';
|
||||
|
||||
import { persistentConfig } from '../config-storage/persist';
|
||||
import { logger } from '../logger';
|
||||
import type { NamespaceHandlers } from '../type';
|
||||
import {
|
||||
activateView,
|
||||
addTab,
|
||||
closeTab,
|
||||
getMainWindow,
|
||||
getOnboardingWindow,
|
||||
getTabsStatus,
|
||||
getTabViewsMeta,
|
||||
getWorkbenchMeta,
|
||||
handleWebContentsResize,
|
||||
initAndShowMainWindow,
|
||||
isActiveTab,
|
||||
launchStage,
|
||||
moveTab,
|
||||
pingAppLayoutReady,
|
||||
showDevTools,
|
||||
showTab,
|
||||
updateWorkbenchMeta,
|
||||
updateWorkbenchViewMeta,
|
||||
} from '../windows-manager';
|
||||
import { showTabContextMenu } from '../windows-manager/context-menu';
|
||||
import { getOrCreateCustomThemeWindow } from '../windows-manager/custom-theme-window';
|
||||
import { getChallengeResponse } from './challenge';
|
||||
import { uiSubjects } from './subject';
|
||||
|
||||
export let isOnline = true;
|
||||
|
||||
export const uiHandlers = {
|
||||
isMaximized: async () => {
|
||||
const window = await getMainWindow();
|
||||
return window?.isMaximized();
|
||||
},
|
||||
isFullScreen: async () => {
|
||||
const window = await getMainWindow();
|
||||
return window?.isFullScreen();
|
||||
},
|
||||
handleThemeChange: async (_, theme: (typeof nativeTheme)['themeSource']) => {
|
||||
nativeTheme.themeSource = theme;
|
||||
},
|
||||
handleMinimizeApp: async () => {
|
||||
const window = await getMainWindow();
|
||||
window?.minimize();
|
||||
},
|
||||
handleMaximizeApp: async () => {
|
||||
const window = await getMainWindow();
|
||||
if (!window) {
|
||||
return;
|
||||
}
|
||||
// allow unmaximize when in full screen mode
|
||||
if (window.isFullScreen()) {
|
||||
window.setFullScreen(false);
|
||||
window.unmaximize();
|
||||
} else if (window.isMaximized()) {
|
||||
window.unmaximize();
|
||||
} else {
|
||||
window.maximize();
|
||||
}
|
||||
},
|
||||
handleWindowResize: async e => {
|
||||
await handleWebContentsResize(e.sender);
|
||||
},
|
||||
handleCloseApp: async () => {
|
||||
app.quit();
|
||||
},
|
||||
handleNetworkChange: async (_, _isOnline: boolean) => {
|
||||
isOnline = _isOnline;
|
||||
},
|
||||
getChallengeResponse: async (_, challenge: string) => {
|
||||
return getChallengeResponse(challenge);
|
||||
},
|
||||
handleOpenMainApp: async () => {
|
||||
if (launchStage.value === 'onboarding') {
|
||||
launchStage.value = 'main';
|
||||
persistentConfig.patch('onBoarding', false);
|
||||
}
|
||||
|
||||
try {
|
||||
const onboarding = await getOnboardingWindow();
|
||||
onboarding?.hide();
|
||||
await initAndShowMainWindow();
|
||||
// need to destroy onboarding window after main window is ready
|
||||
// otherwise the main window will be closed as well
|
||||
onboarding?.destroy();
|
||||
} catch (err) {
|
||||
logger.error('handleOpenMainApp', err);
|
||||
}
|
||||
},
|
||||
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],
|
||||
};
|
||||
}
|
||||
},
|
||||
openExternal(_, url: string) {
|
||||
return shell.openExternal(url);
|
||||
},
|
||||
|
||||
// tab handlers
|
||||
isActiveTab: async e => {
|
||||
return isActiveTab(e.sender);
|
||||
},
|
||||
getWorkbenchMeta: async (_, ...args: Parameters<typeof getWorkbenchMeta>) => {
|
||||
return getWorkbenchMeta(...args);
|
||||
},
|
||||
updateWorkbenchMeta: async (
|
||||
_,
|
||||
...args: Parameters<typeof updateWorkbenchMeta>
|
||||
) => {
|
||||
return updateWorkbenchMeta(...args);
|
||||
},
|
||||
updateWorkbenchViewMeta: async (
|
||||
_,
|
||||
...args: Parameters<typeof updateWorkbenchViewMeta>
|
||||
) => {
|
||||
return updateWorkbenchViewMeta(...args);
|
||||
},
|
||||
getTabViewsMeta: async () => {
|
||||
return getTabViewsMeta();
|
||||
},
|
||||
getTabsStatus: async () => {
|
||||
return getTabsStatus();
|
||||
},
|
||||
addTab: async (_, ...args: Parameters<typeof addTab>) => {
|
||||
await addTab(...args);
|
||||
},
|
||||
showTab: async (_, ...args: Parameters<typeof showTab>) => {
|
||||
await showTab(...args);
|
||||
},
|
||||
closeTab: async (_, ...args: Parameters<typeof closeTab>) => {
|
||||
await closeTab(...args);
|
||||
},
|
||||
activateView: async (_, ...args: Parameters<typeof activateView>) => {
|
||||
await activateView(...args);
|
||||
},
|
||||
moveTab: async (_, ...args: Parameters<typeof moveTab>) => {
|
||||
moveTab(...args);
|
||||
},
|
||||
toggleRightSidebar: async (_, tabId?: string) => {
|
||||
tabId ??= getTabViewsMeta().activeWorkbenchId;
|
||||
if (tabId) {
|
||||
uiSubjects.onToggleRightSidebar$.next(tabId);
|
||||
}
|
||||
},
|
||||
pingAppLayoutReady: async e => {
|
||||
pingAppLayoutReady(e.sender);
|
||||
},
|
||||
showDevTools: async (_, ...args: Parameters<typeof showDevTools>) => {
|
||||
return showDevTools(...args);
|
||||
},
|
||||
showTabContextMenu: async (_, tabKey: string, viewIndex: number) => {
|
||||
return showTabContextMenu(tabKey, viewIndex);
|
||||
},
|
||||
openThemeEditor: async () => {
|
||||
const win = await getOrCreateCustomThemeWindow();
|
||||
win.show();
|
||||
win.focus();
|
||||
},
|
||||
} satisfies NamespaceHandlers;
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './events';
|
||||
export * from './handlers';
|
||||
export * from './subject';
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
import type { AuthenticationRequest } from '../windows-manager';
|
||||
|
||||
export const uiSubjects = {
|
||||
onMaximized$: new Subject<boolean>(),
|
||||
onFullScreen$: new Subject<boolean>(),
|
||||
onToggleRightSidebar$: new Subject<string>(),
|
||||
authenticationRequest$: new Subject<AuthenticationRequest>(),
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
// credits: migrated from https://github.com/electron-userland/electron-builder/blob/master/packages/electron-updater/src/providers/GitHubProvider.ts
|
||||
|
||||
import type { CustomPublishOptions } from 'builder-util-runtime';
|
||||
import { newError } from 'builder-util-runtime';
|
||||
import type {
|
||||
AppUpdater,
|
||||
ResolvedUpdateFileInfo,
|
||||
UpdateFileInfo,
|
||||
UpdateInfo,
|
||||
} from 'electron-updater';
|
||||
import { CancellationToken, Provider } from 'electron-updater';
|
||||
import type { ProviderRuntimeOptions } from 'electron-updater/out/providers/Provider';
|
||||
import {
|
||||
getFileList,
|
||||
parseUpdateInfo,
|
||||
} from 'electron-updater/out/providers/Provider';
|
||||
|
||||
import type { buildType } from '../config';
|
||||
import { isSquirrelBuild } from './utils';
|
||||
|
||||
interface GithubUpdateInfo extends UpdateInfo {
|
||||
tag: string;
|
||||
}
|
||||
|
||||
interface GithubRelease {
|
||||
name: string;
|
||||
tag_name: string;
|
||||
published_at: string;
|
||||
assets: Array<{
|
||||
name: string;
|
||||
url: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface UpdateProviderOptions {
|
||||
feedUrl?: string;
|
||||
channel: typeof buildType;
|
||||
}
|
||||
|
||||
export class AFFiNEUpdateProvider extends Provider<GithubUpdateInfo> {
|
||||
static configFeed(options: UpdateProviderOptions): CustomPublishOptions {
|
||||
return {
|
||||
provider: 'custom',
|
||||
feedUrl: 'https://affine.pro/api/worker/releases',
|
||||
updateProvider: AFFiNEUpdateProvider,
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly options: CustomPublishOptions,
|
||||
_updater: AppUpdater,
|
||||
runtimeOptions: ProviderRuntimeOptions
|
||||
) {
|
||||
super(runtimeOptions);
|
||||
}
|
||||
|
||||
get feedUrl(): URL {
|
||||
const url = new URL(this.options.feedUrl);
|
||||
url.searchParams.set('channel', this.options.channel);
|
||||
url.searchParams.set('minimal', 'true');
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
async getLatestVersion(): Promise<GithubUpdateInfo> {
|
||||
const cancellationToken = new CancellationToken();
|
||||
|
||||
const releasesJsonStr = await this.httpRequest(
|
||||
this.feedUrl,
|
||||
{
|
||||
accept: 'application/json',
|
||||
'cache-control': 'no-cache',
|
||||
},
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
if (!releasesJsonStr) {
|
||||
throw new Error(
|
||||
`Failed to get releases from ${this.feedUrl.toString()}, response is empty`
|
||||
);
|
||||
}
|
||||
|
||||
const releases = JSON.parse(releasesJsonStr);
|
||||
|
||||
if (releases.length === 0) {
|
||||
throw new Error(
|
||||
`No published versions in channel ${this.options.channel}`
|
||||
);
|
||||
}
|
||||
|
||||
const latestRelease = releases[0] as GithubRelease;
|
||||
const tag = latestRelease.tag_name;
|
||||
|
||||
const channelFileName = getChannelFilename(this.getDefaultChannelName());
|
||||
const channelFileAsset = latestRelease.assets.find(({ url }) =>
|
||||
url.endsWith(channelFileName)
|
||||
);
|
||||
|
||||
if (!channelFileAsset) {
|
||||
throw newError(
|
||||
`Cannot find ${channelFileName} in the latest release artifacts.`,
|
||||
'ERR_UPDATER_CHANNEL_FILE_NOT_FOUND'
|
||||
);
|
||||
}
|
||||
|
||||
const channelFileUrl = new URL(channelFileAsset.url);
|
||||
const channelFileContent = await this.httpRequest(channelFileUrl);
|
||||
|
||||
const result = parseUpdateInfo(
|
||||
channelFileContent,
|
||||
channelFileName,
|
||||
channelFileUrl
|
||||
);
|
||||
|
||||
const files: UpdateFileInfo[] = [];
|
||||
|
||||
result.files.forEach(file => {
|
||||
const asset = latestRelease.assets.find(({ name }) => name === file.url);
|
||||
if (asset) {
|
||||
file.url = asset.url;
|
||||
}
|
||||
|
||||
// for windows, we need to determine its installer type (nsis or squirrel)
|
||||
if (process.platform === 'win32') {
|
||||
const isSquirrel = isSquirrelBuild();
|
||||
if (isSquirrel && file.url.endsWith('.nsis.exe')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
files.push(file);
|
||||
});
|
||||
|
||||
if (result.releaseName == null) {
|
||||
result.releaseName = latestRelease.name;
|
||||
}
|
||||
|
||||
if (result.releaseNotes == null) {
|
||||
// TODO(@forehalo): add release notes
|
||||
result.releaseNotes = '';
|
||||
}
|
||||
|
||||
return {
|
||||
tag: tag,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
resolveFiles(updateInfo: GithubUpdateInfo): Array<ResolvedUpdateFileInfo> {
|
||||
const files = getFileList(updateInfo);
|
||||
|
||||
return files.map(file => ({
|
||||
url: new URL(file.url),
|
||||
info: file,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function getChannelFilename(channel: string): string {
|
||||
return `${channel}.yml`;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { app } from 'electron';
|
||||
import { autoUpdater as defaultAutoUpdater } from 'electron-updater';
|
||||
|
||||
import { buildType } from '../config';
|
||||
import { logger } from '../logger';
|
||||
import { isOfflineModeEnabled } from '../utils';
|
||||
import { AFFiNEUpdateProvider } from './affine-update-provider';
|
||||
import { updaterSubjects } from './event';
|
||||
import { WindowsUpdater } from './windows-updater';
|
||||
|
||||
const mode = process.env.NODE_ENV;
|
||||
const isDev = mode === 'development';
|
||||
|
||||
// skip auto update in dev mode & internal
|
||||
const disabled = buildType === 'internal' || isDev;
|
||||
|
||||
export const autoUpdater =
|
||||
process.platform === 'win32' ? new WindowsUpdater() : defaultAutoUpdater;
|
||||
|
||||
export const quitAndInstall = async () => {
|
||||
autoUpdater.quitAndInstall();
|
||||
};
|
||||
|
||||
let downloading = false;
|
||||
let configured = false;
|
||||
let checkingUpdate = false;
|
||||
|
||||
export type UpdaterConfig = {
|
||||
autoCheckUpdate: boolean;
|
||||
autoDownloadUpdate: boolean;
|
||||
};
|
||||
|
||||
const config: UpdaterConfig = {
|
||||
autoCheckUpdate: true,
|
||||
autoDownloadUpdate: true,
|
||||
};
|
||||
|
||||
export const getConfig = (): UpdaterConfig => {
|
||||
return { ...config };
|
||||
};
|
||||
|
||||
export const setConfig = (newConfig: Partial<UpdaterConfig> = {}): void => {
|
||||
configured = true;
|
||||
|
||||
Object.assign(config, newConfig);
|
||||
|
||||
logger.info('Updater configured!', config);
|
||||
|
||||
// if config.autoCheckUpdate is true, trigger a check
|
||||
if (config.autoCheckUpdate) {
|
||||
checkForUpdates().catch(err => {
|
||||
logger.error('Error checking for updates', err);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const checkForUpdates = async () => {
|
||||
if (disabled || checkingUpdate || isOfflineModeEnabled()) {
|
||||
return;
|
||||
}
|
||||
checkingUpdate = true;
|
||||
try {
|
||||
const info = await autoUpdater.checkForUpdates();
|
||||
return info;
|
||||
} finally {
|
||||
checkingUpdate = false;
|
||||
}
|
||||
};
|
||||
|
||||
export const downloadUpdate = async () => {
|
||||
if (disabled || downloading) {
|
||||
return;
|
||||
}
|
||||
downloading = true;
|
||||
updaterSubjects.downloadProgress$.next(0);
|
||||
autoUpdater.downloadUpdate().catch(e => {
|
||||
downloading = false;
|
||||
logger.error('Failed to download update', e);
|
||||
});
|
||||
logger.info('Update available, downloading...');
|
||||
return;
|
||||
};
|
||||
|
||||
export const registerUpdater = async () => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allowAutoUpdate = true;
|
||||
|
||||
autoUpdater.logger = logger;
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.allowPrerelease = buildType !== 'stable';
|
||||
autoUpdater.autoInstallOnAppQuit = false;
|
||||
autoUpdater.autoRunAppAfterInstall = true;
|
||||
|
||||
const feedUrl = AFFiNEUpdateProvider.configFeed({
|
||||
channel: buildType,
|
||||
});
|
||||
|
||||
logger.debug('auto-updater feed config', feedUrl);
|
||||
|
||||
autoUpdater.setFeedURL(feedUrl);
|
||||
|
||||
// register events for checkForUpdates
|
||||
autoUpdater.on('checking-for-update', () => {
|
||||
logger.info('Checking for update');
|
||||
});
|
||||
autoUpdater.on('update-available', info => {
|
||||
logger.info('Update available', info);
|
||||
if (config.autoDownloadUpdate && allowAutoUpdate) {
|
||||
downloadUpdate().catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
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;
|
||||
|
||||
// check update whenever the window is activated
|
||||
let lastCheckTime = 0;
|
||||
app.on('browser-window-focus', () => {
|
||||
(async () => {
|
||||
if (
|
||||
configured &&
|
||||
config.autoCheckUpdate &&
|
||||
lastCheckTime + 1000 * 1800 < Date.now()
|
||||
) {
|
||||
lastCheckTime = Date.now();
|
||||
await checkForUpdates();
|
||||
}
|
||||
})().catch(err => {
|
||||
logger.error('Error checking for updates', 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,45 @@
|
||||
import type { IpcMainInvokeEvent } from 'electron';
|
||||
import { app } from 'electron';
|
||||
|
||||
import type { NamespaceHandlers } from '../type';
|
||||
import type { UpdaterConfig } from './electron-updater';
|
||||
import {
|
||||
checkForUpdates,
|
||||
downloadUpdate,
|
||||
getConfig,
|
||||
quitAndInstall,
|
||||
setConfig,
|
||||
} from './electron-updater';
|
||||
|
||||
export const updaterHandlers = {
|
||||
currentVersion: async () => {
|
||||
return app.getVersion();
|
||||
},
|
||||
quitAndInstall: async () => {
|
||||
return quitAndInstall();
|
||||
},
|
||||
downloadUpdate: async () => {
|
||||
return downloadUpdate();
|
||||
},
|
||||
getConfig: async (): Promise<UpdaterConfig> => {
|
||||
return getConfig();
|
||||
},
|
||||
setConfig: async (
|
||||
_e: IpcMainInvokeEvent,
|
||||
newConfig: Partial<UpdaterConfig>
|
||||
): Promise<void> => {
|
||||
return setConfig(newConfig);
|
||||
},
|
||||
checkForUpdates: async () => {
|
||||
const res = await checkForUpdates();
|
||||
if (res) {
|
||||
const { updateInfo } = res;
|
||||
return {
|
||||
version: updateInfo.version,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
} satisfies NamespaceHandlers;
|
||||
|
||||
export * from './electron-updater';
|
||||
@@ -0,0 +1,18 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { app } from 'electron';
|
||||
|
||||
let _isSquirrelBuild: boolean | null = null;
|
||||
export function isSquirrelBuild() {
|
||||
if (typeof _isSquirrelBuild === 'boolean') {
|
||||
return _isSquirrelBuild;
|
||||
}
|
||||
|
||||
// if it is squirrel build, there will be 'squirrel.exe'
|
||||
// otherwise it is in nsis web mode
|
||||
const files = fs.readdirSync(path.dirname(app.getPath('exe')));
|
||||
_isSquirrelBuild = files.some(it => it.includes('squirrel.exe'));
|
||||
|
||||
return _isSquirrelBuild;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { app } from 'electron';
|
||||
import { NsisUpdater } from 'electron-updater';
|
||||
import { DownloadedUpdateHelper } from 'electron-updater/out/DownloadedUpdateHelper';
|
||||
|
||||
export class WindowsUpdater extends NsisUpdater {
|
||||
protected override downloadedUpdateHelper: DownloadedUpdateHelper =
|
||||
new DownloadedUpdateHelper(app.getPath('sessionData'));
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { CookiesSetDetails } from 'electron';
|
||||
|
||||
import { logger } from './logger';
|
||||
import { globalStateStorage } from './shared-storage/storage';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export const isOfflineModeEnabled = () => {
|
||||
try {
|
||||
return (
|
||||
// todo(pengx17): better abstraction for syncing flags with electron
|
||||
// packages/common/infra/src/modules/feature-flag/entities/flags.ts
|
||||
globalStateStorage.get('affine-flag:enable_offline_mode') ?? false
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get offline mode flag', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface AuthenticationRequest {
|
||||
method: 'magic-link' | 'oauth';
|
||||
payload: Record<string, any>;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Menu } from 'electron';
|
||||
|
||||
import { logger } from '../logger';
|
||||
import {
|
||||
addTab,
|
||||
closeTab,
|
||||
reloadView,
|
||||
type TabAction,
|
||||
WebContentViewsManager,
|
||||
} from './tab-views';
|
||||
|
||||
export const showTabContextMenu = async (tabId: string, viewIndex: number) => {
|
||||
const workbenches = WebContentViewsManager.instance.tabViewsMeta.workbenches;
|
||||
const tabMeta = workbenches.find(w => w.id === tabId);
|
||||
if (!tabMeta) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { resolve, promise } = Promise.withResolvers<TabAction | null>();
|
||||
|
||||
const template: Parameters<typeof Menu.buildFromTemplate>[0] = [
|
||||
tabMeta.pinned
|
||||
? {
|
||||
label: 'Unpin tab',
|
||||
click: () => {
|
||||
WebContentViewsManager.instance.pinTab(tabId, false);
|
||||
},
|
||||
}
|
||||
: {
|
||||
label: 'Pin tab',
|
||||
click: () => {
|
||||
WebContentViewsManager.instance.pinTab(tabId, true);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Refresh tab',
|
||||
click: () => {
|
||||
reloadView().catch(logger.error);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Duplicate tab',
|
||||
click: () => {
|
||||
addTab({
|
||||
basename: tabMeta.basename,
|
||||
view: tabMeta.views,
|
||||
show: false,
|
||||
}).catch(logger.error);
|
||||
},
|
||||
},
|
||||
|
||||
{ type: 'separator' },
|
||||
|
||||
tabMeta.views.length > 1
|
||||
? {
|
||||
label: 'Separate tabs',
|
||||
click: () => {
|
||||
WebContentViewsManager.instance.separateView(tabId, viewIndex);
|
||||
},
|
||||
}
|
||||
: {
|
||||
label: 'Open in split view',
|
||||
click: () => {
|
||||
WebContentViewsManager.instance.openInSplitView({ tabId });
|
||||
},
|
||||
},
|
||||
|
||||
...(workbenches.length > 1
|
||||
? ([
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Close tab',
|
||||
click: () => {
|
||||
closeTab(tabId).catch(logger.error);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Close other tabs',
|
||||
click: () => {
|
||||
const tabsToRetain =
|
||||
WebContentViewsManager.instance.tabViewsMeta.workbenches.filter(
|
||||
w => w.id === tabId || w.pinned
|
||||
);
|
||||
|
||||
WebContentViewsManager.instance.patchTabViewsMeta({
|
||||
workbenches: tabsToRetain,
|
||||
activeWorkbenchId: tabId,
|
||||
});
|
||||
},
|
||||
},
|
||||
] as const)
|
||||
: []),
|
||||
];
|
||||
const menu = Menu.buildFromTemplate(template);
|
||||
menu.popup();
|
||||
// eslint-disable-next-line prefer-const
|
||||
let unsub: (() => void) | undefined;
|
||||
const subscription = WebContentViewsManager.instance.tabAction$.subscribe(
|
||||
action => {
|
||||
resolve(action);
|
||||
unsub?.();
|
||||
}
|
||||
);
|
||||
menu.on('menu-will-close', () => {
|
||||
setTimeout(() => {
|
||||
resolve(null);
|
||||
unsub?.();
|
||||
});
|
||||
});
|
||||
unsub = () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
return promise;
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { BrowserWindow, type Display, screen } from 'electron';
|
||||
|
||||
import { isMacOS } from '../../shared/utils';
|
||||
import { customThemeViewUrl } from '../constants';
|
||||
import { logger } from '../logger';
|
||||
|
||||
let customThemeWindow: Promise<BrowserWindow> | undefined;
|
||||
|
||||
const getScreenSize = (display: Display) => {
|
||||
const { width, height } = isMacOS() ? display.bounds : display.workArea;
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
async function createCustomThemeWindow(additionalArguments: string[]) {
|
||||
logger.info('creating custom theme window');
|
||||
|
||||
const { width: maxWidth, height: maxHeight } = getScreenSize(
|
||||
screen.getPrimaryDisplay()
|
||||
);
|
||||
|
||||
const browserWindow = new BrowserWindow({
|
||||
width: Math.min(maxWidth, 800),
|
||||
height: Math.min(maxHeight, 600),
|
||||
resizable: true,
|
||||
maximizable: false,
|
||||
fullscreenable: false,
|
||||
webPreferences: {
|
||||
webgl: true,
|
||||
preload: join(__dirname, './preload.js'),
|
||||
additionalArguments: additionalArguments,
|
||||
},
|
||||
});
|
||||
|
||||
await browserWindow.loadURL(customThemeViewUrl);
|
||||
|
||||
browserWindow.on('closed', () => {
|
||||
customThemeWindow = undefined;
|
||||
});
|
||||
|
||||
return browserWindow;
|
||||
}
|
||||
|
||||
const getWindowAdditionalArguments = async () => {
|
||||
const { getExposedMeta } = await import('../exposed');
|
||||
const mainExposedMeta = getExposedMeta();
|
||||
return [
|
||||
`--main-exposed-meta=` + JSON.stringify(mainExposedMeta),
|
||||
`--window-name=theme-editor`,
|
||||
];
|
||||
};
|
||||
|
||||
export async function getOrCreateCustomThemeWindow() {
|
||||
const additionalArguments = await getWindowAdditionalArguments();
|
||||
if (
|
||||
!customThemeWindow ||
|
||||
(await customThemeWindow.then(w => w.isDestroyed()))
|
||||
) {
|
||||
customThemeWindow = createCustomThemeWindow(additionalArguments);
|
||||
}
|
||||
|
||||
return customThemeWindow;
|
||||
}
|
||||
|
||||
export async function getCustomThemeWindow() {
|
||||
if (!customThemeWindow) return;
|
||||
const window = await customThemeWindow;
|
||||
if (window.isDestroyed()) return;
|
||||
return window;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from './authentication';
|
||||
export * from './launcher';
|
||||
export * from './main-window';
|
||||
export * from './onboarding';
|
||||
export * from './stage';
|
||||
export * from './tab-views';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,24 @@
|
||||
import { logger } from '../logger';
|
||||
import { initAndShowMainWindow } from './main-window';
|
||||
import { getOnboardingWindow, getOrCreateOnboardingWindow } from './onboarding';
|
||||
import { launchStage } from './stage';
|
||||
|
||||
/**
|
||||
* Launch app depending on launch stage
|
||||
*/
|
||||
export async function launch() {
|
||||
const stage = launchStage.value;
|
||||
if (stage === 'main') {
|
||||
initAndShowMainWindow().catch(e => {
|
||||
logger.error('Failed to restore or create window:', e);
|
||||
});
|
||||
|
||||
getOnboardingWindow()
|
||||
.then(w => w?.destroy())
|
||||
.catch(e => logger.error('Failed to destroy onboarding window:', e));
|
||||
}
|
||||
if (stage === 'onboarding')
|
||||
getOrCreateOnboardingWindow().catch(e => {
|
||||
logger.error('Failed to restore or create onboarding window:', e);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { BrowserWindow, nativeTheme } from 'electron';
|
||||
import electronWindowState from 'electron-window-state';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
import { isLinux, isMacOS, isWindows } from '../../shared/utils';
|
||||
import { buildType } from '../config';
|
||||
import { mainWindowOrigin } from '../constants';
|
||||
import { ensureHelperProcess } from '../helper-process';
|
||||
import { logger } from '../logger';
|
||||
import { uiSubjects } from '../ui/subject';
|
||||
|
||||
const IS_DEV: boolean =
|
||||
process.env.NODE_ENV === 'development' && !process.env.CI;
|
||||
|
||||
function closeAllWindows() {
|
||||
BrowserWindow.getAllWindows().forEach(w => {
|
||||
if (!w.isDestroyed()) {
|
||||
w.destroy();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export class MainWindowManager {
|
||||
static readonly instance = new MainWindowManager();
|
||||
mainWindowReady: Promise<BrowserWindow> | undefined;
|
||||
mainWindow$ = new BehaviorSubject<BrowserWindow | undefined>(undefined);
|
||||
|
||||
private hiddenMacWindow: BrowserWindow | undefined;
|
||||
|
||||
get mainWindow() {
|
||||
return this.mainWindow$.value;
|
||||
}
|
||||
|
||||
// #region private methods
|
||||
private preventMacAppQuit() {
|
||||
if (!this.hiddenMacWindow && isMacOS()) {
|
||||
this.hiddenMacWindow = new BrowserWindow({
|
||||
show: false,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
this.hiddenMacWindow.on('close', () => {
|
||||
this.cleanupWindows();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupWindows() {
|
||||
closeAllWindows();
|
||||
this.mainWindowReady = undefined;
|
||||
this.mainWindow$.next(undefined);
|
||||
this.hiddenMacWindow?.destroy();
|
||||
this.hiddenMacWindow = undefined;
|
||||
}
|
||||
|
||||
private async createMainWindow() {
|
||||
logger.info('create window');
|
||||
const mainWindowState = electronWindowState({
|
||||
defaultWidth: 1000,
|
||||
defaultHeight: 800,
|
||||
});
|
||||
|
||||
await ensureHelperProcess();
|
||||
|
||||
const browserWindow = new BrowserWindow({
|
||||
titleBarStyle: isMacOS()
|
||||
? 'hiddenInset'
|
||||
: isWindows()
|
||||
? 'hidden'
|
||||
: 'default',
|
||||
x: mainWindowState.x,
|
||||
y: mainWindowState.y,
|
||||
width: mainWindowState.width,
|
||||
autoHideMenuBar: isLinux(),
|
||||
minWidth: 640,
|
||||
minHeight: 480,
|
||||
visualEffectState: 'active',
|
||||
vibrancy: 'under-window',
|
||||
// backgroundMaterial: 'mica',
|
||||
height: mainWindowState.height,
|
||||
show: false, // Use 'ready-to-show' event to show window
|
||||
webPreferences: {
|
||||
webgl: true,
|
||||
contextIsolation: true,
|
||||
sandbox: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (isLinux()) {
|
||||
browserWindow.setIcon(
|
||||
// __dirname is `packages/frontend/apps/electron/dist` (the bundled output directory)
|
||||
join(__dirname, `../resources/icons/icon_${buildType}_64x64.png`)
|
||||
);
|
||||
}
|
||||
|
||||
nativeTheme.themeSource = 'light';
|
||||
mainWindowState.manage(browserWindow);
|
||||
|
||||
this.bindEvents(browserWindow);
|
||||
return browserWindow;
|
||||
}
|
||||
|
||||
private bindEvents(mainWindow: BrowserWindow) {
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
mainWindow.on('ready-to-show', () => {
|
||||
logger.info('main window is ready to show');
|
||||
|
||||
uiSubjects.onMaximized$.next(mainWindow.isMaximized());
|
||||
uiSubjects.onFullScreen$.next(mainWindow.isFullScreen());
|
||||
});
|
||||
|
||||
mainWindow.on('close', e => {
|
||||
// TODO(@pengx17): gracefully close the app, for example, ask user to save unsaved changes
|
||||
e.preventDefault();
|
||||
if (!isMacOS()) {
|
||||
closeAllWindows();
|
||||
} else {
|
||||
// hide window on macOS
|
||||
// application quit will be handled by closing the hidden window
|
||||
//
|
||||
// explanation:
|
||||
// - closing the top window (by clicking close button or CMD-w)
|
||||
// - will be captured in "close" event here
|
||||
// - hiding the app to make the app open faster when user click the app icon
|
||||
// - quit the app by "cmd+q" or right click on the dock icon and select "quit"
|
||||
// - all browser windows will capture the "close" event
|
||||
// - the hidden window will close all windows
|
||||
// - "window-all-closed" event will be emitted and eventually quit the app
|
||||
if (mainWindow.isFullScreen()) {
|
||||
mainWindow.once('leave-full-screen', () => {
|
||||
mainWindow.hide();
|
||||
});
|
||||
mainWindow.setFullScreen(false);
|
||||
} else {
|
||||
mainWindow.hide();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const refreshBound = (timeout = 0) => {
|
||||
setTimeout(() => {
|
||||
// FIXME: workaround for theme bug in full screen mode
|
||||
const size = mainWindow.getSize();
|
||||
mainWindow.setSize(size[0] + 1, size[1] + 1);
|
||||
mainWindow.setSize(size[0], size[1]);
|
||||
}, timeout);
|
||||
};
|
||||
|
||||
mainWindow.on('leave-full-screen', () => {
|
||||
// seems call this too soon may cause the app to crash
|
||||
refreshBound();
|
||||
refreshBound(1000);
|
||||
uiSubjects.onMaximized$.next(false);
|
||||
uiSubjects.onFullScreen$.next(false);
|
||||
});
|
||||
|
||||
mainWindow.on('maximize', () => {
|
||||
uiSubjects.onMaximized$.next(true);
|
||||
});
|
||||
|
||||
mainWindow.on('unmaximize', () => {
|
||||
uiSubjects.onMaximized$.next(false);
|
||||
});
|
||||
|
||||
// full-screen == maximized in UI on windows
|
||||
mainWindow.on('enter-full-screen', () => {
|
||||
uiSubjects.onFullScreen$.next(true);
|
||||
});
|
||||
|
||||
mainWindow.on('leave-full-screen', () => {
|
||||
uiSubjects.onFullScreen$.next(false);
|
||||
});
|
||||
}
|
||||
// #endregion
|
||||
|
||||
async ensureMainWindow(): Promise<BrowserWindow> {
|
||||
if (
|
||||
!this.mainWindowReady ||
|
||||
(await this.mainWindowReady.then(w => w.isDestroyed()))
|
||||
) {
|
||||
this.mainWindowReady = this.createMainWindow();
|
||||
this.mainWindow$.next(await this.mainWindowReady);
|
||||
this.preventMacAppQuit();
|
||||
}
|
||||
return this.mainWindowReady;
|
||||
}
|
||||
|
||||
/**
|
||||
* Init main BrowserWindow. Will create a new window if it's not created yet.
|
||||
*/
|
||||
async initAndShowMainWindow() {
|
||||
const mainWindow = await this.ensureMainWindow();
|
||||
|
||||
if (IS_DEV) {
|
||||
// do not gain focus in dev mode
|
||||
mainWindow.showInactive();
|
||||
} else {
|
||||
mainWindow.show();
|
||||
}
|
||||
|
||||
this.preventMacAppQuit();
|
||||
|
||||
return mainWindow;
|
||||
}
|
||||
}
|
||||
|
||||
export async function initAndShowMainWindow() {
|
||||
return MainWindowManager.instance.initAndShowMainWindow();
|
||||
}
|
||||
|
||||
export async function getMainWindow() {
|
||||
return MainWindowManager.instance.ensureMainWindow();
|
||||
}
|
||||
|
||||
export async function showMainWindow() {
|
||||
const window = await getMainWindow();
|
||||
if (!window) return;
|
||||
if (window.isMinimized()) {
|
||||
window.restore();
|
||||
}
|
||||
window.focus();
|
||||
}
|
||||
|
||||
const getWindowAdditionalArguments = async () => {
|
||||
const { getExposedMeta } = await import('../exposed');
|
||||
const mainExposedMeta = getExposedMeta();
|
||||
return [
|
||||
`--main-exposed-meta=` + JSON.stringify(mainExposedMeta),
|
||||
`--window-name=hidden-window`,
|
||||
];
|
||||
};
|
||||
|
||||
function transformToAppUrl(url: URL) {
|
||||
const params = url.searchParams;
|
||||
return mainWindowOrigin + url.pathname + '?' + params.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a URL in a hidden window.
|
||||
*/
|
||||
export async function openUrlInHiddenWindow(urlObj: URL) {
|
||||
const url = transformToAppUrl(urlObj);
|
||||
const win = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 600,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, './preload.js'),
|
||||
additionalArguments: await getWindowAdditionalArguments(),
|
||||
},
|
||||
show: environment.isDebug,
|
||||
});
|
||||
|
||||
if (environment.isDebug) {
|
||||
win.webContents.openDevTools({
|
||||
mode: 'detach',
|
||||
});
|
||||
}
|
||||
|
||||
win.on('close', e => {
|
||||
e.preventDefault();
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.destroy();
|
||||
}
|
||||
});
|
||||
logger.info('loading page at', url);
|
||||
win.loadURL(url).catch(e => {
|
||||
logger.error('failed to load url', e);
|
||||
});
|
||||
return win;
|
||||
}
|
||||
|
||||
// TODO(@pengx17): somehow the page won't load the url passed, help needed
|
||||
export async function openUrlInMainWindow(urlObj: URL) {
|
||||
const url = transformToAppUrl(urlObj);
|
||||
logger.info('loading page at', url);
|
||||
const mainWindow = await getMainWindow();
|
||||
if (mainWindow) {
|
||||
await mainWindow.loadURL(url);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { join } from 'node:path';
|
||||
|
||||
import type { Display } from 'electron';
|
||||
import { BrowserWindow, screen } from 'electron';
|
||||
|
||||
import { isMacOS } from '../../shared/utils';
|
||||
import { onboardingViewUrl } from '../constants';
|
||||
// import { getExposedMeta } from './exposed';
|
||||
import { logger } from '../logger';
|
||||
|
||||
const getScreenSize = (display: Display) => {
|
||||
const { width, height } = isMacOS() ? display.bounds : display.workArea;
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
// todo: not all window need all of the exposed meta
|
||||
const getWindowAdditionalArguments = async () => {
|
||||
const { getExposedMeta } = await import('../exposed');
|
||||
const mainExposedMeta = getExposedMeta();
|
||||
return [
|
||||
`--main-exposed-meta=` + JSON.stringify(mainExposedMeta),
|
||||
`--window-name=onboarding`,
|
||||
];
|
||||
};
|
||||
|
||||
function fullscreenAndCenter(browserWindow: BrowserWindow) {
|
||||
const position = browserWindow.getPosition();
|
||||
const size = browserWindow.getSize();
|
||||
const currentDisplay = screen.getDisplayNearestPoint({
|
||||
x: position[0] + size[0] / 2,
|
||||
y: position[1] + size[1] / 2,
|
||||
});
|
||||
if (!currentDisplay) return;
|
||||
const { width, height } = getScreenSize(currentDisplay);
|
||||
browserWindow.setSize(width, height);
|
||||
browserWindow.center();
|
||||
}
|
||||
|
||||
async function createOnboardingWindow(additionalArguments: string[]) {
|
||||
logger.info('creating onboarding window');
|
||||
|
||||
// get user's screen size
|
||||
const { width, height } = getScreenSize(screen.getPrimaryDisplay());
|
||||
|
||||
const browserWindow = new BrowserWindow({
|
||||
width,
|
||||
height,
|
||||
frame: false,
|
||||
show: false,
|
||||
resizable: false,
|
||||
closable: false,
|
||||
minimizable: false,
|
||||
movable: false,
|
||||
titleBarStyle: 'hidden',
|
||||
maximizable: false,
|
||||
fullscreenable: false,
|
||||
// skipTaskbar: true,
|
||||
transparent: true,
|
||||
hasShadow: false,
|
||||
roundedCorners: false,
|
||||
webPreferences: {
|
||||
webgl: true,
|
||||
preload: join(__dirname, './preload.js'),
|
||||
additionalArguments: additionalArguments,
|
||||
},
|
||||
});
|
||||
|
||||
// workaround for the phantom title bar on windows when losing focus
|
||||
// see https://github.com/electron/electron/issues/39959#issuecomment-1758736966
|
||||
browserWindow.on('focus', () => {
|
||||
browserWindow.setBackgroundColor('#00000000');
|
||||
});
|
||||
|
||||
browserWindow.on('blur', () => {
|
||||
browserWindow.setBackgroundColor('#00000000');
|
||||
});
|
||||
|
||||
browserWindow.on('ready-to-show', () => {
|
||||
// forcing zoom factor to 1 to avoid onboarding display issues
|
||||
browserWindow.webContents.setZoomFactor(1);
|
||||
fullscreenAndCenter(browserWindow);
|
||||
// TODO(@catsjuice): add a timeout to avoid flickering, window is ready, but dom is not ready
|
||||
setTimeout(() => {
|
||||
browserWindow.show();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// When moved to another screen, resize to fit the screen
|
||||
browserWindow.on('moved', () => {
|
||||
fullscreenAndCenter(browserWindow);
|
||||
});
|
||||
|
||||
await browserWindow.loadURL(onboardingViewUrl);
|
||||
|
||||
return browserWindow;
|
||||
}
|
||||
|
||||
let onBoardingWindow: Promise<BrowserWindow> | undefined;
|
||||
|
||||
export async function getOrCreateOnboardingWindow() {
|
||||
const additionalArguments = await getWindowAdditionalArguments();
|
||||
if (
|
||||
!onBoardingWindow ||
|
||||
(await onBoardingWindow.then(w => w.isDestroyed()))
|
||||
) {
|
||||
onBoardingWindow = createOnboardingWindow(additionalArguments);
|
||||
}
|
||||
|
||||
return onBoardingWindow;
|
||||
}
|
||||
|
||||
export async function getOnboardingWindow() {
|
||||
if (!onBoardingWindow) return;
|
||||
const window = await onBoardingWindow;
|
||||
if (window.isDestroyed()) return;
|
||||
return window;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { persistentConfig } from '../config-storage/persist';
|
||||
import type { LaunchStage } from './types';
|
||||
|
||||
export const launchStage: { value: LaunchStage } = {
|
||||
value: persistentConfig.get('onBoarding') ? 'onboarding' : 'main',
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const workbenchViewIconNameSchema = z.enum([
|
||||
'trash',
|
||||
'allDocs',
|
||||
'collection',
|
||||
'tag',
|
||||
'doc', // refers to a doc whose mode is not yet being resolved
|
||||
'page',
|
||||
'edgeless',
|
||||
'journal',
|
||||
]);
|
||||
|
||||
export const workbenchViewMetaSchema = z.object({
|
||||
id: z.string(),
|
||||
path: z
|
||||
.object({
|
||||
pathname: z.string().optional(),
|
||||
hash: z.string().optional(),
|
||||
search: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
// todo: move title/module to cached stated
|
||||
title: z.string().optional(),
|
||||
iconName: workbenchViewIconNameSchema.optional(),
|
||||
});
|
||||
|
||||
export const workbenchMetaSchema = z.object({
|
||||
id: z.string(),
|
||||
activeViewIndex: z.number(),
|
||||
pinned: z.boolean().optional(),
|
||||
basename: z.string(),
|
||||
views: z.array(workbenchViewMetaSchema),
|
||||
});
|
||||
|
||||
export const tabViewsMetaSchema = z.object({
|
||||
activeWorkbenchId: z.string().optional(),
|
||||
workbenches: z.array(workbenchMetaSchema).default([]),
|
||||
});
|
||||
|
||||
export const TabViewsMetaKey = 'tabViewsMetaSchema';
|
||||
export type TabViewsMetaSchema = z.infer<typeof tabViewsMetaSchema>;
|
||||
export type WorkbenchMeta = z.infer<typeof workbenchMetaSchema>;
|
||||
export type WorkbenchViewMeta = z.infer<typeof workbenchViewMetaSchema>;
|
||||
export type WorkbenchViewModule = z.infer<typeof workbenchViewIconNameSchema>;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
export type LaunchStage = 'main' | 'onboarding';
|
||||
@@ -0,0 +1,13 @@
|
||||
import '@sentry/electron/preload';
|
||||
|
||||
import { contextBridge } from 'electron';
|
||||
|
||||
import { appInfo, getElectronAPIs } from './electron-api';
|
||||
import { sharedStorage } from './shared-storage';
|
||||
|
||||
const { apis, events } = getElectronAPIs();
|
||||
|
||||
contextBridge.exposeInMainWorld('__appInfo', appInfo);
|
||||
contextBridge.exposeInMainWorld('__apis', apis);
|
||||
contextBridge.exposeInMainWorld('__events', events);
|
||||
contextBridge.exposeInMainWorld('__sharedStorage', sharedStorage);
|
||||
@@ -0,0 +1,244 @@
|
||||
// Please add modules to `external` in `rollupOptions` to avoid wrong bundling.
|
||||
import type { EventBasedChannel } from 'async-call-rpc';
|
||||
import { AsyncCall } from 'async-call-rpc';
|
||||
import { ipcRenderer } from 'electron';
|
||||
import { Subject } from 'rxjs';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
AFFINE_API_CHANNEL_NAME,
|
||||
AFFINE_EVENT_CHANNEL_NAME,
|
||||
type ExposedMeta,
|
||||
type HelperToRenderer,
|
||||
type RendererToHelper,
|
||||
} from '../shared/type';
|
||||
|
||||
export function getElectronAPIs() {
|
||||
const mainAPIs = getMainAPIs();
|
||||
const helperAPIs = getHelperAPIs();
|
||||
|
||||
return {
|
||||
apis: {
|
||||
...mainAPIs.apis,
|
||||
...helperAPIs.apis,
|
||||
},
|
||||
events: {
|
||||
...mainAPIs.events,
|
||||
...helperAPIs.events,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type Schema =
|
||||
| 'affine'
|
||||
| 'affine-canary'
|
||||
| 'affine-beta'
|
||||
| 'affine-internal'
|
||||
| 'affine-dev';
|
||||
|
||||
// todo: remove duplicated codes
|
||||
const ReleaseTypeSchema = z.enum(['stable', 'beta', 'canary', 'internal']);
|
||||
const envBuildType = (process.env.BUILD_TYPE || 'canary').trim().toLowerCase();
|
||||
const buildType = ReleaseTypeSchema.parse(envBuildType);
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
let schema =
|
||||
buildType === 'stable' ? 'affine' : (`affine-${envBuildType}` as Schema);
|
||||
schema = isDev ? 'affine-dev' : schema;
|
||||
|
||||
export const appInfo = {
|
||||
electron: true,
|
||||
windowName:
|
||||
process.argv.find(arg => arg.startsWith('--window-name='))?.split('=')[1] ??
|
||||
'unknown',
|
||||
viewId:
|
||||
process.argv.find(arg => arg.startsWith('--view-id='))?.split('=')[1] ??
|
||||
'unknown',
|
||||
schema,
|
||||
};
|
||||
|
||||
function getMainAPIs() {
|
||||
const meta: ExposedMeta = (() => {
|
||||
const val = process.argv
|
||||
.find(arg => arg.startsWith('--main-exposed-meta='))
|
||||
?.split('=')[1];
|
||||
|
||||
return val ? JSON.parse(val) : null;
|
||||
})();
|
||||
|
||||
// main handlers that can be invoked from the renderer process
|
||||
const apis: any = (() => {
|
||||
const { handlers: handlersMeta } = meta;
|
||||
|
||||
const all = handlersMeta.map(([namespace, functionNames]) => {
|
||||
const namespaceApis = functionNames.map(name => {
|
||||
const channel = `${namespace}:${name}`;
|
||||
return [
|
||||
name,
|
||||
(...args: any[]) => {
|
||||
return ipcRenderer.invoke(
|
||||
AFFINE_API_CHANNEL_NAME,
|
||||
channel,
|
||||
...args
|
||||
);
|
||||
},
|
||||
];
|
||||
});
|
||||
return [namespace, Object.fromEntries(namespaceApis)];
|
||||
});
|
||||
|
||||
return Object.fromEntries(all);
|
||||
})();
|
||||
|
||||
// main events that can be listened to from the renderer process
|
||||
const events: any = (() => {
|
||||
const { events: eventsMeta } = meta;
|
||||
|
||||
// channel -> callback[]
|
||||
const listenersMap = new Map<string, ((...args: any[]) => void)[]>();
|
||||
|
||||
ipcRenderer.on(AFFINE_EVENT_CHANNEL_NAME, (_event, channel, ...args) => {
|
||||
if (typeof channel !== 'string') {
|
||||
console.error('invalid ipc event', channel);
|
||||
return;
|
||||
}
|
||||
const [namespace, name] = channel.split(':');
|
||||
if (!namespace || !name) {
|
||||
console.error('invalid ipc event', channel);
|
||||
return;
|
||||
}
|
||||
const listeners = listenersMap.get(channel) ?? [];
|
||||
listeners.forEach(listener => listener(...args));
|
||||
});
|
||||
|
||||
const all = eventsMeta.map(([namespace, eventNames]) => {
|
||||
const namespaceEvents = eventNames.map(name => {
|
||||
const channel = `${namespace}:${name}`;
|
||||
return [
|
||||
name,
|
||||
(callback: (...args: any[]) => void) => {
|
||||
listenersMap.set(channel, [
|
||||
...(listenersMap.get(channel) ?? []),
|
||||
callback,
|
||||
]);
|
||||
|
||||
return () => {
|
||||
const listeners = listenersMap.get(channel) ?? [];
|
||||
const index = listeners.indexOf(callback);
|
||||
if (index !== -1) {
|
||||
listeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
},
|
||||
];
|
||||
});
|
||||
return [namespace, Object.fromEntries(namespaceEvents)];
|
||||
});
|
||||
return Object.fromEntries(all);
|
||||
})();
|
||||
|
||||
return { apis, events };
|
||||
}
|
||||
|
||||
const helperPort = new Promise<MessagePort>(resolve =>
|
||||
ipcRenderer.on('helper-connection', e => {
|
||||
console.info('[preload] helper-connection', e);
|
||||
resolve(e.ports[0]);
|
||||
})
|
||||
);
|
||||
|
||||
const createMessagePortChannel = (port: MessagePort): EventBasedChannel => {
|
||||
return {
|
||||
on(listener) {
|
||||
port.onmessage = e => {
|
||||
listener(e.data);
|
||||
};
|
||||
port.start();
|
||||
return () => {
|
||||
port.onmessage = null;
|
||||
port.close();
|
||||
};
|
||||
},
|
||||
send(data) {
|
||||
port.postMessage(data);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
function getHelperAPIs() {
|
||||
const events$ = new Subject<{ channel: string; args: any[] }>();
|
||||
const meta: ExposedMeta | null = (() => {
|
||||
const val = process.argv
|
||||
.find(arg => arg.startsWith('--helper-exposed-meta='))
|
||||
?.split('=')[1];
|
||||
|
||||
return val ? JSON.parse(val) : null;
|
||||
})();
|
||||
|
||||
const rendererToHelperServer: RendererToHelper = {
|
||||
postEvent: (channel, ...args) => {
|
||||
events$.next({ channel, args });
|
||||
},
|
||||
};
|
||||
|
||||
const rpc = AsyncCall<HelperToRenderer>(rendererToHelperServer, {
|
||||
channel: helperPort.then(helperPort =>
|
||||
createMessagePortChannel(helperPort)
|
||||
),
|
||||
log: false,
|
||||
});
|
||||
|
||||
const toHelperHandler = (namespace: string, name: string) => {
|
||||
return rpc[`${namespace}:${name}`];
|
||||
};
|
||||
|
||||
const toHelperEventSubscriber = (namespace: string, name: string) => {
|
||||
return (callback: (...args: any[]) => void) => {
|
||||
const subscription = events$.subscribe(({ channel, args }) => {
|
||||
if (channel === `${namespace}:${name}`) {
|
||||
callback(...args);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const setup = (meta: ExposedMeta) => {
|
||||
const { handlers, events } = meta;
|
||||
|
||||
const helperHandlers = Object.fromEntries(
|
||||
handlers.map(([namespace, functionNames]) => {
|
||||
return [
|
||||
namespace,
|
||||
Object.fromEntries(
|
||||
functionNames.map(name => {
|
||||
return [name, toHelperHandler(namespace, name)];
|
||||
})
|
||||
),
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
const helperEvents = Object.fromEntries(
|
||||
events.map(([namespace, eventNames]) => {
|
||||
return [
|
||||
namespace,
|
||||
Object.fromEntries(
|
||||
eventNames.map(name => {
|
||||
return [name, toHelperEventSubscriber(namespace, name)];
|
||||
})
|
||||
),
|
||||
];
|
||||
})
|
||||
);
|
||||
return [helperHandlers, helperEvents];
|
||||
};
|
||||
|
||||
if (meta) {
|
||||
const [apis, events] = setup(meta);
|
||||
return { apis, events };
|
||||
} else {
|
||||
return { apis: {}, events: {} };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import './bootstrap';
|
||||
@@ -0,0 +1,96 @@
|
||||
import { MemoryMemento } from '@toeverything/infra';
|
||||
import { ipcRenderer } from 'electron';
|
||||
|
||||
import {
|
||||
AFFINE_API_CHANNEL_NAME,
|
||||
AFFINE_EVENT_CHANNEL_NAME,
|
||||
} from '../shared/type';
|
||||
|
||||
const initialGlobalState = ipcRenderer.sendSync(
|
||||
AFFINE_API_CHANNEL_NAME,
|
||||
'sharedStorage:getAllGlobalState'
|
||||
);
|
||||
const initialGlobalCache = ipcRenderer.sendSync(
|
||||
AFFINE_API_CHANNEL_NAME,
|
||||
'sharedStorage:getAllGlobalCache'
|
||||
);
|
||||
|
||||
function invokeWithCatch(key: string, ...args: any[]) {
|
||||
ipcRenderer.invoke(AFFINE_API_CHANNEL_NAME, key, ...args).catch(err => {
|
||||
console.error(`Failed to invoke ${key}`, err);
|
||||
});
|
||||
}
|
||||
|
||||
function createSharedStorageApi(
|
||||
init: Record<string, any>,
|
||||
event: string,
|
||||
api: {
|
||||
del: string;
|
||||
clear: string;
|
||||
set: string;
|
||||
}
|
||||
) {
|
||||
const memory = new MemoryMemento();
|
||||
memory.setAll(init);
|
||||
ipcRenderer.on(AFFINE_EVENT_CHANNEL_NAME, (_event, channel, updates) => {
|
||||
if (channel === `sharedStorage:${event}`) {
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (value === undefined) {
|
||||
memory.del(key);
|
||||
} else {
|
||||
memory.set(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
del(key: string) {
|
||||
memory.del(key);
|
||||
invokeWithCatch(`sharedStorage:${api.del}`, key);
|
||||
},
|
||||
clear() {
|
||||
memory.clear();
|
||||
invokeWithCatch(`sharedStorage:${api.clear}`);
|
||||
},
|
||||
get<T>(key: string): T | undefined {
|
||||
return memory.get(key);
|
||||
},
|
||||
keys() {
|
||||
return memory.keys();
|
||||
},
|
||||
set(key: string, value: unknown) {
|
||||
memory.set(key, value);
|
||||
invokeWithCatch(`sharedStorage:${api.set}`, key, value);
|
||||
},
|
||||
watch<T>(key: string, cb: (i: T | undefined) => void): () => void {
|
||||
const subscription = memory.watch(key).subscribe(i => cb(i as T));
|
||||
return () => subscription.unsubscribe();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const globalState = createSharedStorageApi(
|
||||
initialGlobalState,
|
||||
'onGlobalStateChanged',
|
||||
{
|
||||
clear: 'clearGlobalState',
|
||||
del: 'delGlobalState',
|
||||
set: 'setGlobalState',
|
||||
}
|
||||
);
|
||||
|
||||
export const globalCache = createSharedStorageApi(
|
||||
initialGlobalCache,
|
||||
'onGlobalCacheChanged',
|
||||
{
|
||||
clear: 'clearGlobalCache',
|
||||
del: 'delGlobalCache',
|
||||
set: 'setGlobalCache',
|
||||
}
|
||||
);
|
||||
|
||||
export const sharedStorage = {
|
||||
globalState,
|
||||
globalCache,
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { app, dialog, shell } from 'electron';
|
||||
|
||||
export interface ExposedMeta {
|
||||
handlers: [string, string[]][];
|
||||
events: [string, string[]][];
|
||||
}
|
||||
|
||||
// render <-> helper
|
||||
export interface RendererToHelper {
|
||||
postEvent: (channel: string, ...args: any[]) => void;
|
||||
}
|
||||
|
||||
export interface HelperToRenderer {
|
||||
[key: string]: (...args: any[]) => Promise<any>;
|
||||
}
|
||||
|
||||
// helper <-> main
|
||||
export interface HelperToMain {
|
||||
getMeta: () => ExposedMeta;
|
||||
}
|
||||
|
||||
export type MainToHelper = Pick<
|
||||
typeof dialog & typeof shell & typeof app,
|
||||
| 'showOpenDialog'
|
||||
| 'showSaveDialog'
|
||||
| 'openExternal'
|
||||
| 'showItemInFolder'
|
||||
| 'getPath'
|
||||
>;
|
||||
|
||||
export const AFFINE_API_CHANNEL_NAME = 'affine-ipc-api';
|
||||
export const AFFINE_EVENT_CHANNEL_NAME = 'affine-ipc-event';
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { EventBasedChannel } from 'async-call-rpc';
|
||||
|
||||
export function getTime() {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
export const isMacOS = () => {
|
||||
return process.platform === 'darwin';
|
||||
};
|
||||
|
||||
export const isWindows = () => {
|
||||
return process.platform === 'win32';
|
||||
};
|
||||
|
||||
export const isLinux = () => {
|
||||
return process.platform === 'linux';
|
||||
};
|
||||
|
||||
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 readonly 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