mirror of
https://github.com/eugeny/tabby
synced 2026-05-03 07:50:45 +00:00
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
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:
+130
-49
@@ -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
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user