feat: Implement tabby:// URL scheme handler (#11005)
Package-Build / Lint (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Docs / build (push) Has been cancelled
Package-Build / macOS-Build (arm64, aarch64-apple-darwin) (push) Has been cancelled
Package-Build / macOS-Build (x86_64, x86_64-apple-darwin) (push) Has been cancelled
Package-Build / Linux-Build (amd64, x64, ubuntu-24.04, x86_64-unknown-linux-gnu) (push) Has been cancelled
Package-Build / Linux-Build (arm64, arm64, ubuntu-24.04-arm, aarch64-unknown-linux-gnu, aarch64-linux-gnu-) (push) Has been cancelled
Package-Build / Linux-Build (armhf, arm, ubuntu-24.04, arm-unknown-linux-gnueabihf, arm-linux-gnueabihf-) (push) Has been cancelled
Package-Build / Windows-Build (arm64, aarch64-pc-windows-msvc) (push) Has been cancelled
Package-Build / Windows-Build (x64, x86_64-pc-windows-msvc) (push) Has been cancelled

Co-authored-by: Eugene <inbox@null.page>
This commit is contained in:
Ponder
2026-02-02 05:43:02 +08:00
committed by GitHub
parent 10a4f54732
commit b31a48677d
11 changed files with 248 additions and 71 deletions
+130 -49
View File
@@ -1,52 +1,133 @@
import { app } from 'electron'
export function parseArgs (argv: string[], cwd: string): any {
if (argv[0].includes('node')) {
argv = argv.slice(1)
}
return require('yargs/yargs')(argv.slice(1))
.usage('tabby [command] [arguments]')
.command('open [directory]', 'open a shell in a directory', {
directory: { type: 'string', 'default': cwd },
})
.command(['run [command...]', '/k'], 'run a command in the terminal', {
command: { type: 'string' },
})
.command('profile [profileName]', 'open a tab with specified profile', {
profileName: { type: 'string' },
})
.command('paste [text]', 'paste stdin into the active tab', yargs => {
return yargs.option('escape', {
alias: 'e',
type: 'boolean',
describe: 'Perform shell escaping',
}).positional('text', {
type: 'string',
})
})
.command('recent [index]', 'open a tab with a recent profile', {
profileNumber: { type: 'number' },
})
.command('quickConnect <providerId> <query>', 'open a tab for specified quick connect provider', yargs => {
return yargs.positional('providerId', {
describe: 'The name of a quick connect profile provider (e.g., ssh, telnet)',
type: 'string',
}).positional('query', {
describe: 'The quick connect query string',
type: 'string',
})
})
.version(app.getVersion())
.option('debug', {
alias: 'd',
describe: 'Show DevTools on start',
type: 'boolean',
})
.option('hidden', {
describe: 'Start minimized',
type: 'boolean',
})
.help('help')
.parse()
interface YargsOption {
type?: 'string' | 'number' | 'boolean' | 'array'
alias?: string
describe?: string
default?: any
choices?: string[]
}
interface CommandConfig {
command: string | string[]
description: string
options?: Record<string, YargsOption>
positionals?: Record<string, YargsOption>
}
interface ParserConfig {
usage: string
commands: CommandConfig[]
options: Record<string, YargsOption>
version: string
}
export function createParserConfig (cwd: string): ParserConfig {
return {
usage: 'tabby [command] [arguments]',
commands: [
{
command: 'open [directory]',
description: 'open a shell in a directory',
options: {
directory: { type: 'string', 'default': cwd },
},
},
{
command: ['run [command...]', '/k'],
description: 'run a command in the terminal',
options: {
command: { type: 'array' },
},
},
{
command: 'profile [profileName]',
description: 'open a tab with specified profile',
options: {
profileName: { type: 'string' },
},
},
{
command: 'paste [text]',
description: 'paste stdin into the active tab',
options: {
escape: {
alias: 'e',
type: 'boolean',
describe: 'Perform shell escaping',
},
},
positionals: {
text: { type: 'string' },
},
},
{
command: 'recent [index]',
description: 'open a tab with a recent profile',
options: {
profileNumber: { type: 'number' },
},
},
{
command: 'quickConnect <providerId> <query>',
description: 'open a tab for specified quick connect provider',
positionals: {
providerId: {
describe: 'The name of a quick connect profile provider',
type: 'string',
},
query: {
describe: 'The quick connect query string',
type: 'string',
},
},
},
],
options: {
debug: {
alias: 'd',
describe: 'Show DevTools on start',
type: 'boolean',
},
hidden: {
describe: 'Start minimized',
type: 'boolean',
},
},
version: app.getVersion(),
}
}
function applyOptionsToYargs (yargsInstance: any, options: Record<string, YargsOption>, method: 'option' | 'positional') {
return Object.entries(options).reduce(
(yargs, [key, value]) => yargs[method](key, value),
yargsInstance,
)
}
function createParserFromConfig (config: ParserConfig) {
const yargs = require('yargs/yargs')
let parser = yargs().usage(config.usage)
config.commands.forEach(cmd => {
const builder = (yargsInstance: any) => {
let instance = yargsInstance
if (cmd.options) {
instance = applyOptionsToYargs(instance, cmd.options, 'option')
}
if (cmd.positionals) {
instance = applyOptionsToYargs(instance, cmd.positionals, 'positional')
}
return instance
}
parser = parser.command(cmd.command, cmd.description, builder)
})
parser = applyOptionsToYargs(parser, config.options, 'option')
return parser.version(config.version).help('help')
}
export function parseArgs (argv: string[], cwd: string): any {
const args = argv[0].includes('node') ? argv.slice(2) : argv.slice(1)
const config = createParserConfig(cwd)
const parser = createParserFromConfig(config)
return parser.parse(args)
}
+22 -1
View File
@@ -15,7 +15,7 @@ import './sentry'
import './lru'
import { parseArgs } from './cli'
import { Application } from './app'
import electronDebug = require('electron-debug')
import electronDebug from 'electron-debug'
import { loadConfig } from './config'
@@ -35,6 +35,15 @@ process.mainModule = module
const application = new Application(configStore)
// Register tabby:// URL scheme
if (process.defaultApp) {
if (process.argv.length >= 2) {
app.setAsDefaultProtocolClient('tabby', process.execPath, [process.argv[1]])
}
} else {
app.setAsDefaultProtocolClient('tabby')
}
ipcMain.on('app:new-window', () => {
application.newWindow()
})
@@ -60,6 +69,18 @@ app.on('activate', async () => {
}
})
// Handle URL scheme on macOS
app.on('open-url', async (event, url) => {
event.preventDefault()
console.log('Received open-url event:', url)
if (!application.hasWindows()) {
process.argv.push(url)
} else {
await app.whenReady()
application.handleSecondInstance([url], process.cwd())
}
})
app.on('second-instance', async (_event, newArgv, cwd) => {
application.handleSecondInstance(newArgv, cwd)
})
+62
View File
@@ -0,0 +1,62 @@
import { createParserConfig } from './cli'
import { parse as parseShellCommand } from 'shell-quote'
export function isTabbyURL (arg: string): boolean {
return arg.toLowerCase().startsWith('tabby://')
}
export function parseTabbyURL (url: string, cwd: string = process.cwd()): any {
try {
if (!isTabbyURL(url)) {
return null
}
// NOTE: the url host may be lowercased (xdg-open), need to use the original command
const urlInstance = new URL(url)
const command = urlInstance.host || urlInstance.pathname.replace(/^\/+/, '')
const config = createParserConfig(cwd)
const commandConfig = config.commands.find(cmd => {
const primaryCommand = Array.isArray(cmd.command) ? cmd.command[0] : cmd.command
return command.toLowerCase() === primaryCommand.split(/\s+/)[0].toLowerCase()
})
if (!commandConfig) {
console.error(`Unknown command in tabby:// URL: ${command}`)
return null
}
const primaryCommand = Array.isArray(commandConfig.command) ? commandConfig.command[0] : commandConfig.command
const actualCommand = primaryCommand.split(/\s+/)[0]
const argv: any = {
_: [actualCommand],
}
for (const [key, value] of urlInstance.searchParams.entries()) {
let parsedValue: any = value
const optionConfig = commandConfig.options?.[key] ?? commandConfig.positionals?.[key]
if (optionConfig) {
switch (optionConfig.type) {
case 'boolean':
parsedValue = value === 'true' || value === ''
break
case 'number':
parsedValue = parseInt(value, 10)
break
case 'array':
parsedValue = parseShellCommand(value).filter(item => typeof item === 'string')
break
case 'string':
default:
parsedValue = value
break
}
} else {
parsedValue = value
}
argv[key] = parsedValue
}
console.log(`URL Handler - Safely parsed [${url}] to:`, JSON.stringify(argv))
return argv
} catch (e) {
console.error('Failed to parse tabby:// URL:', e)
return null
}
}
+7 -1
View File
@@ -11,6 +11,7 @@ import { compare as compareVersions } from 'compare-versions'
import type { Application } from './app'
import { parseArgs } from './cli'
import { parseTabbyURL, isTabbyURL } from './urlHandler'
let DwmEnableBlurBehindWindow: any = null
if (process.platform === 'win32') {
@@ -278,7 +279,12 @@ export class Window {
}
passCliArguments (argv: string[], cwd: string, secondInstance: boolean): void {
this.send('cli', parseArgs(argv, cwd), cwd, secondInstance)
const urlArg = argv.find(arg => isTabbyURL(arg))
if (urlArg) {
this.send('cli', parseTabbyURL(urlArg, cwd), cwd, secondInstance)
} else {
this.send('cli', parseArgs(argv, cwd), cwd, secondInstance)
}
}
private async enableDockedWindowStyles (enabled: boolean) {
+5
View File
@@ -3,6 +3,10 @@ appId: org.tabby
productName: Tabby
compression: normal
npmRebuild: false
protocols:
- name: Tabby URL
schemes:
- tabby
files:
- '**/*'
- dist
@@ -74,6 +78,7 @@ linux:
desktop:
entry:
StartupWMClass: tabby
MimeType: x-scheme-handler/tabby
snap:
plugs:
- default
+14 -1
View File
@@ -1,5 +1,18 @@
export interface CLIEvent {
argv: any
argv: {
_: string[],
// Commands are hardcoded for now
directory?: string,
command?: string[],
profileName?: string,
text?: string,
escape?: boolean,
providerId?: string,
query?: string,
debug?: boolean,
hidden?: boolean,
profileNumber?: number,
}
cwd: string
secondInstance: boolean
}
+3 -3
View File
@@ -21,15 +21,15 @@ export class ProfileCLIHandler extends CLIHandler {
const op = event.argv._[0]
if (op === 'profile') {
this.handleOpenProfile(event.argv.profileName)
this.handleOpenProfile(event.argv.profileName!)
return true
}
if (op === 'recent') {
this.handleOpenRecentProfile(event.argv.profileNumber)
this.handleOpenRecentProfile(event.argv.profileNumber!)
return true
}
if (op === 'quickConnect') {
this.handleOpenQuickConnect(event.argv.providerId, event.argv.query)
this.handleOpenQuickConnect(event.argv.providerId!, event.argv.query!)
return true
}
return false
+1 -1
View File
@@ -32,7 +32,7 @@ export class ElectronUACService extends UACService {
}
const options = { ...sessionOptions }
options.args = [options.command, ...options.args ?? []]
options.args = [options.command, ...options.args]
options.command = helperPath
return options
}
+2 -2
View File
@@ -20,9 +20,9 @@ export class TerminalCLIHandler extends CLIHandler {
const op = event.argv._[0]
if (op === 'open') {
this.handleOpenDirectory(path.resolve(event.cwd, event.argv.directory))
this.handleOpenDirectory(path.resolve(event.cwd, event.argv.directory!))
} else if (op === 'run') {
this.handleRunCommand(event.argv.command)
this.handleRunCommand(event.argv.command!)
} else {
return false
}
@@ -17,18 +17,7 @@ export class LocalProfileSettingsComponent implements ProfileSettingsComponent<L
private platform: PlatformService,
) { }
ngOnInit () {
this.profile.options.env = this.profile.options.env ?? {}
this.profile.options.args = this.profile.options.args ?? []
}
async pickWorkingDirectory (): Promise<void> {
// const profile = await this.terminal.getProfileByID(this.config.store.terminal.profile)
// const shell = this.shells.find(x => x.id === profile?.shell)
// if (!shell) {
// return
// }
const cwd = await this.platform.pickDirectory()
if (!cwd) {
return
+2 -2
View File
@@ -19,8 +19,8 @@ export class TerminalCLIHandler extends CLIHandler {
const op = event.argv._[0]
if (op === 'paste') {
let text = event.argv.text
if (event.argv.escape) {
let text = event.argv.text!
if (event.argv.escape!) {
text = shellQuote.quote([text])
}
this.handlePaste(text)