├── .gitignore ├── .perltidyrc ├── LICENSE ├── README.md ├── dot.tmuxc.conf ├── extras ├── update-tmux.fish └── xscreensaver-watcher.sh ├── tmuxc └── tmuxc.1 /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | -------------------------------------------------------------------------------- /.perltidyrc: -------------------------------------------------------------------------------- 1 | -i=2 --maximum-line-length=120 -ce -cb 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Zach Dykstra 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tmuxc 2 | 3 | tmuxc attaches to a local or remote tmux session and breaks out each window into a local terminal. Each window in a session is tracked by tmuxc, and when found, a shell script is created that is executed by a terminal of your choice. The shell script creates a new cloned session that's part of the base session you're monitoring, sets a few options, and focuses a specific window. When that window in tmux is closed, the cloned session is killed, causing the script to finish executing and the terminal to close. 4 | 5 | `tmuxc` is written in pure core Perl, so it should work as-is on most any modern system. It's been successfully tested on Void Linux, FreeBSD, OpenBSD, Ubuntu, Arch. 6 | 7 | # .tmux.conf changes 8 | 9 | A few changes to tmux's configuration will need to be made. Because of the way tmuxc operates, new windows that are created in a session should not automatically get focus. This behavior is nominally controlled by the `-d` flag on new-window and breakp. A full list of changes to .tmux.conf are listed below. 10 | 11 | ``` 12 | # Create a new window, with out focus. This is only needed if you want to use tmux hotkeys to create a window. If you use tmuxc to create a new winodow, this keybind can be left alone. 13 | bind-key c new-window -d 14 | 15 | # Break the current pane out into a new window, without focus. There is no tmuxc shortcut for this action. 16 | bind b breakp -d 17 | ``` 18 | 19 | # Configuration file 20 | 21 | The tmuxc configuration file is written as an anonymous Perl hash. The syntax is generally easy to pick up - adapting the example listed below to your environment should be a quick process. 22 | 23 | Example `~/.config/tmuxc.conf` for a local tmux instance 24 | ``` 25 | { 26 | temp => "$ENV{'XDG_RUNTIME_DIR'}/tmuxc/", 27 | terminal => [ qw(kitty) ], 28 | ssh_args => [ qw(-A) ], 29 | selector => [ 'bemenu -l 10 -i -n -w -P > -p tmuxc --fn "Hack 18"' ], 30 | input_prompt => [ 'bemenu -l 10 -i -n -w --fn "Hack 18" -p' ], 31 | session => 'global-session', 32 | hosts => { 33 | localhost => { 34 | 'global-session' => { 35 | initialize => [ qw(tmux new-session -d -s global-session) ], 36 | }, 37 | }, 38 | }, 39 | } 40 | ``` 41 | 42 | 43 | Example `~/.config/tmuxc.conf` for a local and remote tmux instance 44 | ``` 45 | { 46 | temp => "$ENV{'XDG_RUNTIME_DIR'}/tmuxc/", 47 | terminal => [ qw(kitty) ], 48 | ssh_args => [ qw(-A) ], 49 | selector => [ 'bemenu -l 10 -i -n -w -P > -p tmuxc --fn "Hack 18"' ], 50 | input_prompt => [ 'bemenu -l 10 -i -n -w --fn "Hack 18" -p' ], 51 | session => 'global-session', 52 | hosts => { 53 | localhost => { 54 | 'global-session' => { 55 | initialize => [ qw(tmux new-session -d -s global-session) ], 56 | }, 57 | }, 58 | 'my.remote.server.com' => { 59 | 'global-session' => { 60 | initialize => [ qw(tmux new-session -d -s global-session) ], 61 | }, 62 | }, 63 | }, 64 | } 65 | ``` 66 | 67 | Example advanced configuration: 68 | 69 | ``` 70 | { 71 | terminal => [qw(kitty)], 72 | session => 'global-session', 73 | ssh_args => [qw(-A -X)], 74 | selector => [ qw(bemenu -l 20 -i -n -w -P > -p tmuxc --fn), "Hack 18" ], 75 | input_prompt => [ qw(bemenu -l 20 -i -n -w --fn), "Hack 18", qw(-p) ], 76 | temp => "$ENV{'XDG_RUNTIME_DIR'}/tmuxc/", 77 | detach => 0, 78 | hosts => { 79 | localhost => { 80 | 'global-session' => { 81 | blacklist => ['ignore'], 82 | initialize => 83 | [qw(tmuxp load -2 -d ~/Documents/global-session.yml)], 84 | }, 85 | 'monitoring' => { 86 | skipnw => 1, 87 | initialize => [qw(tmuxp load -2 -d ~/Documents/monitoring.yml)], 88 | }, 89 | }, 90 | 'irc.vm' => { 91 | 'weechat' => { 92 | skipnw => 1, 93 | blacklist => [ 'ignore', 'top' ], 94 | initialize => 95 | [qw(/usr/local/bin/tmuxp-3.6 load -2 -d ~/weechat.yml)], 96 | }, 97 | }, 98 | 'bastion.host' => { 99 | swm => 1, 100 | tmux_bin => '/home/myuser/bin/tmux', 101 | 'global-session' => { 102 | initialize => [qw( tmux new-session -d -s global-session )], 103 | on_connect => [ 'set status on', 'set-option prefix C-k', ], 104 | }, 105 | 'ansible-host' => { 106 | initialize => [qw( tmux new-session -d -s ansible )], 107 | on_connect => [ 'set status off', 'set-option set-titles-string "#T"' ], 108 | }, 109 | 'puppet-host' => { 110 | swm => 0, 111 | initialize => [qw( tmux new-session -d -s puppet )], 112 | on_connect => [ 'set status off' ], 113 | }, 114 | }, 115 | }, 116 | } 117 | ``` 118 | 119 | The value for the `terminal` key should be set to what your terminal uses to execute a custom command. Examples: 120 | 121 | * `kitty` 122 | * `xterm` 123 | * `xfce4-terminal -e` 124 | * `termit -e` 125 | * `gnome-terminal -e` 126 | 127 | 128 | To connect to a remote host, a number of conditions should be met for optimal use. First, ensure your local machine has keyed access to the remote host. Second, ensure that SSH multiplexing is allowed. Modern defaults usually allow 10 multiplexed clients. This means you can have 9 windows open, and one control session. This can be increased by modifying `MaxSessions` in sshd_config on the remote host. 129 | 130 | Each host requires a separate instance of tmuxc to be run. Using the above example, you'd run the following: 131 | 132 | * `tmuxc -b` to connect to the localhost instance, under the default session of 'global-session', then daemonize tmuxc 133 | * `tmuxc -h my.remote.server.com -b` to connect to the remote host, under the default session of 'global-session', then daemonize tmuxc. 134 | 135 | Hosts and sessions do not need to be in the configuration file. You can simply run `tmuxc` as-is. The configuration file is just parsed by the internal `tmuxc -l` launcher mode to present a session selector for you. 136 | 137 | When a terminal is closed with out exiting the program running in the tmux window (e.g. exiting a shell), that specific window is marked as 'ignored' for the life of the tmuxc process. That means that when you create a new window, the one you just closed and a new one do not open up. You can re-open all windows for a session by running `tmuxc -m` and selecting Open All Windows, and then picking a session if multiple exist. 138 | 139 | 140 | # Hotkey integrations 141 | 142 | Below is a sample i3 integration: 143 | 144 | ``` 145 | # Present the session launcher menu 146 | bindsym $mod+equal exec --no-startup-id tmuxc -l 147 | # Present a single-depth menu showing all sessions and the actions allowed for each 148 | bindsym $mod+m exec --no-startup-id tmuxc -M 149 | # Open a new terminal in the running session, or present a menu if multiple sessions are running 150 | bindsym $mod+Return exec --no-startup-id tmuxc -n 151 | ``` 152 | 153 | # Global/host/session options 154 | 155 | If the default option is acceptable, you do not need to define it again in a configuration file. However, if you want to change it for a specific host or even a specific session, you can do so. Define the configuration key and value at the appropriate level, and it will override that setting. 156 | 157 | * `alive_count (default 2)`: Set the SSH `ServerAliveCountMax` option for control master connections. 158 | * `alive_interval (default 3)`: Set the SSH `ServerAliveInterval` option for control master connections. 159 | * `background (default 0)`: Set to 1 to automatically detach/background the tmuxc process after launch. 160 | * `conn_timeout (default 3)`: Set the SSH `ConnectTimeout` option for control master connections. 161 | * `detach (default 0)`: Set to 1 to attempt to detach all other clients connected to the TMUX session. 162 | * `env_prefix (default TMUX_SESSION)`: Combine this prefix with `_NAME` and inject the tmux session name into the environment. 163 | * `exitlast (default 0)`: Set to 1 to exit the control daemon after the last window in the tmux session exits. 164 | * `input_prompt (default rofi -dmenu -p)`: Define the prompt for user input, notably when creating an ephemeral session. 165 | * `log_facility (default "")`: Define the syslog log facility. 166 | * `log_level (default 5)`: Define the logging level, values of 5, 6 or 7 are accepted. 167 | * `on_connect (default[])`: When operating in single-window-mode, define an array of tmux commands to be sent to the cloned session to which you are attached. This option is only executed when `swm` is true. 168 | * `persist (default 10s)`: Set the SSH `ControlPersist` option for control master connections. 169 | * `prettyps (default 1)`: Control the process name on the command line, reducing it to `binaryname session@host`. 170 | * `poe (default none)`: Enable purge-on-exit for `ephemeral` or `all` sessions. This will execute a `kill-session` against the session on exit. 171 | * `reconnect (default 1)`: Attempt to reconnect to the remote host and re-open windows if SSH drops. This is useful for laptops that are suspended frequently. 172 | * `selector (default: rofi -dmenu -i)`: Define the menu command, used for picking a session / session action. 173 | * `skipnw (default 0)`: Do not launch a new terminal when a new window is created in tmux. 174 | * `ssh_args(default [])`: Additional arguments to add to each SSH connection. 175 | * `ssh_prereqs(default [])`: An array of commands that is converted to a single shell command, run on the local host. If the exit code is 0, `tmuxc` will not launch the session. 176 | * `ssh_control(default %C)`: SSH tokens to name the ControlMaster path. Refer to `ssh_config(5)` for more details. 177 | * `swm (default 0)`: Enable single-window-mode. This is the traditional approach to tmux, it simply opens a terminal with your full tmux session attached in it. Refer to `on_connect` for session tuning (set status on, for example, if it's off by default.) 178 | * `temp (default $HOME/.tmuxc/)`: The location of the runtime temp directory. While this can be changed on a per-host and per-session basis, it should be avoided. `tmuxc` uses a shared directory to discover other running instances and to insert commands into other running sessions. Unless a shared directory is used, instances will be 'lost' to each other. 179 | * `terminal (default xterm)`: The default terminal to use when opening up new windows. 180 | * `tmux_bin (default tmux)`: The name of the tmux binary to use, can also include the full path like `/home/user/bin/tmux-3.0`. 181 | 182 | Run `tmuxc -h -s -o` to see the merged configuration values for a given host and session. Some of the values shown (chost, command, control, etc) are generated at run-time and are not able to be configured by a user. 183 | -------------------------------------------------------------------------------- /dot.tmuxc.conf: -------------------------------------------------------------------------------- 1 | { 2 | terminal => [qw(kitty)], 3 | session => 'global-session', 4 | ssh_args => [qw(-A)], 5 | selector => [ qw(bemenu -l 10 -i -n -w -P > -p tmuxc --fn), "Hack 18" ], 6 | input_prompt => [ qw(bemenu -l 10 -i -n -w --fn), "Hack 18", qw(-p) ], 7 | temp => "$ENV{'XDG_RUNTIME_DIR'}/tmuxc/", 8 | detach => 1, 9 | hosts => { 10 | localhost => { 11 | 'global-session' => { 12 | blacklist => ['ignore'], 13 | initialize => [qw(tmuxp load -2 -d ~/Documents/global-session.yml)], 14 | }, 15 | 'dev-session' => { 16 | log_level => LOG_DEBUG, 17 | initialize => [qw(tmux new-session -d -s dev-session)], 18 | }, 19 | }, 20 | 'shellhost.test.com' => { 21 | tmux_bin => '/opt/bin/tmux', 22 | 'global-session' => { 23 | blacklist => ['ignore'], 24 | initialize => [qw(/usr/local/bin/tmuxp-3.6 load -2 -d ~/Documents/global-session.yml)], 25 | }, 26 | }, 27 | 'ansiblehost.test.com' => { 28 | skipnw => 1, 29 | blacklist => ['ignore'], 30 | initialize => [qw(tmux new-session -d -s ansible)], 31 | }, 32 | }, 33 | } 34 | -------------------------------------------------------------------------------- /extras/update-tmux.fish: -------------------------------------------------------------------------------- 1 | function update_tmux --on-event fish_preexec 2 | if set -q TMUX_PANE 3 | export (tmux show-environment -t (env TMUX= tmux lsw -F "#{session_name}" | head -1) | grep -v "^-" | xargs -L 1) 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /extras/xscreensaver-watcher.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | process() { 4 | while read input; do 5 | case "$input" in 6 | UNBLANK*) tmuxc -p ;; 7 | LOCK*) tmuxc -p ;; 8 | esac 9 | done 10 | } 11 | 12 | /usr/local/bin/xscreensaver-command -watch | process 13 | -------------------------------------------------------------------------------- /tmuxc: -------------------------------------------------------------------------------- 1 | #!/bin/perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | our $VERSION = '1.9.1'; 7 | 8 | use Getopt::Long qw(:config no_ignore_case auto_version); 9 | use Time::HiRes qw( sleep gettimeofday ); 10 | use POSIX qw ( mkfifo setsid WNOHANG ); 11 | use FindBin qw ($RealScript $RealBin); 12 | use Sys::Syslog qw(:standard :macros); 13 | use Pod::Usage qw(pod2usage); 14 | use File::Basename; 15 | use Sys::Hostname; 16 | use IPC::Open2; 17 | use Fcntl; 18 | 19 | use Data::Dumper; 20 | $Data::Dumper::Indent = 1; 21 | $Data::Dumper::Sortkeys = 1; 22 | $Data::Dumper::Purity = 1; 23 | 24 | use constant REFSUB => ref sub { }; 25 | use constant REFHASH => ref {}; 26 | use constant REFARRAY => ref []; 27 | 28 | # Add signal handlers, load our config on start 29 | BEGIN { 30 | $SIG{INT} = \&CleanExit; 31 | $SIG{TERM} = \&CleanExit; 32 | $SIG{HUP} = \&LoadConfig; 33 | $SIG{PIPE} = sub { }; 34 | } 35 | 36 | my ( 37 | $config, $configfile, $pause, $noclean, $host, 38 | $help, %publicCommands, %defaultOptions, %cli, %mapping, 39 | %commandParse, %commandText, %menu, $cliAction, @children, 40 | ); 41 | 42 | # Looking for configs in all the right places 43 | my @configtests; 44 | 45 | use Config; 46 | if ( $Config{osname} eq "haiku" ) { 47 | push( @configtests, "/boot/home/config/settings" ); 48 | } 49 | 50 | if ( defined( $ENV{'HOME'} ) ) { 51 | push( @configtests, join( '/', ( $ENV{'HOME'}, qw(.tmuxc.conf) ) ) ); 52 | push( @configtests, join( '/', ( $ENV{'HOME'}, qw(.config/tmuxc.conf) ) ) ); 53 | push( @configtests, join( '/', ( $ENV{'HOME'}, qw(.config/tmuxc/tmuxc.conf) ) ) ); 54 | } 55 | 56 | if ( defined( $ENV{'XDG_CONFIG_HOME'} ) ) { 57 | push( @configtests, join( '/', ( $ENV{'XDG_CONFIG_HOME'}, qw( tmuxc.settings) ) ) ); 58 | } 59 | 60 | if ( scalar @configtests > 0 ) { 61 | foreach (@configtests) { 62 | if (-f) { 63 | $configfile = $_; 64 | } 65 | } 66 | } else { 67 | print "Unable to find a configuration file\n"; 68 | exit; 69 | } 70 | 71 | unless ( length $configfile ) { 72 | $configfile = $configtests[0]; 73 | } 74 | 75 | $noclean = 1; 76 | $pause = 0; 77 | 78 | # Manipulate ARGV before we got to our options parser 79 | if ( ( scalar(@ARGV) == 1 ) and ( $ARGV[0] =~ m/@/ ) ) { 80 | 81 | # If we have a single argument, try to parse it as a session@host 82 | unshift( @ARGV, qw(-s) ); 83 | } elsif ( scalar(@ARGV) == 0 ) { 84 | 85 | # No arguments, show the help output 86 | unshift( @ARGV, qw(--help) ); 87 | } 88 | 89 | GetOptions( 90 | "host|h=s" => \$cli{host}, 91 | "background|b" => \$cli{background}, 92 | "config|c=s" => \$configfile, 93 | "detach|d" => \$cli{detach}, 94 | "reconnect|R" => \$cli{reconnect}, 95 | "closeas|C" => \$cli{closeas}, 96 | "exitlast|E" => \$cli{exitlast}, 97 | "print|o" => \$cli{printOptions}, 98 | "wm|w=s" => sub { 99 | ( undef, my $wm ) = @_; 100 | my @valid = ( "single", "multi" ); 101 | unless ( grep { $_ eq $wm } @valid ) { 102 | print "Invalid window mode [" . join( ", ", @valid ) . "]: $wm"; 103 | exit; 104 | } else { 105 | $cli{wm} = $wm; 106 | } 107 | }, 108 | "verbose|v+" => sub { 109 | $cli{verbosity} += 1; 110 | }, 111 | "poe|P=s" => sub { 112 | ( undef, my $scope ) = @_; 113 | my @valid = ( "none", "ephemeral", "any" ); 114 | unless ( grep { $_ eq $scope } @valid ) { 115 | print "Invalid purge on exit scope [" . join( ", ", @valid ) . "]: $scope"; 116 | exit; 117 | } else { 118 | $cli{poe} = $scope; 119 | } 120 | }, 121 | "ephemeral|S" => sub { 122 | $cliAction = \&EphemeralSession; 123 | }, 124 | "session|s=s" => sub { 125 | ( undef, my $session ) = @_; 126 | if ( $session =~ m/@/ ) { 127 | ( $cli{session}, $cli{host} ) = split( /@/, $session ); 128 | } else { 129 | $cli{session} = $session; 130 | } 131 | }, 132 | "new|n" => sub { 133 | $cliAction = sub { 134 | my $cmd = "new-window -d"; 135 | my $hostsession = SelectSession($cmd); 136 | InjectCommand( $cmd, $hostsession ); 137 | }; 138 | }, 139 | "launcher|l" => sub { 140 | $cliAction = \&Launcher; 141 | }, 142 | "known|k:s" => sub { 143 | ( undef, $cli{csv} ) = @_; 144 | $cliAction = sub { 145 | KnownSessions( $cli{csv} ); 146 | }; 147 | }, 148 | "remote|K:s" => sub { 149 | ( undef, my $rhost ) = @_; 150 | $cliAction = sub { 151 | RemoteSessions($rhost); 152 | } 153 | }, 154 | "menu|m" => sub { 155 | $cliAction = sub { 156 | Menu("paged"); 157 | }; 158 | }, 159 | "singlemenu|M" => sub { 160 | $cliAction = sub { 161 | Menu("single"); 162 | }; 163 | }, 164 | "exit|e" => sub { 165 | $cliAction = sub { 166 | my $cmd = "tmuxc KillSelf"; 167 | my $hostsession = SelectSession($cmd); 168 | InjectCommand( $cmd, $hostsession ); 169 | }; 170 | }, 171 | "pause|p" => sub { 172 | $cliAction = sub { 173 | my $cmd = "tmuxc PauseSelf"; 174 | my $hostsession = SelectSession($cmd); 175 | InjectCommand( $cmd, $hostsession ); 176 | },; 177 | }, 178 | "raw|r=s" => sub { 179 | ( undef, $cli{raw} ) = @_; 180 | $cliAction = sub { 181 | my $hostsession = SelectSession( $cli{raw} ); 182 | InjectCommand( $cli{raw}, $hostsession ); 183 | }; 184 | }, 185 | "help|?" => sub { 186 | pod2usage( -verbose => 2 ); 187 | exit; 188 | }, 189 | ); 190 | 191 | # Load our configuration file 192 | LoadConfig; 193 | 194 | # Set our log_level based on verbose flags, start logging 195 | unless ( $config->{log_facility} eq '' ) { 196 | openlog( $RealScript, "nofatal,ndelay,pid", $config->{log_facility} ); 197 | setlogmask( LOG_UPTO( $config->{log_level} ) ); 198 | } 199 | 200 | Log( LOG_DEBUG, \$config ); 201 | 202 | # The last actionable option defined on the command line wins - execute it! 203 | if ( ref $cliAction eq REFSUB ) { 204 | $cliAction->(); 205 | exit; 206 | } 207 | 208 | # Check if an instance for this specific host and session is running 209 | if ( -f $config->{pidfile} ) { 210 | open PID, '<', $config->{pidfile}; 211 | my $pid = ; 212 | close PID; 213 | if ( kill( 0, $pid ) ) { 214 | Log( LOG_DEBUG, "$config->{pidfile} exists, and is running" ); 215 | die "$config->{pidfile} exists, and pid $pid is running\n"; 216 | } 217 | } 218 | 219 | # Double fork 220 | if ( $config->{background} ) { 221 | my $fpid = fork; 222 | exit if $fpid; # kill first parent 223 | open STDIN, '/dev/null'; 224 | open STDOUT, '>/dev/null'; 225 | open STDERR, '>/dev/null'; 226 | setsid(); 227 | $fpid = fork; 228 | exit if $fpid; 229 | } 230 | 231 | if ( $config->{log_level} == LOG_NOTICE ) { 232 | close STDERR; 233 | } 234 | 235 | $|++; 236 | 237 | # Write our PID to a file, regardless of fg or bg state 238 | open PID, '>', $config->{pidfile}; 239 | print PID $$; 240 | close PID; 241 | 242 | # Disable the noclean flag, now that we've written a PID to disk 243 | $noclean = 0; 244 | 245 | # Set our proc entry to tmuxc session@host 246 | if ( $config->{prettyps} ) { 247 | $0 = sprintf( "%s %s@%s", $RealScript, $config->{session}, $config->{chost} ); 248 | } 249 | 250 | Log( LOG_NOTICE, "running as PID $$" ); 251 | 252 | # Export our session name as an environment variable 253 | my $session_env = join( '_', $config->{env_prefix}, "NAME" ); 254 | $ENV{$session_env} = $config->{session}; 255 | 256 | while (1) { 257 | 258 | %mapping = (); 259 | my @cmd; 260 | 261 | # pre-emptively kill any child processes we've previously tracked 262 | while (@children) { 263 | my $child = pop @children; 264 | Log( LOG_DEBUG, "Killing child process $child" ); 265 | kill 'INT', $child; 266 | waitpid $child, 0; 267 | } 268 | 269 | my $chost = $config->{chost}; 270 | my $sess = $config->{session}; 271 | 272 | # Remote host mode! 273 | # Check if we have a ControlMaster session, create it if not 274 | if ( defined($host) ) { 275 | 276 | if ( $config->{hosts}{$chost}{$sess}{ssh_prereqs} ) { 277 | foreach my $prereq ( @{ $config->{hosts}{$chost}{$sess}{ssh_prereqs} } ) { 278 | unless ( CheckSSHPrereqs( $host, [$prereq] ) ) { 279 | Log( LOG_DEBUG, "SSH prereq '$prereq' failed" ); 280 | exit; 281 | } 282 | } 283 | Log( LOG_DEBUG, "SSH prereqs executed successfully" ); 284 | } 285 | 286 | # Check if we have a ControlMaster session, create it if not 287 | unless ( CheckControlMaster($host) ) { 288 | 289 | # Master isn't running, start up a new ControlMaster 290 | my $failures = 0; 291 | my $backoff = 1; 292 | while (1) { 293 | @cmd = buildCommand( 294 | undef, 295 | [ 296 | qq(ssh), 297 | qq(-o ConnectTimeout=$config->{conn_timeout}), 298 | qq(-o ServerAliveInterval=$config->{alive_interval}), 299 | qq(-o ServerAliveCountMax=$config->{alive_count}), 300 | qq(-o ControlPersist=$config->{persist}), 301 | qq(-o ControlPath=$config->{temp}ssh/$config->{ssh_control}), 302 | qq(-o ControlMaster=yes), 303 | qq(@{ $config->{ssh_args} }), 304 | qq(-f -N), 305 | $host 306 | ] 307 | ); 308 | Log( LOG_DEBUG, "ControlMaster for $host not running, creating one" ); 309 | Log( LOG_DEBUG, join( ' ', @cmd ) ); 310 | qx(@cmd); 311 | 312 | if ( ( $? >> 8 ) != 0 ) { 313 | unless ( $config->{reconnect} ) { 314 | Log( LOG_INFO, "Unable to connect to $host, reconnect is not enabled" ); 315 | exit; 316 | } 317 | 318 | $failures += 1; 319 | Log( LOG_INFO, "Retrying connection to $host in $backoff seconds ($failures failures)" ); 320 | sleep $backoff; 321 | 322 | # Exponential backoff up to 64 seconds 323 | unless ( $failures > 6 ) { 324 | $backoff = ( 1 * 2**$failures ); 325 | } 326 | 327 | } else { 328 | 329 | # ControlMaster was successfully established 330 | Log( LOG_DEBUG, "ControlMaster session to $host established" ); 331 | last; 332 | } 333 | } 334 | } else { 335 | 336 | # ControlMaster is already established 337 | Log( LOG_DEBUG, "Reusing ControlMaster session to $host" ); 338 | } 339 | } 340 | 341 | my $initialize = $config->{hosts}{$chost}{$sess}{initialize} 342 | || [ qq(env $session_env=$config->{session}), $config->{tmux_bin}, qw(new-session -d -s), $sess ]; 343 | $initialize = join( ' ', @{$initialize} ); 344 | 345 | my $control = join( '/', $config->{chost}, $config->{control} ); 346 | $control =~ s/\//-/g; 347 | 348 | my $script = join( '', ( $config->{temp}, $control ) ); 349 | 350 | my $setup_script = qq| 351 | exec 2>/dev/null 352 | 353 | if ! $config->{tmux_bin} has-session -t $config->{session}; then 354 | $initialize 355 | fi 356 | 357 | if ! $config->{tmux_bin} has-session -t $config->{control}; then 358 | $config->{tmux_bin} new-session -d -t $config->{session} -s $config->{control} 359 | fi 360 | 361 | if VERSION="\$( $config->{tmux_bin} -V )"; then 362 | echo "\${VERSION}" 363 | else 364 | echo "tmux unknown" 365 | fi 366 | |; 367 | 368 | open SCRIPT, '>', $script; 369 | print SCRIPT $setup_script; 370 | close SCRIPT; 371 | 372 | chmod 0700, $script; 373 | 374 | Log( LOG_DEBUG, "Created setup script: $script" ); 375 | 376 | if ($host) { 377 | @cmd = buildCommand( $host, [qw(sh)] ); 378 | push( @cmd, "< $script" ); 379 | } else { 380 | @cmd = buildCommand( undef, [$script] ); 381 | } 382 | 383 | my $setup_response = qx(@cmd); 384 | unlink $script; 385 | 386 | ( undef, $config->{version} ) = split( ' ', $setup_response ); 387 | Log( LOG_DEBUG, "tmux version $config->{version} detected" ); 388 | 389 | # Attach to control session in control mode 390 | @cmd = buildCommand( $host, [ $config->{tmux_bin}, qw(-C attach -t), $config->{control}, ] ); 391 | Log( LOG_DEBUG, join( ' ', @cmd ) ); 392 | 393 | my ( $pid, $child_out, $child_in ); 394 | my $blockCapture = 0; 395 | my ( @block, $function, $result ); 396 | 397 | $pid = open2( $child_out, $child_in, @cmd ); 398 | Log( LOG_DEBUG, "Control session running as PID $pid" ); 399 | push( @children, $pid ); 400 | 401 | # Create our command channel 402 | unless ( -p $config->{command} ) { 403 | unlink $config->{command}; 404 | mkfifo( $config->{command}, 0700 ); 405 | } 406 | 407 | # Open it up non-blocking, read-only 408 | sysopen( CONTROL_FIFO, $config->{command}, O_NONBLOCK | O_RDONLY ); 409 | 410 | # Install our USR1 handler to process anything in the FIFO 411 | $SIG{USR1} = sub { 412 | CONTROL_FIFO->clearerr; 413 | my @commands = ; 414 | Log( LOG_DEBUG, "Processing commands in FIFO handler" ); 415 | Log( LOG_DEBUG, \@commands ); 416 | foreach my $line (@commands) { 417 | if ($pause) { 418 | next unless ( $line =~ m/tmuxc PauseSelf/ ); 419 | } 420 | 421 | # Lines prefixed with tmuxc hit our internal command dispatcher 422 | if ( $line =~ m/^tmuxc\s(.*)/ ) { 423 | 424 | # The command causes tmux to print a block output, which we later parse 425 | if ( exists( $commandText{$1} ) ) { 426 | print $child_in $commandText{$1}; 427 | } elsif ( exists( $publicCommands{$1} ) ) { 428 | 429 | # Execute a command directly with no required input 430 | $publicCommands{$1}->($child_in); 431 | } else { 432 | Log( LOG_INFO, "FIFO Handler: Unknown command $1" ); 433 | } 434 | } else { 435 | 436 | # everything else is assumed to be a raw tmux command 437 | print $child_in "$line\n"; 438 | } 439 | } 440 | }; 441 | 442 | # Single Window Mode short-circuits the entire control mode processing loop 443 | 444 | if ( $config->{wm} eq "single" ) { 445 | spawnSingleTerm( $host, $config->{control}, $child_in ); 446 | } else { 447 | 448 | # Commands sent to $child_in are not processed until the main loop. 449 | # The output of these commands will be read from $child_out and processed. 450 | # 451 | # Seed our initial list of windows 452 | print $child_in $commandText{'ListWindows'}; 453 | 454 | # Detach any clients connected to windows 455 | if ( $config->{detach} ) { 456 | print $child_in $commandText{'BulkDetach'}; 457 | } 458 | 459 | # Attach clients, force-regenerate a client map 460 | print $child_in $commandText{'BulkSpawn'}; 461 | print $child_in $commandText{'ClientMap'}; 462 | 463 | # Kick off a RefreshClient command to disable output if possible 464 | print $child_in $commandText{'RefreshClient'}; 465 | 466 | # Main event loop - this processes lines sent over the control session 467 | while ( my $line = <$child_out> ) { 468 | next if $pause; 469 | next if $line =~ m/^\%output/; 470 | $line =~ s/\r\n/\n/g; 471 | Log( LOG_DEBUG, "$line" ); 472 | 473 | # Trigger block capture mode 474 | if ( $line =~ m/^\%begin/ ) { 475 | $blockCapture = 1; 476 | next; 477 | } 478 | 479 | # Blocks can end with either end or error 480 | if ( $line =~ m/^\%end/ ) { 481 | $blockCapture = 0; 482 | next unless defined($function); 483 | Log( LOG_DEBUG, "Captured $function block" ); 484 | 485 | my $cmd = $function; 486 | if ( exists( $commandParse{$cmd} ) ) { 487 | $commandParse{$cmd}->( \@block, $child_in ); 488 | } else { 489 | Log( LOG_DEBUG, "No handler for $cmd" ); 490 | } 491 | @block = (); 492 | $function = undef; 493 | next; 494 | } 495 | 496 | # We messed up somewhere, reset the stack and carry on 497 | if ( $line =~ m/^\%error/ ) { 498 | Log( LOG_DEBUG, \@block ); 499 | $blockCapture = 0; 500 | $function = undef; 501 | @block = (); 502 | } 503 | 504 | # Push the line to the current block 505 | if ($blockCapture) { 506 | ( $function, $result ) = split( /:/, $line ); 507 | chomp($result); 508 | push @block, $result; 509 | next; 510 | } 511 | 512 | # This event produces a TON of spam 513 | # Linked sessions result in every session producing this, with out saying where it comes from 514 | # Multiple conditions are used to keep from spawning an infinite sequence of windows 515 | # %window-add @101 516 | if ( $line =~ m/^\%window-add\s(\@\d+)/ ) { 517 | if ( exists $mapping{windows}{$1}{'pending'} ) { 518 | 519 | # This is removed only by spawnTerm 520 | Log( LOG_DEBUG, "Skipping window-add: Session pending for $1" ); 521 | next; 522 | } elsif ( defined $mapping{clients}{$1} ) { 523 | Log( LOG_DEBUG, "Skipping window-add: One or more clients attached to $1" ); 524 | next; 525 | } elsif ( $mapping{windows}{$1}{'blacklisted'} ) { 526 | 527 | # When a window is added to a running session, we do not know the name of the window 528 | # blacklisting only works when initially connecting to a session 529 | Log( LOG_DEBUG, "Skipping window-add: $1 is blacklisted" ); 530 | next; 531 | } elsif ( $mapping{windows}{$1}{ignore} ) { 532 | Log( LOG_DEBUG, "Skipping window-add: Ignore flag set for $1" ); 533 | next; 534 | } else { 535 | $mapping{windows}{$1}{'pending'} = 1; 536 | print $child_in $commandText{'ListWindows'}; 537 | Log( LOG_DEBUG, "window-add: Spawning a terminal for $1" ); 538 | spawnTerm( $host, $sess, $1, $child_in ); 539 | next; 540 | } 541 | } 542 | 543 | # Update our internal knowledge of the window name 544 | # %window-renamed @29 fish 545 | if ( $line =~ m/^\%window-renamed\s(@\d+)\s(.*)/ ) { 546 | Log( LOG_DEBUG, "Running $commandText{'ListWindows'}" ); 547 | print $child_in $commandText{'ListWindows'}; 548 | next; 549 | } 550 | 551 | # Update our client <> window map - two clients likely point to a single window 552 | # %session-window-changed $92 @115 553 | if ( $line =~ m/^\%session-window-changed\s(\$\d+)\s(\@\d+)/ ) { 554 | Log( LOG_DEBUG, "Running $commandText{'ClientMap'}" ); 555 | print $child_in $commandText{'ClientMap'}; 556 | next; 557 | } 558 | 559 | # Produced when a client attaches to a session, 560 | # %client-session-changed /dev/pts/10 $163 penthe-global-session-1545894242-319412-45 561 | if ( $line =~ m/^\%client-session-changed\s(.*)\s(\$\d+)\s([\w-]+)/ ) { 562 | Log( LOG_DEBUG, "Running $commandText{'ClientMap'}" ); 563 | print $child_in $commandText{'ClientMap'}; 564 | next; 565 | } 566 | 567 | # Produced when a client attaches or detaches (xterm is killed) 568 | # %sessions-changed 569 | if ( $line =~ m/^\%sessions-changed/ ) { 570 | Log( LOG_DEBUG, "Running $commandText{'ClientMap'}" ); 571 | print $child_in $commandText{'ClientMap'}; 572 | next; 573 | } 574 | 575 | # This event is fired once for each window in a session when a terminal (xterm, etc) is killed 576 | # Since it doesn't contain any other actionable values, it's unusable 577 | # %window-close @101 578 | if ( $line =~ m/^\%window-close\s(@\d+)/ ) { 579 | next; 580 | } 581 | 582 | # Produced when the process in a tmux window ends 583 | # Kill all attached sessions viewing that window 584 | # %unlinked-window-close @95 585 | if ( $line =~ m/^\%unlinked-window-close\s(@\d+)/ ) { 586 | 587 | # skip this if it doesn't exist in our mapping 588 | # it came from another session group 589 | next unless exists $mapping{clients}{$1}; 590 | while ( @{ $mapping{clients}{$1} } ) { 591 | my $client = pop @{ $mapping{clients}{$1} }; 592 | Log( LOG_DEBUG, "Killing session $client, window $1 closed" ); 593 | print $child_in $commandText{'KillClient'} . "$client\n"; 594 | } 595 | delete $mapping{clients}{$1}; 596 | delete $mapping{windows}{$1}; 597 | print $child_in $commandText{'ListWindows'}; 598 | print $child_in $commandText{'ClientMap'}; 599 | 600 | } 601 | } 602 | } 603 | 604 | if ( $config->{exitlast} ) { 605 | Log( LOG_DEBUG, "Control FIFO closed, checking if ControlMaster is still open" ); 606 | if ( CheckControlMaster($host) ) { 607 | 608 | # If the ControlMaster session is still open, the tmux session has been closed - exit 609 | Log( LOG_DEBUG, "Exiting after last window in tmux session closed" ); 610 | exit; 611 | } 612 | } else { 613 | exit unless ( $config->{reconnect} ); 614 | Log( LOG_DEBUG, "ControlMaster has closed, attempting to reconnect" ); 615 | } 616 | } 617 | 618 | END { 619 | exit if $noclean; 620 | 621 | if ( 622 | ( exists $config->{poe} and $config->{poe} eq "any" ) 623 | or ( ( exists $config->{ephemeral} and $config->{ephemeral} ) 624 | and ( exists $config->{poe} and $config->{poe} eq "ephemeral" ) ) 625 | ) 626 | { 627 | my @cmd = buildCommand( $host, [ $config->{tmux_bin}, qw(kill-session -t), $config->{session}, ] ); 628 | Log( LOG_DEBUG, "Killing session $config->{session}" ); 629 | qx(@cmd); 630 | 631 | @cmd = buildCommand( $host, [ $config->{tmux_bin}, qw(kill-session -t), $config->{control}, ] ); 632 | Log( LOG_DEBUG, "Killing control session $config->{control}" ); 633 | qx(@cmd); 634 | } 635 | 636 | kill 'INT', @children if scalar(@children); 637 | CleanExit; 638 | } 639 | 640 | sub CleanExit { 641 | exit if $noclean; 642 | 643 | if ( exists $config->{command} ) { 644 | unlink $config->{command}; 645 | } 646 | 647 | if ( exists $config->{pidfile} ) { 648 | unlink $config->{pidfile}; 649 | } 650 | 651 | exit; 652 | } 653 | 654 | sub CheckSSHPrereqs { 655 | my $host = shift; 656 | 657 | my $ssh_prereqs = shift; 658 | 659 | my @cmd = buildCommand( undef, $ssh_prereqs ); 660 | Log( LOG_DEBUG, "Executing connectivity test: " . join( ' ', @cmd ) ); 661 | qx(@cmd); 662 | 663 | return ( $? >> 8 ) ? 0 : 1; 664 | } 665 | 666 | sub CheckControlMaster { 667 | my $host = shift; 668 | 669 | my @cmd = buildCommand( 670 | undef, 671 | [ 672 | qq(ssh -q -O check), 673 | qq(-o ConnectTimeout=$config->{conn_timeout}), 674 | qq(-o ControlPath=$config->{temp}ssh/$config->{ssh_control} 2>/dev/null), 675 | $host 676 | ] 677 | ); 678 | Log( LOG_DEBUG, "Checking ControlMaster for $host" ); 679 | Log( LOG_DEBUG, join( ' ', @cmd ) ); 680 | qx(@cmd); 681 | 682 | return ( $? >> 8 ) ? 0 : 1; 683 | } 684 | 685 | # Load our default config file / user defined 686 | # Define other control values used throughout the multiverse 687 | sub LoadConfig { 688 | undef $config; 689 | 690 | %defaultOptions = ( 691 | "detach" => 0, 692 | "reconnect" => 1, 693 | "closeas" => 0, 694 | "background" => 0, 695 | "log_facility" => "", 696 | "log_level" => LOG_NOTICE, 697 | "exitlast" => 0, 698 | "terminal" => [qw(xterm)], 699 | "ssh_args" => [qw()], 700 | "ssh_control" => "%C", 701 | "alive_interval" => 3, 702 | "alive_count" => 2, 703 | "conn_timeout" => 3, 704 | "persist" => "10s", 705 | "selector" => [qw(rofi -dmenu -i)], 706 | "input_prompt" => [qw(rofi -dmenu -p)], 707 | "tmux_bin" => "tmux", 708 | "env_prefix" => "TMUX_SESSION", 709 | "temp" => join( '/', ( $ENV{'HOME'}, qw(.tmuxc) ) ), 710 | "skipnw" => 0, 711 | "prettyps" => 1, 712 | "wm" => "multi", 713 | "on_connect" => [qw()], 714 | "poe" => "none", 715 | ); 716 | 717 | # Read in the config file 718 | if ( -e $configfile ) { 719 | $config = do($configfile) or die $!; 720 | } 721 | 722 | # strip a trailing slash if present, thanks Brian D Foy 723 | #$config->{temp} =~ s|/\z||; 724 | 725 | # Add a trailing slash, so joins are easier 726 | $config->{temp} =~ s|/*$|/|; 727 | 728 | # Set our hostname config key 729 | my $chost = defined( $cli{host} ) ? $cli{host} : "localhost"; 730 | $config->{chost} = $chost; 731 | 732 | # Set our $host 733 | $host = 734 | ( defined( $cli{host} ) and ( $cli{host} ne "localhost" ) ) 735 | ? $cli{host} 736 | : undef; 737 | 738 | # Determine which session name takes precedence 739 | if ( defined( $cli{session} ) ) { 740 | 741 | # Set on the command line 742 | $config->{session} = $cli{session}; 743 | } elsif ( defined( $config->{hosts}{$chost} ) ) { 744 | 745 | # Pick the first session under this host, sorted, that is not a config key 746 | foreach my $key ( sort keys %{ $config->{hosts}{$chost} } ) { 747 | next if defined( $defaultOptions{$key} ); 748 | $config->{session} = $key; 749 | } 750 | } elsif ( not defined( $config->{session} ) ) { 751 | 752 | # Not set at all in the config file, set a default 753 | $config->{session} = "global-session"; 754 | } 755 | 756 | # '.' is forbidden in tmux session names 757 | $config->{session} =~ tr/\./-/; 758 | 759 | my $sess = $config->{session}; 760 | 761 | # Record if this is an ephemeral session 762 | $config->{ephemeral} = ( defined( $config->{hosts}{$chost}{$sess} ) ) ? 0 : 1; 763 | 764 | # log level is a little tricky 765 | if ( defined( $cli{verbosity} ) ) { 766 | 767 | # I've got two -v's and a log debug 768 | if ( $cli{verbosity} > 2 ) { 769 | $cli{log_level} = LOG_DEBUG; 770 | } else { 771 | $cli{log_level} = $cli{verbosity} + LOG_NOTICE; 772 | } 773 | } 774 | 775 | # Look up a number of keys that can be set at different levels 776 | # If no value can be found, use a sane default 777 | foreach my $key ( keys %defaultOptions ) { 778 | if ( defined( $cli{$key} ) ) { 779 | 780 | # set on the CLI 781 | $config->{$key} = $cli{$key}; 782 | } elsif ( defined( $config->{hosts}{$chost}{$sess}{$key} ) ) { 783 | 784 | # Set for a specific host/session combo 785 | $config->{$key} = $config->{hosts}{$chost}{$sess}{$key}; 786 | } elsif ( defined( $config->{hosts}{$chost}{$key} ) ) { 787 | 788 | # Set for a specific host 789 | $config->{$key} = $config->{hosts}{$chost}{$key}; 790 | } elsif ( defined( $config->{$key} ) ) { 791 | 792 | # Set globally in the config 793 | next; 794 | } else { 795 | 796 | # Not defined anywhere, set our fall-back default 797 | $config->{$key} = $defaultOptions{$key}; 798 | } 799 | } 800 | 801 | # Make our temp directory and subdirectories 802 | mkdir $config->{temp} unless -d $config->{temp}; 803 | my @subs = ( "ssh", "control", "pid" ); 804 | foreach my $dir (@subs) { 805 | my $fulldir = join( '', $config->{temp}, $dir ); 806 | mkdir $fulldir unless -d $fulldir; 807 | } 808 | 809 | # Set our hostname 810 | ( $config->{hostname}, undef ) = split( /\./, hostname, 2 ); 811 | 812 | # Define our control session 813 | if ( $config->{wm} eq "single" ) { 814 | $config->{control} = join( '/', ( qw(tmuxc), $config->{hostname}, $config->{session} ) ); 815 | } else { 816 | $config->{control} = join( '/', ( qw(tmuxc), $config->{session}, qw(control) ) ); 817 | } 818 | 819 | # Define our command fifo 820 | $config->{command} = 821 | join( '', ( $config->{temp}, qw(control/), $config->{session}, qw(@), $config->{chost}, qw(.control) ) ); 822 | 823 | # Define our PID file 824 | $config->{pidfile} = join( '', ( $config->{temp}, qw(pid/), $config->{session}, qw(@), $config->{chost}, qw(.pid) ) ); 825 | 826 | %publicCommands = ( 827 | 'KillSelf' => \&KillSelf, 828 | 'PauseSelf' => \&PauseSelf, 829 | 'LoadConfig' => \&LoadConfig, 830 | 'ToggleEphemeral' => \&ToggleEphemeral, 831 | ); 832 | 833 | # The first field in the format string indicates the lookup key for %commandParse 834 | %commandText = ( 835 | 'ListWindows' => "lsw -F 'ListWindows:#{session_group},#{window_id},#{window_name},#{window_index}' -t " 836 | . $config->{session} . "\n", 837 | 'BulkSpawn' => "lsc -F 'BulkSpawn:#{session_group},#{session_id},#{session_name},#{window_id}'\n", 838 | 'BulkDetach' => "lsc -F 'BulkDetach:#{session_group},#{session_id},#{session_name},#{window_id}'\n", 839 | 'ClientMap' => "lsc -F 'ClientMap:#{session_group},#{session_id},#{session_name},#{window_id},#{window_name}'\n", 840 | 'KillClient' => "kill-session -t ", 841 | 'RefreshClient' => "lsc -F 'RefreshClient:#{client_name},#{session_group},#{session_name}'\n", 842 | ); 843 | 844 | %commandParse = ( 845 | 'ListWindows' => \&ListWindows, 846 | 'BulkSpawn' => \&BulkSpawn, 847 | 'BulkDetach' => \&BulkDetach, 848 | 'ClientMap' => \&ClientMap, 849 | 'RefreshClient' => \&RefreshClient, 850 | ); 851 | 852 | %menu = ( 853 | 'Create Ephemeral Session' => \&EphemeralSession, 854 | 'Create New Window' => 'new-window -d', 855 | 'Detach Session Windows' => 'tmuxc BulkDetach', 856 | 'Kill Control Daemon' => 'tmuxc KillSelf', 857 | 'Open All Windows' => 'tmuxc BulkSpawn', 858 | 'Pause Session' => 'tmuxc PauseSelf', 859 | 'Reload Configuration' => 'tmuxc LoadConfig', 860 | 'Toggle Ephemeral' => 'tmuxc ToggleEphemeral', 861 | ); 862 | 863 | if ( defined( $cli{printOptions} ) ) { 864 | foreach my $key ( sort keys %{$config} ) { 865 | next if ( ref $config->{$key} eq REFHASH ); 866 | my $ref = ref $config->{$key}; 867 | if ( ref $config->{$key} eq REFARRAY ) { 868 | my $combined = join( ' ', @{ $config->{$key} } ); 869 | print "$key: $combined\n"; 870 | } else { 871 | print "$key: $config->{$key}\n"; 872 | } 873 | } 874 | exit; 875 | } 876 | } 877 | 878 | # Simple command dispatcher, InjectCommand picks/prompts for the session 879 | sub Menu { 880 | my $mode = shift; 881 | 882 | my ( $child_out, $child_in ); 883 | my $pid = open2( $child_out, $child_in, @{ $config->{selector} } ); 884 | 885 | if ( $mode eq "paged" ) { 886 | foreach my $item ( sort keys %menu ) { 887 | print $child_in "$item\n"; 888 | } 889 | } elsif ( $mode eq "single" ) { 890 | my $instances = InstanceCheck(); 891 | 892 | my @sessions; 893 | if ( defined( $instances->{running} ) ) { 894 | @sessions = @{ $instances->{running} }; 895 | } else { 896 | exit; 897 | } 898 | 899 | # Put Create Ephemeral Session at the top 900 | foreach my $key ( sort keys %menu ) { 901 | next unless $key =~ m/Create Ephemeral Session/; 902 | print $child_in "$key\n"; 903 | } 904 | for my $hostsession ( sort @sessions ) { 905 | foreach my $key ( sort keys %menu ) { 906 | 907 | # Add everything but Create Ephemeral Session 908 | next if $key =~ m/Create Ephemeral Session/; 909 | print $child_in "$hostsession ~ $key\n"; 910 | } 911 | } 912 | } 913 | 914 | close($child_in); 915 | my $selection = <$child_out>; 916 | exit unless defined($selection); 917 | chomp($selection); 918 | 919 | my ( $hostsession, $action ); 920 | 921 | # Single page action menu 922 | if ( $selection =~ m/\~/ ) { 923 | ( $hostsession, $action ) = split( /\ ~\ /, $selection ); 924 | InjectCommand( $menu{$action}, $hostsession ); 925 | } elsif ( ref $menu{$selection} eq REFSUB ) { 926 | 927 | # reference to a sub, just execute it 928 | $menu{$selection}->(); 929 | } else { 930 | 931 | # Menu option to do an action on an unspecified session 932 | $hostsession = SelectSession( $menu{$selection} ); 933 | InjectCommand( $menu{$selection}, $hostsession ); 934 | } 935 | } 936 | 937 | sub EphemeralSession { 938 | my ( $child_out, $child_in, @prompt ); 939 | @prompt = @{ $config->{input_prompt} }; 940 | push( @prompt, "Session" ); 941 | my $pid = open2( $child_out, $child_in, @prompt ); 942 | 943 | print $child_in ""; 944 | close($child_in); 945 | my $option = <$child_out>; 946 | exit unless defined($option); 947 | chomp($option); 948 | 949 | my ( $session, $host ) = split( /@/, $option, 2 ); 950 | unless ( defined $host and length $host ) { 951 | $host = 'localhost'; 952 | } 953 | 954 | LaunchNewInstance( join( '@', $session, $host ), "-E" ); 955 | exit; 956 | } 957 | 958 | sub RemoteSessions { 959 | my $host = shift; 960 | 961 | my ( $child_out, $child_in, @prompt ); 962 | 963 | # Prompt for a host if none were provided 964 | unless ( length($host) ) { 965 | my $pid = open2( $child_out, $child_in, @{ $config->{selector} } ); 966 | print $child_in "Enter unlisted host name\n"; 967 | foreach my $host ( sort keys %{ $config->{hosts} } ) { 968 | print $child_in "$host\n"; 969 | } 970 | 971 | close($child_in); 972 | $host = <$child_out>; 973 | exit unless defined($host); 974 | chomp($host); 975 | 976 | if ( $host eq "Enter unlisted host name" ) { 977 | @prompt = @{ $config->{input_prompt} }; 978 | push( @prompt, "Host" ); 979 | $pid = open2( $child_out, $child_in, @prompt ); 980 | 981 | print $child_in ""; 982 | close($child_in); 983 | $host = <$child_out>; 984 | exit unless defined($host); 985 | chomp($host); 986 | } 987 | } 988 | 989 | my @cmd = buildCommand( $host, [ $config->{tmux_bin}, qw(list-sessions) ] ); 990 | my @rsessions = qx(@cmd); 991 | Log( LOG_DEBUG, \@rsessions ); 992 | 993 | my $instances = InstanceCheck(); 994 | 995 | my @ksessions; 996 | if ( defined( $instances->{running} ) ) { 997 | @ksessions = @{ $instances->{running} }; 998 | Log( LOG_DEBUG, \@ksessions ); 999 | } 1000 | 1001 | my $pid = open2( $child_out, $child_in, @{ $config->{selector} } ); 1002 | 1003 | for my $hostsession ( sort @rsessions ) { 1004 | chomp($hostsession); 1005 | my ( $rsession, undef ) = split( /:/, $hostsession ); 1006 | next if ( $rsession =~ m/^tmuxc\// ); 1007 | $rsession = "$rsession\@$host"; 1008 | next if ( grep { $_ eq $rsession } @ksessions ); 1009 | Log( LOG_DEBUG, "Prompting for $rsession" ); 1010 | print $child_in "$rsession\n"; 1011 | } 1012 | 1013 | close($child_in); 1014 | my $response = <$child_out>; 1015 | exit unless defined($response); 1016 | chomp($response); 1017 | 1018 | LaunchNewInstance( $response, "--poe none" ); 1019 | exit; 1020 | } 1021 | 1022 | sub InstanceCheck { 1023 | 1024 | # Generate a list of instances from config file 1025 | my ( @check, $instances ); 1026 | foreach my $host ( sort keys %{ $config->{hosts} } ) { 1027 | foreach my $session ( sort keys %{ $config->{hosts}{$host} } ) { 1028 | 1029 | # Skip if this is an option name / reserved word 1030 | next if defined( $defaultOptions{$session} ); 1031 | push @check, "$session\@$host"; 1032 | } 1033 | } 1034 | 1035 | # Check each instance defined in config file 1036 | for my $session (@check) { 1037 | my $control = 1038 | join( '', ( $config->{temp}, qw(control/), $session, ".control" ) ); 1039 | my $pidfile = 1040 | join( '', ( $config->{temp}, qw(pid/), $session, ".pid" ) ); 1041 | if ( -f $pidfile ) { 1042 | open PID, '<', $pidfile; 1043 | my $pid = ; 1044 | close PID; 1045 | 1046 | my $running = kill( 0, $pid ); 1047 | if ($running) { 1048 | Log( LOG_DEBUG, "Running session: $session" ); 1049 | push @{ $instances->{running} }, $session; 1050 | $instances->{details}{$session}{pid} = $pid; 1051 | $instances->{details}{$session}{control} = $control; 1052 | $instances->{details}{$session}{ephemeral} = 0; 1053 | } else { 1054 | push @{ $instances->{dead} }, $session; 1055 | } 1056 | } else { 1057 | push @{ $instances->{dead} }, $session; 1058 | } 1059 | } 1060 | 1061 | my $cglob = join( '', ( $config->{temp}, qw(control/), "*.control" ) ); 1062 | my @controls = glob($cglob); 1063 | 1064 | for my $control (@controls) { 1065 | my $session = basename($control); 1066 | $session =~ s/\.control//; 1067 | 1068 | my $pidfile = $control; 1069 | $pidfile =~ s/control/pid/g; 1070 | 1071 | if ( -f $pidfile ) { 1072 | open PID, '<', $pidfile; 1073 | my $pid = ; 1074 | close PID; 1075 | 1076 | my $running = kill( 0, $pid ); 1077 | if ($running) { 1078 | unless ( grep { $_ eq $session } @{ $instances->{running} } ) { 1079 | push @{ $instances->{running} }, $session; 1080 | $instances->{details}{$session}{pid} = $pid; 1081 | $instances->{details}{$session}{control} = $control; 1082 | $instances->{details}{$session}{ephemeral} = 1; 1083 | } 1084 | } else { 1085 | 1086 | # If the pid isn't running, this is a one-off session that somehow lingered on 1087 | # Remove the pidfile and the control socket 1088 | unlink $pidfile; 1089 | unlink $control; 1090 | } 1091 | } else { 1092 | 1093 | # No pidfile associated with this control socket, purge it 1094 | unlink $control; 1095 | } 1096 | } 1097 | return $instances; 1098 | } 1099 | 1100 | sub KnownSessions { 1101 | my $flag = shift; 1102 | 1103 | my $instances = InstanceCheck; 1104 | 1105 | if ( length($flag) ) { 1106 | print "# state,session name,pid,control socket\n"; 1107 | } 1108 | 1109 | if ( defined( $instances->{running} ) ) { 1110 | unless ( length($flag) ) { 1111 | print "Active: \n"; 1112 | } 1113 | for ( sort @{ $instances->{running} } ) { 1114 | unless ( length($flag) ) { 1115 | print " $_"; 1116 | if ( $instances->{details}{$_}{ephemeral} ) { 1117 | print " (ephemeral)\n"; 1118 | } else { 1119 | print "\n"; 1120 | } 1121 | print " Control: " . $instances->{details}{$_}{control} . "\n"; 1122 | print " PID: " . $instances->{details}{$_}{pid} . "\n"; 1123 | } else { 1124 | print join( $flag, qw(active), $_, $instances->{details}{$_}{pid}, $instances->{details}{$_}{control} ); 1125 | print "\n"; 1126 | } 1127 | } 1128 | } 1129 | if ( defined( $instances->{dead} ) ) { 1130 | unless ( length($flag) ) { 1131 | print "Inactive: \n"; 1132 | } 1133 | for ( sort @{ $instances->{dead} } ) { 1134 | unless ( length($flag) ) { 1135 | print " $_\n"; 1136 | } else { 1137 | print join( $flag, qw(inactive), $_, '', '' ); 1138 | print "\n"; 1139 | } 1140 | } 1141 | } 1142 | exit; 1143 | } 1144 | 1145 | # Optionally requires a command, as this can influence the behavior 1146 | sub SelectSession { 1147 | my $command = shift; 1148 | 1149 | my $instances = InstanceCheck; 1150 | 1151 | my @sessions; 1152 | if ( defined( $instances->{running} ) ) { 1153 | @sessions = @{ $instances->{running} }; 1154 | } else { 1155 | exit; 1156 | } 1157 | my @sessmenu; 1158 | 1159 | # An exact host and session were provided 1160 | if ( $cli{host} and $cli{session} ) { 1161 | my $session = join( '', $cli{session}, qw(@), $cli{host} ); 1162 | if ( grep { $_ eq $session } @sessions ) { 1163 | push @sessmenu, $session; 1164 | } 1165 | 1166 | # Find all hosts that match what was provided on the CLI 1167 | } elsif ( $cli{host} ) { 1168 | for (@sessions) { 1169 | if ( $_ =~ m/$cli{host}/ ) { 1170 | push @sessmenu, $_; 1171 | } 1172 | } 1173 | 1174 | # Find all sessions that match what was provided on the CLi 1175 | } elsif ( $cli{session} ) { 1176 | for (@sessions) { 1177 | if ( $_ =~ m/$cli{session}/ ) { 1178 | push @sessmenu, $_; 1179 | } 1180 | } 1181 | 1182 | # Nothing provided, prompt with all running hosts/sessions 1183 | } else { 1184 | @sessmenu = @sessions; 1185 | } 1186 | 1187 | # I really should learn splice some day 1188 | if ( ( $command =~ m/new-window/ ) 1189 | and ( not defined( $cli{host} ) ) 1190 | and ( not defined( $cli{session} ) ) ) 1191 | { 1192 | my @clean; 1193 | for (@sessmenu) { 1194 | my ( $sess, $host ) = split( '@', $_, 2 ); 1195 | unless ( $config->{hosts}{$host}{$sess}{skipnw} ) { 1196 | push @clean, $_; 1197 | } 1198 | } 1199 | @sessmenu = @clean; 1200 | } 1201 | 1202 | # Generate a menu / or pick if there's only one session 1203 | my ( $response, $session ); 1204 | if ( scalar(@sessmenu) > 1 ) { 1205 | my ( $child_out, $child_in ); 1206 | my $pid = open2( $child_out, $child_in, @{ $config->{selector} } ); 1207 | 1208 | for (@sessmenu) { 1209 | my ( $sess, $host ) = split( '@', $_, 2 ); 1210 | print $child_in "$sess\@$host\n"; 1211 | } 1212 | close($child_in); 1213 | 1214 | $session = <$child_out>; 1215 | exit unless defined($session); 1216 | chomp($session); 1217 | } elsif ( scalar(@sessmenu) == 1 ) { 1218 | $session = shift @sessmenu; 1219 | } else { 1220 | Log( LOG_NOTICE, "No running sessions" ); 1221 | exit; 1222 | } 1223 | Log( LOG_DEBUG, "Selected: $session" ); 1224 | return $session; 1225 | } 1226 | 1227 | # Determine which control sessions are running, prompt for anything else 1228 | sub Launcher { 1229 | my ( @dead, $child_out, $child_in ); 1230 | 1231 | my $instances = InstanceCheck; 1232 | 1233 | if ( defined( $instances->{dead} ) ) { 1234 | @dead = @{ $instances->{dead} }; 1235 | } 1236 | 1237 | my $response; 1238 | if ( scalar(@dead) > 0 ) { 1239 | my $pid = open2( $child_out, $child_in, @{ $config->{selector} } ); 1240 | for (@dead) { 1241 | my ( $sess, $host ) = split( '@', $_, 2 ); 1242 | print $child_in "$sess\@$host\n"; 1243 | print $child_in "$sess\@$host (Single Window Mode)\n"; 1244 | print $child_in "$sess\@$host (Multi Window Mode)\n"; 1245 | } 1246 | close($child_in); 1247 | $response = <$child_out>; 1248 | close($child_out); 1249 | } else { 1250 | exit; 1251 | } 1252 | exit unless defined($response); 1253 | chomp($response); 1254 | 1255 | my $flags; 1256 | if ( $response =~ m/Single Window Mode/ ) { 1257 | ( $response, undef ) = split( ' ', $response ); 1258 | $flags = "--wm single"; 1259 | } 1260 | if ( $response =~ m/Multi Window Mode/ ) { 1261 | ( $response, undef ) = split( ' ', $response ); 1262 | $flags = "--wm multi"; 1263 | } 1264 | 1265 | LaunchNewInstance( $response, $flags ); 1266 | } 1267 | 1268 | # Do the double fork dance and launch a new tmuxc instance 1269 | sub LaunchNewInstance { 1270 | my ( $session, $flags ) = @_; 1271 | 1272 | my ( $sess, $host ) = split( /@/, $session, 2 ); 1273 | my $myself = join( '/', ( $RealBin, $RealScript ) ); 1274 | 1275 | $flags = "" unless ( defined $flags and length $flags ); 1276 | 1277 | my $cmd; 1278 | if ( $host eq "localhost" ) { 1279 | $cmd = join( ' ', ( $myself, qw(-s), $sess, qw(-b), $flags ) ); 1280 | } else { 1281 | $cmd = join( ' ', ( $myself, qw(-h), $host, qw(-s), $sess, qw(-b), $flags ) ); 1282 | } 1283 | 1284 | Log( LOG_DEBUG, "LaunchNewInstance: $cmd" ); 1285 | 1286 | my $fpid = fork; 1287 | if ( $fpid == 0 ) { 1288 | exit 255 unless open STDIN, '/dev/null'; 1289 | exit 255 unless open STDOUT, '>/dev/null'; 1290 | exit 255 unless open STDERR, '>/dev/null'; 1291 | setsid(); 1292 | $fpid = fork; 1293 | if ( $fpid == 0 ) { 1294 | $noclean = 0; 1295 | exec($cmd); 1296 | } 1297 | $noclean = 1; 1298 | exit; 1299 | } 1300 | waitpid( $fpid, 0 ); 1301 | $noclean = 1; 1302 | exit; 1303 | } 1304 | 1305 | # Given a session@host, inject a command into the FIFO and send a USR1 signal 1306 | sub InjectCommand { 1307 | my ( $command, $session ) = @_; 1308 | 1309 | my $control = 1310 | join( '', ( $config->{temp}, qw(control/), $session, ".control" ) ); 1311 | my $pidfile = join( '', ( $config->{temp}, qw(pid/), $session, ".pid" ) ); 1312 | 1313 | Log( LOG_DEBUG, "PID file is $pidfile" ); 1314 | Log( LOG_DEBUG, "Control file is $control" ); 1315 | 1316 | my $pid; 1317 | if ( -f $pidfile ) { 1318 | open PID, '<', $pidfile; 1319 | $pid = ; 1320 | close PID; 1321 | } 1322 | 1323 | unless ( kill( 0, $pid ) ) { 1324 | print "$pid not running exiting!"; 1325 | unlink $pidfile; 1326 | exit; 1327 | } 1328 | 1329 | open CONTROL, '>', $control; 1330 | print CONTROL "$command"; 1331 | close CONTROL; 1332 | 1333 | kill( 'USR1', $pid ); 1334 | exit; 1335 | } 1336 | 1337 | sub shellQuote { 1338 | my @return; 1339 | 1340 | for my $group (@_) { 1341 | my @args = @$group; 1342 | for (@args) { 1343 | s/'/'"'"'/g; 1344 | } 1345 | push @return, join ' ', map { /^[-\/|@=_0-9a-z]+$/i ? $_ : qq{'$_'} } @args; 1346 | } 1347 | 1348 | return join '|', @return; 1349 | } 1350 | 1351 | # Wraps a given command with SSH when a remote host is defined 1352 | sub buildCommand { 1353 | my $remote = shift; 1354 | return @{ $_[0] } unless $remote; 1355 | 1356 | my $cpath = join( '', $config->{temp}, qw(ssh/), $config->{ssh_control} ); 1357 | my @cmd = 1358 | grep { length } qw(ssh -q -t), @{ $config->{ssh_args} }, qw(-S), $cpath, 1359 | $remote, shellQuote(@_); 1360 | 1361 | return @cmd; 1362 | } 1363 | 1364 | sub spawnSingleTerm { 1365 | my ( $host, $session, $child_in ) = @_; 1366 | 1367 | if ( length $config->{on_connect} ) { 1368 | for my $option ( @{ $config->{on_connect} } ) { 1369 | print Log( LOG_DEBUG, $option ); 1370 | print $child_in $option . "\n"; 1371 | } 1372 | } 1373 | 1374 | my @cmd = 1375 | buildCommand( $host, [ qq(env $session_env=$config->{session}), $config->{tmux_bin}, qw(attach -t), $session, ] ); 1376 | my $attach_command = join( ' ', @cmd ); 1377 | 1378 | $session =~ s/\//-/g; 1379 | my $script = join( '', ( $config->{temp}, $session ) ); 1380 | my $start_script = qq|#!/bin/sh 1381 | test -f $script && rm $script 1382 | exec $attach_command 1383 | |; 1384 | 1385 | open SCRIPT, '>', $script; 1386 | print SCRIPT $start_script; 1387 | close SCRIPT; 1388 | chmod 0700, $script; 1389 | 1390 | my @exec = @{ $config->{terminal} }; 1391 | push @exec, $script; 1392 | Log( LOG_DEBUG, "Terminal spawn command" ); 1393 | Log( LOG_DEBUG, \@exec ); 1394 | my $fpid = fork; 1395 | if ( $fpid == 0 ) { 1396 | exit 255 unless open STDIN, '/dev/null'; 1397 | exit 255 unless open STDOUT, '>/dev/null'; 1398 | exit 255 unless open STDERR, '>/dev/null'; 1399 | exec @exec; 1400 | } 1401 | push( @children, $fpid ); 1402 | waitpid( $fpid, 0 ); 1403 | pop(@children); 1404 | return; 1405 | } 1406 | 1407 | # Write out a custom shell script and launch it with our terminal 1408 | sub spawnTerm { 1409 | my ( $host, $session, $window, $child_in ) = @_; 1410 | my ( undef, $id ) = split( '@', $window ); 1411 | 1412 | my $clone_session = join( '/', ( qw(tmuxc), $config->{hostname}, $session, $id ) ); 1413 | 1414 | my $clone_command = 1415 | join( ' ', ( qw(new-session -d -t), $session, qw(-s), $clone_session, qw(; select-window -t), $window, "\n" ) ); 1416 | print Log( LOG_DEBUG, $clone_command ); 1417 | print $child_in $clone_command; 1418 | 1419 | my @cmd = buildCommand( $host, 1420 | [ qq(env $session_env=$config->{session}), $config->{tmux_bin}, qw(attach -t), $clone_session, ] ); 1421 | my $attach_command = join( ' ', @cmd ); 1422 | 1423 | $clone_session =~ s/\//-/g; 1424 | my $script = join( '', ( $config->{temp}, $clone_session ) ); 1425 | my $start_script = qq|#!/bin/sh 1426 | test -f $script && rm $script 1427 | exec $attach_command 1428 | |; 1429 | 1430 | open SCRIPT, '>', $script; 1431 | print SCRIPT $start_script; 1432 | close SCRIPT; 1433 | chmod 0700, $script; 1434 | 1435 | my @exec = @{ $config->{terminal} }; 1436 | push @exec, $script; 1437 | Log( LOG_DEBUG, "Terminal spawn command" ); 1438 | Log( LOG_DEBUG, \@exec ); 1439 | my $fpid = fork; 1440 | if ( $fpid == 0 ) { 1441 | exit 255 unless open STDIN, '/dev/null'; 1442 | exit 255 unless open STDOUT, '>/dev/null'; 1443 | exit 255 unless open STDERR, '>/dev/null'; 1444 | setsid(); 1445 | $fpid = fork; 1446 | if ( $fpid == 0 ) { 1447 | exec @exec; 1448 | } 1449 | $noclean = 1; 1450 | exit; 1451 | } 1452 | waitpid( $fpid, 0 ); 1453 | return; 1454 | } 1455 | 1456 | # Return 1 if blacklisted, 0 if not 1457 | sub checkIfBlacklisted { 1458 | my $window = shift; 1459 | Log( LOG_DEBUG, "Checking if $window is blacklisted on $config->{chost}" ); 1460 | 1461 | my $chost = $config->{chost}; 1462 | my $sess = $config->{session}; 1463 | 1464 | if ( defined( $config->{hosts}{$chost}{$sess}{blacklist} ) ) { 1465 | if ( grep { $_ eq $window } @{ $config->{hosts}{$chost}{$sess}{blacklist} } ) { 1466 | Log( LOG_DEBUG, "Blacklisted window: $window" ); 1467 | return 1; 1468 | } 1469 | } 1470 | return 0; 1471 | } 1472 | 1473 | # List every window, set the blacklisted flag where appropriate 1474 | sub ListWindows { 1475 | my $block = shift; 1476 | for my $entry ( @{$block} ) { 1477 | my ( $group, $id, $name, $wid ) = split( ',', $entry ); 1478 | $mapping{windows}{$id}{'name'} = $name; 1479 | $mapping{windows}{$id}{'index'} = $wid; 1480 | $mapping{windows}{$id}{'blacklisted'} = 1481 | ( checkIfBlacklisted($name) ) ? 1 : 0; 1482 | } 1483 | Log( LOG_DEBUG, "ListWindows end" ); 1484 | Log( LOG_DEBUG, \%mapping ); 1485 | } 1486 | 1487 | # Detach all clients from all windows, leaving everything else running 1488 | # This will NOT mark a window as ignored 1489 | sub BulkDetach { 1490 | my $block = shift; 1491 | my $child_in = shift; 1492 | 1493 | Log( LOG_DEBUG, "BulkDetach start" ); 1494 | Log( LOG_DEBUG, \%mapping ); 1495 | 1496 | for my $entry ( @{$block} ) { 1497 | my ( $group, $sid, $name, $wid ) = split( ',', $entry ); 1498 | 1499 | # Filter for our specific session group 1500 | next unless $config->{session} eq $group; 1501 | 1502 | # Filter out our control session 1503 | next if $config->{control} eq $name; 1504 | 1505 | Log( LOG_INFO, "Detaching terminal from $sid" ); 1506 | print $child_in $commandText{'KillClient'} . "$sid\n"; 1507 | } 1508 | } 1509 | 1510 | # Spawn a terminal for all non-blacklisted windows 1511 | sub BulkSpawn { 1512 | my $block = shift; 1513 | my $child_in = shift; 1514 | 1515 | Log( LOG_DEBUG, "BulkSpawn start" ); 1516 | Log( LOG_DEBUG, \%mapping ); 1517 | 1518 | for my $entry ( @{$block} ) { 1519 | my ( $group, $sid, $name, $wid ) = split( ',', $entry ); 1520 | 1521 | # Filter for our specific session group 1522 | next unless $config->{session} eq $group; 1523 | 1524 | # Filter out our control session 1525 | next if $config->{control} eq $name; 1526 | 1527 | # Filter for our hostname in the linked session name 1528 | next unless $name =~ m/$config->{hostname}/; 1529 | 1530 | # Don't push duplicate session ids 1531 | unless ( grep { $_ eq $sid } @{ $mapping{clients}{$wid} } ) { 1532 | push @{ $mapping{clients}{$wid} }, $sid; 1533 | } 1534 | } 1535 | 1536 | foreach my $window ( keys %{ $mapping{windows} } ) { 1537 | if ( exists $mapping{clients}{$window} ) { 1538 | next; 1539 | } else { 1540 | unless ( checkIfBlacklisted( $mapping{windows}{$window}{name} ) ) { 1541 | Log( LOG_INFO, "Spawning a terminal for $window" ); 1542 | delete $mapping{clients}{$window}; 1543 | $mapping{windows}{$window}{'pending'} = 1; 1544 | spawnTerm( $host, $config->{session}, $window, $child_in ); 1545 | } 1546 | } 1547 | } 1548 | Log( LOG_DEBUG, "BulkSpawn end" ); 1549 | Log( LOG_DEBUG, \%mapping ); 1550 | } 1551 | 1552 | # This handler is used to set options for attached clients 1553 | # Currently it's a one-shot command that checks the tmux version to disable output on the control session 1554 | sub RefreshClient { 1555 | my $block = shift; 1556 | my $child_in = shift; 1557 | 1558 | for my $entry ( @{$block} ) { 1559 | my ( $cname, $group, $sname ) = split( ',', $entry ); 1560 | 1561 | next unless $sname eq $config->{control}; 1562 | 1563 | # tmux 3.0 introduced the no-output flag for control mode 1564 | my ( $major, undef ) = split( '.', $config->{version} ); 1565 | if ( $major ge 3 ) { 1566 | my $refresh = join( ' ', qw(refresh-client -t), $cname, qw(-F no-output) ); 1567 | Log( LOG_DEBUG, "Disabling output for control session" ); 1568 | Log( LOG_DEBUG, $refresh ); 1569 | print $child_in "$refresh\n"; 1570 | } 1571 | 1572 | last; 1573 | } 1574 | } 1575 | 1576 | # Build a map of all windows and the clients viewing the window 1577 | # If a client was viewing a window but has been closed, mark that window as ignored 1578 | # Future open-all commands will skip that window while the same tmuxc controller is running 1579 | sub ClientMap { 1580 | my $block = shift; 1581 | my $child_in = shift; 1582 | my $sess = $config->{session}; 1583 | my @found_windows; 1584 | 1585 | Log( LOG_DEBUG, "ClientMap start" ); 1586 | Log( LOG_DEBUG, \%mapping ); 1587 | 1588 | # Purge existing mappings 1589 | my %previous = %mapping{clients}; 1590 | delete $mapping{clients}; 1591 | 1592 | # Regenerate client <> window mappings 1593 | for my $entry ( @{$block} ) { 1594 | my ( $group, $sid, $name, $wid, $wname ) = split( ',', $entry ); 1595 | 1596 | # Filter for our specific session group 1597 | next unless $config->{session} eq $group; 1598 | 1599 | # Filter out our control session 1600 | next if $config->{control} eq $name; 1601 | 1602 | # Filter for our hostname in the linked session name 1603 | next unless $name =~ m/$config->{hostname}/; 1604 | 1605 | # This is a session we control, attached to a window 1606 | push @{ $mapping{clients}{$wid} }, $sid; 1607 | $mapping{windows}{$wid}{'name'} = $wname; 1608 | 1609 | # This is no longer pending, a session is pointed to it 1610 | delete $mapping{windows}{$wid}{'pending'}; 1611 | push @found_windows, $wid; 1612 | } 1613 | 1614 | # Ignore windows that were closed 1615 | foreach my $window ( keys %{ $previous{clients} } ) { 1616 | unless ( defined( $mapping{clients}{$window} ) ) { 1617 | Log( LOG_INFO, "Setting ignore flag for $window" ); 1618 | $mapping{windows}{$window}{ignore} = 1; 1619 | } 1620 | } 1621 | 1622 | # Set options for newly attached windows 1623 | # This should possibly be handled somewhere else? 1624 | foreach my $client ( keys %{ $mapping{clients} } ) { 1625 | unless ( defined( $previous{clients}{$client} ) ) { 1626 | 1627 | # It's critical that this be done AFTER a client has attached to a session 1628 | # If it is done before, the session will be destroyed 1629 | print $child_in "set-option -t $client destroy-unattached on\n"; 1630 | Log( LOG_DEBUG, "Setting destroy-unattached on for $client" ); 1631 | } 1632 | } 1633 | 1634 | Log( LOG_DEBUG, "ClientMap end" ); 1635 | Log( LOG_DEBUG, \%mapping ); 1636 | 1637 | if ( $config->{closeas} ) { 1638 | my $close = 1; 1639 | foreach my $win ( keys %{ $mapping{windows} } ) { 1640 | next 1641 | if ( defined $mapping{windows}{$win}{ignored} 1642 | and $mapping{windows}{$win}{ignored} ); 1643 | next 1644 | if ( defined $mapping{windows}{$win}{blacklisted} 1645 | and $mapping{windows}{$win}{blacklisted} ); 1646 | next if ( defined $mapping{clients}{$win} ); 1647 | 1648 | # We aren't done spawning windows, so don't exit yet 1649 | $close = 0; 1650 | } 1651 | 1652 | if ($close) { 1653 | exit; 1654 | } 1655 | } 1656 | } 1657 | 1658 | sub ToggleEphemeral { 1659 | $config->{ephemeral} ^= 1; 1660 | Log( LOG_DEBUG, "Ephemeral is now $config->{ephemeral}" ); 1661 | } 1662 | 1663 | sub PauseSelf { 1664 | $pause ^= 1; 1665 | Log( LOG_DEBUG, "Pause is now $pause" ); 1666 | } 1667 | 1668 | sub KillSelf { 1669 | Log( LOG_DEBUG, "Received KillSelf command, killing $$" ); 1670 | kill 'INT', $$; 1671 | } 1672 | 1673 | sub Log { 1674 | my $level = shift; 1675 | my $entry = shift; 1676 | 1677 | my ( $s, $m ) = gettimeofday; 1678 | 1679 | chomp($entry); 1680 | if ( $level le $config->{log_level} ) { 1681 | if ( $config->{log_level} == LOG_DEBUG ) { 1682 | unless ( ref($entry) ) { 1683 | syslog( $config->{log_level}, "%s", $entry ); 1684 | print STDERR "## $s.$m $entry\n"; 1685 | } else { 1686 | print STDERR Dumper($entry); 1687 | } 1688 | return; 1689 | } else { 1690 | syslog( $config->{log_level}, "%s", $entry ); 1691 | print STDOUT "$entry\n"; 1692 | return; 1693 | } 1694 | } 1695 | } 1696 | 1697 | __END__ 1698 | 1699 | =head1 NAME 1700 | 1701 | B - TMUX terminal manager 1702 | 1703 | =head1 SYNOPSIS 1704 | 1705 | B [options] 1706 | 1707 | =head1 OPTIONS 1708 | 1709 | =over 4 1710 | 1711 | =item B<--host|h> 1712 | 1713 | Connect to a tmux instance on the listed host. 1714 | 1715 | =item B<--session|s> 1716 | 1717 | Attach to the listed tmux session, or attach to the session@hostname. 1718 | 1719 | =item B<--background|b> 1720 | 1721 | After connecting to a host and instance, background the daemon. 1722 | 1723 | =item B<--config|c> 1724 | 1725 | Use the specified configuration file, overriding the default location of F<~/.tmuxc.conf>. 1726 | 1727 | =item B<--detach|d> 1728 | 1729 | After connecting to a host and instance, detach any clients connected to the session. 1730 | 1731 | =item B<--verbose|v> 1732 | 1733 | Enable verbose/debugging mode to print extra details. 1734 | 1735 | =item B<--reconnect|R> 1736 | 1737 | Reconnect to a remote host via SSH if the connection drops. This is enabled by default. This option overrides a host/session specific disable flag. 1738 | 1739 | =item B<--closeas|C> 1740 | 1741 | Exit the control daemon after spawning terminals for every window. 1742 | 1743 | =item B<--exitlast|E> 1744 | 1745 | Exit the control daemon after the last windown in the tmux session is closed. 1746 | 1747 | =item B<--new|n> 1748 | 1749 | Create a new window for the specified tmux session on the specified host. 1750 | 1751 | =item B<--launcher|l> 1752 | 1753 | Open up a menu listing sessions from the .tmuxc.conf file that aren't running. 1754 | 1755 | =item B<--menu|m> 1756 | 1757 | Open up a menu listing control options. If one instance is running, the command is executed on that instance. If multiple are running, prompt the running sessions. 1758 | 1759 | =item B<--poe|P> 1760 | 1761 | Enable purge-on-exit for sessions of type 'none', 'ephemeral' or 'all'. This executes a I against the session on tmuxc exit. 1762 | 1763 | =item B<--singlemenu|M> 1764 | 1765 | Open up a menu listing control options. The options take the form of session@host ~ , and are shown as a single-depth menu. Smart pagers (rofi, bemenu, etc) allow you to type partial words in any order to narrow down the option to execute. 1766 | 1767 | =item B<--wm|w> 1768 | 1769 | =over 2 1770 | 1771 | =item B 1772 | 1773 | Enable single-window-mode. This opens up a single terminal for an entire tmux session, consistent with typical tmux usage. 1774 | 1775 | =item B 1776 | 1777 | Enable multi-window-mode. This opens up a terminal for each window in the tmux session. This is the default behavior. 1778 | 1779 | =back 1780 | 1781 | =item B<--known|k> | B<--known|k=,> 1782 | 1783 | List active and inactive sessions. Takes an optional character argument to enable CSV output for machine parsing. 1784 | 1785 | =item B | B 1786 | 1787 | List tmux sessions on a remote host that do not have a local instance of tmuxc attached to them, then connect to the session. 1788 | 1789 | =item B<--print|o> 1790 | 1791 | Print configuration options for the host and session selected from command line arguments. 1792 | 1793 | =item B<--ephemeral|S> 1794 | 1795 | Prompt for a new session. The session can take the form of B or B 1796 | 1797 | =item B<--exit|e> 1798 | 1799 | Tell a specified tmuxc instance to exit. 1800 | 1801 | =item B<--pause|p> 1802 | 1803 | Tell a specified tmuxc instance to toggle it's pause mode. When in pause mode, all tmux messages received are ignored, as are any commands sent to the FIFO or on the command line. 1804 | 1805 | =item B<--raw|r> 1806 | 1807 | Issue a raw tmux or tmuxc command to a specified instance. 1808 | 1809 | =back 1810 | 1811 | =head1 EXAMPLES 1812 | 1813 | Connect to a session labeled I on the host I, then background. 1814 | 1815 | tmuxc -h shellhost -s global-session -b 1816 | 1817 | Connect to the default session on localhost, then background. 1818 | 1819 | tmuxc -b 1820 | 1821 | Issue the raw tmux command I to the default session on localhost. 1822 | 1823 | tmuxc -r 'new-window -d' 1824 | 1825 | =head1 SCRIPTING 1826 | 1827 | Each running instance of tmuxc creates a command FIFO that can be used to control tmux and tmuxc. The FIFO takes the form of F, and by default is available in F<~/.tmuxc/control/>. Once you've written a command to the FIFO, send the B signal to tmuxc. The PID is available in F<~/.tmuxc/pid/hostname-session.pid>. 1828 | 1829 | See B for examples. 1830 | 1831 | =head1 RAW COMMANDS 1832 | 1833 | Raw commands can take multiple forms. Commands prefaced with I hit the internal 1834 | command dispatcher. Any other command is passed as-is to tmux. Most tmuxc commands are 1835 | of no real use to end-users. 1836 | 1837 | =head2 EXAMPLES 1838 | 1839 | tmuxc PauseSelf 1840 | new-window -d 1841 | 1842 | =head2 TMUXC INTERNAL COMMANDS 1843 | 1844 | =over 6 1845 | 1846 | =item B 1847 | 1848 | Instruct tmuxc to spawn terminals for all windows that do not have an attached client. 1849 | 1850 | =item B 1851 | 1852 | Instruct tmuxc to close all clients attached to windows. 1853 | 1854 | =item B 1855 | 1856 | Instruct tmuxc to cleanly exit. 1857 | 1858 | =item B 1859 | 1860 | Instruct tmuxc to ignore all input from the command line and from tmux, until this option is toggled again. 1861 | 1862 | =item B 1863 | 1864 | Instruct tmuxc to reload the configuration file from disk. The configuration file path discovered at the initial start of tmuxc is re-used. 1865 | 1866 | =item B 1867 | 1868 | Flip the internal ephemeral flag for a running instance of tmuxc. This change is not persisted to the configuration file. 1869 | 1870 | =back 1871 | 1872 | =head1 AUTHOR 1873 | 1874 | Zach Dykstra 1875 | 1876 | =cut 1877 | -------------------------------------------------------------------------------- /tmuxc.1: -------------------------------------------------------------------------------- 1 | .\" -*- mode: troff; coding: utf-8 -*- 2 | .\" Automatically generated by Pod::Man 5.01 (Pod::Simple 3.43) 3 | .\" 4 | .\" Standard preamble: 5 | .\" ======================================================================== 6 | .de Sp \" Vertical space (when we can't use .PP) 7 | .if t .sp .5v 8 | .if n .sp 9 | .. 10 | .de Vb \" Begin verbatim text 11 | .ft CW 12 | .nf 13 | .ne \\$1 14 | .. 15 | .de Ve \" End verbatim text 16 | .ft R 17 | .fi 18 | .. 19 | .\" \*(C` and \*(C' are quotes in nroff, nothing in troff, for use with C<>. 20 | .ie n \{\ 21 | . ds C` "" 22 | . ds C' "" 23 | 'br\} 24 | .el\{\ 25 | . ds C` 26 | . ds C' 27 | 'br\} 28 | .\" 29 | .\" Escape single quotes in literal strings from groff's Unicode transform. 30 | .ie \n(.g .ds Aq \(aq 31 | .el .ds Aq ' 32 | .\" 33 | .\" If the F register is >0, we'll generate index entries on stderr for 34 | .\" titles (.TH), headers (.SH), subsections (.SS), items (.Ip), and index 35 | .\" entries marked with X<> in POD. Of course, you'll have to process the 36 | .\" output yourself in some meaningful fashion. 37 | .\" 38 | .\" Avoid warning from groff about undefined register 'F'. 39 | .de IX 40 | .. 41 | .nr rF 0 42 | .if \n(.g .if rF .nr rF 1 43 | .if (\n(rF:(\n(.g==0)) \{\ 44 | . if \nF \{\ 45 | . de IX 46 | . tm Index:\\$1\t\\n%\t"\\$2" 47 | .. 48 | . if !\nF==2 \{\ 49 | . nr % 0 50 | . nr F 2 51 | . \} 52 | . \} 53 | .\} 54 | .rr rF 55 | .\" ======================================================================== 56 | .\" 57 | .IX Title "TMUXC 1" 58 | .TH TMUXC 1 2023-11-06 1.9.1 "tmuxc - TMUX terminal manager" 59 | .\" For nroff, turn off justification. Always turn off hyphenation; it makes 60 | .\" way too many mistakes in technical documents. 61 | .if n .ad l 62 | .nh 63 | .SH NAME 64 | tmuxc \- TMUX terminal manager 65 | .SH SYNOPSIS 66 | .IX Header "SYNOPSIS" 67 | \&\fBtmuxc\fR [options] 68 | .SH OPTIONS 69 | .IX Header "OPTIONS" 70 | .IP \fB\-\-host|h\fR 4 71 | .IX Item "--host|h" 72 | Connect to a tmux instance on the listed host. 73 | .IP \fB\-\-session|s\fR 4 74 | .IX Item "--session|s" 75 | Attach to the listed tmux session, or attach to the session@hostname. 76 | .IP \fB\-\-background|b\fR 4 77 | .IX Item "--background|b" 78 | After connecting to a host and instance, background the daemon. 79 | .IP \fB\-\-config|c\fR 4 80 | .IX Item "--config|c" 81 | Use the specified configuration file, overriding the default location of \fI~/.tmuxc.conf\fR. 82 | .IP \fB\-\-detach|d\fR 4 83 | .IX Item "--detach|d" 84 | After connecting to a host and instance, detach any clients connected to the session. 85 | .IP \fB\-\-verbose|v\fR 4 86 | .IX Item "--verbose|v" 87 | Enable verbose/debugging mode to print extra details. 88 | .IP \fB\-\-reconnect|R\fR 4 89 | .IX Item "--reconnect|R" 90 | Reconnect to a remote host via SSH if the connection drops. This is enabled by default. This option overrides a host/session specific disable flag. 91 | .IP \fB\-\-closeas|C\fR 4 92 | .IX Item "--closeas|C" 93 | Exit the control daemon after spawning terminals for every window. 94 | .IP \fB\-\-exitlast|E\fR 4 95 | .IX Item "--exitlast|E" 96 | Exit the control daemon after the last windown in the tmux session is closed. 97 | .IP \fB\-\-new|n\fR 4 98 | .IX Item "--new|n" 99 | Create a new window for the specified tmux session on the specified host. 100 | .IP \fB\-\-launcher|l\fR 4 101 | .IX Item "--launcher|l" 102 | Open up a menu listing sessions from the .tmuxc.conf file that aren't running. 103 | .IP \fB\-\-menu|m\fR 4 104 | .IX Item "--menu|m" 105 | Open up a menu listing control options. If one instance is running, the command is executed on that instance. If multiple are running, prompt the running sessions. 106 | .IP \fB\-\-poe|P\fR 4 107 | .IX Item "--poe|P" 108 | Enable purge-on-exit for sessions of type 'none', 'ephemeral' or 'all'. This executes a \fIkill-session\fR against the session on tmuxc exit. 109 | .IP \fB\-\-singlemenu|M\fR 4 110 | .IX Item "--singlemenu|M" 111 | Open up a menu listing control options. The options take the form of session@host ~ , and are shown as a single-depth menu. Smart pagers (rofi, bemenu, etc) allow you to type partial words in any order to narrow down the option to execute. 112 | .IP \fB\-\-wm|w\fR 4 113 | .IX Item "--wm|w" 114 | .RS 4 115 | .PD 0 116 | .IP \fBsingle\fR 2 117 | .IX Item "single" 118 | .PD 119 | Enable single-window-mode. This opens up a single terminal for an entire tmux session, consistent with typical tmux usage. 120 | .IP \fBmulti\fR 2 121 | .IX Item "multi" 122 | Enable multi-window-mode. This opens up a terminal for each window in the tmux session. This is the default behavior. 123 | .RE 124 | .RS 4 125 | .RE 126 | .IP "\fB\-\-known|k\fR | \fB\-\-known|k=,\fR" 4 127 | .IX Item "--known|k | --known|k=," 128 | List active and inactive sessions. Takes an optional character argument to enable CSV output for machine parsing. 129 | .IP "\fBremote|K\fR | \fBremote|K host\fR" 4 130 | .IX Item "remote|K | remote|K host" 131 | List tmux sessions on a remote host that do not have a local instance of tmuxc attached to them, then connect to the session. 132 | .IP \fB\-\-print|o\fR 4 133 | .IX Item "--print|o" 134 | Print configuration options for the host and session selected from command line arguments. 135 | .IP \fB\-\-ephemeral|S\fR 4 136 | .IX Item "--ephemeral|S" 137 | Prompt for a new session. The session can take the form of \fBsession\fR or \fBsession@hostname\fR 138 | .IP \fB\-\-exit|e\fR 4 139 | .IX Item "--exit|e" 140 | Tell a specified tmuxc instance to exit. 141 | .IP \fB\-\-pause|p\fR 4 142 | .IX Item "--pause|p" 143 | Tell a specified tmuxc instance to toggle it's pause mode. When in pause mode, all tmux messages received are ignored, as are any commands sent to the FIFO or on the command line. 144 | .IP \fB\-\-raw|r\fR 4 145 | .IX Item "--raw|r" 146 | Issue a raw tmux or tmuxc command to a specified instance. 147 | .SH EXAMPLES 148 | .IX Header "EXAMPLES" 149 | Connect to a session labeled \fIglobal-session\fR on the host \fIshellhost\fR, then background. 150 | .PP 151 | .Vb 1 152 | \& tmuxc \-h shellhost \-s global\-session \-b 153 | .Ve 154 | .PP 155 | Connect to the default session on localhost, then background. 156 | .PP 157 | .Vb 1 158 | \& tmuxc \-b 159 | .Ve 160 | .PP 161 | Issue the raw tmux command \fInew-window \-d\fR to the default session on localhost. 162 | .PP 163 | .Vb 1 164 | \& tmuxc \-r \*(Aqnew\-window \-d\*(Aq 165 | .Ve 166 | .SH SCRIPTING 167 | .IX Header "SCRIPTING" 168 | Each running instance of tmuxc creates a command FIFO that can be used to control tmux and tmuxc. The FIFO takes the form of \fIhostname\-session.control\fR, and by default is available in \fI~/.tmuxc/control/\fR. Once you've written a command to the FIFO, send the \fBUSR1\fR signal to tmuxc. The PID is available in \fI~/.tmuxc/pid/hostname\-session.pid\fR. 169 | .PP 170 | See \fBRAW COMMANDS\fR for examples. 171 | .SH "RAW COMMANDS" 172 | .IX Header "RAW COMMANDS" 173 | Raw commands can take multiple forms. Commands prefaced with \fItmuxc\fR hit the internal 174 | command dispatcher. Any other command is passed as-is to tmux. Most tmuxc commands are 175 | of no real use to end-users. 176 | .SS EXAMPLES 177 | .IX Subsection "EXAMPLES" 178 | .Vb 2 179 | \& tmuxc PauseSelf 180 | \& new\-window \-d 181 | .Ve 182 | .SS "TMUXC INTERNAL COMMANDS" 183 | .IX Subsection "TMUXC INTERNAL COMMANDS" 184 | .IP \fBBulkSpawn\fR 6 185 | .IX Item "BulkSpawn" 186 | Instruct tmuxc to spawn terminals for all windows that do not have an attached client. 187 | .IP \fBBulkDetach\fR 6 188 | .IX Item "BulkDetach" 189 | Instruct tmuxc to close all clients attached to windows. 190 | .IP \fBKillSelf\fR 6 191 | .IX Item "KillSelf" 192 | Instruct tmuxc to cleanly exit. 193 | .IP \fBPauseSelf\fR 6 194 | .IX Item "PauseSelf" 195 | Instruct tmuxc to ignore all input from the command line and from tmux, until this option is toggled again. 196 | .IP \fBLoadConfig\fR 6 197 | .IX Item "LoadConfig" 198 | Instruct tmuxc to reload the configuration file from disk. The configuration file path discovered at the initial start of tmuxc is re-used. 199 | .IP \fBToggleEphemeral\fR 6 200 | .IX Item "ToggleEphemeral" 201 | Flip the internal ephemeral flag for a running instance of tmuxc. This change is not persisted to the configuration file. 202 | .SH AUTHOR 203 | .IX Header "AUTHOR" 204 | Zach Dykstra 205 | --------------------------------------------------------------------------------