From c607015ade0889b6a69b49229d1cb6c8b1e466dd Mon Sep 17 00:00:00 2001 From: John Mertz Date: Mon, 25 Jul 2022 17:03:18 -0400 Subject: [PATCH] Refactor, add SystemD Journaling --- sway/wallpaper.pl | 428 +++++++++++++++++++++++++++++----------------- 1 file changed, 272 insertions(+), 156 deletions(-) diff --git a/sway/wallpaper.pl b/sway/wallpaper.pl index 9a39915..175a639 100755 --- a/sway/wallpaper.pl +++ b/sway/wallpaper.pl @@ -1,29 +1,32 @@ #!/usr/bin/env perl -# TODO: configure systemD logging -# TODO: refactor sloppy use of global variables -# TODO: clean up debugging output +# TODO: add option to allow recursive sub-directories (in choose_image()) # TODO: license, etc. use strict; use warnings; use POSIX; +use File::Copy; use lib "$ENV{HOME}/perl5/lib/perl5"; +$ENV{PWD} = '/tmp' unless (defined($ENV{PWD})); + +use constant ERROR => { + LOG_EMERG => 8, + LOG_ALERT => 7, + LOG_CRIT => 6, + LOG_ERR => 5, + LOG_WARNING => 4, + LOG_NOTICE => 3, + LOG_INFO => 2, + LOG_DEBUG => 1 +}; -our $debug = 1; # For testing, will output configuration and errors if true -our %settings; our @e; -our $pidfile = "/tmp/$ENV{USER}-wallpaper.pid"; - -use AnyEvent::Sway; -our $s = AnyEvent::Sway->new(); - -our $o = $s->get_outputs->recv(); -die "No outputs detected.\n" unless (scalar(@$o)); sub usage() { + my $self = shift; print("$0 [output(s)] [-d|--daemon]\n Sets a wallpaper by cropping an appropriate sized section of a larger image.\n All arguments other than the below are assumed to be output names, as defined @@ -36,6 +39,9 @@ are currently enabled will be set.\n each rotation, if provided (default: 300).\n --nocrop Don't crop a selection from the image. Instead, pass the whole -n image to swaybg and let it handle the scaling\n +--verbose=N Define minimum log level. Counting from 1: LOG_DEBUG, LOG_INFO, +-v N LOG_NOTICE, LOG_WARNING, LOG_ERR, LOG_CRIT, LOG_ALERT, LOG_EMERG + Default: 5; If provided without a value for N then all (1). --help This menu -h\n You can send SIGUSR1 to force the daemon to reload immediately. Rotation timer @@ -43,45 +49,136 @@ will be reset.\n"); exit(0); } +sub new +{ + my ($class, %args) = @_; + + use AnyEvent::Sway; + $args{ipc} = AnyEvent::Sway->new(); + use Image::Magick; + $args{im} = Image::Magick->new(); + $args{error} = (); + + $args{pidfile} = "/tmp/$ENV{USER}-wallpaper.pid"; + + return bless { %args }; +} + +my $wp = new("Wallpapers"); + +if (-e $wp->{pidfile}) { + if (-r $wp->{pidfile}) { + if (open(my $fh, '<', $wp->{pidfile})) { + my $pid = <$fh>; + chomp($pid); + use Proc::ProcessTable; + my $pt = Proc::ProcessTable->new(); + foreach my $p ( @{ $pt->table() } ) { + if ($p->{pid} eq $pid) { + my $name = $0; + $name =~ s/$ENV{PWD}//; + if ($p->{'cmndline'} =~ m#$name#) { + $wp->do_log("LOG_CRIT", "Another process is already running with PID: $pid (running and listed in $wp->{pidfile})"); + # Die locally because do_log will remove pidfile that this iteration does not belong to + exit(1); + } else { + $wp->do_log("LOG_CRIT", "Found matching $pid with different cmdline: $p->{cmndline} (not $0)",1); + # PID in pidfile doesn't look like it is another wallpaper + unlink($wp->{pidfile}); + last; + } + return $p->{'pid'}; + } + } + } else { + $wp->do_log("LOG_CRIT", "Pidfile $wp->{pidfile} exists, but cannot be opened. Assuming it is running already."); + # Die locally because do_log will remove pidfile that this iteration does not belong to + exit(1); + } + } else { + $wp->do_log("LOG_CRIT", "Pidfile $wp->{pidfile} exists, but is not readable. Assuming it is running already."); + # Die locally because do_log will remove pidfile that this iteration does not belong to + exit(1); + } +} + # SIGUSR reset the timer, force immediate reload, continue looping $SIG{USR1} = sub { alarm 0; - run(%settings); + $wp->do_log("LOG_INFO", "Reloading due to SIGUSR1"); }; # SIGTERM reset the timer, clean pid, and allow for the main loop to finish run $SIG{TERM} = sub { - alarm 0; - clean(); - $settings{daemon} = 0; + $wp->do_log("LOG_INFO", "Going down clean due to SIGTERM"); + clean($wp); + $wp->{daemon} = 0; }; # SIGKILL clean pid then exit immediately $SIG{KILL} = sub { - clean(); + $wp->do_log("LOG_INFO", "Hard kill with SIGKILL, attempting to remove pidfile"); + clean($wp); exit(0); }; +# simply returns the array of hashes provided by swaymsg +sub get_outputs +{ + my $self = shift; + my $o = $self->{ipc}->get_outputs->recv() || $self->do_log('LOG_WARNING',"Failed to query 'get_outputs'"); + die "No outputs detected.\n" unless (scalar(@$o) > 0); + return $o; +} + +# returns the same as above but with the 'name' as a hash key for easier lookup +sub get_active +{ + my $self = shift; + if (defined($self->{outputs}) && scalar($self->{outputs})) { + my %active = (); + foreach my $o (@{$self->{outputs}}) { + my $name; + my %details; + next unless ($o->{active}); + $active{$o->{name}} = (); + foreach (keys(%$o)) { + $active{$o->{name}}->{$_} = $o->{$_} unless ($_ eq 'name'); + } + } + $self->do_log('LOG_WARNING',"No outputs detected.") unless (scalar(keys(%active))); + return \%active; + } else { + print "You haven't initialized get_outputs"; + $self->do_log('LOG_WARNING',"Output list not defined yet. Try 'get_outputs()' first"); + return \{}; + } +} + sub clean { - if (-e $pidfile) { - open (my $fh, '<', $pidfile); + my $self = shift; + if (-e $self->{pidfile}) { + open (my $fh, '<', $self->{pidfile}); my $p = <$fh>; close($fh); chomp $p; kill($p); - unlink($pidfile); + unlink($self->{pidfile}); } } sub choose_image { - my @w = glob("$settings{path}/*"); + my $self = shift; + my @w = glob($self->{'path'}."/*"); return undef unless (scalar(@w)); + $self->do_log("LOG_DEBUG", "Found ".scalar(@w)." files in $self->{path}"); my @i; foreach (@w) { - if (-d "$settings{path}/$_") { + if (-d $_) { + $self->do_log("LOG_DEBUG", "Ignoring sub-directory $_"); next; } if ($_ =~ m/\.(png|jpg)$/) { @@ -92,24 +189,9 @@ sub choose_image return $i[rand(scalar(@i))] || return undef; } -sub get_size -{ - my $target = shift; - foreach my $output (@$o) { - if ($output->{'name'} eq $target) { - return ( - #$output->{'current_mode'}->{'width'}, - #$output->{'current_mode'}->{'height'} - $output->{'rect'}->{'width'}, - $output->{'rect'}->{'height'} - ); - } - } - return undef; -} - sub crop { + my $self = shift; my $image = shift; my $ow = shift; my $oh = shift; @@ -117,25 +199,21 @@ sub crop my $cropped = $image; $cropped =~ s#^.*/([^/]*)\.([^\.]+)$#$1-cropped.$2#; + #$image = "/tmp/Pharma-out.png.jpg"; use Image::Magick; my $im = Image::Magick->new(); - - $im->Read($image); + die "$image is not readable" unless (-r $image); + my $ret = $im->Read($image); + return 0 if ($ret); my ($iw, $ih) = $im->Get("columns", "rows"); + $self->do_log("LOG_DEBUG", "Image has dimensions ${iw}x${ih}"); # Return full size if it is smaller in either dimension then the output if ($iw <= $ow || $ih <= $oh) { - print "Not cropping $image because it is too small\n"; - $cropped =~ s#-cropped\.([^\.]+)$#\.$1#; - use File::Copy; - File::Copy::copy($image,$cropped); - return $cropped if (-e $cropped); + $self->do_log("LOG_DEBUG", "Not cropping because ${iw}x${ih} is smaller or equal to the output (${ow}x${oh}) in at least 1 dimension."); return undef; } - print "Image size: $iw $ih\n"; - print "output size: $ow $oh\n"; - my ($x, $y); $x = int(rand($iw-$ow)); $y = int(rand($ih-$oh)); @@ -149,75 +227,118 @@ sub crop return $cropped if ( -e $cropped ); } -sub get_active -{ - my @targets; - foreach (@$o) { - push (@targets, $_->{name}); - } - return @targets; -} - sub set_background { + my $self = shift; my $target = shift || return "No target or image provided"; my $cropped = shift || return "No image provided"; # TODO get fallback from javascript my $cmd = "output $target background $cropped fill #000000"; - print "Running $cmd\n"; - my $ret = $s->message(0,$cmd)->recv; + $self->do_log("LOG_DEBUG", "Running $cmd\n"); + my $ret = $self->{ipc}->message(0,$cmd)->recv; if ($ret->[0]->{success}) { - print "Success!\n"; + $self->do_log("LOG_DEBUG", "Success!"); return undef; } return "Failed to run Sway IPC command '$cmd'"; } -sub run +sub do_log { - my @err; - # Local copy of targets so that it will re-check active every time - my @t = $settings{targets} if (defined($settings{targets})); - unless (scalar(@t)) { - @t = get_active(); - push(@err, "No target outputs") unless (scalar(@t)); - } - foreach my $a (@t) { - my $image = choose_image($settings{path}); - if (defined($image)) { - if ( -r "$image" ) { - print "selected $image\n"; - my $cropped; - if ($settings{crop}) { - if ($settings{debug}) { - print "Cropping image for '$a' using '$image'\n"; - } - my ($ow, $oh) = get_size($a); - $cropped = crop($image, $ow, $oh); - unless ($cropped) { - push(@err, "Failed to generate cropped image") unless ($cropped); - next; - } - } else { - $cropped = $image; - } - my $e = set_background($a,$cropped); - push(@err, $e) if (defined($e)); - if ($settings{crop}) { - print "Deleting $cropped\n"; - #unlink($cropped) || push(@err, "Failed to delete $cropped after setting: $!"); - } - } else { - push(@err, "$a: Unable to read image $image"); - } - } else { - push(@err, "$a: Unable to select image from $settings{path}"); + my $self = shift; + my $level = shift; + my $msg = shift; + my $die = shift || 0; + my $min = $self->{verbose} || 5; + + if ($self->{daemon}) { + use Log::Journald qw(send); + send( + PRIORITY => ERROR->{$level}, + MESSAGE => $msg, + PERL_PACKAGE => 'Sway Wallpapers' + ) || warn "Could not send log ($level $msg): $!"; + if ($die) { + $msg = '(FATAL) ' . $msg; + exit(1); + } + } else { + if (ERROR->{$level} >= $min) { + printf("%11s %s\n", $level, $msg); } } - if ($settings{debug} && $settings{daemon}) { - print STDERR join("\n",@err); - } else { - @e = @err; + if ($die) { + $self->clean(); + exit(1); + } +} + +sub run +{ + my $self = shift; + $self->do_log("LOG_DEBUG", "Fetching outputs from IPC"); + $self->{outputs} = $self->get_outputs(); + # Local copy of targets so that it will re-check active every time + my @t = @{$self->{targets}} if (scalar(@{$self->{targets}})); + $self->do_log("LOG_DEBUG", "Removing inactive ouputs"); + my $active = $self->get_active(); + # If specific targets were not defined, use all active + unless (scalar(@t)) { + @t = keys(%$active); + push(@{$self->{error}}, "No target outputs") unless (scalar(@t)); + } + $self->do_log("LOG_DEBUG", "Looping desired ouputs"); + foreach my $target (@t) { + $self->do_log("LOG_DEBUG", "Ensuring that desired output is active"); + unless (defined($active->{$target})) { + $self->do_log('LOG_DEBUG', "Target $target is not an active output"); + next; + } + $self->do_log("LOG_DEBUG", "Selecting image for $target"); + my $image = $self->choose_image(); + if (defined($image)) { + $self->do_log('LOG_DEBUG', "Selected $image"); + if ( -r "$image" ) { + my $cropped; + if ($self->{crop}) { + $self->do_log('LOG_DEBUG',"Cropping image for '$target' using '$image'"); + $cropped = $self->crop( + $image, + $active->{$target}->{rect}->{width}, + $active->{$target}->{rect}->{height} + ); + if ($cropped) { + } else { + # Create a tmp copy since it will be deleted later + # If PWD is the wallpaper path, make the tmp in /tmp + if ($self->{path} eq $ENV{PWD}) { + $cropped = $image; + $cropped =~ s%$ENV{PWD}%/tmp%; + # Else make the tmp in PWD + } else { + $cropped = '/'.$image; + $cropped =~ s%.*/([^/+])%$ENV{PWD}/$1%; + } + $self->do_log('LOG_DEBUG',"Creating copy of original: $cropped"); + File::Copy::copy($image,$cropped) || $self->do_log('LOG_WARNING',"Failed to copy to $cropped: $!"); + } + } else { + $self->do_log('LOG_DEBUG', "Cropping is disabled"); + $cropped = $image; + } + $self->set_background($target,$cropped); + if ($self->{crop}) { + $self->do_log('LOG_DEBUG', "Deleting $cropped"); + # Give swaybg a second, otherwise the file will be missing before it ends + sleep(1); + unlink($cropped) || $self->do_log("LOG_WARNING", "Failed to delete $cropped after setting: $!"); + } + } else { + $self->do_log("LOG_WARNING", "Failed to read $image"); + } + } else { + $self->do_log("LOG_WARNING", "Failed to select image from $self->{path}"); + } } } @@ -229,27 +350,39 @@ my $daemon; my $delay; my $crop; my $path; +my $verbose; while (my $arg = shift(@ARGV)) { if ($arg eq '-h' || $arg eq '--help') { - usage(); + $wp->usage(); } elsif ($arg =~ m/^\-\-path=?(.+)$/) { die "Redundant argument '$arg'. Wallpaper path already set.\n" if ($path); $path = $1; } elsif ($arg eq '-p') { die "Redundant argument '$arg'. Wallpaper path already set.\n" if ($path); $path = shift(@ARGV); - } elsif ($arg =~ m/^\-\-daemon=?(.+)$/) { + } elsif ($arg =~ m/^\-\-daemon=?(.+)?$/) { die "Redundant argument '$arg'. Daemon mode already set.\n" if ($daemon); - $delay = $1; + $delay = $1 || 300; $daemon = 1; } elsif ($arg eq '-d') { die "Redundant argument '$arg'. Daemon mode already set.\n" if ($daemon); - if ($ARGV[0] =~ m/^\d+$/) { + if (scalar(@ARGV) && $ARGV[0] =~ m/^\d+$/) { $delay = shift(@ARGV); } $daemon = 1; - } elsif ($arg eq '--nocrop' || $arg eq '-') { - die "Redundant argument '$arg'. No-crop already set.\n" unless ($crop); + } elsif ($arg eq '--nocrop' || $arg eq '-n') { + die "Redundant argument '$arg'. No-crop already set.\n" if (defined($crop)); + $crop = 0; + } elsif ($arg =~ m/^\-\-verbose=?(.+)?$/) { + die "Redundant argument '$arg'. Verbosity already set.\n" if ($verbose); + $verbose = $1 || 1; + } elsif ($arg eq '-p') { + die "Redundant argument '$arg'. Verbosity already set.\n" if ($verbose); + if (scalar(@ARGV) && $ARGV[0] =~ m/^\d$/) { + $verbose = shift(@ARGV); + } + } elsif ($arg =~ m/^-/) { + die "Unrecognized argument: $arg\n"; } else { push(@targets,$arg); } @@ -259,51 +392,41 @@ while (my $arg = shift(@ARGV)) { # Validate arguments ################################################################################ +$wp->do_log("LOG_DEBUG", "Validating arguments"); die "Invalid rotation delay: $delay" if (defined($delay) && $delay !~ m/^\d+$/); - die "Invalid wallpaper path '$path'. Not a directory." if (defined($path) && !-d $path); +die "Invalid verbosity level: '$verbose'\n" if (defined($verbose) && $verbose =~ m/^[1-8]$/); -if (scalar(@targets)) { - my @kept; - foreach my $t (@targets) { - my $hit = 0; - foreach (@$o) { - if ($_->{name} eq $t) { - push(@kept, $t); - $hit++; - last; - } - } - print STDERR "Requested output '$t' not found\n" unless ($hit); - } - die "None of the requested outputs are active" unless (scalar(@kept)); - @targets = @kept; -} - -$settings{targets} = @targets || undef; -$settings{daemon} = $daemon || 0; -$settings{path} = $path || "$ENV{HOME}/wallpapers"; -$settings{crop} = $crop || 1; -$settings{delay} = $delay || 300; -$settings{debug} = $debug || 0; +$wp->do_log("LOG_DEBUG", "Configuring object"); +$wp->{targets} = \@targets || undef; +$wp->{daemon} = $daemon || 0; +$wp->{path} = $path || "$ENV{HOME}/wallpapers"; +$wp->{crop} = $crop || 1; +$wp->{delay} = $delay || 300; +$wp->{verbose} = $verbose || 0; +$wp->{error} = []; ################################################################################ -# For if daemonized +# Fork if daemonized ################################################################################ -if ($settings{daemon}) { +if ($wp->{daemon}) { + $wp->do_log("LOG_DEBUG", "Forking daemon"); my $p = fork(); if ($p) { - if (open(my $fh, ">", $pidfile)) { - print $fh "$p" || die "Failed to write pid ($p) to pidfile $pidfile"; + $wp->do_log("LOG_DEBUG", "Writing PID ($p) to pidfile ($wp->{pidfile})"); + if (open(my $fh, ">", $wp->{pidfile})) { + print $fh "$p" || die "Failed to write pid ($p) to pidfile ".$wp->{pidfile}; close($fh); } else { - print "Failed to open pidfile: $pidfile: $!\n"; + print "Failed to open pidfile ".$wp->{pidfile}.": $!\n"; } + # Short delay necessary for SystemD to find PID + sleep(1); exit(0); } - #setpgrp; - #setsid; + $wp->do_log("LOG_DEBUG", "Daemon running"); + setpgrp(0, 0); umask 0; } @@ -311,31 +434,24 @@ if ($settings{daemon}) { # Main ################################################################################ +my $first = 1; do { - run(%settings); - if ($settings{daemon}) { + $wp->do_log("LOG_INFO", "Reloading wallpaper") unless ($first); + $wp->run(); + if ($wp->{daemon}) { my $normal = "reload wallpaper"; eval { - $SIG{ALRM} = sub { print "$normal\n" }; - alarm $settings{delay}; + $SIG{ALRM} = sub { return "$normal" }; + alarm $wp->{delay}; POSIX::pause(); alarm 0; }; - if ($@ && $@ !~ quotemeta($normal)) { - if ($settings{debug}) { - print $normal; - } - } + $wp->do_log("LOG_WARNING", "Reload failed: $@") if ($@ && $@ !~ quotemeta($normal)); } -} while ($settings{daemon}); + $first = 0 if ($first); +} while ($wp->{daemon}); -################################################################################ # If we made it to here, it was not daemonized. Output errors and exit -################################################################################ - -if (scalar(@e)) { - die "Failure while not running as daemon:\n" . - join("\n",@e); -} +$wp->do_log("LOG_DEBUG", "Finishing") unless ($wp->{daemon}); exit(0);