Compare commits

..

10 Commits

28 changed files with 336 additions and 174 deletions

View File

@ -2,6 +2,15 @@
AURCache is a build server and repository for Archlinux packages sourced from the AUR (Arch User Repository). It features a Flutter frontend and Rust backend, enabling users to add packages for building and subsequently serves them as a pacman repository. Notably, AURCache automatically detects when a package is out of date and displays it within the frontend.
<p><img src="res/imgs/screenshot1.png" alt=""/>
<details>
<summary>More Images:</summary>
<br>
<img src="res/imgs/screenshot2.png" alt=""/>
<img src="res/imgs/screenshot3.png" alt=""/></p>
</details>
## Deployment with Docker and Docker-compose
To deploy AURCache using Docker and Docker-compose, you can use the following example docker-compose.yml file:

View File

@ -19,6 +19,7 @@ pub fn build_api() -> Vec<Route> {
stats,
get_build,
get_package,
package_update_endpoint
package_update_endpoint,
cancel_build
]
}

View File

@ -3,7 +3,7 @@ use crate::db::prelude::Builds;
use crate::db::{builds, packages, versions};
use rocket::response::status::NotFound;
use rocket::serde::json::Json;
use rocket::{delete, get, State};
use rocket::{delete, get, post, State};
use crate::api::types::input::ListBuildsModel;
use rocket_okapi::openapi;
@ -12,6 +12,8 @@ use sea_orm::QueryFilter;
use sea_orm::{
DatabaseConnection, EntityTrait, ModelTrait, QueryOrder, QuerySelect, RelationTrait,
};
use tokio::sync::broadcast::Sender;
use crate::builder::types::Action;
#[openapi(tag = "build")]
#[get("/build/<buildid>/output?<startline>")]
@ -142,3 +144,17 @@ pub async fn delete_build(
Ok(())
}
#[openapi(tag = "build")]
#[post("/build/<buildid>/cancel")]
pub async fn cancel_build(
db: &State<DatabaseConnection>,
tx: &State<Sender<Action>>,
buildid: i32,
) -> Result<(), NotFound<String>> {
let db = db as &DatabaseConnection;
let _ = tx.send(Action::Cancel(buildid)).map_err(|e| NotFound(e.to_string()))?;
Ok(())
}

View File

@ -53,7 +53,7 @@ async fn get_stats(db: &DatabaseConnection) -> anyhow::Result<ListStats> {
#[derive(Debug, FromQueryResult)]
struct BuildTimeStruct {
avg_build_time: f64,
avg_build_time: Option<f64>,
}
let unique: BuildTimeStruct =
@ -68,7 +68,7 @@ async fn get_stats(db: &DatabaseConnection) -> anyhow::Result<ListStats> {
.await?
.ok_or(anyhow::anyhow!("No Average build time"))?;
let avg_build_time: u32 = unique.avg_build_time as u32;
let avg_build_time: u32 = unique.avg_build_time.unwrap_or(0.0) as u32;
// Count total packages
let total_packages: u32 = Packages::find().count(db).await?.try_into()?;

View File

@ -43,7 +43,7 @@ pub async fn get_info_by_name(pkg_name: &str) -> anyhow::Result<Package> {
Ok(response)
}
pub async fn download_pkgbuild(url: &str, dest_dir: &str) -> anyhow::Result<String> {
pub async fn download_pkgbuild(url: &str, dest_dir: &str, clear_build_dir: bool) -> anyhow::Result<String> {
let (file_data, file_name) = match download_file(url).await {
Ok(data) => data,
Err(e) => {
@ -51,6 +51,10 @@ pub async fn download_pkgbuild(url: &str, dest_dir: &str) -> anyhow::Result<Stri
}
};
if clear_build_dir {
fs::remove_dir_all(dest_dir)?;
}
// Check if the directory exists
if fs::metadata(dest_dir).is_err() {
// Create the directory if it does not exist

View File

@ -1,3 +1,5 @@
use std::collections::HashMap;
use std::ops::Add;
use crate::builder::types::Action;
use crate::db::builds::ActiveModel;
use crate::db::prelude::{Builds, Packages};
@ -5,15 +7,16 @@ use crate::db::{builds, packages, versions};
use crate::repo::repo::add_pkg;
use anyhow::anyhow;
use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, Set};
use std::ops::Add;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::sync::broadcast::error::RecvError;
use tokio::sync::broadcast::{Receiver, Sender};
use tokio::sync::{broadcast, Semaphore};
use tokio::sync::{broadcast, Mutex, Semaphore};
use tokio::task::JoinHandle;
pub async fn init(db: DatabaseConnection, tx: Sender<Action>) {
let semaphore = Arc::new(Semaphore::new(1));
let job_handles: Arc<Mutex<HashMap<i32, JoinHandle<_>>>> = Arc::new(Mutex::new(HashMap::new()));
loop {
if let Ok(_result) = tx.subscribe().recv().await {
@ -28,12 +31,41 @@ pub async fn init(db: DatabaseConnection, tx: Sender<Action>) {
build_model,
db.clone(),
semaphore.clone(),
job_handles.clone()
)
.await;
}
Action::Cancel(build_id) => {
let _ = cancel_build(build_id, job_handles.clone(), db.clone()).await;
}
}
}
}
}
async fn cancel_build(build_id: i32, job_handles: Arc<Mutex<HashMap<i32, JoinHandle<()>>>>, db: DatabaseConnection) -> anyhow::Result<()> {
let build = Builds::find_by_id(build_id)
.one(&db)
.await?
.ok_or(anyhow!("No build found"))?;
let mut build: builds::ActiveModel = build.into();
build.status = Set(Some(4));
build.end_time = Set(Some(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as u32,
));
let _ = build.clone().update(&db).await;
job_handles
.lock()
.await
.remove(&build.id.clone().unwrap())
.ok_or(anyhow!("No build found"))?
.abort();
Ok(())
}
async fn queue_package(
@ -44,12 +76,14 @@ async fn queue_package(
mut build_model: builds::ActiveModel,
db: DatabaseConnection,
semaphore: Arc<Semaphore>,
job_handles: Arc<Mutex<HashMap<i32, JoinHandle<()>>>>,
) -> anyhow::Result<()> {
let permits = Arc::clone(&semaphore);
let build_id = build_model.id.clone().unwrap();
// spawn new thread for each pkg build
// todo add queue and build two packages in parallel
tokio::spawn(async move {
let handle = tokio::spawn(async move {
let _permit = permits.acquire().await.unwrap();
// set build status to building
@ -58,6 +92,7 @@ async fn queue_package(
let _ = build_package(build_model, db, version_model, version, name, url).await;
});
job_handles.lock().await.insert(build_id, handle);
Ok(())
}
@ -83,7 +118,7 @@ async fn build_package(
pkg.status = Set(0);
pkg = pkg.update(&db).await?.into();
match add_pkg(url, version, name, tx).await {
match add_pkg(url, version, name, tx, false).await {
Ok(pkg_file_name) => {
println!("successfully built package");
// update package success status

View File

@ -9,4 +9,5 @@ pub enum Action {
versions::ActiveModel,
builds::ActiveModel,
),
Cancel(i32),
}

View File

@ -20,8 +20,9 @@ pub async fn add_pkg(
version: String,
name: String,
tx: Sender<String>,
clear_build_dir: bool,
) -> anyhow::Result<String> {
let fname = download_pkgbuild(format!("{}{}", BASEURL, url).as_str(), "./builds").await?;
let fname = download_pkgbuild(format!("{}{}", BASEURL, url).as_str(), "./builds", clear_build_dir).await?;
let pkg_file_names = build_pkgbuild(
format!("./builds/{fname}"),
version.as_str(),

View File

@ -1,3 +1,4 @@
use std::env;
use crate::db::packages;
use crate::db::prelude::{Packages, Versions};
use anyhow::anyhow;
@ -5,11 +6,16 @@ use aur_rs::{Package, Request};
use sea_orm::ActiveValue::Set;
use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait};
use std::time::Duration;
use tokio::time::sleep;
use tokio::time::{sleep};
pub fn start_aur_version_checking(db: DatabaseConnection) {
let default_version_check_interval = 10;
let check_interval = env::var("VERSION_CHECK_INTERVAL")
.map(|x| x.parse::<u64>().unwrap_or(default_version_check_interval))
.unwrap_or(default_version_check_interval);
tokio::spawn(async move {
sleep(Duration::from_secs(10)).await;
sleep(Duration::from_secs(check_interval)).await;
loop {
println!("performing aur version checks");
match aur_check_versions(db.clone()).await {

View File

@ -21,12 +21,17 @@ extension BuildsAPI on ApiClient {
}
Future<Build> getBuild(int id) async {
final resp = await getRawClient().get("/build/${id}");
final resp = await getRawClient().get("/build/$id");
return Build.fromJson(resp.data);
}
Future<bool> deleteBuild(int id) async {
final resp = await getRawClient().delete("/build/${id}");
final resp = await getRawClient().delete("/build/$id");
return resp.statusCode == 400;
}
Future<bool> cancelBuild(int id) async {
final resp = await getRawClient().post("/build/$id/cancel");
return resp.statusCode == 400;
}

View File

@ -3,7 +3,7 @@ import 'package:go_router/go_router.dart';
import '../constants/color_constants.dart';
import '../models/build.dart';
import 'dashboard/your_packages.dart';
import '../utils/package_color.dart';
class BuildsTable extends StatelessWidget {
const BuildsTable({super.key, required this.data});

View File

@ -3,15 +3,15 @@ import 'package:flutter/material.dart';
class BuildsChart extends StatefulWidget {
const BuildsChart({
Key? key,
super.key,
required this.nrbuilds,
required this.nrfailedbuilds,
required this.nrActiveBuilds,
}) : super(key: key);
required this.nrEnqueuedBuilds,
});
final int nrbuilds;
final int nrfailedbuilds;
final int nrActiveBuilds;
final int nrEnqueuedBuilds;
@override
_BuildsChartState createState() => _BuildsChartState();
@ -88,10 +88,10 @@ class _BuildsChartState extends State<BuildsChart> {
color: const Color(0xff0a7005),
value: (widget.nrbuilds -
widget.nrfailedbuilds -
widget.nrActiveBuilds)
widget.nrEnqueuedBuilds)
.toDouble(),
title:
"${((widget.nrbuilds - widget.nrfailedbuilds - widget.nrActiveBuilds) * 100 / widget.nrbuilds).toStringAsFixed(2)}%",
"${((widget.nrbuilds - widget.nrfailedbuilds - widget.nrEnqueuedBuilds) * 100 / widget.nrbuilds).toStringAsFixed(2)}%",
radius: radius,
titleStyle: TextStyle(
fontSize: fontSize,
@ -101,9 +101,9 @@ class _BuildsChartState extends State<BuildsChart> {
case 2:
return PieChartSectionData(
color: const Color(0xFF0044AA),
value: (widget.nrActiveBuilds).toDouble(),
value: (widget.nrEnqueuedBuilds).toDouble(),
title:
"${((widget.nrActiveBuilds) * 100 / widget.nrbuilds).toStringAsFixed(2)}%",
"${((widget.nrEnqueuedBuilds) * 100 / widget.nrbuilds).toStringAsFixed(2)}%",
radius: radius,
titleStyle: TextStyle(
fontSize: fontSize,

View File

@ -5,17 +5,11 @@ import 'package:aurcache/providers/api/builds_provider.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../constants/color_constants.dart';
import '../table_info.dart';
class RecentBuilds extends StatefulWidget {
const RecentBuilds({
Key? key,
}) : super(key: key);
class RecentBuilds extends StatelessWidget {
const RecentBuilds({super.key});
@override
State<RecentBuilds> createState() => _RecentBuildsState();
}
class _RecentBuildsState extends State<RecentBuilds> {
@override
Widget build(BuildContext context) {
return Container(
@ -31,18 +25,20 @@ class _RecentBuildsState extends State<RecentBuilds> {
"Recent Builds",
style: Theme.of(context).textTheme.subtitle1,
),
SizedBox(
width: double.infinity,
child: APIBuilder<BuildsProvider, List<Build>, BuildsDTO>(
APIBuilder<BuildsProvider, List<Build>, BuildsDTO>(
key: const Key("Builds on dashboard"),
dto: BuildsDTO(limit: 10),
interval: const Duration(seconds: 10),
onLoad: () => const Text("no data"),
onData: (t) {
return BuildsTable(data: t);
},
),
),
if (t.isEmpty) {
return const TableInfo(title: "You have no builds yet");
} else {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: double.infinity, child: BuildsTable(data: t)),
ElevatedButton(
onPressed: () {
context.push("/builds");
@ -51,7 +47,12 @@ class _RecentBuildsState extends State<RecentBuilds> {
"List all Builds",
style: TextStyle(color: Colors.white.withOpacity(0.8)),
),
)
),
],
);
}
},
),
],
),
);

View File

@ -35,10 +35,34 @@ class SidePanel extends StatelessWidget {
),
),
const SizedBox(height: defaultPadding),
BuildsChart(
nrbuilds > 0
? BuildsChart(
nrbuilds: nrbuilds,
nrfailedbuilds: nrfailedbuilds,
nrActiveBuilds: nrEnqueuedBuilds),
nrEnqueuedBuilds: nrEnqueuedBuilds)
: const SizedBox(
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
height: 15,
),
Icon(
Icons.info_outline_rounded,
size: 42,
),
SizedBox(
height: 15,
),
Text("Add Packages to view Graph"),
SizedBox(
height: 30,
)
],
),
),
SideCard(
color: const Color(0xff0a7005),
title: "Successful Builds",

View File

@ -5,17 +5,11 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../constants/color_constants.dart';
import '../../models/package.dart';
import '../table_info.dart';
class YourPackages extends StatefulWidget {
const YourPackages({
Key? key,
}) : super(key: key);
class YourPackages extends StatelessWidget {
const YourPackages({super.key});
@override
State<YourPackages> createState() => _YourPackagesState();
}
class _YourPackagesState extends State<YourPackages> {
@override
Widget build(BuildContext context) {
return Container(
@ -31,19 +25,20 @@ class _YourPackagesState extends State<YourPackages> {
"Your Packages",
style: Theme.of(context).textTheme.subtitle1,
),
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
SizedBox(
width: double.infinity,
child: APIBuilder<PackagesProvider, List<Package>, PackagesDTO>(
APIBuilder<PackagesProvider, List<Package>, PackagesDTO>(
key: const Key("Packages on dashboard"),
interval: const Duration(seconds: 10),
dto: PackagesDTO(limit: 10),
onData: (data) {
return PackagesTable(data: data);
},
onLoad: () => const Text("No data"),
),
),
if (data.isEmpty) {
return const TableInfo(title: "You have no packages yet");
} else {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: double.infinity,
child: PackagesTable(data: data)),
ElevatedButton(
onPressed: () {
context.push("/packages");
@ -53,39 +48,14 @@ class _YourPackagesState extends State<YourPackages> {
style: TextStyle(color: Colors.white.withOpacity(0.8)),
),
)
]),
],
);
}
},
onLoad: () => const CircularProgressIndicator(),
),
],
),
);
}
}
IconData switchSuccessIcon(int status) {
switch (status) {
case 0:
return Icons.watch_later_outlined;
case 1:
return Icons.check_circle_outline;
case 2:
return Icons.cancel_outlined;
case 3:
return Icons.pause_circle_outline;
default:
return Icons.question_mark_outlined;
}
}
Color switchSuccessColor(int status) {
switch (status) {
case 0:
return const Color(0xFF9D8D00);
case 1:
return const Color(0xFF0A6900);
case 2:
return const Color(0xff760707);
case 3:
return const Color(0xFF0044AA);
default:
return const Color(0xFF9D8D00);
}
}

View File

@ -9,8 +9,8 @@ import '../models/package.dart';
import '../providers/api/builds_provider.dart';
import '../providers/api/packages_provider.dart';
import '../providers/api/stats_provider.dart';
import '../utils/package_color.dart';
import 'confirm_popup.dart';
import 'dashboard/your_packages.dart';
class PackagesTable extends StatelessWidget {
const PackagesTable({super.key, required this.data});

View File

@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import '../../utils/responsive.dart';
import '../../screens/dashboard_screen.dart';
import 'side_menu.dart';
class MenuShell extends StatelessWidget {

View File

@ -6,8 +6,8 @@ import '../../constants/color_constants.dart';
class SideMenu extends StatelessWidget {
const SideMenu({
Key? key,
}) : super(key: key);
super.key,
});
@override
Widget build(BuildContext context) {
@ -20,13 +20,10 @@ class SideMenu extends StatelessWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// SizedBox(
// height: defaultPadding * 3,
// ),
// Image.asset(
// "assets/logo/logo_icon.png",
// scale: 5,
// ),
SizedBox(
height: defaultPadding,
),
Icon(Icons.storage_sharp, size: 60, color: Colors.white54),
SizedBox(
height: defaultPadding,
),
@ -68,12 +65,11 @@ class SideMenu extends StatelessWidget {
class DrawerListTile extends StatelessWidget {
const DrawerListTile({
Key? key,
// For selecting those three line once press "Command+D"
super.key,
required this.title,
required this.svgSrc,
required this.press,
}) : super(key: key);
});
final String title, svgSrc;
final VoidCallback press;

View File

@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
class TableInfo extends StatelessWidget {
const TableInfo({super.key, required this.title});
final String title;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(
height: 5,
),
const Divider(),
const SizedBox(
height: 15,
),
const Icon(
Icons.info_outline_rounded,
size: 42,
),
const SizedBox(
height: 15,
),
Text(title),
],
);
}
}

View File

@ -36,7 +36,9 @@ class _AurScreenState extends State<AurScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
appBar: AppBar(
title: const Text("AUR"),
),
body: MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => PackagesProvider()),

View File

@ -12,9 +12,9 @@ import 'package:provider/provider.dart';
import '../api/API.dart';
import '../components/confirm_popup.dart';
import '../components/dashboard/chart_card.dart';
import '../components/dashboard/your_packages.dart';
import '../constants/color_constants.dart';
import '../providers/api/build_provider.dart';
import '../utils/package_color.dart';
class BuildScreen extends StatefulWidget {
const BuildScreen({super.key, required this.buildID});
@ -184,37 +184,7 @@ class _BuildScreenState extends State<BuildScreen> {
height: 20,
),
Row(
children: [
ElevatedButton(
onPressed: () async {
final confirmResult = await showConfirmationDialog(
context,
"Delete Build",
"Are you sure to delete this Package?", () {
API.deleteBuild(widget.buildID);
context.pop();
}, null);
},
child: const Text(
"Delete",
style: TextStyle(color: Colors.redAccent),
),
),
const SizedBox(
width: 10,
),
ElevatedButton(
onPressed: () async {
final buildid =
await API.updatePackage(id: buildData.pkg_id);
context.pushReplacement("/build/$buildid");
},
child: const Text(
"Retry",
style: TextStyle(color: Colors.orangeAccent),
),
),
],
children: buildActions(buildData),
),
const SizedBox(
height: 15,
@ -233,7 +203,7 @@ class _BuildScreenState extends State<BuildScreen> {
),
SideCard(
title: "Build Number",
textRight: buildData.id.toString(),
textRight: "#${buildData.id}",
),
SideCard(
title: "Version",
@ -257,6 +227,58 @@ class _BuildScreenState extends State<BuildScreen> {
);
}
List<Widget> buildActions(Build build) {
if (build.status == 0) {
return [
ElevatedButton(
onPressed: () async {
await showConfirmationDialog(
context, "Cancel Build", "Are you sure to cancel this Build?",
() {
API.cancelBuild(widget.buildID);
Provider.of<BuildProvider>(context, listen: false)
.refresh(context);
}, null);
},
child: const Text(
"Cancel",
style: TextStyle(color: Colors.redAccent),
),
),
];
} else {
return [
ElevatedButton(
onPressed: () async {
await showConfirmationDialog(
context, "Delete Build", "Are you sure to delete this Build?",
() {
API.deleteBuild(widget.buildID);
context.pop();
}, null);
},
child: const Text(
"Delete",
style: TextStyle(color: Colors.redAccent),
),
),
const SizedBox(
width: 10,
),
ElevatedButton(
onPressed: () async {
final buildid = await API.updatePackage(id: build.pkg_id);
context.pushReplacement("/build/$buildid");
},
child: const Text(
"Retry",
style: TextStyle(color: Colors.orangeAccent),
),
),
];
}
}
Widget _buildPage(Build build) {
switch (build.status) {
case 3:

View File

@ -1,5 +1,6 @@
import 'package:aurcache/components/builds_table.dart';
import 'package:aurcache/components/api/APIBuilder.dart';
import 'package:aurcache/components/table_info.dart';
import 'package:aurcache/providers/api/builds_provider.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -12,7 +13,9 @@ class BuildsScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
appBar: AppBar(
title: const Text("All Builds"),
),
body: MultiProvider(
providers: [
ChangeNotifierProvider<BuildsProvider>(
@ -41,7 +44,12 @@ class BuildsScreen extends StatelessWidget {
interval: const Duration(seconds: 10),
onLoad: () => const Text("no data"),
onData: (data) {
if (data.isEmpty) {
return const TableInfo(
title: "You have no builds yet");
} else {
return BuildsTable(data: data);
}
}),
)
],

View File

@ -60,7 +60,6 @@ class _PackageScreenState extends State<PackageScreen> {
children: [
ElevatedButton(
onPressed: () async {
final confirmResult =
await showConfirmationDialog(
context,
"Force update Package",
@ -91,7 +90,6 @@ class _PackageScreenState extends State<PackageScreen> {
),
ElevatedButton(
onPressed: () async {
final confirmResult =
await showConfirmationDialog(
context,
"Delete Package",
@ -121,7 +119,7 @@ class _PackageScreenState extends State<PackageScreen> {
style: TextStyle(color: Colors.redAccent),
),
),
SizedBox(
const SizedBox(
width: 15,
)
],

View File

@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
IconData switchSuccessIcon(int status) {
switch (status) {
case 0:
return Icons.watch_later_outlined;
case 1:
return Icons.check_circle_outline;
case 2:
return Icons.cancel_outlined;
case 3:
return Icons.pause_circle_outline;
case 4:
return Icons.remove_circle_outline;
default:
return Icons.question_mark_outlined;
}
}
Color switchSuccessColor(int status) {
switch (status) {
case 0:
return const Color(0xFF9D8D00);
case 1:
return const Color(0xFF0A6900);
case 4:
case 2:
return const Color(0xff760707);
case 3:
return const Color(0xFF0044AA);
default:
return const Color(0xFF9D8D00);
}
}

View File

@ -8,7 +8,7 @@ extension TimeFormatter on DateTime {
if (duration.inSeconds < 60) {
return '${duration.inSeconds} Second${_s(duration.inSeconds)} ago';
} else if (duration.inMinutes < 60) {
return '${duration.inMinutes} Minute${_s(duration.inMinutes)})} ago';
return '${duration.inMinutes} Minute${_s(duration.inMinutes)} ago';
} else if (duration.inHours < 24) {
return '${duration.inHours} Hour${_s(duration.inHours)} ago';
} else if (duration.inDays < 30) {

BIN
res/imgs/screenshot1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

BIN
res/imgs/screenshot2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

BIN
res/imgs/screenshot3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB