scripts/sway/wallpaper.pl

497 lines
19 KiB
Perl
Executable File

#!/usr/bin/env perl
# 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 @e;
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
in my sway/displays.pl script. If no outputs are provided, all available that
are currently enabled will be set.\n
--path=<path> Path to wallpaper directory.
-p <path> Default: $ENV{HOME}/wallpapers\n
--daemon=N Configures the wallpaper to be periodically rotated for all
-d N of the given outputs. N indicates the number of seconds between
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).
--recursive=N Enumerate images recursively through directories in the path.
-r N N indicates the directory depth, unlimited if no N is provided.
--help This menu
-h\n
You can send SIGUSR1 to force the daemon to reload immediately. Rotation timer
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;
$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 {
$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 {
$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
{
my $self = shift;
if (-e $self->{pidfile}) {
open (my $fh, '<', $self->{pidfile});
my $p = <$fh>;
close($fh);
chomp $p;
kill($p);
unlink($self->{pidfile});
}
}
sub dig_dirs
{
my $self = shift;
my $paths_ref = shift;
my $path = shift;
my $depth = shift || 0;
unless (-e $path) {
return(undef);
}
if ($path =~ m/\/\.\.?$/) {
return;
}
foreach (glob("$path/*"), glob("$path/.*")) {
if ($path =~ m/\/\.\.?$/) {
next();
} elsif (-l $_) {
push(@$paths_ref, $_);
} elsif (-d $_ && ($self->{recursive} == '-1' || $depth < $self->{recursive})) {
$self->dig_dirs($paths_ref,$_,$depth+1);
} else {
push(@$paths_ref, $_);
}
}
}
sub choose_image
{
my $self = shift;
my @i;
my $depth = 0;
my @w;
$self->dig_dirs(\@w,$self->{path});
return undef unless (scalar(@w));
$self->do_log("LOG_DEBUG", "Found ".scalar(@w)." files in $self->{path} up to depth $self->{recursive}");
foreach (@w) {
if (-d $_) {
$self->do_log("LOG_DEBUG", "Ignoring sub-directory $_");
next;
}
if ($_ =~ m/\.(png|jpg)$/) {
push(@i,$_);
}
}
return $i[rand(scalar(@i))] || return undef;
}
sub crop
{
my $self = shift;
my $image = shift;
my $ow = shift;
my $oh = shift;
my $cropped = $image;
$cropped =~ s#^.*/([^/]*)\.([^\.]+)$#$1-cropped.$2#;
#$image = "/tmp/Pharma-out.png.jpg";
use Image::Magick;
my $im = Image::Magick->new();
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) {
$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;
}
my ($x, $y);
$x = int(rand($iw-$ow));
$y = int(rand($ih-$oh));
my $err = $im->Crop(geometry=>"${ow}x${oh}+${x}+${y}");
die "$err" if ($err);
$err = $im->Write($cropped);
die "$err" if ($err);
return $cropped if ( -e $cropped );
}
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";
$self->do_log("LOG_DEBUG", "Running $cmd\n");
my $ret = $self->{ipc}->message(0,$cmd)->recv;
if ($ret->[0]->{success}) {
$self->do_log("LOG_DEBUG", "Success!");
return undef;
}
return "Failed to run Sway IPC command '$cmd'";
}
sub do_log
{
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 ($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}");
}
}
}
################################################################################
# Collect arguments
################################################################################
my @targets;
my $daemon;
my $delay;
my $crop;
my $path;
my $verbose;
my $recursive = 0;
while (my $arg = shift(@ARGV)) {
if ($arg eq '-h' || $arg eq '--help') {
$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=?(.+)?$/) {
die "Redundant argument '$arg'. Daemon mode already set.\n" if ($daemon);
$delay = $1 || 300;
$daemon = 1;
} elsif ($arg eq '-d') {
die "Redundant argument '$arg'. Daemon mode already set.\n" if ($daemon);
if (scalar(@ARGV) && $ARGV[0] =~ m/^\d+$/) {
$delay = shift(@ARGV);
}
$daemon = 1;
} 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 '-v') {
die "Redundant argument '$arg'. Verbosity already set.\n" if ($verbose);
if (scalar(@ARGV) && $ARGV[0] =~ m/^\d$/) {
$verbose = shift(@ARGV);
}
} elsif ($arg =~ m/^\-\-recursive=?(.+)?$/) {
die "Redundant argument '$arg'. Recursive search already set.\n" unless ($recursive == 0);
$recursive = $1 || -1;
} elsif ($arg eq '-r') {
die "Redundant argument '$arg'. Recursive search already set.\n" unless ($recursive == 0);
if (scalar(@ARGV) && $ARGV[0] =~ m/^\d+$/) {
$recursive = shift(@ARGV);
} else {
$recursive = -1;
}
} elsif ($arg =~ m/^-/) {
die "Unrecognized argument: $arg\n";
} else {
push(@targets,$arg);
}
}
################################################################################
# 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]$/);
$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->{recursive}= $recursive;
$wp->{error} = [];
################################################################################
# Fork if daemonized
################################################################################
if ($wp->{daemon}) {
$wp->do_log("LOG_DEBUG", "Forking daemon");
my $p = fork();
if ($p) {
$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 ".$wp->{pidfile}.": $!\n";
}
# Short delay necessary for SystemD to find PID
sleep(1);
exit(0);
}
$wp->do_log("LOG_DEBUG", "Daemon running");
setpgrp(0, 0);
umask 0;
}
################################################################################
# Main
################################################################################
my $first = 1;
do {
$wp->do_log("LOG_INFO", "Reloading wallpaper") unless ($first);
$wp->run();
if ($wp->{daemon}) {
my $normal = "reload wallpaper";
eval {
$SIG{ALRM} = sub { return "$normal" };
alarm $wp->{delay};
POSIX::pause();
alarm 0;
};
$wp->do_log("LOG_WARNING", "Reload failed: $@") if ($@ && $@ !~ quotemeta($normal));
}
$first = 0 if ($first);
} while ($wp->{daemon});
# If we made it to here, it was not daemonized. Output errors and exit
$wp->do_log("LOG_DEBUG", "Finishing") unless ($wp->{daemon});
exit(0);