#!/usr/bin/perl

use strict;
use warnings;
use 5.010;

# VERSION
# PODNAME: systray-mdstat
# ABSTRACT: system tray icon which shows the state of local MD RAIDs

=head1 SYNOPSIS

B<systray-mdstat> [ B<-h> | B<--help> | B<--usage> ]

=head1 DESCRIPTION

This program allows one to display an icon indicating the state of
local Linux Software (MD) RAIDs in any freedesktop.org-compliant
status area.

=head1 SEE ALSO

L<smart-notifier(1)>

=head1 NOTES ABOUT THE ORIGIN OF THE CODE

Parts of the code are very loosely based on the outer framework of
fdpowermon by Wouter Verhelst under Poul-Henning Kamp's "Beer-ware"
license, and the mdstat check from Debian's hobbit-plugins package
written by Christoph Berg under the MIT license. (Both stated that the
amount of code I copied is too small to make their copyright apply,
hence I'm not bound to the licenses they used for their code.)

=cut

use List::Util qw(max);
use File::ShareDir ':ALL';
use Try::Tiny;
use Desktop::Notify;
use Cwd 'abs_path';
use POSIX 'strftime';

my $proc_mdstat = '/proc/mdstat';
my @icon_dirs = qw( share );
my @states = qw( spare ok warn fail );
my @mdstat_checks = (
    # regular expression  => state => string
    [ qr/\[U+\]/                => 1     => 'OK'       ],
    [ qr/(verify|check)/        => 1     => ' (%s)'    ],
    [ qr/(resync(?:=\S+)?)/     => 2     => ' (%s)'    ],
    [ qr/\(F\)/                 => 3     => '<b>FAILED</b>'   ],
    [ qr/\[[U_]*_+[U_]*\]/      => 3     => '<b>DEGRADED</b>' ],
    );

my $old_state = 0;
my $old_text = '';
my $icon;

main() unless caller(0);

sub main {
    use Getopt::Long qw( :config no_auto_abbrev );
    use Gtk3 -init;
    use Glib qw(TRUE FALSE);
    use Pod::Usage;

    my $help;
    my $usage;
    GetOptions(
        'help|h'        => \$help,
        'usage'         => \$usage,
        'read-from=s'   => \$proc_mdstat,
        ) or exit 1;

    pod2usage(0) if $help;
    pod2usage(
            -verbose => 2,
            -exitval => 0,
        ) if $usage;

    if (@ARGV) {
        pod2usage(
            -message => "E: $0 takes no arguments\n",
            -exitval => 1,
            );
    }

    $icon = Gtk3::StatusIcon->new();
    $icon->set_visible(1);
    $icon->signal_connect('button-release-event', \&show_menu);

    populate_icon_dirs();

    check_mdstat();
    Glib::Timeout->add_seconds(3, \&check_mdstat);
    Gtk3->main();
}


sub show_menu {
    my ($icon, $event) = @_;

    # Only show menu if right button was clicked
    if (ref($event) and $event->button == 3) {
        my $right_click = Gtk3::Menu->new;

        my $about_item = Gtk3::ImageMenuItem->new('About');
        $about_item->set_image(
            Gtk3::Image->new_from_icon_name('application-about', 'menu'));
        $about_item->signal_connect('activate' => \&show_about_dialog);
        $right_click->append($about_item);

        my $exit_item = Gtk3::ImageMenuItem->new('Quit');
        $exit_item->set_image(
            Gtk3::Image->new_from_icon_name('application-exit', 'menu'));
        $exit_item->signal_connect('activate' => sub { Gtk3->main_quit(); });
        $right_click->append($exit_item);

        $right_click->show_all;
        $right_click->popup(undef, undef, sub {
            Gtk3::StatusIcon::position_menu($right_click, 0, 0, $icon);
                            }, [1, 1], 0, 0);
    }
    return 1;
}

sub show_about_dialog {
    # https://developer.gnome.org/gtk3/stable/GtkAboutDialog.html
    my $aboutdialog = Gtk3::AboutDialog->new();

    my @authors = ('Axel Beckert');
    my @artists = ('Michael Cramer');

    $aboutdialog->set_program_name('systray-mdstat');
    $aboutdialog->set_copyright(
        "Copyright \xa9 2017-2020 ".join(', ', @authors));
    $aboutdialog->set_license_type('GTK_LICENSE_GPL_3_0');
    # important: set_authors and set_documenters need an array ref!
    # with a normal array it doesn't work!
    $aboutdialog->set_authors(\@authors);
    $aboutdialog->set_artists(\@artists);
    $aboutdialog->set_website('https://github.com/xtaran/systray-mdstat');
    $aboutdialog->set_website_label('systray-mdstat on GitHub');
    # to close the aboutdialog when 'close' is clicked
    $aboutdialog->signal_connect('response' => sub { shift->destroy(); } );

    $aboutdialog->show();
}


sub populate_icon_dirs {
    # Unfortunately File::ShareDir functions die instead of returning
    # undef if they can't find an according directory.
    try {
        push(@icon_dirs, dist_dir('systray-mdstat'));
    };
}


sub read_and_parse_mdstat {
    # 0 = unknown
    # 1 = ok
    # 2 = warning
    # 3 = alarm
    my $state = 0;
    my $text = '';
    my $path = shift;

    my $last_md = '';
    if (-e $path) {
        open(my $MDSTAT, '<', $path)
            or die "Can't read from $path despite it exists: $!";
        while (my $line = <$MDSTAT>) {
            if ($line =~ /^(md\d+) *: /) {
                $last_md = $1;
                $text .= "\n" unless $text eq '';
            } else {
                foreach my $check (@mdstat_checks) {
                    my $use_format = $check->[2] =~ /\%/;
                    if ($line =~ $check->[0]) {
                        $state = max($state, $check->[1]);
                        if ($use_format) {
                            $text .= sprintf($check->[2], $1);
                        } else {
                            $text .= "$last_md: ".$check->[2];
                        }
                    }
                }
            }
        }
        close $MDSTAT;
        $text = "$path exists but doesn't contain any RAID."
            unless $text;
    } else {
        $text = "No $path found";
    }

    return($text, $state);
}

sub check_mdstat {
    my ($text, $state) = read_and_parse_mdstat($proc_mdstat);
    my $timestamp = strftime('%c', localtime);
    &update_icon($icon, $text, $state);

    my $message; # Preamble + time stamp + actual content ($text)
    if ($old_state == 0 and $state != 0) {
        $message = <<"EOM";
$timestamp

Current $proc_mdstat state:

$text
EOM
    } else {
        $message = <<"EOM";
$timestamp

$proc_mdstat state changed:

$text
EOM
    }
    if ($state != $old_state or $text ne $old_text) {
        &send_notification($icon, "Software RAID State", $message, $state);
        $old_state = $state;
        $old_text = $text;
    }

    # Return explicitly true so that Gtk3 continues to run this
    # function.
    return TRUE;
}

sub update_icon {
    my ($icon, $text, $state) = @_;
    my $path = find_icon_path("harddrive".$states[$state]);

    # Strip out HTML tags for tool tips
    $text =~ s/<[^>]*?>//gs;

    if ($icon) {
        $icon->set_tooltip_text($text);
        $icon->set_from_file($path);
    } else {
        return "UPDATE ICON: text=$text path=$path";
    }
}

sub send_notification {
    my ($icon, $title, $text, $state) = @_;

    # Check if we're running under a GUI by looking at $icon
    if ($icon) {
        Desktop::Notify->new(
            app_icon => abs_path(find_icon_path("harddrive".$states[$state])),
        )->create(
            summary => $title,
            body    => $text,
            timeout => 0,
            hints   => {
                urgency => $state-1,
            },
        )->show;
    } else {
        return "NOTIFY: title=$title text=$text";
    }
}

sub find_icon_path {
    my $basename = shift;
    my $filename = "$basename.png";
    my $fullpath;
    foreach my $path (@icon_dirs) {
        if (-r "$path/$filename") {
            $fullpath = "$path/$filename";
            last;
        }
    }
    die "Icon $filename not found inside ".join(', ', @icon_dirs)
        unless $fullpath;
    return $fullpath;
}
