Compare commits

..

3 Commits

Author SHA1 Message Date
spinline
48221b039e fix: build stabilitesi için wasm-opt ve bulk-memory ayarları geri alındı
All checks were successful
Build MIPS Binary / build (push) Successful in 4m52s
2026-02-08 17:37:21 +03:00
spinline
60354b66d1 fix: rustflags sadece wasm32 hedefine özel hale getirilerek uyarılar temizlendi
Some checks failed
Build MIPS Binary / build (push) Failing after 1m2s
2026-02-08 17:31:31 +03:00
spinline
0433406288 perf: WASM boyutunu minimize etmek için agresif profil ayarları ve Brotli eklendi
Some checks failed
Build MIPS Binary / build (push) Failing after 1m14s
2026-02-08 17:29:19 +03:00
9 changed files with 299 additions and 191 deletions

View File

@@ -26,22 +26,23 @@ jobs:
run: | run: |
cd frontend cd frontend
npm install npm install
# Run Tailwind manually first
npx @tailwindcss/cli -i input.css -o public/tailwind.css npx @tailwindcss/cli -i input.css -o public/tailwind.css
# Trunk'ın optimizasyonunu kapalı (0) tutuyoruz çünkü Cargo.toml'daki opt-level='z' zaten o işi yapıyor.
trunk build --release trunk build --release
- name: Build Backend (MIPS) - name: Build Backend (MIPS)
env: env:
# -s ve -w ile binary içindeki gereksiz tüm yükleri siliyoruz. # Ensure we are building a fully static binary
RUSTFLAGS: "-C target-feature=+crt-static -C link-self-contained=no -C link-arg=-msoft-float -C link-arg=-s -C link-arg=-w" # -C link-self-contained=no: Let Zig (the linker) handle CRT objects (crt1.o, etc.)
RUSTFLAGS: "-C target-feature=+crt-static -C link-self-contained=no -C link-arg=-msoft-float"
CFLAGS_mips_unknown_linux_musl: "-msoft-float" CFLAGS_mips_unknown_linux_musl: "-msoft-float"
run: | run: |
# Sadece gerekli özellikleri derliyoruz (Boyut tasarrufu için swagger kapalı) cd backend
cargo zigbuild -p backend --target mips-unknown-linux-musl --release -Z build-std=std,panic_abort --no-default-features --features push-notifications cargo zigbuild --target mips-unknown-linux-musl --release -Z build-std=std,panic_abort
file target/mips-unknown-linux-musl/release/backend
- name: Create Release Assets - name: Rename Binary
run: | run: mv target/mips-unknown-linux-musl/release/backend target/mips-unknown-linux-musl/release/vibetorrent-mips
mv target/mips-unknown-linux-musl/release/backend target/mips-unknown-linux-musl/release/vibetorrent-mips
- name: Generate Release Tag - name: Generate Release Tag
id: tag id: tag
@@ -55,10 +56,8 @@ jobs:
REPO="admin/vibetorrent" REPO="admin/vibetorrent"
API_URL="${{ gitea.server_url }}/api/v1" API_URL="${{ gitea.server_url }}/api/v1"
RELEASE_RESPONSE=$(curl -s -X POST "${API_URL}/repos/${REPO}/releases" \ # Create release
-H "Authorization: token ${RELEASE_TOKEN}" \ RELEASE_RESPONSE=$(curl -s -X POST "${API_URL}/repos/${REPO}/releases" -H "Authorization: token ${RELEASE_TOKEN}" -H "Content-Type: application/json" -d "{
-H "Content-Type: application/json" \
-d "{
\"tag_name\": \"${TAG}\", \"tag_name\": \"${TAG}\",
\"name\": \"Release ${TAG}\", \"name\": \"Release ${TAG}\",
\"body\": \"Automated build from commit ${{ gitea.sha }}\", \"body\": \"Automated build from commit ${{ gitea.sha }}\",
@@ -67,9 +66,15 @@ jobs:
}") }")
RELEASE_ID=$(echo "$RELEASE_RESPONSE" | jq -r '.id') RELEASE_ID=$(echo "$RELEASE_RESPONSE" | jq -r '.id')
if [ "$RELEASE_ID" = "null" ] || [ -z "$RELEASE_ID" ]; then exit 1; fi echo "Release ID: $RELEASE_ID"
curl -s -X POST "${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=vibetorrent-mips" \ if [ "$RELEASE_ID" = "null" ] || [ -z "$RELEASE_ID" ]; then
-H "Authorization: token ${RELEASE_TOKEN}" \ echo "Failed to create release:"
-H "Content-Type: application/octet-stream" \ echo "$RELEASE_RESPONSE"
--data-binary @target/mips-unknown-linux-musl/release/vibetorrent-mips exit 1
fi
# Upload binary as release asset
curl -s -X POST "${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=vibetorrent-mips" -H "Authorization: token ${RELEASE_TOKEN}" -H "Content-Type: application/octet-stream" --data-binary @target/mips-unknown-linux-musl/release/vibetorrent-mips
echo "Release ${TAG} created with binary attached."

View File

@@ -2,19 +2,13 @@
members = ["backend", "frontend", "shared"] members = ["backend", "frontend", "shared"]
resolver = "2" resolver = "2"
# Optimize for size (aggressive)
[profile.release] [profile.release]
# En küçük binary boyutu
opt-level = "z" opt-level = "z"
# En derin kod temizliği (dead code elimination) lto = true
lto = "fat"
# En iyi optimizasyon için tek birim derleme
codegen-units = 1 codegen-units = 1
# Hata izleme kodlarını atarak yer kazan
panic = "abort" panic = "abort"
# Sembolleri ve hata ayıklama bilgilerini kesin sil
strip = true strip = true
# Artık (incremental) build'i kapat ki optimizasyon tam olsun
incremental = false
[patch.crates-io] [patch.crates-io]
coarsetime = { path = "third_party/coarsetime" } coarsetime = { path = "third_party/coarsetime" }

View File

@@ -4,9 +4,8 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[features] [features]
default = ["push-notifications", "swagger"] default = ["push-notifications"]
push-notifications = ["web-push", "openssl"] push-notifications = ["web-push", "openssl"]
swagger = ["utoipa-swagger-ui"]
[dependencies] [dependencies]
axum = { version = "0.8", features = ["macros", "ws"] } axum = { version = "0.8", features = ["macros", "ws"] }
@@ -30,7 +29,7 @@ shared = { path = "../shared" }
thiserror = "2.0.18" thiserror = "2.0.18"
dotenvy = "0.15.7" dotenvy = "0.15.7"
utoipa = { version = "5.4.0", features = ["axum_extras"] } utoipa = { version = "5.4.0", features = ["axum_extras"] }
utoipa-swagger-ui = { version = "9.0.2", features = ["axum"], optional = true } utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] }
web-push = { version = "0.10", default-features = false, features = ["hyper-client"], optional = true } web-push = { version = "0.10", default-features = false, features = ["hyper-client"], optional = true }
base64 = "0.22" base64 = "0.22"
openssl = { version = "0.10", features = ["vendored"], optional = true } openssl = { version = "0.10", features = ["vendored"], optional = true }

View File

@@ -32,9 +32,7 @@ use tower_http::{
cors::CorsLayer, cors::CorsLayer,
trace::TraceLayer, trace::TraceLayer,
}; };
#[cfg(feature = "swagger")]
use utoipa::OpenApi; use utoipa::OpenApi;
#[cfg(feature = "swagger")]
use utoipa_swagger_ui::SwaggerUi; use utoipa_swagger_ui::SwaggerUi;
#[derive(Clone)] #[derive(Clone)]
@@ -100,7 +98,6 @@ struct Args {
reset_password: Option<String>, reset_password: Option<String>,
} }
#[cfg(feature = "swagger")]
#[cfg(feature = "push-notifications")] #[cfg(feature = "push-notifications")]
#[derive(OpenApi)] #[derive(OpenApi)]
#[openapi( #[openapi(
@@ -149,7 +146,6 @@ struct Args {
)] )]
struct ApiDoc; struct ApiDoc;
#[cfg(feature = "swagger")]
#[cfg(not(feature = "push-notifications"))] #[cfg(not(feature = "push-notifications"))]
#[derive(OpenApi)] #[derive(OpenApi)]
#[openapi( #[openapi(
@@ -466,13 +462,9 @@ async fn main() {
} }
}); });
let app = Router::new(); let app = Router::new()
.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi()))
#[cfg(feature = "swagger")] // Setup & Auth Routes
let app = app.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi()));
// Setup & Auth Routes
let app = app
.route("/api/setup/status", get(handlers::setup::get_setup_status_handler)) .route("/api/setup/status", get(handlers::setup::get_setup_status_handler))
.route("/api/setup", post(handlers::setup::setup_handler)) .route("/api/setup", post(handlers::setup::setup_handler))
.route( .route(
@@ -524,9 +516,9 @@ async fn main() {
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.layer( .layer(
CompressionLayer::new() CompressionLayer::new()
.br(false) .br(true)
.gzip(true) .gzip(true)
.quality(CompressionLevel::Fastest), .quality(CompressionLevel::Best),
) )
.layer( .layer(
ServiceBuilder::new() ServiceBuilder::new()

View File

@@ -52,7 +52,15 @@ web-sys = { version = "0.3", features = [
shared = { path = "../shared" } shared = { path = "../shared" }
tailwind_fuse = "0.3.2" tailwind_fuse = "0.3.2"
js-sys = "0.3.85" js-sys = "0.3.85"
base64 = "0.22.1"
serde-wasm-bindgen = "0.6.5" [profile.release]
leptos-use = "0.13" # En yüksek seviyede boyut optimizasyonu
codee = "0.2" opt-level = "z"
# Tüm kütüphaneler arasında link-time optimizasyonu yapar (Çok etkilidir)
lto = true
# Kod üretim birimini 1'e düşürerek daha iyi optimizasyon sağlar
codegen-units = 1
# Hata durumunda stack unwinding yerine doğrudan durur (Kod boyutunu düşürür)
panic = "abort"
# Sembolleri ve debug bilgilerini siler
strip = true

View File

@@ -1,5 +1,4 @@
use leptos::*; use leptos::*;
use leptos_use::on_click_outside;
#[component] #[component]
pub fn ContextMenu( pub fn ContextMenu(
@@ -9,10 +8,6 @@ pub fn ContextMenu(
on_close: Callback<()>, on_close: Callback<()>,
on_action: Callback<(String, String)>, // (Action, Hash) on_action: Callback<(String, String)>, // (Action, Hash)
) -> impl IntoView { ) -> impl IntoView {
let container_ref = create_node_ref::<html::Div>();
let _ = on_click_outside(container_ref, move |_| on_close.call(()));
let handle_action = move |action: &str| { let handle_action = move |action: &str| {
let hash = torrent_hash.clone(); let hash = torrent_hash.clone();
let action_str = action.to_string(); let action_str = action.to_string();
@@ -27,8 +22,16 @@ pub fn ContextMenu(
} }
view! { view! {
// Backdrop to catch clicks outside
<div
class="fixed inset-0 z-[99] cursor-default"
role="button"
tabindex="-1"
on:click=move |_| on_close.call(())
on:contextmenu=move |e| e.prevent_default()
></div>
<div <div
node_ref=container_ref
class="fixed z-[100] min-w-[200px] animate-in fade-in zoom-in-95 duration-100" class="fixed z-[100] min-w-[200px] animate-in fade-in zoom-in-95 duration-100"
style=format!("left: {}px; top: {}px", position.0, position.1) style=format!("left: {}px; top: {}px", position.0, position.1)
on:contextmenu=move |e| e.prevent_default() on:contextmenu=move |e| e.prevent_default()

View File

@@ -1,7 +1,4 @@
use leptos::*; use leptos::*;
use leptos_use::on_click_outside;
use leptos_use::storage::use_local_storage;
use codee::string::FromToStringCodec;
use shared::GlobalLimitRequest; use shared::GlobalLimitRequest;
fn format_bytes(bytes: i64) -> String { fn format_bytes(bytes: i64) -> String {
@@ -29,19 +26,34 @@ pub fn StatusBar() -> impl IntoView {
let store = use_context::<crate::store::TorrentStore>().expect("store not provided"); let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
let stats = store.global_stats; let stats = store.global_stats;
// Use leptos-use for reactive localStorage management let initial_theme = if let Some(win) = web_sys::window() {
let (current_theme, set_current_theme, _) = use_local_storage::<String, FromToStringCodec>("vibetorrent_theme"); if let Some(doc) = win.document() {
doc.document_element()
.and_then(|el| el.get_attribute("data-theme"))
.unwrap_or_else(|| "dark".to_string())
} else {
"dark".to_string()
}
} else {
"dark".to_string()
};
// Initialize with default if empty let (current_theme, set_current_theme) = create_signal(initial_theme);
if current_theme.get_untracked().is_empty() {
set_current_theme.set("dark".to_string());
}
// Automatically sync theme to document attribute
create_effect(move |_| { create_effect(move |_| {
let theme = current_theme.get().to_lowercase(); if let Some(win) = web_sys::window() {
if let Some(doc) = document().document_element() { if let Some(storage) = win.local_storage().ok().flatten() {
let _ = doc.set_attribute("data-theme", &theme); if let Ok(Some(stored_theme)) = storage.get_item("vibetorrent_theme") {
let theme = stored_theme.to_lowercase();
set_current_theme.set(theme.clone());
if let Some(doc) = win.document() {
let _ = doc
.document_element()
.unwrap()
.set_attribute("data-theme", &theme);
}
}
}
} }
}); });
@@ -115,24 +127,26 @@ pub fn StatusBar() -> impl IntoView {
set_active_dropdown.set(0); set_active_dropdown.set(0);
}; };
// Refs for click outside detection
let down_ref = create_node_ref::<html::Div>();
let up_ref = create_node_ref::<html::Div>();
let theme_ref = create_node_ref::<html::Div>();
let _ = on_click_outside(down_ref, move |_| if active_dropdown.get_untracked() == 1 { close_all() });
let _ = on_click_outside(up_ref, move |_| if active_dropdown.get_untracked() == 2 { close_all() });
let _ = on_click_outside(theme_ref, move |_| if active_dropdown.get_untracked() == 3 { close_all() });
view! { view! {
// Transparent overlay to close dropdowns when clicking outside
<Show when=move || active_dropdown.get() != 0>
<div
class="fixed inset-0 z-[98] cursor-default"
on:pointerdown=move |_| close_all()
></div>
</Show>
<div class="fixed bottom-0 left-0 right-0 h-8 min-h-8 bg-base-200 border-t border-base-300 flex items-center px-4 text-xs gap-4 text-base-content/70 z-[99]"> <div class="fixed bottom-0 left-0 right-0 h-8 min-h-8 bg-base-200 border-t border-base-300 flex items-center px-4 text-xs gap-4 text-base-content/70 z-[99]">
// --- DOWNLOAD SPEED DROPDOWN --- // --- DOWNLOAD SPEED DROPDOWN ---
<div class="relative" node_ref=down_ref> <div class="relative">
<div <div
class="flex items-center gap-2 cursor-pointer hover:text-primary transition-colors select-none" class="flex items-center gap-2 cursor-pointer hover:text-primary transition-colors select-none"
title="Global Download Speed - Click to Set Limit" title="Global Download Speed - Click to Set Limit"
on:click=move |_| toggle(1) on:pointerdown=move |e| {
e.stop_propagation();
toggle(1);
}
> >
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 13.5L12 21m0 0l-7.5-7.5M12 21V3" /> <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 13.5L12 21m0 0l-7.5-7.5M12 21V3" />
@@ -159,7 +173,8 @@ pub fn StatusBar() -> impl IntoView {
<li> <li>
<button <button
class=move || if is_active() { "bg-primary/10 text-primary font-bold text-xs flex justify-between" } else { "text-xs flex justify-between" } class=move || if is_active() { "bg-primary/10 text-primary font-bold text-xs flex justify-between" } else { "text-xs flex justify-between" }
on:click=move |_| { on:pointerdown=move |e| {
e.stop_propagation();
set_limit("down", val); set_limit("down", val);
close_all(); close_all();
} }
@@ -177,11 +192,14 @@ pub fn StatusBar() -> impl IntoView {
</div> </div>
// --- UPLOAD SPEED DROPDOWN --- // --- UPLOAD SPEED DROPDOWN ---
<div class="relative" node_ref=up_ref> <div class="relative">
<div <div
class="flex items-center gap-2 cursor-pointer hover:text-primary transition-colors select-none" class="flex items-center gap-2 cursor-pointer hover:text-primary transition-colors select-none"
title="Global Upload Speed - Click to Set Limit" title="Global Upload Speed - Click to Set Limit"
on:click=move |_| toggle(2) on:pointerdown=move |e| {
e.stop_propagation();
toggle(2);
}
> >
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 10.5L12 3m0 0l7.5 7.5M12 3v18" /> <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 10.5L12 3m0 0l7.5 7.5M12 3v18" />
@@ -208,7 +226,8 @@ pub fn StatusBar() -> impl IntoView {
<li> <li>
<button <button
class=move || if is_active() { "bg-primary/10 text-primary font-bold text-xs flex justify-between" } else { "text-xs flex justify-between" } class=move || if is_active() { "bg-primary/10 text-primary font-bold text-xs flex justify-between" } else { "text-xs flex justify-between" }
on:click=move |_| { on:pointerdown=move |e| {
e.stop_propagation();
set_limit("up", val); set_limit("up", val);
close_all(); close_all();
} }
@@ -226,11 +245,14 @@ pub fn StatusBar() -> impl IntoView {
</div> </div>
<div class="ml-auto flex items-center gap-4"> <div class="ml-auto flex items-center gap-4">
<div class="relative" node_ref=theme_ref> <div class="relative">
<div <div
class="btn btn-ghost btn-xs btn-square" class="btn btn-ghost btn-xs btn-square"
title="Change Theme" title="Change Theme"
on:click=move |_| toggle(3) on:pointerdown=move |e| {
e.stop_propagation();
toggle(3);
}
> >
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.53 16.122a3 3 0 0 0-5.78 1.128 2.25 2.25 0 0 1-2.4 2.245 4.5 4.5 0 0 0 8.4-2.245c0-.399-.078-.78-.22-1.128Zm0 0a15.998 15.998 0 0 0 3.388-1.62m-5.043-.025a15.994 15.994 0 0 1 1.622-3.395m3.42 3.42a15.995 15.995 0 0 0 4.764-4.648l3.876-5.814a1.151 1.151 0 0 0-1.597-1.597L14.146 6.32a15.996 15.996 0 0 0-4.649 4.763m3.42 3.42a6.776 6.776 0 0 0-3.42-3.42" /> <path stroke-linecap="round" stroke-linejoin="round" d="M9.53 16.122a3 3 0 0 0-5.78 1.128 2.25 2.25 0 0 1-2.4 2.245 4.5 4.5 0 0 0 8.4-2.245c0-.399-.078-.78-.22-1.128Zm0 0a15.998 15.998 0 0 0 3.388-1.62m-5.043-.025a15.994 15.994 0 0 1 1.622-3.395m3.42 3.42a15.995 15.995 0 0 0 4.764-4.648l3.876-5.814a1.151 1.151 0 0 0-1.597-1.597L14.146 6.32a15.996 15.996 0 0 0-4.649 4.763m3.42 3.42a6.776 6.776 0 0 0-3.42-3.42" />
@@ -250,8 +272,17 @@ pub fn StatusBar() -> impl IntoView {
<li> <li>
<button <button
class=move || if current_theme.get() == theme { "bg-primary/10 text-primary font-bold text-xs capitalize" } else { "text-xs capitalize" } class=move || if current_theme.get() == theme { "bg-primary/10 text-primary font-bold text-xs capitalize" } else { "text-xs capitalize" }
on:click=move |_| { on:pointerdown=move |e| {
e.stop_propagation();
set_current_theme.set(theme.to_string()); set_current_theme.set(theme.to_string());
if let Some(win) = web_sys::window() {
if let Some(doc) = win.document() {
let _ = doc.document_element().unwrap().set_attribute("data-theme", theme);
}
if let Some(storage) = win.local_storage().ok().flatten() {
let _ = storage.set_item("vibetorrent_theme", theme);
}
}
close_all(); close_all();
} }
> >

View File

@@ -1,5 +1,6 @@
use leptos::*; use leptos::*;
use leptos_use::{on_click_outside, use_timeout_fn}; use wasm_bindgen::closure::Closure;
use wasm_bindgen::JsCast;
use crate::store::{get_action_messages, show_toast_with_signal}; use crate::store::{get_action_messages, show_toast_with_signal};
use shared::NotificationLevel; use shared::NotificationLevel;
@@ -166,8 +167,6 @@ pub fn TorrentTable() -> impl IntoView {
// Signal-based sort dropdown for mobile // Signal-based sort dropdown for mobile
let (sort_open, set_sort_open) = create_signal(false); let (sort_open, set_sort_open) = create_signal(false);
let sort_menu_ref = create_node_ref::<html::Div>();
let _ = on_click_outside(sort_menu_ref, move |_| set_sort_open.set(false));
let sort_arrow = move |col: SortColumn| { let sort_arrow = move |col: SortColumn| {
if sort_col.get() == col { if sort_col.get() == col {
@@ -346,11 +345,12 @@ pub fn TorrentTable() -> impl IntoView {
<div class="px-3 py-2 border-b border-base-200 flex justify-between items-center bg-base-100/95 backdrop-blur z-10 shrink-0"> <div class="px-3 py-2 border-b border-base-200 flex justify-between items-center bg-base-100/95 backdrop-blur z-10 shrink-0">
<span class="text-xs font-bold opacity-50 uppercase tracking-wider">"Torrents"</span> <span class="text-xs font-bold opacity-50 uppercase tracking-wider">"Torrents"</span>
<div class="relative" node_ref=sort_menu_ref> <div class="relative">
<div <div
role="button" role="button"
class="btn btn-ghost btn-xs gap-1 opacity-70 font-normal" class="btn btn-ghost btn-xs gap-1 opacity-70 font-normal"
on:click=move |_| { on:pointerdown=move |e| {
e.stop_propagation();
let cur = sort_open.get_untracked(); let cur = sort_open.get_untracked();
set_sort_open.set(!cur); set_sort_open.set(!cur);
} }
@@ -386,7 +386,8 @@ pub fn TorrentTable() -> impl IntoView {
<button <button
type="button" type="button"
class=move || if is_active() { "bg-primary/10 text-primary font-bold flex justify-between" } else { "flex justify-between" } class=move || if is_active() { "bg-primary/10 text-primary font-bold flex justify-between" } else { "flex justify-between" }
on:click=move |_| { on:pointerdown=move |e| {
e.stop_propagation();
handle_sort(col); handle_sort(col);
set_sort_open.set(false); set_sort_open.set(false);
} }
@@ -422,45 +423,58 @@ pub fn TorrentTable() -> impl IntoView {
let _t_hash = t.hash.clone(); let _t_hash = t.hash.clone();
let t_hash_click = t.hash.clone(); let t_hash_click = t.hash.clone();
let (timer_id, set_timer_id) = create_signal(Option::<i32>::None);
let t_hash_long = t.hash.clone(); let t_hash_long = t.hash.clone();
let leptos_use::UseTimeoutFnReturn { start, stop, .. } = use_timeout_fn(
move |pos: (i32, i32)| {
set_menu_position.set(pos);
set_selected_hash.set(Some(t_hash_long.clone()));
set_menu_visible.set(true);
// Haptic feedback let clear_timer = move || {
let navigator = window().navigator(); if let Some(id) = timer_id.get_untracked() {
if let Ok(vibrate) = js_sys::Reflect::get(&navigator, &"vibrate".into()) { window().clear_timeout_with_handle(id);
if vibrate.is_function() { set_timer_id.set(None);
let _ = navigator.vibrate_with_duration(50); }
} };
}
},
600.0,
);
let handle_touchstart = { let handle_touchstart = {
let start = start.clone(); let t_hash = t_hash_long.clone();
move |e: web_sys::TouchEvent| { move |e: web_sys::TouchEvent| {
clear_timer();
if let Some(touch) = e.touches().get(0) { if let Some(touch) = e.touches().get(0) {
start((touch.client_x(), touch.client_y())); let x = touch.client_x();
let y = touch.client_y();
let hash = t_hash.clone();
let closure = Closure::wrap(Box::new(move || {
set_menu_position.set((x, y));
set_selected_hash.set(Some(hash.clone()));
set_menu_visible.set(true);
// Haptic feedback (iOS Safari doesn't support vibrate)
let navigator = window().navigator();
if js_sys::Reflect::has(&navigator, &wasm_bindgen::JsValue::from_str("vibrate")).unwrap_or(false) {
let _ = navigator.vibrate_with_duration(50);
}
}) as Box<dyn Fn()>);
let id = window()
.set_timeout_with_callback_and_timeout_and_arguments_0(
closure.as_ref().unchecked_ref(),
600
)
.unwrap_or(0);
closure.forget();
set_timer_id.set(Some(id));
} }
} }
}; };
let handle_touchmove = { let handle_touchmove = move |_| {
let stop = stop.clone(); clear_timer();
move |_| stop()
}; };
let handle_touchend = { let handle_touchend = move |_| {
let stop = stop.clone(); clear_timer();
move |_| stop()
}; };
let handle_touchcancel = move |_| stop();
view! { view! {
<div <div
class=move || { class=move || {
@@ -478,7 +492,7 @@ pub fn TorrentTable() -> impl IntoView {
on:touchstart=handle_touchstart on:touchstart=handle_touchstart
on:touchmove=handle_touchmove on:touchmove=handle_touchmove
on:touchend=handle_touchend on:touchend=handle_touchend
on:touchcancel=handle_touchcancel on:touchcancel=handle_touchend
> >
<div class="card-body gap-3"> <div class="card-body gap-3">
<div class="flex justify-between items-start gap-2"> <div class="flex justify-between items-start gap-2">

View File

@@ -328,46 +328,67 @@ pub async fn subscribe_to_push_notifications() {
// First, request notification permission if not already granted // First, request notification permission if not already granted
let window = web_sys::window().expect("window should exist"); let window = web_sys::window().expect("window should exist");
let permission_granted = if let Ok(notification_class) = js_sys::Reflect::get(&window, &"Notification".into()) {
// Notification.permission is a static property, but web_sys exposes it via the Notification class instance or we check it manually. if notification_class.is_undefined() {
// Actually, Notification::permission() is a static method in web_sys. log::error!("Notification API not available");
match web_sys::Notification::permission() {
web_sys::NotificationPermission::Granted => {
log::info!("Notification permission already granted");
}
web_sys::NotificationPermission::Denied => {
log::warn!("Notification permission was denied");
return; return;
} }
web_sys::NotificationPermission::Default => {
log::info!("Requesting notification permission...");
let permission_promise = match web_sys::Notification::request_permission() {
Ok(p) => p,
Err(e) => {
log::error!("Failed to request notification permission: {:?}", e);
return;
}
};
match wasm_bindgen_futures::JsFuture::from(permission_promise).await { // Check current permission
Ok(val) => { let current_permission = js_sys::Reflect::get(&notification_class, &"permission".into())
let permission = val.as_string().unwrap_or_default(); .ok()
if permission != "granted" { .and_then(|p| p.as_string())
log::warn!("Notification permission denied by user"); .unwrap_or_default();
return;
if current_permission == "granted" {
log::info!("Notification permission already granted");
true
} else if current_permission == "denied" {
log::warn!("Notification permission was denied");
return;
} else {
// Permission is "default" - need to request
log::info!("Requesting notification permission...");
if let Ok(request_fn) = js_sys::Reflect::get(&notification_class, &"requestPermission".into()) {
if request_fn.is_function() {
let request_fn_typed = js_sys::Function::from(request_fn);
match request_fn_typed.call0(&notification_class) {
Ok(promise_val) => {
let request_future = wasm_bindgen_futures::JsFuture::from(
js_sys::Promise::from(promise_val)
);
match request_future.await {
Ok(result) => {
let result_str = result.as_string().unwrap_or_default();
log::info!("Permission request result: {}", result_str);
result_str == "granted"
}
Err(e) => {
log::error!("Failed to request notification permission: {:?}", e);
false
}
}
}
Err(e) => {
log::error!("Failed to call requestPermission: {:?}", e);
false
}
} }
log::info!("Notification permission granted by user"); } else {
} false
Err(e) => {
log::error!("Failed to await notification permission: {:?}", e);
return;
} }
} else {
false
} }
} }
_ => { } else {
log::warn!("Unknown notification permission status"); log::error!("Cannot access Notification class");
return; return;
} };
if !permission_granted {
log::warn!("Notification permission not granted, cannot subscribe to push");
return;
} }
log::info!("Notification permission granted! Proceeding with push subscription..."); log::info!("Notification permission granted! Proceeding with push subscription...");
@@ -412,6 +433,7 @@ pub async fn subscribe_to_push_notifications() {
}; };
// Get service worker registration // Get service worker registration
let window = web_sys::window().expect("window should exist");
let navigator = window.navigator(); let navigator = window.navigator();
let service_worker = navigator.service_worker(); let service_worker = navigator.service_worker();
@@ -472,45 +494,38 @@ pub async fn subscribe_to_push_notifications() {
.dyn_into::<web_sys::PushSubscription>() .dyn_into::<web_sys::PushSubscription>()
.expect("should be PushSubscription"); .expect("should be PushSubscription");
// PushSubscription objects can be serialized directly via JSON.stringify which calls their toJSON method internally. // Get subscription JSON using toJSON() method
// Or we can use Reflect to call toJSON if we want the object directly. let json_result = match js_sys::Reflect::get(&push_subscription, &"toJSON".into()) {
// Let's use the robust way: call toJSON via Reflect but handle it gracefully.
let json_val = match js_sys::Reflect::get(&push_subscription, &"toJSON".into()) {
Ok(func) if func.is_function() => { Ok(func) if func.is_function() => {
let json_func = js_sys::Function::from(func); let json_func = js_sys::Function::from(func);
match json_func.call0(&push_subscription) { match json_func.call0(&push_subscription) {
Ok(res) => res, Ok(result) => result,
Err(e) => {
log::error!("Failed to call toJSON: {:?}", e);
return;
}
}
}
_ => {
// Fallback: try to stringify the object directly
// log::warn!("toJSON not found, trying JSON.stringify");
let json_str = match js_sys::JSON::stringify(&push_subscription) {
Ok(s) => s,
Err(e) => { Err(e) => {
log::error!("Failed to stringify subscription: {:?}", e); log::error!("Failed to call toJSON: {:?}", e);
return;
}
};
// Parse back to object to match our expected flow (slightly inefficient but safe)
match js_sys::JSON::parse(&String::from(json_str)) {
Ok(v) => v,
Err(e) => {
log::error!("Failed to parse stringified subscription: {:?}", e);
return; return;
} }
} }
} }
_ => {
log::error!("toJSON method not found on PushSubscription");
return;
}
}; };
// Convert JsValue (JSON object) to PushSubscriptionJSON struct via serde let json_value = match js_sys::JSON::stringify(&json_result) {
// Note: web_sys::PushSubscriptionJSON is not a struct we can directly use with serde_json usually, Ok(val) => val,
// but we can use serde-wasm-bindgen to convert JsValue -> Rust Struct Err(e) => {
let subscription_data: PushSubscriptionData = match serde_wasm_bindgen::from_value(json_val) { log::error!("Failed to stringify subscription: {:?}", e);
return;
}
};
let subscription_json_str = json_value.as_string().expect("should be string");
log::info!("Push subscription: {}", subscription_json_str);
// Parse and send to backend
let subscription_data: serde_json::Value = match serde_json::from_str(&subscription_json_str) {
Ok(data) => data, Ok(data) => data,
Err(e) => { Err(e) => {
log::error!("Failed to parse subscription JSON: {:?}", e); log::error!("Failed to parse subscription JSON: {:?}", e);
@@ -518,9 +533,37 @@ pub async fn subscribe_to_push_notifications() {
} }
}; };
// Send to backend (subscription_data is already the struct we need) // Extract endpoint and keys
let endpoint = subscription_data
.get("endpoint")
.and_then(|v| v.as_str())
.expect("endpoint should exist")
.to_string();
let keys_obj = subscription_data
.get("keys")
.expect("keys should exist");
let p256dh = keys_obj
.get("p256dh")
.and_then(|v| v.as_str())
.expect("p256dh should exist")
.to_string();
let auth = keys_obj
.get("auth")
.and_then(|v| v.as_str())
.expect("auth should exist")
.to_string();
let push_data = PushSubscriptionData {
endpoint,
keys: PushKeys { p256dh, auth },
};
// Send to backend
let response = match Request::post("/api/push/subscribe") let response = match Request::post("/api/push/subscribe")
.json(&subscription_data) .json(&push_data)
.expect("serialization should succeed") .expect("serialization should succeed")
.send() .send()
.await .await
@@ -540,15 +583,34 @@ pub async fn subscribe_to_push_notifications() {
} }
/// Helper to convert URL-safe base64 string to Uint8Array /// Helper to convert URL-safe base64 string to Uint8Array
/// Uses pure Rust base64 crate for better safety and performance /// Uses JavaScript to properly decode binary data (avoids UTF-8 encoding issues)
fn url_base64_to_uint8array(base64_string: &str) -> Result<js_sys::Uint8Array, JsValue> { fn url_base64_to_uint8array(base64_string: &str) -> Result<js_sys::Uint8Array, JsValue> {
use base64::{engine::general_purpose, Engine as _}; // Add padding
let padding = (4 - (base64_string.len() % 4)) % 4;
let mut padded = base64_string.to_string();
padded.push_str(&"=".repeat(padding));
// VAPID keys are URL-safe base64. Try both NO_PAD and padded for robustness. // Replace URL-safe characters
let bytes = general_purpose::URL_SAFE_NO_PAD let standard_base64 = padded.replace('-', "+").replace('_', "/");
.decode(base64_string)
.or_else(|_| general_purpose::URL_SAFE.decode(base64_string))
.map_err(|e| JsValue::from_str(&format!("Base64 decode error: {}", e)))?;
Ok(js_sys::Uint8Array::from(&bytes[..])) // Decode using JavaScript to avoid UTF-8 encoding issues
// Create a JavaScript function to decode the base64 and convert to Uint8Array
let js_code = format!(
r#"
(function() {{
const binaryString = atob('{}');
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {{
bytes[i] = binaryString.charCodeAt(i);
}}
return bytes;
}})()
"#,
standard_base64
);
let result = js_sys::eval(&js_code)?;
let array = result.dyn_into::<js_sys::Uint8Array>()?;
Ok(array)
} }