feat: deeplink

This commit is contained in:
linonetwo 2025-02-01 23:56:01 +08:00
parent bf8a2995b6
commit bd2b8a4449
12 changed files with 153 additions and 43 deletions

View file

@ -89,6 +89,7 @@ const config = {
executableName: 'tidgi',
config: {
maintainer: 'Lin Onetwo <linonetwo012@gmail.com>',
mimeType: ['x-scheme-handler/tidgi'],
},
},
{
@ -97,6 +98,7 @@ const config = {
executableName: 'tidgi',
config: {
maintainer: 'Lin Onetwo <linonetwo012@gmail.com>',
mimeType: ['x-scheme-handler/tidgi'],
},
},
/**

View file

@ -21,6 +21,7 @@ import serviceIdentifier from '@services/serviceIdentifier';
import { WindowNames } from '@services/windows/WindowProperties';
import { IDatabaseService } from '@services/database/interface';
import { IDeepLinkService } from '@services/deepLink/interface';
import { initializeObservables } from '@services/libs/initializeObservables';
import { reportErrorToGithubWithTemplates } from '@services/native/reportError';
import type { IUpdaterService } from '@services/updater/interface';
@ -51,8 +52,6 @@ protocol.registerSchemesAsPrivileged([
{ scheme: 'file', privileges: { bypassCSP: true, allowServiceWorkers: true, supportFetchAPI: true, corsEnabled: true, stream: true } },
{ scheme: 'mailto', privileges: { standard: true } },
]);
// TODO: handle workspace name + tiddler name in uri https://www.electronjs.org/docs/latest/tutorial/launch-app-from-url-in-another-app
app.setAsDefaultProtocolClient('tidgi');
bindServiceAndProxy();
const preferenceService = container.get<IPreferenceService>(serviceIdentifier.Preference);
const updaterService = container.get<IUpdaterService>(serviceIdentifier.Updater);
@ -61,6 +60,7 @@ const wikiService = container.get<IWikiService>(serviceIdentifier.Wiki);
const windowService = container.get<IWindowService>(serviceIdentifier.Window);
const workspaceViewService = container.get<IWorkspaceViewService>(serviceIdentifier.WorkspaceView);
const databaseService = container.get<IDatabaseService>(serviceIdentifier.Database);
const deepLinkService = container.get<IDeepLinkService>(serviceIdentifier.DeepLink);
app.on('second-instance', async () => {
// see also src/helpers/singleInstance.ts
// Someone tried to run a second instance, for example, when `runOnBackground` is true, we should focus our window.
@ -83,6 +83,8 @@ void preferenceService.get('ignoreCertificateErrors').then((ignoreCertificateErr
const commonInit = async (): Promise<void> => {
await app.whenReady();
// if user want a menubar, we create a new window for that
// handle workspace name + tiddler name in uri https://www.electronjs.org/docs/latest/tutorial/launch-app-from-url-in-another-app
deepLinkService.initializeDeepLink('tidgi');
await Promise.all([
windowService.open(WindowNames.main),
preferenceService.get('attachToMenubar').then(async (attachToMenubar) => {

View file

@ -1,6 +1,6 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { getWorkspaceMenuTemplate, openWorkspaceTagTiddler } from '@services/workspaces/getWorkspaceMenuTemplate';
import { getWorkspaceMenuTemplate } from '@services/workspaces/getWorkspaceMenuTemplate';
import { IWorkspaceWithMetadata } from '@services/workspaces/interface';
import { MouseEvent, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
@ -31,7 +31,7 @@ export function SortableWorkspaceSelectorButton({ index, workspace, showSidebarT
workspaceClickedLoadingSetter(true);
try {
setLocation(`/${WindowNames.main}/${PageType.wiki}/${id}/`);
await openWorkspaceTagTiddler(workspace, window.service);
await window.service.workspace.openWorkspaceTiddler(workspace);
} catch (error) {
if (error instanceof Error) {
await window.service.native.log('error', error.message);

View file

@ -9,6 +9,7 @@ import { AsyncifyProxy } from 'electron-ipc-cat/common';
import { AuthenticationServiceIPCDescriptor, IAuthenticationService } from '@services/auth/interface';
import { ContextServiceIPCDescriptor, IContextService } from '@services/context/interface';
import { DeepLinkServiceIPCDescriptor, IDeepLinkService } from '@services/deepLink/interface';
import { GitServiceIPCDescriptor, IGitService } from '@services/git/interface';
import { IMenuService, MenuServiceIPCDescriptor } from '@services/menu/interface';
import { INativeService, NativeServiceIPCDescriptor } from '@services/native/interface';
@ -44,6 +45,7 @@ export const wikiGitWorkspace = createProxy<IWikiGitWorkspaceService>(WikiGitWor
export const window = createProxy<IWindowService>(WindowServiceIPCDescriptor);
export const workspace = createProxy<AsyncifyProxy<IWorkspaceService>>(WorkspaceServiceIPCDescriptor);
export const workspaceView = createProxy<IWorkspaceViewService>(WorkspaceViewServiceIPCDescriptor);
export const deepLink = createProxy<IDeepLinkService>(DeepLinkServiceIPCDescriptor);
export const descriptors = {
auth: AuthenticationServiceIPCDescriptor,
@ -64,4 +66,5 @@ export const descriptors = {
window: WindowServiceIPCDescriptor,
workspace: WorkspaceServiceIPCDescriptor,
workspaceView: WorkspaceViewServiceIPCDescriptor,
deepLink: DeepLinkServiceIPCDescriptor,
};

View file

@ -0,0 +1,73 @@
import { lazyInject } from '@services/container';
import { logger } from '@services/libs/log';
import serviceIdentifier from '@services/serviceIdentifier';
import { IWorkspaceService } from '@services/workspaces/interface';
import { app } from 'electron';
import { injectable } from 'inversify';
import path from 'node:path';
import { IDeepLinkService } from './interface';
@injectable()
export class DeepLinkService implements IDeepLinkService {
@lazyInject(serviceIdentifier.Workspace)
private readonly workspaceService!: IWorkspaceService;
/**
* Handle link and open the workspace.
* @param requestUrl like `tidgi://lxqsftvfppu_z4zbaadc0/#:Index` or `tidgi://lxqsftvfppu_z4zbaadc0/#%E6%96%B0%E6%9D%A1%E7%9B%AE`
*/
private readonly deepLinkHandler: (requestUrl: string) => Promise<void> = async (requestUrl) => {
logger.info(`Receiving deep link`, { requestUrl, function: 'deepLinkHandler' });
const url = new URL(requestUrl);
const workspaceId = url.hostname;
const workspace = await this.workspaceService.get(workspaceId);
if (workspace === undefined) {
logger.error(`Workspace not found`, { workspaceId, function: 'deepLinkHandler' });
return;
}
let tiddlerName = url.hash.substring(1); // remove '#:'
if (tiddlerName.includes(':')) {
tiddlerName = tiddlerName.split(':')[1];
}
logger.info(`Open deep link`, { workspaceId, tiddlerName, function: 'deepLinkHandler' });
await this.workspaceService.openWorkspaceTiddler(workspace, tiddlerName);
};
public initializeDeepLink(protocol: string) {
if (process.defaultApp) {
if (process.argv.length >= 2) {
app.setAsDefaultProtocolClient(protocol, process.execPath, [path.resolve(process.argv[1])]);
}
} else {
app.setAsDefaultProtocolClient(protocol);
}
if (process.platform === 'darwin') {
this.setupMacOSHandler();
} else {
this.setupWindowsLinuxHandler();
}
}
private setupMacOSHandler(): void {
app.on('open-url', (event, url) => {
event.preventDefault();
this.deepLinkHandler(url);
});
}
private setupWindowsLinuxHandler(): void {
const gotTheLock = app.requestSingleInstanceLock();
if (gotTheLock) {
app.on('second-instance', (event, commandLine) => {
const url = commandLine.pop();
if (url !== undefined && url !== '') {
this.deepLinkHandler(url);
}
});
} else {
app.quit();
}
}
}

View file

@ -0,0 +1,16 @@
import { ProxyPropertyType } from 'electron-ipc-cat/common';
export interface IDeepLinkService {
/**
* Initialize deep link service.
* @param protocol The protocol to be used for deep linking.
*/
initializeDeepLink(protocol: string): void;
}
export const DeepLinkServiceIPCDescriptor = {
channel: 'DeepLinkChannel',
properties: {
initializeDeepLink: ProxyPropertyType.Function,
},
};

View file

@ -9,6 +9,7 @@ import serviceIdentifier from '@services/serviceIdentifier';
import { Authentication } from '@services/auth';
import { ContextService } from '@services/context';
import { DatabaseService } from '@services/database';
import { DeepLinkService } from '@services/deepLink';
import { Git } from '@services/git';
import { MenuService } from '@services/menu';
import { NativeService } from '@services/native';
@ -32,6 +33,8 @@ import type { IContextService } from '@services/context/interface';
import { ContextServiceIPCDescriptor } from '@services/context/interface';
import type { IDatabaseService } from '@services/database/interface';
import { DatabaseServiceIPCDescriptor } from '@services/database/interface';
import type { IDeepLinkService } from '@services/deepLink/interface';
import { DeepLinkServiceIPCDescriptor } from '@services/deepLink/interface';
import type { IGitService } from '@services/git/interface';
import { GitServiceIPCDescriptor } from '@services/git/interface';
import type { IMenuService } from '@services/menu/interface';
@ -85,6 +88,7 @@ export function bindServiceAndProxy(): void {
container.bind<IWindowService>(serviceIdentifier.Window).to(Window).inSingletonScope();
container.bind<IWorkspaceService>(serviceIdentifier.Workspace).to(Workspace).inSingletonScope();
container.bind<IWorkspaceViewService>(serviceIdentifier.WorkspaceView).to(WorkspaceView).inSingletonScope();
container.bind<IDeepLinkService>(serviceIdentifier.DeepLink).to(DeepLinkService).inSingletonScope();
const authService = container.get<IAuthenticationService>(serviceIdentifier.Authentication);
const contextService = container.get<IContextService>(serviceIdentifier.Context);
@ -105,6 +109,7 @@ export function bindServiceAndProxy(): void {
const windowService = container.get<IWindowService>(serviceIdentifier.Window);
const workspaceService = container.get<IWorkspaceService>(serviceIdentifier.Workspace);
const workspaceViewService = container.get<IWorkspaceViewService>(serviceIdentifier.WorkspaceView);
const deepLinkService = container.get<IDeepLinkService>(serviceIdentifier.DeepLink);
registerProxy(authService, AuthenticationServiceIPCDescriptor);
registerProxy(contextService, ContextServiceIPCDescriptor);
@ -125,4 +130,5 @@ export function bindServiceAndProxy(): void {
registerProxy(windowService, WindowServiceIPCDescriptor);
registerProxy(workspaceService, WorkspaceServiceIPCDescriptor);
registerProxy(workspaceViewService, WorkspaceViewServiceIPCDescriptor);
registerProxy(deepLinkService, DeepLinkServiceIPCDescriptor);
}

View file

@ -12,13 +12,12 @@ import type { IPagesService } from '@services/pages/interface';
import type { IPreferenceService } from '@services/preferences/interface';
import serviceIdentifier from '@services/serviceIdentifier';
import { ISyncService } from '@services/sync/interface';
import type { IUpdaterService } from '@services/updater/interface';
import type { IViewService } from '@services/view/interface';
import type { IWikiService } from '@services/wiki/interface';
import type { IWikiGitWorkspaceService } from '@services/wikiGitWorkspace/interface';
import type { IWindowService } from '@services/windows/interface';
import { WindowNames } from '@services/windows/WindowProperties';
import { getWorkspaceMenuTemplate, openWorkspaceTagTiddler } from '@services/workspaces/getWorkspaceMenuTemplate';
import { getWorkspaceMenuTemplate } from '@services/workspaces/getWorkspaceMenuTemplate';
import type { IWorkspaceService } from '@services/workspaces/interface';
import type { IWorkspaceViewService } from '@services/workspacesView/interface';
import { app, ContextMenuParams, Menu, MenuItem, MenuItemConstructorOptions, shell, WebContents } from 'electron';
@ -51,9 +50,6 @@ export class MenuService implements IMenuService {
@lazyInject(serviceIdentifier.Preference)
private readonly preferenceService!: IPreferenceService;
@lazyInject(serviceIdentifier.Updater)
private readonly updaterService!: IUpdaterService;
@lazyInject(serviceIdentifier.View)
private readonly viewService!: IViewService;
@ -384,7 +380,7 @@ export class MenuService implements IMenuService {
tagName: workspace.tagName ?? (workspace.isSubWiki ? workspace.name : `${workspace.name} ${i18n.t('WorkspaceSelector.DefaultTiddlers')}`),
}),
click: async () => {
await openWorkspaceTagTiddler(workspace, services);
await this.workspaceService.openWorkspaceTiddler(workspace);
},
})),
}),

View file

@ -18,4 +18,5 @@ export default {
Window: Symbol.for('Window'),
Workspace: Symbol.for('Workspace'),
WorkspaceView: Symbol.for('WorkspaceView'),
DeepLink: Symbol.for('DeepLinkService'),
};

View file

@ -1,11 +1,10 @@
/* eslint-disable @typescript-eslint/strict-boolean-expressions */
import { WikiChannel } from '@/constants/channels';
import { getDefaultHTTPServerIP } from '@/constants/urls';
import type { IAuthenticationService } from '@services/auth/interface';
import { IContextService } from '@services/context/interface';
import { IGitService } from '@services/git/interface';
import type { INativeService } from '@services/native/interface';
import { IPagesService, PageType } from '@services/pages/interface';
import { IPagesService } from '@services/pages/interface';
import { ISyncService } from '@services/sync/interface';
import { SupportedStorageServices } from '@services/types';
import type { IViewService } from '@services/view/interface';
@ -30,7 +29,7 @@ interface IWorkspaceMenuRequiredServices {
wiki: Pick<IWikiService, 'wikiOperationInBrowser' | 'wikiOperationInServer'>;
wikiGitWorkspace: Pick<IWikiGitWorkspaceService, 'removeWorkspace'>;
window: Pick<IWindowService, 'open'>;
workspace: Pick<IWorkspaceService, 'getActiveWorkspace' | 'getSubWorkspacesAsList'>;
workspace: Pick<IWorkspaceService, 'getActiveWorkspace' | 'getSubWorkspacesAsList' | 'openWorkspaceTiddler'>;
workspaceView: Pick<
IWorkspaceViewService,
| 'wakeUpWorkspaceView'
@ -43,33 +42,6 @@ interface IWorkspaceMenuRequiredServices {
>;
}
export async function openWorkspaceTagTiddler(workspace: IWorkspace, service: IWorkspaceMenuRequiredServices): Promise<void> {
const { id: idToActive, isSubWiki, tagName, mainWikiID } = workspace;
// switch to workspace page
const [oldActiveWorkspace] = await Promise.all([
service.workspace.getActiveWorkspace(),
service.pages.setActivePage(PageType.wiki),
service.native.log('debug', 'openWorkspaceTagTiddler', { workspace }),
]);
// if is a new main workspace, active its browser view first
if (!isSubWiki && idToActive) {
if (oldActiveWorkspace?.id !== idToActive) {
await service.workspaceView.setActiveWorkspaceView(idToActive);
}
return;
}
// is not a new main workspace
// open tiddler in the active view
if (isSubWiki && mainWikiID) {
if (oldActiveWorkspace?.id !== mainWikiID) {
await service.workspaceView.setActiveWorkspaceView(mainWikiID);
}
if (tagName) {
await service.wiki.wikiOperationInBrowser(WikiChannel.openTiddler, mainWikiID, [tagName]);
}
}
}
export async function getWorkspaceMenuTemplate(
workspace: IWorkspace,
t: TFunction<[_DefaultNamespace, ...Array<Exclude<FlatNamespace, _DefaultNamespace>>]>,
@ -83,7 +55,7 @@ export async function getWorkspaceMenuTemplate(
tagName: tagName ?? (isSubWiki ? name : `${name} ${t('WorkspaceSelector.DefaultTiddlers')}`),
}),
click: async () => {
await openWorkspaceTagTiddler(workspace, service);
await service.workspace.openWorkspaceTiddler(workspace);
},
},
{

View file

@ -12,6 +12,7 @@ import path from 'path';
import { BehaviorSubject, Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { WikiChannel } from '@/constants/channels';
import { DELAY_MENU_REGISTER } from '@/constants/parameters';
import { getDefaultTidGiUrl } from '@/constants/urls';
import { IAuthenticationService } from '@services/auth/interface';
@ -20,7 +21,7 @@ import { IDatabaseService } from '@services/database/interface';
import { i18n } from '@services/libs/i18n';
import { logger } from '@services/libs/log';
import type { IMenuService } from '@services/menu/interface';
import { IPagesService } from '@services/pages/interface';
import { IPagesService, PageType } from '@services/pages/interface';
import serviceIdentifier from '@services/serviceIdentifier';
import { SupportedStorageServices } from '@services/types';
import type { IViewService } from '@services/view/interface';
@ -167,7 +168,13 @@ export class Workspace implements IWorkspaceService {
}
private getSync(id: string): IWorkspace | undefined {
return this.getWorkspacesSync()[id];
const workspaces = this.getWorkspacesSync();
if (id in workspaces) {
return workspaces[id];
}
// Try find with lowercased key. sometimes user will use id that is all lowercased. Because tidgi:// url is somehow lowercased.
const foundKey = Object.keys(workspaces).find((key) => key.toLowerCase() === id.toLowerCase());
return foundKey ? workspaces[foundKey] : undefined;
}
public get$(id: string): Observable<IWorkspace | undefined> {
@ -470,4 +477,31 @@ export class Workspace implements IWorkspaceService {
const workspaceMetaData = this.getMetaDataSync(id);
return typeof workspaceMetaData?.didFailLoadErrorMessage === 'string' && workspaceMetaData.didFailLoadErrorMessage.length > 0;
}
public async openWorkspaceTiddler(workspace: IWorkspace, title?: string): Promise<void> {
const { id: idToActive, isSubWiki, mainWikiID } = workspace;
const oldActiveWorkspace = await this.getActiveWorkspace();
await this.pagesService.setActivePage(PageType.wiki);
logger.log('debug', 'openWorkspaceTiddler', { workspace });
// If is main wiki, open the wiki, and open provided title, or simply switch to it if no title provided
if (!isSubWiki && idToActive) {
if (oldActiveWorkspace?.id !== idToActive) {
await this.workspaceViewService.setActiveWorkspaceView(idToActive);
}
if (title) {
await this.wikiService.wikiOperationInBrowser(WikiChannel.openTiddler, idToActive, [title]);
}
return;
}
// If is sub wiki, open the main wiki first and open the tag or provided title
if (isSubWiki && mainWikiID) {
if (oldActiveWorkspace?.id !== mainWikiID) {
await this.workspaceViewService.setActiveWorkspaceView(mainWikiID);
}
const subWikiTag = title ?? workspace.tagName;
if (subWikiTag) {
await this.wikiService.wikiOperationInBrowser(WikiChannel.openTiddler, mainWikiID, [subWikiTag]);
}
}
}
}

View file

@ -203,6 +203,10 @@ export interface IWorkspaceService {
getWorkspaces(): Promise<Record<string, IWorkspace>>;
getWorkspacesAsList(): Promise<IWorkspace[]>;
getWorkspacesWithMetadata(): IWorkspacesWithMetadata;
/**
* Open a tiddler in the workspace, open workspace's tag by default.
*/
openWorkspaceTiddler(workspace: IWorkspace, title?: string): Promise<void>;
remove(id: string): Promise<void>;
removeWorkspacePicture(id: string): Promise<void>;
set(id: string, workspace: IWorkspace, immediate?: boolean): Promise<void>;
@ -241,6 +245,7 @@ export const WorkspaceServiceIPCDescriptor = {
getWorkspaces: ProxyPropertyType.Function,
getWorkspacesAsList: ProxyPropertyType.Function,
getWorkspacesWithMetadata: ProxyPropertyType.Function,
openWorkspaceTiddler: ProxyPropertyType.Function,
remove: ProxyPropertyType.Function,
removeWorkspacePicture: ProxyPropertyType.Function,
set: ProxyPropertyType.Function,