diff --git a/app/lib/cli.ts b/app/lib/cli.ts index 1b8fb06e..6666d02e 100644 --- a/app/lib/cli.ts +++ b/app/lib/cli.ts @@ -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 ', '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 + positionals?: Record +} + +interface ParserConfig { + usage: string + commands: CommandConfig[] + options: Record + 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 ', + 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, 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) } diff --git a/app/lib/index.ts b/app/lib/index.ts index 5326393c..11682ee7 100644 --- a/app/lib/index.ts +++ b/app/lib/index.ts @@ -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) }) diff --git a/app/lib/urlHandler.ts b/app/lib/urlHandler.ts new file mode 100644 index 00000000..7adbb845 --- /dev/null +++ b/app/lib/urlHandler.ts @@ -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 + } +} diff --git a/app/lib/window.ts b/app/lib/window.ts index 167fab51..613e4797 100644 --- a/app/lib/window.ts +++ b/app/lib/window.ts @@ -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) { diff --git a/electron-builder.yml b/electron-builder.yml index 8d8a1cd3..1493038e 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -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 diff --git a/tabby-core/src/api/cli.ts b/tabby-core/src/api/cli.ts index e1752556..643330b3 100644 --- a/tabby-core/src/api/cli.ts +++ b/tabby-core/src/api/cli.ts @@ -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 } diff --git a/tabby-core/src/cli.ts b/tabby-core/src/cli.ts index 99235f65..e8238ff3 100644 --- a/tabby-core/src/cli.ts +++ b/tabby-core/src/cli.ts @@ -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 diff --git a/tabby-electron/src/services/uac.service.ts b/tabby-electron/src/services/uac.service.ts index e73713c2..6533487d 100644 --- a/tabby-electron/src/services/uac.service.ts +++ b/tabby-electron/src/services/uac.service.ts @@ -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 } diff --git a/tabby-local/src/cli.ts b/tabby-local/src/cli.ts index 44642192..612fa143 100644 --- a/tabby-local/src/cli.ts +++ b/tabby-local/src/cli.ts @@ -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 } diff --git a/tabby-local/src/components/localProfileSettings.component.ts b/tabby-local/src/components/localProfileSettings.component.ts index 41c46feb..fb5b6bcf 100644 --- a/tabby-local/src/components/localProfileSettings.component.ts +++ b/tabby-local/src/components/localProfileSettings.component.ts @@ -17,18 +17,7 @@ export class LocalProfileSettingsComponent implements ProfileSettingsComponent { - // 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 diff --git a/tabby-terminal/src/cli.ts b/tabby-terminal/src/cli.ts index f5b98055..700befdc 100644 --- a/tabby-terminal/src/cli.ts +++ b/tabby-terminal/src/cli.ts @@ -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)