#!/usr/bin/perl # Copyright (C) 2018-2024 Mageia # Martin Whitaker # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2, or (at your option) # any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software Foundation, # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1335, USA. use strict; use warnings; use Glib qw(TRUE FALSE); use Gtk3 '-init'; use Locale::TextDomain qw(qarepo); use MDK::Common; use URPM; use utf8; my $version = '(devel)'; binmode(STDOUT, ":utf8"); ############################################################################### # States and Status ############################################################################### my %status_text = ( disabled => N("Disabled"), enabled => N("Enabled"), changed => N("Needs update"), failed => N("Update failed") ); my $state; ############################################################################### # Initial Configuration ############################################################################### my $home = $ENV{HOME} || './'; # Only use pkexec if not run by root. my $pkexec = $> ? 'pkexec' : ''; my %config; # Settings are stored in the config file in key=value format. my $config_file = "$home/.qareporc"; if (open(my $f, '<', $config_file)) { while (my $line = <$f>) { chomp($line); my ($key, $value) = split(/=/, $line); $config{$key} = $value if $key; } close($f); } # Use sensible defaults for settings not in the config file. my $mirror = $config{MIRROR} // 'rsync://mirrors.kernel.org/mirrors/mageia'; my $release = $config{RELEASE} // '9'; my $arch = $config{ARCH} // 'x86_64'; my $nonfree = $config{NONFREE} // 1; my $tainted = $config{TAINTED} // 1; my $qa_repo = $config{QA_REPO} // "$home/qa-testing"; my %qa_repo_names = ( i586 => 'QA Testing (32-bit)', x86_64 => 'QA Testing (64-bit)' ); my $qa_repo_name; my $active_qa_repo; my $last_release = $release; my $last_arch = ''; my $fatal_message = N("*** application will terminate ***"); my $fatal_error; ############################################################################### # GUI Main Window ############################################################################### my $window = Gtk3::Window->new('toplevel'); my $grid = Gtk3::Grid->new(); my $label1 = Gtk3::Label->new(N("Mirror:")); my $entry1 = Gtk3::Entry->new(); my $label2 = Gtk3::Label->new(N("Release:")); my $entry2 = Gtk3::Entry->new(); my $label3 = Gtk3::Label->new(N("QA Repo:")); my $entry3 = Gtk3::Entry->new(); # PO: abbreviation of "architecture", e.g. i586, x86_64 my $label4 = Gtk3::Label->new(N("Arch:")); my $entry4 = Gtk3::ComboBoxText->new(); my $label5 = Gtk3::Label->new(N("RPMs:")); my $entry5 = Gtk3::TextView->new(); my $scroll = Gtk3::ScrolledWindow->new(); my $label6 = Gtk3::Label->new(N("Status:")); my $status = Gtk3::Label->new(''); my $button1 = Gtk3::Button->new(N("Quit")); my $button2 = Gtk3::Button->new(N("Disable")); my $button3 = Gtk3::Button->new(N("Enable")); my $button4 = Gtk3::Button->new(N("Clear")); my $button5 = Gtk3::Button->new(N("Downgrade")); my $check0 = Gtk3::CheckButton->new_with_label("core"); my $check1 = Gtk3::CheckButton->new_with_label("nonfree"); my $check2 = Gtk3::CheckButton->new_with_label("tainted"); my $check3 = Gtk3::CheckButton->new_with_label(N("fuzzy\nversion")); # PO: abbreviation of "add dependencies" my $check4 = Gtk3::CheckButton->new_with_label(N("add\ndeps")); $window->set_title("QA Repo $version"); $window->set_default_size(600, 400); $window->set_border_width(10); $window->signal_connect(delete_event => \&quit); $grid->set_row_spacing(10); $grid->set_column_spacing(10); $label1->set_halign('GTK_ALIGN_END'); $entry1->set_text($mirror); $entry1->set_hexpand(TRUE); $entry1->signal_connect(changed => \&changed); $label2->set_halign('GTK_ALIGN_END'); $entry2->set_text($release); $entry2->set_width_chars(2); $entry2->set_hexpand(TRUE); $entry2->signal_connect(changed => \&changed); $label3->set_halign('GTK_ALIGN_END'); $entry3->set_text($qa_repo); $entry3->set_hexpand(TRUE); $entry3->signal_connect(changed => \&changed); $label4->set_halign('GTK_ALIGN_END'); $entry4->append_text('i586'); $entry4->append_text('x86_64'); if ($arch eq 'x86_64') { $entry4->set_active(1); } else { $entry4->set_active(0); } $entry4->signal_connect(changed => \&changed); $label5->set_valign('GTK_ALIGN_START'); $label5->set_halign('GTK_ALIGN_END'); $entry5->get_buffer->signal_connect(changed => \&changed); $scroll->set_hexpand(TRUE); $scroll->set_vexpand(TRUE); $scroll->add($entry5); $label6->set_halign('GTK_ALIGN_END'); $status->set_halign('GTK_ALIGN_START'); $button1->signal_connect(clicked => \&quit); $button2->signal_connect(clicked => \&disable); $button3->signal_connect(clicked => \&enable); $button4->set_vexpand(TRUE); $button4->set_valign('GTK_ALIGN_END'); $button4->signal_connect(clicked => \&clear); $button5->signal_connect(clicked => \&downgrade); $check0->set_active(TRUE); $check0->set_sensitive(FALSE); $check1->set_active($nonfree); $check1->signal_connect(clicked => \&changed); $check2->set_active($tainted); $check2->signal_connect(clicked => \&changed); $check3->set_active(FALSE); $check3->signal_connect(clicked => \&changed); $check4->set_active(FALSE); $check4->signal_connect(clicked => \&changed); $grid->attach($label1, 0, 0, 1, 1); $grid->attach($entry1, 1, 0, 5, 1); $grid->attach($label2, 1, 1, 1, 1); $grid->attach($entry2, 2, 1, 1, 1); $grid->attach($check0, 3, 1, 1, 1); $grid->attach($check1, 4, 1, 1, 1); $grid->attach($check2, 5, 1, 1, 1); $grid->attach($label3, 0, 2, 1, 1); $grid->attach($entry3, 1, 2, 4, 1); $grid->attach($entry4, 5, 2, 1, 1); $grid->attach($label5, 0, 3, 1, 1); $grid->attach($scroll, 1, 3, 5, 5); $grid->attach($label6, 0, 8, 1, 1); $grid->attach($status, 1, 8, 5, 1); $grid->attach($button1, 7, 0, 1, 1); $grid->attach($button2, 7, 2, 1, 1); $grid->attach($button3, 7, 3, 1, 1); $grid->attach($check3, 7, 4, 1, 1); $grid->attach($check4, 7, 5, 1, 1); $grid->attach($button4, 7, 6, 1, 1); $grid->attach($button5, 7, 7, 1, 1); $window->add($grid); ############################################################################### # GUI Dialogue Window ############################################################################### my $dialogue_window = Gtk3::Window->new('toplevel'); my $dialogue_grid = Gtk3::Grid->new(); my $dialogue_label = Gtk3::Label->new(); my $dialogue_text = Gtk3::TextView->new(); my $dialogue_scroll = Gtk3::ScrolledWindow->new(); my $dialogue_button = Gtk3::Button->new(N("Dismiss")); $dialogue_window->set_default_size(600, 300); $dialogue_window->set_border_width(10); $dialogue_window->set_type_hint('dialog'); $dialogue_window->signal_connect(delete_event => \&dialogue_dismiss); $dialogue_grid->set_row_spacing(10); $dialogue_grid->set_column_spacing(10); $dialogue_label->set_halign('GTK_ALIGN_START'); $dialogue_text->set_editable(FALSE); $dialogue_scroll->set_hexpand(TRUE); $dialogue_scroll->set_vexpand(TRUE); $dialogue_scroll->add($dialogue_text); $dialogue_button->signal_connect(clicked => \&dialogue_dismiss); $dialogue_button->set_halign('GTK_ALIGN_CENTER'); $dialogue_grid->attach($dialogue_label, 0, 0, 1, 1); $dialogue_grid->attach($dialogue_scroll, 0, 1, 1, 1); $dialogue_grid->attach($dialogue_button, 0, 2, 1, 1); $dialogue_window->add($dialogue_grid); ############################################################################### # GUI Start ############################################################################### changed(); $window->show_all(); Gtk3->main(); ############################################################################### # GUI Callbacks ############################################################################### sub changed { $arch = trim($entry4->get_active_text()); if ($arch ne $last_arch) { $last_arch = $arch; set_qa_repo_info(); if ($active_qa_repo) { set_state('enabled'); } else { set_state('disabled'); } } else { set_state('changed'); } } sub quit { get_settings(); if (open(my $f, '>', $config_file)) { printf $f "MIRROR=%s\n", $mirror; printf $f "RELEASE=%s\n", $release; printf $f "ARCH=%s\n", $arch; printf $f "NONFREE=%d\n", $nonfree; printf $f "TAINTED=%d\n", $tainted; printf $f "QA_REPO=%s\n", $qa_repo; close($f); } Gtk3->main_quit(); } sub disable { disable_buttons(); disable_repo(); set_state('disabled'); } sub enable { check_no_testing_media(N("This may enable unwanted packages to be installed.")) or return; disable_buttons(); get_settings(); if (sync_repo()) { if ($active_qa_repo) { update_repo(); } else { enable_repo(); } } else { if ($active_qa_repo) { disable_repo(); } } if ($active_qa_repo) { set_state('enabled'); } else { set_state('failed'); } } sub clear { $entry5->get_buffer()->set_text(''); } sub downgrade { check_no_testing_media(N("This may stop some packages from being downgraded.")) or return; disable_buttons(); if ($active_qa_repo) { disable_repo(); } downgrade_packages(); set_state('disabled'); } sub dialogue_dismiss { if ($fatal_error) { Gtk3->main_quit(); } else { $dialogue_window->hide_on_delete() } } ############################################################################### # Subsidiary Functions ############################################################################### sub check_no_testing_media { my ($message2) = @_; if (system("urpmq --list-media active --list-url | grep -q updates_testing") == 0) { my $message1 = N("Some updates_testing media are enabled."); my $message3 = N("Please disable these media and try again."); show_error_dialogue(($message1, $message2, $message3)); return 0; } 1; } sub set_qa_repo_info { $qa_repo_name = $qa_repo_names{$arch}; my $repo_name_and_url = `urpmq --list-url | grep '$qa_repo_name '`; chomp($repo_name_and_url); $repo_name_and_url =~ s#file://##; $active_qa_repo = $repo_name_and_url =~ s/\Q$qa_repo_name\E\s+(\S+)\/$arch/$1/r; if ($repo_name_and_url && $active_qa_repo ne $qa_repo) { disable_repo(); } $entry5->get_buffer->set_text(join("\n", get_existing_rpms())); } sub set_state { my ($new_state) = @_; $state = $new_state; $status->set_label($status_text{$state}); $button1->set_sensitive(TRUE); $button2->set_sensitive($active_qa_repo); $button3->set_sensitive($state ne 'enabled'); $button4->set_sensitive(TRUE); $button5->set_sensitive($state ne 'changed'); if ($state eq 'changed' || $state eq 'failed') { $button3->set_label(N("Update")); } else { $button3->set_label(N("Enable")); } } sub disable_buttons { $button1->set_sensitive(FALSE); $button2->set_sensitive(FALSE); $button3->set_sensitive(FALSE); $button4->set_sensitive(FALSE); $button5->set_sensitive(FALSE); gtk_update(); } sub get_settings { $mirror = trim($entry1->get_text()); $release = trim($entry2->get_text()); $arch = trim($entry4->get_active_text()); $nonfree = $check1->get_active(); $tainted = $check2->get_active(); $qa_repo = trim($entry3->get_text()); if ($active_qa_repo && $active_qa_repo ne $qa_repo) { disable_repo(); } } sub get_requested_rpms { my $buffer = $entry5->get_buffer(); my $start = $buffer->get_start_iter(); my $end = $buffer->get_end_iter(); my $fuzzy_version = $check3->get_active(); my @lines = split("\n", $buffer->get_text($start, $end, FALSE)); if ($fuzzy_version) { # replace version-release with wildcard s/-\d.*-.+(\.mga$release(?:(?:\.$arch|\.noarch)(?:\.rpm)?)?)$/-\\d*$1/ foreach @lines; } s/^\s+// foreach @lines; # trim leading white space s/\s+$// foreach @lines; # trim trailing white space grep { $_ ne '' } @lines; # and discard blank lines } sub get_existing_rpms { map { basename($_) } glob("$qa_repo/$arch/*.rpm"); } sub disable_repo { my $arch_type = $arch eq 'x86_64' ? '64' : '32'; if (system("$pkexec /usr/libexec/qarepo-helper disable $arch_type") == 0) { $active_qa_repo = ''; } else { # PO: preserve %s - it is replaced by the name of the repository my $message = sprintf(N("couldn't disable the '%s' local repository"), $qa_repo_name); show_error_dialogue($message, $fatal_message); print_error($message, 'fatal'); } } sub enable_repo { my $arch_type = $arch eq 'x86_64' ? '64' : '32'; if (system("$pkexec /usr/libexec/qarepo-helper enable $arch_type $qa_repo/$arch") == 0) { $active_qa_repo = $qa_repo; } else { # PO: preserve %s - it is replaced by the name of the repository my $message = sprintf(N("couldn't enable the '%s' local repository"), $qa_repo_name); show_error_dialogue($message); print_error($message); $active_qa_repo = ''; } } sub update_repo { my $arch_type = $arch eq 'x86_64' ? '64' : '32'; if (system("$pkexec /usr/libexec/qarepo-helper update $arch_type") != 0) { # PO: preserve %s - it is replaced by the name of the repository my $message = sprintf(N("couldn't update the '%s' local repository"), $qa_repo_name); show_error_dialogue($message); print_error($message); disable_repo(); } } sub clear_repo { my ($type) = @_; my @existing_rpms = grep { $_ =~ /$type/ } get_existing_rpms(); if (@existing_rpms) { if (!unlink(map { "$qa_repo/$arch/$_" } @existing_rpms)) { my $message = N("couldn't delete existing RPMs in the local repository"); show_error_dialogue($message, $fatal_message); print_error($message, 'fatal'); } } } my @sync_errors; sub sync_repo { $status->set_label(N("Updating")); @sync_errors = (); my $sync_file; if ($mirror =~ /^rsync:/) { $sync_file = \&sync_file_rsync; } elsif ($mirror =~ /^ftp:/ || $mirror =~ /^https?:/) { $sync_file = \&sync_file_aria2; } elsif ($mirror !~ /^\w+:/) { $sync_file = \&sync_file_link; } else { my $message = N("unsupported mirror URL type"); show_error_dialogue($message); print_error($message); return 0; } if ($release ne $last_release) { $last_release = $release; clear_repo(); gtk_update(); } my $add_dependencies = $check4->get_active(); my $remote_repo = "$mirror/distrib/$release/$arch/media"; my $local_repo = "$qa_repo/$arch"; my @mediatypes = ( 'core' ); push @mediatypes, 'nonfree' if $nonfree; push @mediatypes, 'tainted' if $tainted; my $download_dir = "$qa_repo/.download"; mkdir_p($download_dir); my %rpm_dependencies; foreach my $media_type (@mediatypes) { my $synthesis = 'synthesis.hdlist.cz'; my $remote_dir = "$remote_repo/$media_type/updates_testing/media_info"; &$sync_file("$remote_dir/$synthesis", $download_dir) or next; gtk_update(); my $urpm = new URPM; $urpm->parse_synthesis("$download_dir/$synthesis"); $urpm->traverse(sub { my ($pkg) = @_; my $name = $pkg->fullname(); my $rpm = "$name.rpm"; %{$rpm_dependencies{$rpm}} = (); if ($add_dependencies) { my @requires = ( $pkg->requires_nosense(), $pkg->recommends_nosense ); $urpm->traverse_tag('whatprovides', \@requires, sub { my ($pkg) = @_; my $name = $pkg->fullname(); ${$rpm_dependencies{$rpm}}{"$name.rpm"} = 1; }); } }); if (!unlink("$download_dir/$synthesis")) { # PO: preserve %s - it is replaced by the name of the file my $message = sprintf(N("couldn't delete the downloaded synthesis file '%s'"), $download_dir . '/' . $synthesis); show_error_dialogue($message, $fatal_message); print_error($message, 'fatal'); } gtk_update(); } my %selection; my @requests = get_requested_rpms(); while (@requests) { foreach my $request (@requests) { my $pattern = wildcard_to_regexp($request); my $matched = 0; foreach my $candidate (keys %rpm_dependencies) { if ($candidate =~ /^($pattern)((\.($arch|noarch))?\.rpm)?$/) { $selection{$candidate} = 1; $selection{$_} ||= 2 foreach keys %{$rpm_dependencies{$candidate}}; $matched = 1; } } # PO: preserve %s - it is replaced by the name of a package $matched or sync_error(sprintf(N("'%s' was not found in the remote repository"), $request)); } # avoid infinite loop if we haven't found a match last if @sync_errors; # recurse through any new dependencies @requests = grep { $selection{$_} == 2 } keys %selection; } if (@sync_errors) { show_error_dialogue(@sync_errors); return 0; } my @required_rpms = sort keys %selection; my @existing_rpms = get_existing_rpms(); my @unwanted_rpms = difference2(\@existing_rpms, \@required_rpms); if (@unwanted_rpms) { if (!unlink(map { "$local_repo/$_" } @unwanted_rpms)) { my $message = "couldn't delete unwanted RPMs in the local repository"; show_error_dialogue($message, $fatal_message); print_error($message, 'fatal'); } } my $old_pubkey = "$local_repo/media_info/pubkey"; if (-e $old_pubkey) { if (!unlink($old_pubkey)) { my $message = N("couldn't delete the old pubkey in the local repository"); show_error_dialogue($message, $fatal_message); print_error($message, 'fatal'); } } mkdir_p("$local_repo/media_info"); gtk_update(); foreach my $rpm (difference2(\@required_rpms, \@existing_rpms)) { my $remote_url = $remote_repo; if ($rpm =~ /tainted/) { $remote_url .= "/tainted/updates_testing/$rpm"; } elsif ($rpm =~ /nonfree/) { $remote_url .= "/nonfree/updates_testing/$rpm"; } else { $remote_url .= "/core/updates_testing/$rpm"; } &$sync_file($remote_url, $local_repo); gtk_update(); } &$sync_file("$remote_repo/core/updates_testing/media_info/pubkey", "$local_repo/media_info"); gtk_update(); if (@sync_errors) { print_error(N("failed to download all the files")); } else { system("genhdlist2 --allow-empty-media $local_repo") == 0 or sync_error(N("failed to update hdlist")); } if (@sync_errors) { show_error_dialogue(@sync_errors); return 0; } 1; } sub sync_file_rsync { my ($src_url, $dst_dir) = @_; # PO: preserve %s - it is replaced by the URL of the file being downloaded print sprintf(N("fetching %s"), $src_url), "\n"; system("rsync -q $src_url $dst_dir") == 0 # PO: preserve %s - it is replaced by the URL of the file or sync_error(sprintf(N("failed to download file '%s'"), $src_url)); } sub sync_file_aria2 { my ($src_url, $dst_dir) = @_; # PO: preserve %s - it is replaced by the URL of the file being downloaded print sprintf(N("fetching %s"), $src_url), "\n"; system("aria2c -q -d $dst_dir $src_url") == 0 and return 1; # aria2c leaves empty or partially downloaded files. my $dst_file = $dst_dir . '/' . basename($src_url); unlink($dst_file) if -e $dst_file; # PO: preserve %s - it is replaced by the URL of the file sync_error(sprintf(N("failed to download file '%s'"), $src_url)); } sub sync_file_link { my ($src_file, $dst_dir) = @_; -e $src_file && symlink($src_file, $dst_dir . '/' . basename($src_file)) # PO: preserve %s - it is replaced by the URL of the file or sync_error(sprintf(N("failed to link to file '%s'"), $src_file)); } sub sync_error { my ($message) = @_; push @sync_errors, $message; print_error($message); 0; } sub downgrade_packages { my $synthesis = "$qa_repo/$arch/media_info/synthesis.hdlist.cz"; if (! -e $synthesis) { my $message = N("no synthesis file found in the local repository"); show_error_dialogue($message); print_error($message); return 0; } my @packages; my $urpm = new URPM; $urpm->parse_synthesis($synthesis); $urpm->traverse(sub { my ($pkg) = @_; my $full_name = $pkg->fullname; if (system("rpm --quiet -q $full_name") == 0) { push @packages, $pkg->name(); } }); if (@packages) { @packages = sort @packages; show_downgrade_dialogue("urpmi --downgrade @packages"); } else { show_error_dialogue(N("none of the listed packages are installed")); } } sub trim { my ($text) = @_; $text =~ s/^\s+//; $text =~ s/\s+$//; $text; } sub wildcard_to_regexp { my ($pattern) = @_; $pattern =~ s/\./\\./g; $pattern =~ s/\+/\\+/g; $pattern =~ s/\*/.*/g; $pattern =~ s/\?/./g; $pattern; } sub show_downgrade_dialogue { $dialogue_window->set_title(N("Downgrade")); $dialogue_label->set_text(N("The following command may be used to downgrade the listed packages:")); $dialogue_text->get_buffer()->set_text(join("\n", @_)); $dialogue_text->set_wrap_mode('GTK_WRAP_WORD_CHAR'); $dialogue_window->show_all(); } sub show_error_dialogue { $dialogue_window->set_title(N("Error")); $dialogue_label->set_text(N("The following error(s) occurred:")); $dialogue_text->get_buffer()->set_text(join("\n", @_)); $dialogue_text->set_wrap_mode('GTK_WRAP_NONE'); $dialogue_window->show_all(); } sub print_error { my ($message, $o_fatal) = @_; print N("ERROR:"), " ", $message, ".\n"; $fatal_error = $o_fatal; } sub gtk_update { while (Gtk3::events_pending()) { Gtk3::main_iteration(); } } # Use gettext to translate the text. Assume the returned string is UTF-8. sub N { my ($text) = @_; my $str = __($text); utf8::decode($str); $str; }