diff --git a/README.md b/README.md index 38372d8..a659268 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,14 @@ Head over to the [Releases](https://github.com/Geomitron/Bridge/releases) page t - ✅ Advanced song search. - ✅ Chart issue scanner (for people making charts). +## Deep links + +Installed copies of Bridge can open an exact chart from another application or website using its MD5 hash: + +`bridge://chart/0123456789abcdef0123456789abcdef` + +The link opens the chart in Bridge. The user can then review it and download it with their configured library and format settings. + ### What's new in v3.4.0 - Add new "Quality Reviewed" filter for drum charts diff --git a/electron-builder.json b/electron-builder.json index a3ba97c..66d51e7 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -6,6 +6,14 @@ "files": [ "dist/**/*" ], + "protocols": [ + { + "name": "Bridge chart link", + "schemes": [ + "bridge" + ] + } + ], "publish": { "provider": "github", "releaseType": "release" diff --git a/src-angular/app/app.component.ts b/src-angular/app/app.component.ts index 5d2d636..1e9705b 100644 --- a/src-angular/app/app.component.ts +++ b/src-angular/app/app.component.ts @@ -1,7 +1,8 @@ import { Component, inject, signal } from '@angular/core' -import { RouterOutlet } from '@angular/router' +import { Router, RouterOutlet } from '@angular/router' import { ToolbarComponent } from './components/toolbar/toolbar.component' +import { SearchService } from './core/services/search.service' import { SettingsService } from './core/services/settings.service' @Component({ @@ -13,16 +14,36 @@ import { SettingsService } from './core/services/settings.service' }) export class AppComponent { private settingsService = inject(SettingsService) + private searchService = inject(SearchService) + private router = inject(Router) settingsLoaded = signal(false) + private pendingChartDeepLink: string | null = null constructor() { + window.electron.on.chartDeepLink(chartHash => { + this.pendingChartDeepLink = chartHash + if (this.settingsLoaded()) { + void this.openChartDeepLink() + } + }) + // Ensure settings are loaded before rendering the application this.settingsService.loadSettings() - .then(() => { + .then(async () => { + // Pull startup links after the renderer listener is ready. + const initialChartDeepLink = await window.electron.invoke.getPendingChartDeepLink() + if (!this.pendingChartDeepLink) { + this.pendingChartDeepLink = initialChartDeepLink + } console.log('[DEBUG] Setting settingsLoaded = true') this.settingsLoaded.set(true) console.log('[DEBUG] settingsLoaded:', this.settingsLoaded()) + if (this.pendingChartDeepLink) { + void this.openChartDeepLink() + } else { + this.searchService.search().subscribe() + } }) .catch(err => console.error('Failed to load settings:', err)) @@ -49,4 +70,13 @@ export class AppComponent { } }) } + + private async openChartDeepLink(): Promise { + const chartHash = this.pendingChartDeepLink + this.pendingChartDeepLink = null + if (!chartHash) return + + await this.router.navigate(['/browse']) + this.searchService.searchByHash(chartHash).subscribe() + } } diff --git a/src-angular/app/components/browse/result-table/result-table.component.ts b/src-angular/app/components/browse/result-table/result-table.component.ts index f35b2dd..1d54051 100644 --- a/src-angular/app/components/browse/result-table/result-table.component.ts +++ b/src-angular/app/components/browse/result-table/result-table.component.ts @@ -56,6 +56,7 @@ export class ResultTableComponent implements AfterViewChecked { this.lastDataLength = 0 this.shouldCheckScrollAfterRender = true this.checkChartsInLibrary() + this.selectRequestedChart() } else if (event?.type === 'update') { this.shouldCheckScrollAfterRender = true this.checkChartsInLibrary() @@ -63,6 +64,18 @@ export class ResultTableComponent implements AfterViewChecked { }) } + private selectRequestedChart() { + const requestedHash = this.searchService.requestedChartHash + if (!requestedHash) return + + // Wait until the parent has connected the row selection output. + requestAnimationFrame(() => { + const song = this.songs().find(group => group.some(chart => chart.md5 === requestedHash)) + this.searchService.requestedChartHash = null + if (song) this.onRowClicked(song) + }) + } + /** * Called after Angular checks the component's view. * This is the reliable place to check scroll position after data changes. diff --git a/src-angular/app/core/services/search.service.ts b/src-angular/app/core/services/search.service.ts index 17a4eff..8ab7933 100644 --- a/src-angular/app/core/services/search.service.ts +++ b/src-angular/app/core/services/search.service.ts @@ -20,7 +20,7 @@ export class SearchService { readonly currentPage = signal(1) readonly isDefaultSearch = signal(true) readonly isAdvancedSearch = signal(false) - readonly lastAdvancedSearch = signal(null) + readonly lastAdvancedSearch = signal | null>(null) readonly groupedSongs = signal([]) @@ -44,6 +44,7 @@ export class SearchService { // Signal for notifying components of search events readonly searchEvent = signal<{ type: 'new' | 'update'; response: Partial } | null>(null) + requestedChartHash: string | null = null readonly areMorePages = computed(() => { const response = this.songsResponse() @@ -55,11 +56,15 @@ export class SearchService { this.http.get<{ "name": string; "sha1": string }[]>('https://clonehero.gitlab.io/sources/icons.json').subscribe(result => { this.availableIcons.set(result.map(r => r.name)) }) + } - // Perform initial search - setTimeout(() => { - this.search().subscribe() - }, 0) + public searchByHash(hash: string) { + this.requestedChartHash = hash + // Ignore saved filters so they cannot hide the linked chart. + return this.advancedSearch({ + source: 'bridge', + hash, + }) } setInstrument(value: Instrument | null) { @@ -164,7 +169,7 @@ export class SearchService { ) } - public advancedSearch(search: AdvancedSearch, nextPage = false) { + public advancedSearch(search: Partial, nextPage = false) { this.searchLoading.set(true) this.isDefaultSearch.set(false) this.isAdvancedSearch.set(true) diff --git a/src-electron/DeepLink.ts b/src-electron/DeepLink.ts new file mode 100644 index 0000000..9b4f376 --- /dev/null +++ b/src-electron/DeepLink.ts @@ -0,0 +1,33 @@ +const chartHashPattern = /^[a-f0-9]{32}$/i + +export function parseChartDeepLink(value: string): string | null { + let url: URL + try { + url = new URL(value) + } catch { + return null + } + + const hash = url.pathname.slice(1) + if ( + url.protocol !== 'bridge:' + || url.host !== 'chart' + || url.username !== '' + || url.password !== '' + || url.search !== '' + || url.hash !== '' + || !chartHashPattern.test(hash) + ) { + return null + } + + return hash.toLowerCase() +} + +export function findChartDeepLink(args: string[]): string | null { + for (const arg of args) { + const chartHash = parseChartDeepLink(arg) + if (chartHash) return chartHash + } + return null +} diff --git a/src-electron/IpcHandler.ts b/src-electron/IpcHandler.ts index e055524..2b82be5 100644 --- a/src-electron/IpcHandler.ts +++ b/src-electron/IpcHandler.ts @@ -9,8 +9,12 @@ import { getSettings, setSettings } from './ipc/SettingsHandler.ipc.js' import { downloadUpdate, getCurrentVersion, getUpdateAvailable, quitAndInstall, retryUpdate } from './ipc/UpdateHandler.ipc.js' import { getPlatform, getThemeColors, isMaximized, maximize, minimize, openUrl, quit, restore, showFile, showFolder, showOpenDialog, toggleDevTools } from './ipc/UtilHandlers.ipc.js' -export function getIpcInvokeHandlers(): IpcInvokeHandlers { +// Injected to avoid importing the main process into the handler registry. +export function getIpcInvokeHandlers( + getPendingChartDeepLink: IpcInvokeHandlers['getPendingChartDeepLink'], +): IpcInvokeHandlers { return { + getPendingChartDeepLink, getSettings, getCurrentVersion, getPlatform, diff --git a/src-electron/main.ts b/src-electron/main.ts index 23c8e0e..8cfe193 100644 --- a/src-electron/main.ts +++ b/src-electron/main.ts @@ -4,8 +4,9 @@ import windowStateKeeper from 'electron-window-state' import * as path from 'path' import * as url from 'url' -import { IpcFromMainEmitEvents } from '../src-shared/interfaces/ipc.interface.js' +import type { IpcFromMainEmitEvents } from '../src-shared/interfaces/ipc.interface.js' import { dataPath } from '../src-shared/Paths.js' +import { findChartDeepLink, parseChartDeepLink } from './DeepLink.js' import { settings } from './ipc/SettingsHandler.ipc.js' import { retryUpdate } from './ipc/UpdateHandler.ipc.js' import { getIpcInvokeHandlers, getIpcToMainEmitHandlers } from './IpcHandler.js' @@ -18,9 +19,16 @@ const _dirname = path.dirname(_filename) export let mainWindow: BrowserWindow const args = process.argv.slice(1) const isDevBuild = args.some(val => val === '--dev') +const protocol = 'bridge' +let pendingChartDeepLink = findChartDeepLink(process.argv) +registerProtocol() restrictToSingleInstance() handleOSXWindowClosed() +app.on('open-url', (event, deepLinkUrl) => { + event.preventDefault() + queueChartDeepLink(parseChartDeepLink(deepLinkUrl)) +}) app.on('ready', async () => { createBridgeWindow() if (!isDevBuild) { @@ -28,6 +36,14 @@ app.on('ready', async () => { } }) +function registerProtocol() { + if (isDevBuild && process.argv[1]) { + app.setAsDefaultProtocolClient(protocol, process.execPath, [path.resolve(process.argv[1])]) + } else { + app.setAsDefaultProtocolClient(protocol) + } +} + /** * Only allow a single Bridge window to be open at any one time. * If this is attempted, restore the open window instead. @@ -35,7 +51,8 @@ app.on('ready', async () => { function restrictToSingleInstance() { const isFirstBridgeInstance = app.requestSingleInstanceLock() if (!isFirstBridgeInstance) app.quit() - app.on('second-instance', () => { + app.on('second-instance', (_event, commandLine) => { + queueChartDeepLink(findChartDeepLink(commandLine)) if (mainWindow !== undefined) { if (mainWindow.isMinimized()) mainWindow.restore() mainWindow.focus() @@ -86,7 +103,7 @@ async function createBridgeWindow() { mainWindow.webContents.setZoomFactor(settings.zoomFactor) // IPC handlers - for (const [key, handler] of Object.entries(getIpcInvokeHandlers())) { + for (const [key, handler] of Object.entries(getIpcInvokeHandlers(takePendingChartDeepLink))) { // eslint-disable-next-line @typescript-eslint/no-explicit-any ipcMain.handle(key, (_event, ...args) => (handler as any)(args[0])) } @@ -105,6 +122,24 @@ async function createBridgeWindow() { } } +function queueChartDeepLink(chartHash: string | null) { + if (!chartHash) return + pendingChartDeepLink = chartHash + deliverPendingChartDeepLink() +} + +function deliverPendingChartDeepLink() { + if (!pendingChartDeepLink || !mainWindow || mainWindow.isDestroyed() || mainWindow.webContents.isLoading()) return + emitIpcEvent('chartDeepLink', pendingChartDeepLink) + pendingChartDeepLink = null +} + +export async function takePendingChartDeepLink() { + const chartHash = pendingChartDeepLink + pendingChartDeepLink = null + return chartHash +} + /** * Initialize a BrowserWindow object with initial parameters */ diff --git a/src-electron/preload.ts b/src-electron/preload.ts index 0dd7b60..cf6f26f 100644 --- a/src-electron/preload.ts +++ b/src-electron/preload.ts @@ -19,6 +19,7 @@ function getListenerAdder(key: K) { const electronApi: ContextBridgeApi = { invoke: { + getPendingChartDeepLink: getInvoker('getPendingChartDeepLink'), getSettings: getInvoker('getSettings'), getCurrentVersion: getInvoker('getCurrentVersion'), getPlatform: getInvoker('getPlatform'), @@ -97,6 +98,7 @@ const electronApi: ContextBridgeApi = { catalogOpenFolder: getEmitter('catalogOpenFolder'), }, on: { + chartDeepLink: getListenerAdder('chartDeepLink'), errorLog: getListenerAdder('errorLog'), updateError: getListenerAdder('updateError'), updateAvailable: getListenerAdder('updateAvailable'), diff --git a/src-shared/interfaces/ipc.interface.ts b/src-shared/interfaces/ipc.interface.ts index 2b2691d..4d39863 100644 --- a/src-shared/interfaces/ipc.interface.ts +++ b/src-shared/interfaces/ipc.interface.ts @@ -20,6 +20,10 @@ export interface ContextBridgeApi { * The list of possible async IPC events that return values. */ export interface IpcInvokeEvents { + getPendingChartDeepLink: { + input: void + output: string | null + } getSettings: { input: void output: Settings @@ -282,6 +286,7 @@ export type IpcToMainEmitHandlers = { * The list of possible async IPC events sent from the main process that don't return values. */ export interface IpcFromMainEmitEvents { + chartDeepLink: string errorLog: string updateError: string updateAvailable: UpdateInfo | null