249 lines
12 KiB
Perl
Executable File
249 lines
12 KiB
Perl
Executable File
#!/usr/bin/env perl
|
|
package App::AyudanteLobo;
|
|
|
|
use strict;
|
|
|
|
#TODO POD documentation
|
|
|
|
use Cwd; use POSIX;
|
|
use Date::Format qw/time2str/;
|
|
use Net::SSH2; use Try::Tiny;
|
|
use POE; use POE::Component::DirWatch::WithCaller;
|
|
use Unix::PID; use YAML;
|
|
|
|
#Load platform-specific modules here too to avoid missing modules causing a crash later
|
|
for ($^O) {
|
|
#Clipboard.pm doesn't integrate nicely with the OSX clipboard due to datatype differences
|
|
#nor does it integrate well with windows, as it doesn't support empty()
|
|
require Mac::Pasteboard if /darwin/;
|
|
require Win32::Clipboard if /MSWin32/;
|
|
#Unsurprisingly it's perfectly fine for linux :/
|
|
require Clipboard unless /darwin/ or /MSWin32/;
|
|
#Speech::Synthesis is a great wrapper for Win32::SAPI5 and should work for Festival
|
|
#It unfortunately doesn't work -at all- on modern OSX due to API changes/Carbon deprecation
|
|
require Speech::Synthesis unless /darwin/;
|
|
#Win32::API needed here for moderately pleasant use of SnoreNotify
|
|
require Win32::API if /MSWin32/;
|
|
}
|
|
|
|
my $ME = __PACKAGE__;
|
|
#will bump to v1.0 upon full cross-platform compatibility and submission to CPAN.
|
|
#version number reflective of how close the module is to 'complete'
|
|
my $VERSION = "0.7";
|
|
my $HOSTNAME = `hostname`; chomp $HOSTNAME;
|
|
|
|
my ($base, $Conf);
|
|
|
|
my $running = 0;
|
|
my $sighup = 0;
|
|
|
|
sub scp_upload {
|
|
my $file = shift; my $ssh = Net::SSH2->new();
|
|
try {
|
|
$ssh->connect($Conf->{'upload'}->{server},$Conf->{'upload'}->{port},Timeout => 3);
|
|
} catch {
|
|
logger(2,"SSH connection failed (exception): $_") and return 0;
|
|
};
|
|
logger(2,"SSH connection failed (error): ".$ssh->error()) and return 0 if $ssh->error;
|
|
$ssh->auth_publickey($Conf->{'upload'}->{user},$Conf->{'upload'}->{sshkeypath}.'.pub',$Conf->{'upload'}->{sshkeypath});
|
|
logger(2,"SSH connection failed (auth error): ".$ssh->error()) and return 0 unless $ssh->auth_ok;
|
|
$Conf->{'upload'}->{remotepath} .= "/" unless $Conf->{'upload'}->{remotepath} =~ /\/$/;
|
|
logger(2,"File upload failed: ".$ssh->error) and return 0 unless $ssh->scp_put("$file",$Conf->{'upload'}->{remotepath}.$file->basename);
|
|
$ssh->disconnect; return 1;
|
|
}
|
|
sub timefmt2str {
|
|
return Date::Format::time2str(shift,time());
|
|
}
|
|
sub clipb_copy {
|
|
my $t = shift;
|
|
Clipboard->copy($t) and return unless $^O eq 'darwin' or $^O eq 'MSWin32';
|
|
Win32::Clipboard::Empty() and Win32::Clipboard::Set($t) and return unless $^O eq 'darwin';
|
|
my $p = Mac::Pasteboard->new();
|
|
$p->clear(); #Clear clipboard first to avoid a clipboard ownership error
|
|
$p->copy($t);
|
|
$p->copy($t, "public.utf8-plain-text");
|
|
$p->copy($t, "public.utf16-plain-text");
|
|
$p->copy($t, "public.utf16-external-plain-text");
|
|
}
|
|
sub banner {
|
|
#TODO make these less ghetto.
|
|
#There might be something in Win32::OLE I can use for toast notifications.
|
|
#Mac::Carbon probably provides something too, but it's too old to use on modern OSX.
|
|
my ($title,$text) = @_;
|
|
system($Conf->{general}->{'notify'}->{toast_exe}." -appID MaffC.App-AyudanteLobo -silent -t \"$title\" -m \"$text\"") if $^O eq 'MSWin32';
|
|
system("/usr/bin/osascript -e 'display notification \"$text\" with title \"$title\"' &") if $^O eq 'darwin';
|
|
#TODO some form of notification banner on *nix
|
|
}
|
|
sub speak_osx {
|
|
my ($r,$v);
|
|
my $t=shift;
|
|
#We have to use `say` here because Mac::Speech is old and busted.
|
|
$v = "-v".$Conf->{general}->{'notify'}->{speech_voice} if length $Conf->{general}->{'notify'}->{speech_voice};
|
|
$r = "-r".$Conf->{general}->{'notify'}->{speech_rate} if length $Conf->{general}->{'notify'}->{speech_rate};
|
|
system("/usr/bin/say $v $r '$t' &");
|
|
}
|
|
sub speak_w32 {
|
|
#TODO genericise this sub, set engine to sapi5/festival based on platform
|
|
my $t=shift;
|
|
my %args=(
|
|
engine => 'SAPI5',
|
|
voice => ''
|
|
);
|
|
$args{voice}=$Conf->{general}->{'notify'}->{speech_voice} if length $Conf->{general}->{'notify'}->{speech_voice};
|
|
my $synth=Speech::Synthesis->new(%args);
|
|
$synth->speak($t);
|
|
}
|
|
|
|
sub speak {
|
|
for ($^O) {
|
|
speak_osx(@_) if /darwin/;
|
|
speak_w32(@_) if /MSWin32/;
|
|
}
|
|
#TODO festival support on *nix
|
|
}
|
|
|
|
# Functions
|
|
sub init {
|
|
#if $HOME ('nix) or $HOMEDRIVE/$HOMEPATH (win32) exist, use those for our basedir, else use cwd
|
|
$base = $ENV{HOME};
|
|
if($^O eq 'MSWin32') {
|
|
$base = $ENV{HOMEDRIVE}.$ENV{HOMEPATH};
|
|
$base =~ s/\\/\//g;
|
|
}
|
|
$base = cwd() unless length $base and -d $base;
|
|
#chdir to ensure relative paths work as expected for the user
|
|
chdir $base;
|
|
my $confp = $ENV{LOBORC} || "$base/.ayudante-loborc"; my $loaded = 0;
|
|
$loaded = init_conf($confp) if -e $confp and -f $confp and -r $confp and not -z $confp;
|
|
if ($Conf->{general}->{storelogs} or not $loaded) {
|
|
open(STDOUT, ">>".$Conf->{general}->{logfile}) if length $Conf->{general}->{logfile};
|
|
open(STDERR, ">>".$Conf->{general}->{errlogfile}) if length $Conf->{general}->{errlogfile};
|
|
select((select(STDOUT), $|=1)[0]);
|
|
}
|
|
$loaded == 0 and logger(9,"Configuration file at $confp either doesn't exist or is unreadable/empty.");
|
|
$loaded == -1 and logger(9,"Configuration file at $confp exists but could not be loaded. Please check it is fully-valid YAML.");
|
|
$loaded == -2 and logger(9,"Configuration file at $confp loaded but did not contain any enabled monitors.");
|
|
$loaded == -3 and logger(9,"Configuration file at $confp loaded but was missing a required configuration parameter.");
|
|
#then we run the kernel once, I forget why but I remember it being a problem
|
|
POE::Kernel->run();
|
|
#at this point the config file should be /pretty/ kawaii, so we start initialising
|
|
my $pid = Unix::PID->new()->is_pidfile_running($Conf->{general}->{pidfile}) || 0;
|
|
kill 'HUP', $pid and logger(8, "$ME already running, restarting.") if $pid != $$ and $pid > 0;
|
|
Unix::PID->new()->pid_file($Conf->{general}->{pidfile}) or logger(9, "Failed to write PID to ".$Conf->{general}->{pidfile});
|
|
#indicate we should start
|
|
logger(1,"Starting $ME..");
|
|
$running = 1;
|
|
#set up AppUserModelID for windows' toast notifications
|
|
if (defined $Conf->{general}->{'notify'}->{'banner'} and $Conf->{general}->{'notify'}->{'banner'} and length $Conf->{general}->{'notify'}->{toast_exe}) {
|
|
my $SetProcessAppID = Win32::API::More->new('shell32', 'SetCurrentProcessExplicitAppUserModelID', 'N', 'n');
|
|
logger(9,"Error while loading Shell32:SetCurrentProcessExplicitAppUserModelID via Win32::API: $^E") unless $SetProcessAppID;
|
|
#$SetProcessAppID->UseMI64(1); #Commented out because perl complains that no such subroutine exists
|
|
my $ret = $SetProcessAppID->Call(unpack('J',pack('p',"MaffC.App-AyudanteLobo")));
|
|
logger(9,"Error while calling Shell32:SetCurrentProcessExplicitAppUserModelID: $^E") unless $ret == 0;
|
|
#Windows requires a shortcut exist in order for notifications from an AppID to actually succeed, so we create one where the user won't see it.
|
|
#This is done upon startup, with no care for if a shortcut already exists. It'll either fail to create, which is fine, or overwrite, which is also fine.
|
|
system($Conf->{general}->{'notify'}->{toast_exe}." -install \"Startup\\ayudante-lobo\" $ENV{PAR_PROGNAME} MaffC.App-AyudanteLobo");
|
|
}
|
|
#set up signal handlers so we can handle SIGHUPs and handle quitting gracefully.
|
|
$SIG{$_} = \&sigtrap for qw/HUP INT QUIT TERM/;
|
|
#then we initialise monitors
|
|
init_mons();
|
|
logger(1, "$ME version $VERSION started.");
|
|
}
|
|
sub init_conf {
|
|
$Conf = YAML::LoadFile(shift) or return -1;
|
|
$base = $Conf->{general}->{home} if defined $Conf->{general}->{home} and length $Conf->{general}->{home};
|
|
chdir $base;
|
|
return -3 unless defined $Conf->{general}->{tmp} and length $Conf->{general}->{tmp};
|
|
$Conf->{general}->{tmp} .= '/' unless $Conf->{general}->{tmp} =~ /\/$/;
|
|
mkdir $Conf->{general}->{tmp} or logger(9,"Error creating work directory ".$Conf->{general}->{tmp}.": $!") unless -d $Conf->{general}->{tmp};
|
|
$Conf->{general}->{storelogs} = 1 unless defined $Conf->{general}->{storelogs} and $Conf->{general}->{storelogs} =~ /^[01]$/;
|
|
$Conf->{general}->{pidfile} = "$base/.$ME.pid" unless exists $Conf->{general}->{pidfile};
|
|
$Conf->{general}->{logfile} = "$base/.$ME.log" unless exists $Conf->{general}->{logfile};
|
|
$Conf->{general}->{errlogfile} = "$base/.$ME.err" unless defined $Conf->{general}->{errlogfile} and length $Conf->{general}->{errlogfile};
|
|
$Conf->{general}->{storelogs} = 0 unless length $Conf->{general}->{logfile} or length $Conf->{general}->{errlogfile};
|
|
return -2 unless scalar keys %{$Conf->{monitor}};
|
|
my $c;
|
|
for my $monitor (keys %{$Conf->{monitor}}) { $c++ unless defined $Conf->{monitor}->{$monitor}->{disable} and $Conf->{monitor}->{$monitor}->{disable} == 1; }
|
|
return -2 unless $c;
|
|
}
|
|
sub init_mons {
|
|
POE::Session->create( inline_states => { _start => sub {
|
|
foreach our $monitor (keys %{$Conf->{monitor}}) {
|
|
next if defined $Conf->{monitor}->{$monitor}->{disable} and $Conf->{monitor}->{$monitor}->{disable} == 1;
|
|
$Conf->{monitor}->{$monitor}->{poll} = 5 unless defined $Conf->{monitor}->{$monitor}->{poll};
|
|
$Conf->{monitor}->{$monitor}->{ignoreseen} = 0 unless defined $Conf->{monitor}->{$monitor}->{ignoreseen};
|
|
$_[HEAP]->{$monitor} = POE::Component::DirWatch::WithCaller->new(
|
|
alias => $monitor,
|
|
directory => $Conf->{monitor}->{$monitor}->{dir},
|
|
filter => \&filter,
|
|
file_callback => \&trigger,
|
|
interval => $Conf->{monitor}->{$monitor}->{poll},
|
|
ignore_seen => $Conf->{monitor}->{$monitor}->{ignoreseen},
|
|
ensure_seen => $Conf->{monitor}->{$monitor}->{ignoreseen},
|
|
);
|
|
}
|
|
}});
|
|
}
|
|
sub sigtrap {
|
|
my $sig = shift;
|
|
logger(2, "Caught SIG$sig: ".($sig eq 'HUP'? 'Restarting..' : 'Exiting..'));
|
|
$running = 0;
|
|
$sig eq 'HUP' and $sighup = 1;
|
|
}
|
|
sub logger {
|
|
my ($pri,$msg) = @_;
|
|
print timefmt2str('%e %B %T')." $HOSTNAME $ME\[$$] ($pri): $msg\n" unless $pri =~ /^[29]$/;
|
|
print STDERR timefmt2str('%e %B %T')." $HOSTNAME $ME\[$$] ($pri): $msg\n" if $pri =~ /^[29]$/;
|
|
notify($ME,$msg) if $pri == 3 and $Conf->{general}->{'notify'}->{on}->{error} == 1;
|
|
exit 0 if $pri == 8;
|
|
exit 1 if $pri == 9;
|
|
return $pri;
|
|
}
|
|
|
|
# Monitor-specific subroutines.
|
|
sub filter {
|
|
my ($sender_mon,$file) = @_;
|
|
return 0 if $file->is_dir;
|
|
return $file =~ /$Conf->{monitor}->{$sender_mon}->{match}->{regexp}/ if defined $Conf->{monitor}->{$sender_mon}->{match}->{regexp};
|
|
return 1 if defined $Conf->{monitor}->{$sender_mon}->{match}->{spotlight_meta} and qx(/usr/bin/mdls -name $Conf->{monitor}->{$sender_mon}->{match}->{spotlight_meta} "$file") =~ /^$Conf->{monitor}->{$sender_mon}->{match}->{spotlight_meta} = (?!\(null\)).*$/;
|
|
return 0;
|
|
}
|
|
sub trigger {
|
|
my ($sender_mon,$file) = @_;
|
|
#logger(1,"sender: $sender_mon, file: $file");
|
|
$file->move_to($Conf->{general}->{tmp}.name($sender_mon,$file->basename));
|
|
upload($file) or $file->move_to($Conf->{general}->{tmp}.$file->basename) and return if defined $Conf->{monitor}->{$sender_mon}->{action}->{'upload'} and $Conf->{monitor}->{$sender_mon}->{action}->{'upload'} == 1;
|
|
$file->move_to($Conf->{monitor}->{$sender_mon}->{action}->{target}."/".$file->basename) or logger(2,"Couldn't move ".$file->basename." to ".$Conf->{monitor}->{$sender_mon}->{action}->{target}) if defined $Conf->{monitor}->{$sender_mon}->{action}->{move} and $Conf->{monitor}->{$sender_mon}->{action}->{move} == 1 and defined $Conf->{monitor}->{$sender_mon}->{action}->{target};
|
|
$file->remove() if defined $Conf->{monitor}->{$sender_mon}->{action}->{delete} and $Conf->{monitor}->{$sender_mon}->{action}->{delete} == 1;
|
|
}
|
|
sub notify {
|
|
banner(@_) if $Conf->{general}->{'notify'}->{'banner'} == 1;
|
|
speak($_[($_[0] eq $ME)? 1 : 0]) if $Conf->{general}->{'notify'}->{speech} == 1;
|
|
}
|
|
sub name {
|
|
my ($sender_mon,$filename) = @_;
|
|
$Conf->{monitor}->{$sender_mon}->{action}->{rename} = 1 unless defined $Conf->{monitor}->{$sender_mon}->{action}->{rename};
|
|
return $filename if $Conf->{monitor}->{$sender_mon}->{action}->{rename} =~ /^0$/;
|
|
my $genfilename = timefmt2str($Conf->{general}->{filename});
|
|
return $genfilename."_$filename" if $Conf->{monitor}->{$sender_mon}->{action}->{rename} eq 'prepend';
|
|
$filename =~ s/(\.[a-z0-9]+)$//i and $genfilename .= $1;
|
|
return $filename."_$genfilename" if $Conf->{monitor}->{$sender_mon}->{action}->{rename} eq 'append';
|
|
return $genfilename;
|
|
}
|
|
sub upload {
|
|
my $file = shift;
|
|
notify("Uploading file","Uploading ".$file->basename." to ".$Conf->{'upload'}->{server}.".") if $Conf->{general}->{'notify'}->{on}->{'upload'} == 1;
|
|
scp_upload($file) or logger(3,"Failed to upload ".$file->basename.".") and return 0;
|
|
clipb_copy("http".(($Conf->{'upload'}->{pubssl}==1)? 's' : '')."://".$Conf->{'upload'}->{pubdomain}."/".($file->basename =~ s/ /%20/r));
|
|
notify("File uploaded",$file->basename." uploaded to ".$Conf->{'upload'}->{server}.".") if $Conf->{general}->{'notify'}->{on}->{'upload'} == 1;
|
|
return 1;
|
|
}
|
|
|
|
# Main
|
|
init();
|
|
POE::Kernel->run_while(\$running);
|
|
logger($sighup? 1 : 8,"Halting $ME..");
|
|
#TODO investigate a way to signal lobo to restart on windows, since SIGHUP isn't supported
|
|
exec $^X, $0, @ARGV;
|