Skip to content

Commit

Permalink
Userland: Add Bits, a BitTorrent GUI client based on LibBitTorrent
Browse files Browse the repository at this point in the history
  • Loading branch information
pdelagrave committed Oct 17, 2023
1 parent d957b6c commit 2a10d95
Show file tree
Hide file tree
Showing 13 changed files with 707 additions and 0 deletions.
7 changes: 7 additions & 0 deletions Base/res/apps/Bits.af
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[App]
Name=Bits
Executable=/bin/Bits
Category=Internet

[Launcher]
FileTypes=torrent
229 changes: 229 additions & 0 deletions Userland/Applications/Bits/BitsWidget.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
/*
* Copyright (c) 2023, Pierre Delagrave <[email protected]>
*
* SPDX-License-Identifier: BSD-2-Clause
*/

#include "BitsWidget.h"
#include "GeneralTorrentInfoWidget.h"
#include <AK/NumberFormat.h>
#include <LibFileSystemAccessClient/Client.h>
#include <LibGUI/Action.h>
#include <LibGUI/Application.h>
#include <LibGUI/BoxLayout.h>
#include <LibGUI/Menu.h>
#include <LibGUI/MessageBox.h>
#include <LibGUI/SortingProxyModel.h>
#include <LibGUI/Splitter.h>

int TorrentModel::column_count(GUI::ModelIndex const&) const
{
return Column::__Count;
}

ErrorOr<String> TorrentModel::column_name(int column) const
{
switch (column) {
case Column::Name:
return "Name"_string;
case Column::Size:
return "Size"_string;
case Column::State:
return "State"_string;
case Column::Progress:
return "Progress"_string;
case Column::DownloadSpeed:
return "Download Speed"_string;
case Column::UploadSpeed:
return "Upload Speed"_string;
case Column::Path:
return "Path"_string;
default:
VERIFY_NOT_REACHED();
}
}

GUI::Variant TorrentModel::data(GUI::ModelIndex const& index, GUI::ModelRole role) const
{
if (role == GUI::ModelRole::TextAlignment)
return Gfx::TextAlignment::CenterLeft;
if (role == GUI::ModelRole::Display) {
auto torrent = torrent_at(index.row());

switch (index.column()) {
case Column::Name:
return torrent.display_name;
case Column::Size:
return AK::human_readable_quantity(torrent.size);
case Column::State:
return state_to_string(torrent.state);
case Column::Progress:
return DeprecatedString::formatted("{:.1}%", torrent.state == BitTorrent::TorrentState::CHECKING ? torrent.check_progress : torrent.progress);
case Column::DownloadSpeed:
return DeprecatedString::formatted("{}/s", human_readable_size(torrent.download_speed));
case Column::UploadSpeed:
return DeprecatedString::formatted("{}/s", human_readable_size(torrent.upload_speed));
case Column::Path:
return torrent.save_path;
default:
VERIFY_NOT_REACHED();
}
}
return {};
}

int TorrentModel::row_count(GUI::ModelIndex const&) const
{
return m_torrents->size();
}

BitTorrent::TorrentView TorrentModel::torrent_at(int index) const
{
return m_torrents->get(m_hashes.at(index)).release_value();
}

void TorrentModel::update(NonnullOwnPtr<HashMap<BitTorrent::InfoHash, BitTorrent::TorrentView>> torrents)
{
m_torrents = move(torrents);
m_hashes = m_torrents->keys();
did_update(UpdateFlag::DontInvalidateIndices);
}

void BitsWidget::open_file(String const& filename, NonnullOwnPtr<Core::File> file, bool start)
{
dbgln("Opening file {}", filename);
auto maybe_meta_info = BitTorrent::MetaInfo::create(*file);
file->close();

if (maybe_meta_info.is_error()) {
GUI::MessageBox::show_error(this->window(), String::formatted("Error parsing torrent file: {}", maybe_meta_info.error()).release_value());
return;
}

auto meta_info = maybe_meta_info.release_value();
auto info_hash = BitTorrent::InfoHash(meta_info->info_hash());
m_engine->add_torrent(move(meta_info), Core::StandardPaths::downloads_directory());

if (start)
m_engine->start_torrent(info_hash);
}

ErrorOr<NonnullRefPtr<BitsWidget>> BitsWidget::create(NonnullRefPtr<BitTorrent::Engine> engine, GUI::Window* window)
{
auto widget = TRY(adopt_nonnull_ref_or_enomem(new (nothrow) BitsWidget(move(engine))));

widget->set_layout<GUI::VerticalBoxLayout>();

auto file_menu = window->add_menu("&File"_string);

file_menu->add_action(GUI::CommonActions::make_open_action([window, widget](auto&) {
FileSystemAccessClient::OpenFileOptions options {
.window_title = "Open a torrent file"sv,
.path = Core::StandardPaths::home_directory(),
.requested_access = Core::File::OpenMode::Read,
.allowed_file_types = { { GUI::FileTypeFilter { "Torrent Files", { { "torrent" } } }, GUI::FileTypeFilter::all_files() } }
};
auto maybe_file = FileSystemAccessClient::Client::the().open_file(window, options);
if (maybe_file.is_error()) {
dbgln("err: {}", maybe_file.error());
return;
}

widget->open_file(maybe_file.value().filename(), maybe_file.value().release_stream(), false);
}));

file_menu->add_action(GUI::CommonActions::make_quit_action([&](auto&) {
GUI::Application::the()->quit();
}));

auto start_torrent_action = GUI::Action::create("Start",
[widget](GUI::Action&) {
widget->m_torrents_table_view->selection().for_each_index([widget](GUI::ModelIndex const& index) {
widget->m_engine->start_torrent(widget->m_torrent_model->torrent_at(index.row()).info_hash);
});
});

auto stop_torrent_action = GUI::Action::create("Stop",
[widget](GUI::Action&) {
widget->m_torrents_table_view->selection().for_each_index([widget](GUI::ModelIndex const& index) {
widget->m_engine->stop_torrent(widget->m_torrent_model->torrent_at(index.row()).info_hash);
});
});

auto cancel_checking_torrent_action = GUI::Action::create("Cancel checking",
[widget](GUI::Action&) {
widget->m_torrents_table_view->selection().for_each_index([widget](GUI::ModelIndex const& index) {
widget->m_engine->cancel_checking(widget->m_torrent_model->torrent_at(index.row()).info_hash);
});
});

auto& main_splitter = widget->add<GUI::VerticalSplitter>();
main_splitter.layout()->set_spacing(4);

widget->m_torrent_model = make_ref_counted<TorrentModel>();
widget->m_torrents_table_view = main_splitter.add<GUI::TableView>();
widget->m_torrents_table_view->set_model(widget->m_torrent_model);
widget->m_torrents_table_view->set_selection_mode(GUI::AbstractView::SelectionMode::MultiSelection);

widget->m_torrents_table_view->on_context_menu_request = [widget, start_torrent_action, stop_torrent_action, cancel_checking_torrent_action](GUI::ModelIndex const& model_index, GUI::ContextMenuEvent const& event) {
if (model_index.is_valid()) {
widget->m_torrent_context_menu = GUI::Menu::construct();
BitTorrent::TorrentState state = widget->m_torrent_model->torrent_at(model_index.row()).state;
if (state == BitTorrent::TorrentState::STOPPED || state == BitTorrent::TorrentState::ERROR)
widget->m_torrent_context_menu->add_action(start_torrent_action);
else if (state == BitTorrent::TorrentState::STARTED || state == BitTorrent::TorrentState::SEEDING)
widget->m_torrent_context_menu->add_action(stop_torrent_action);
else if (state == BitTorrent::TorrentState::CHECKING)
widget->m_torrent_context_menu->add_action(cancel_checking_torrent_action);

widget->m_torrent_context_menu->popup(event.screen_position());
}
};

widget->m_bottom_tab_widget = main_splitter.add<GUI::TabWidget>();
widget->m_bottom_tab_widget->set_preferred_height(14);
widget->m_general_widget = widget->m_bottom_tab_widget->add_tab<GeneralTorrentInfoWidget>("General"_string);
widget->m_peer_list_widget = widget->m_bottom_tab_widget->add_tab<PeerListWidget>("Peers"_string);

auto selected_torrent = [widget]() -> Optional<BitTorrent::TorrentView> {
int selected_index = widget->m_torrents_table_view->selection().first().row();
if (selected_index >= 0)
return widget->m_torrent_model->torrent_at(selected_index);
else
return {};
};

auto update_general_widget = [widget, selected_torrent] {
widget->m_general_widget->update(selected_torrent());
};

auto update_peer_list_widget = [widget, selected_torrent] {
auto peers = selected_torrent().map([&](auto torrent) -> auto { return torrent.peers; }).value_or({});
widget->m_peer_list_widget->update(peers);
};

widget->m_torrents_table_view->on_selection_change = [update_peer_list_widget, update_general_widget] {
update_general_widget();
update_peer_list_widget();
};

widget->m_engine->register_views_update_callback(200, [&, widget, &event_loop = Core::EventLoop::current(), update_general_widget, update_peer_list_widget](NonnullOwnPtr<HashMap<BitTorrent::InfoHash, BitTorrent::TorrentView>> torrents) {
event_loop.deferred_invoke([&, widget, update_general_widget, update_peer_list_widget, torrents = move(torrents)]() mutable {
u64 progress = 0;
for (auto const& torrent : *torrents) {
progress += torrent.value.progress;
}
widget->window()->set_progress(torrents->size() > 0 ? progress / torrents->size() : 0);
widget->m_torrent_model->update(move(torrents));
update_general_widget();
update_peer_list_widget();
});
});

return widget;
}

BitsWidget::BitsWidget(NonnullRefPtr<BitTorrent::Engine> engine)
: m_engine(move(engine))
{
}
61 changes: 61 additions & 0 deletions Userland/Applications/Bits/BitsWidget.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
* Copyright (c) 2023, Pierre Delagrave <[email protected]>
*
* SPDX-License-Identifier: BSD-2-Clause
*/

#pragma once

#include "GeneralTorrentInfoWidget.h"
#include "PeerListWidget.h"
#include <LibBitTorrent/Engine.h>
#include <LibGUI/TableView.h>
#include <LibGUI/Widget.h>

class TorrentModel final : public GUI::Model {
public:
virtual int column_count(GUI::ModelIndex const& index = GUI::ModelIndex()) const override;
virtual ErrorOr<String> column_name(int i) const override;
virtual GUI::Variant data(GUI::ModelIndex const& index, GUI::ModelRole role) const override;
virtual int row_count(GUI::ModelIndex const& index = GUI::ModelIndex()) const override;

BitTorrent::TorrentView torrent_at(int index) const;
void update(NonnullOwnPtr<HashMap<BitTorrent::InfoHash, BitTorrent::TorrentView>>);

private:
enum Column {
Name,
Size,
State,
Progress,
DownloadSpeed,
UploadSpeed,
Path,
__Count
};

NonnullOwnPtr<HashMap<BitTorrent::InfoHash, BitTorrent::TorrentView>> m_torrents { make<HashMap<BitTorrent::InfoHash, BitTorrent::TorrentView>>() };
Vector<BitTorrent::InfoHash> m_hashes;
};

class BitsWidget final : public GUI::Widget {
C_OBJECT(BitsWidget)
public:
static ErrorOr<NonnullRefPtr<BitsWidget>> create(NonnullRefPtr<BitTorrent::Engine> engine, GUI::Window* window);
virtual ~BitsWidget() override = default;
void open_file(String const& filename, NonnullOwnPtr<Core::File>, bool start);

private:
BitsWidget(NonnullRefPtr<BitTorrent::Engine>);
RefPtr<GUI::Menu> m_torrent_context_menu;

RefPtr<GUI::TableView> m_torrents_table_view;
RefPtr<TorrentModel> m_torrent_model;
RefPtr<Core::Timer> m_update_timer;

RefPtr<GUI::TabWidget> m_bottom_tab_widget;
RefPtr<GeneralTorrentInfoWidget> m_general_widget;
RefPtr<PeerListWidget> m_peer_list_widget;

NonnullRefPtr<BitTorrent::Engine> m_engine;
};
15 changes: 15 additions & 0 deletions Userland/Applications/Bits/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
serenity_component(
Bits
RECOMMENDED
TARGETS Bits
)

set(SOURCES
BitsWidget.cpp
GeneralTorrentInfoWidget.cpp
PeerListWidget.cpp
main.cpp
)

serenity_app(Bits ICON hard-disk)
target_link_libraries(Bits PRIVATE LibCore LibGfx LibGUI LibDesktop LibFileSystemAccessClient LibMain LibBitTorrent)
48 changes: 48 additions & 0 deletions Userland/Applications/Bits/GeneralTorrentInfoWidget.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2023, Pierre Delagrave <[email protected]>
*
* SPDX-License-Identifier: BSD-2-Clause
*/

#include "GeneralTorrentInfoWidget.h"
#include <LibGUI/BoxLayout.h>
#include <LibGUI/Painter.h>

TorrentProgressBar::TorrentProgressBar()
{
set_fixed_height(20);
}

void TorrentProgressBar::update(Optional<BitTorrent::BitField> bitfield)
{
m_bitfield = bitfield;
GUI::Widget::update();
}

void TorrentProgressBar::paint_event(GUI::PaintEvent&)
{
GUI::Painter painter(*this);
painter.clear_rect(rect(), Color::White);

if (!m_bitfield.has_value())
return;

int piece_width = max(rect().width() / m_bitfield->size(), 1);
for (int i = 0; i < (int)m_bitfield->size(); i++) {
if (m_bitfield->get(i))
painter.fill_rect({ i * piece_width, 0, piece_width, height() }, Color::Blue);
}
}

GeneralTorrentInfoWidget::GeneralTorrentInfoWidget()
{
set_layout<GUI::VerticalBoxLayout>(4);
m_progress_bar = add<TorrentProgressBar>();
}
void GeneralTorrentInfoWidget::update(Optional<BitTorrent::TorrentView> torrent)
{
if (torrent.has_value())
m_progress_bar->update(torrent.value().bitfield);
else
m_progress_bar->update({});
}
Loading

0 comments on commit 2a10d95

Please sign in to comment.