Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions electron-builder.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@
"files": [
"dist/**/*"
],
"protocols": [
{
"name": "Bridge chart link",
"schemes": [
"bridge"
]
}
],
"publish": {
"provider": "github",
"releaseType": "release"
Expand Down
34 changes: 32 additions & 2 deletions src-angular/app/app.component.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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))

Expand All @@ -49,4 +70,13 @@ export class AppComponent {
}
})
}

private async openChartDeepLink(): Promise<void> {
const chartHash = this.pendingChartDeepLink
this.pendingChartDeepLink = null
if (!chartHash) return

await this.router.navigate(['/browse'])
this.searchService.searchByHash(chartHash).subscribe()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,26 @@ 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()
}
})
}

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.
Expand Down
17 changes: 11 additions & 6 deletions src-angular/app/core/services/search.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export class SearchService {
readonly currentPage = signal(1)
readonly isDefaultSearch = signal(true)
readonly isAdvancedSearch = signal(false)
readonly lastAdvancedSearch = signal<AdvancedSearch | null>(null)
readonly lastAdvancedSearch = signal<Partial<AdvancedSearch> | null>(null)

readonly groupedSongs = signal<ChartData[][]>([])

Expand All @@ -44,6 +44,7 @@ export class SearchService {

// Signal for notifying components of search events
readonly searchEvent = signal<{ type: 'new' | 'update'; response: Partial<SearchResult> } | null>(null)
requestedChartHash: string | null = null

readonly areMorePages = computed(() => {
const response = this.songsResponse()
Expand All @@ -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) {
Expand Down Expand Up @@ -164,7 +169,7 @@ export class SearchService {
)
}

public advancedSearch(search: AdvancedSearch, nextPage = false) {
public advancedSearch(search: Partial<AdvancedSearch>, nextPage = false) {
this.searchLoading.set(true)
this.isDefaultSearch.set(false)
this.isAdvancedSearch.set(true)
Expand Down
33 changes: 33 additions & 0 deletions src-electron/DeepLink.ts
Original file line number Diff line number Diff line change
@@ -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
}
6 changes: 5 additions & 1 deletion src-electron/IpcHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
41 changes: 38 additions & 3 deletions src-electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -18,24 +19,40 @@ 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) {
retryUpdate()
}
})

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.
*/
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()
Expand Down Expand Up @@ -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]))
}
Expand All @@ -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
*/
Expand Down
2 changes: 2 additions & 0 deletions src-electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ function getListenerAdder<K extends keyof IpcFromMainEmitEvents>(key: K) {

const electronApi: ContextBridgeApi = {
invoke: {
getPendingChartDeepLink: getInvoker('getPendingChartDeepLink'),
getSettings: getInvoker('getSettings'),
getCurrentVersion: getInvoker('getCurrentVersion'),
getPlatform: getInvoker('getPlatform'),
Expand Down Expand Up @@ -97,6 +98,7 @@ const electronApi: ContextBridgeApi = {
catalogOpenFolder: getEmitter('catalogOpenFolder'),
},
on: {
chartDeepLink: getListenerAdder('chartDeepLink'),
errorLog: getListenerAdder('errorLog'),
updateError: getListenerAdder('updateError'),
updateAvailable: getListenerAdder('updateAvailable'),
Expand Down
5 changes: 5 additions & 0 deletions src-shared/interfaces/ipc.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down