diff --git a/forge.config.js b/forge.config.js index 4031a8a8..bc7e3f74 100644 --- a/forge.config.js +++ b/forge.config.js @@ -89,6 +89,7 @@ const config = { executableName: 'tidgi', config: { maintainer: 'Lin Onetwo ', + mimeType: ['x-scheme-handler/tidgi'], }, }, { @@ -97,6 +98,7 @@ const config = { executableName: 'tidgi', config: { maintainer: 'Lin Onetwo ', + mimeType: ['x-scheme-handler/tidgi'], }, }, /** diff --git a/src/main.ts b/src/main.ts index 8395de49..1ac6bc91 100755 --- a/src/main.ts +++ b/src/main.ts @@ -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(serviceIdentifier.Preference); const updaterService = container.get(serviceIdentifier.Updater); @@ -61,6 +60,7 @@ const wikiService = container.get(serviceIdentifier.Wiki); const windowService = container.get(serviceIdentifier.Window); const workspaceViewService = container.get(serviceIdentifier.WorkspaceView); const databaseService = container.get(serviceIdentifier.Database); +const deepLinkService = container.get(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 => { 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) => { diff --git a/src/pages/Main/WorkspaceIconAndSelector/SortableWorkspaceSelectorButton.tsx b/src/pages/Main/WorkspaceIconAndSelector/SortableWorkspaceSelectorButton.tsx index fed8b9db..2e908566 100644 --- a/src/pages/Main/WorkspaceIconAndSelector/SortableWorkspaceSelectorButton.tsx +++ b/src/pages/Main/WorkspaceIconAndSelector/SortableWorkspaceSelectorButton.tsx @@ -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); diff --git a/src/preload/common/services.ts b/src/preload/common/services.ts index 9f39d851..8fa6d289 100644 --- a/src/preload/common/services.ts +++ b/src/preload/common/services.ts @@ -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(WikiGitWor export const window = createProxy(WindowServiceIPCDescriptor); export const workspace = createProxy>(WorkspaceServiceIPCDescriptor); export const workspaceView = createProxy(WorkspaceViewServiceIPCDescriptor); +export const deepLink = createProxy(DeepLinkServiceIPCDescriptor); export const descriptors = { auth: AuthenticationServiceIPCDescriptor, @@ -64,4 +66,5 @@ export const descriptors = { window: WindowServiceIPCDescriptor, workspace: WorkspaceServiceIPCDescriptor, workspaceView: WorkspaceViewServiceIPCDescriptor, + deepLink: DeepLinkServiceIPCDescriptor, }; diff --git a/src/services/deepLink/index.ts b/src/services/deepLink/index.ts new file mode 100644 index 00000000..9fe28c98 --- /dev/null +++ b/src/services/deepLink/index.ts @@ -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 = 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(); + } + } +} diff --git a/src/services/deepLink/interface.ts b/src/services/deepLink/interface.ts new file mode 100644 index 00000000..e89f411f --- /dev/null +++ b/src/services/deepLink/interface.ts @@ -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, + }, +}; diff --git a/src/services/libs/bindServiceAndProxy.ts b/src/services/libs/bindServiceAndProxy.ts index 4593ddac..0936450d 100644 --- a/src/services/libs/bindServiceAndProxy.ts +++ b/src/services/libs/bindServiceAndProxy.ts @@ -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(serviceIdentifier.Window).to(Window).inSingletonScope(); container.bind(serviceIdentifier.Workspace).to(Workspace).inSingletonScope(); container.bind(serviceIdentifier.WorkspaceView).to(WorkspaceView).inSingletonScope(); + container.bind(serviceIdentifier.DeepLink).to(DeepLinkService).inSingletonScope(); const authService = container.get(serviceIdentifier.Authentication); const contextService = container.get(serviceIdentifier.Context); @@ -105,6 +109,7 @@ export function bindServiceAndProxy(): void { const windowService = container.get(serviceIdentifier.Window); const workspaceService = container.get(serviceIdentifier.Workspace); const workspaceViewService = container.get(serviceIdentifier.WorkspaceView); + const deepLinkService = container.get(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); } diff --git a/src/services/menu/index.ts b/src/services/menu/index.ts index ab2a6020..d765af54 100644 --- a/src/services/menu/index.ts +++ b/src/services/menu/index.ts @@ -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); }, })), }), diff --git a/src/services/serviceIdentifier.ts b/src/services/serviceIdentifier.ts index 3830bc8a..43f60ac0 100644 --- a/src/services/serviceIdentifier.ts +++ b/src/services/serviceIdentifier.ts @@ -18,4 +18,5 @@ export default { Window: Symbol.for('Window'), Workspace: Symbol.for('Workspace'), WorkspaceView: Symbol.for('WorkspaceView'), + DeepLink: Symbol.for('DeepLinkService'), }; diff --git a/src/services/workspaces/getWorkspaceMenuTemplate.ts b/src/services/workspaces/getWorkspaceMenuTemplate.ts index 3015eaab..bbdbe7dd 100644 --- a/src/services/workspaces/getWorkspaceMenuTemplate.ts +++ b/src/services/workspaces/getWorkspaceMenuTemplate.ts @@ -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; wikiGitWorkspace: Pick; window: Pick; - workspace: Pick; + workspace: Pick; workspaceView: Pick< IWorkspaceViewService, | 'wakeUpWorkspaceView' @@ -43,33 +42,6 @@ interface IWorkspaceMenuRequiredServices { >; } -export async function openWorkspaceTagTiddler(workspace: IWorkspace, service: IWorkspaceMenuRequiredServices): Promise { - 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>]>, @@ -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); }, }, { diff --git a/src/services/workspaces/index.ts b/src/services/workspaces/index.ts index a00269b1..e5032a30 100644 --- a/src/services/workspaces/index.ts +++ b/src/services/workspaces/index.ts @@ -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 { @@ -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 { + 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]); + } + } + } } diff --git a/src/services/workspaces/interface.ts b/src/services/workspaces/interface.ts index b53bb171..976f0e0a 100644 --- a/src/services/workspaces/interface.ts +++ b/src/services/workspaces/interface.ts @@ -203,6 +203,10 @@ export interface IWorkspaceService { getWorkspaces(): Promise>; getWorkspacesAsList(): Promise; getWorkspacesWithMetadata(): IWorkspacesWithMetadata; + /** + * Open a tiddler in the workspace, open workspace's tag by default. + */ + openWorkspaceTiddler(workspace: IWorkspace, title?: string): Promise; remove(id: string): Promise; removeWorkspacePicture(id: string): Promise; set(id: string, workspace: IWorkspace, immediate?: boolean): Promise; @@ -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,