Compare commits

...

57 Commits

Author SHA1 Message Date
spinline
7361421641 Fix middleware signature: Specify Request<Body> explicitly
All checks were successful
Build MIPS Binary / build (push) Successful in 4m7s
2026-02-07 15:13:05 +03:00
spinline
d6ecc08398 Upgrade axum-extra to 0.10 for Axum 0.8 compatibility
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-07 15:11:08 +03:00
spinline
472bac85f3 Fix compilation errors: Resolve utoipa derive issues, add time dependency, and correct Axum middleware signature
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-07 15:08:53 +03:00
spinline
bb3ec14a75 Fix compilation errors: Add missing dependencies, fix module visibility, and update Axum middleware types
Some checks failed
Build MIPS Binary / build (push) Failing after 3m27s
2026-02-07 14:58:35 +03:00
spinline
d53d661ad1 Implement authentication system with SQLite: Add login/setup pages, auth middleware, and database integration
Some checks failed
Build MIPS Binary / build (push) Failing after 3m42s
2026-02-07 14:43:25 +03:00
spinline
92720c15b3 Fix mobile dropdown interaction: Revert to pointerdown with stop_propagation and use overlay for reliable closing
All checks were successful
Build MIPS Binary / build (push) Successful in 3m57s
2026-02-07 14:14:37 +03:00
spinline
5e59f66056 Fix dropdown closing behavior on mobile by adding transparent overlay
All checks were successful
Build MIPS Binary / build (push) Successful in 3m45s
2026-02-07 13:54:00 +03:00
spinline
f2ca741188 Fix cache issue by updating service worker strategy and remove GitHub workflows
All checks were successful
Build MIPS Binary / build (push) Successful in 3m44s
2026-02-07 13:41:00 +03:00
spinline
767077195a chore: trigger ci build to use runner v3 (with file command)
All checks were successful
Build MIPS Binary / build (push) Successful in 4m16s
2026-02-07 01:26:33 +03:00
spinline
5539dc2289 chore: trigger ci build to use runner v2
Some checks failed
Build MIPS Binary / build (push) Failing after 4m17s
2026-02-07 00:50:13 +03:00
spinline
f99fc4a134 fix: use pure MIPS asm for __atomic_is_lock_free, remove unsupported --defsym
Some checks are pending
Build MIPS Binary / build (push) Waiting to run
2026-02-07 00:40:39 +03:00
spinline
f4d0351c5b chore: update runner shim and linker flags for mips atomics
Some checks failed
Build MIPS Binary / build (push) Failing after 3m52s
2026-02-07 00:19:36 +03:00
spinline
c2492b2749 fix(ci): let zig handle CRT object files via link-self-contained=no
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-07 00:04:48 +03:00
spinline
18001ed5a2 fix(ci): use build-std for mips target in Dockerfile
Some checks failed
Build MIPS Binary / build (push) Failing after 4m8s
2026-02-06 23:58:19 +03:00
spinline
47da0fca55 ci: test updated runner environment
Some checks failed
Build MIPS Binary / build (push) Failing after 1m2s
2026-02-06 23:48:56 +03:00
spinline
0985f328e2 fix(ci): inject libatomic for mips and force static linking
Some checks failed
Build MIPS Binary / build (push) Failing after 1m2s
2026-02-06 23:41:30 +03:00
spinline
146b312b4c fix(runner): use reliable URL for act_runner and optimize targets
Some checks failed
Build MIPS Binary / build (push) Failing after 4m4s
2026-02-06 23:32:58 +03:00
spinline
153568e81d ci: trigger first super-fast build
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-06 23:19:37 +03:00
spinline
fefe86da31 refactor: simplify workflow to use new custom runner with pre-installed tools
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-06 22:34:40 +03:00
spinline
11ba548297 chore: remove failing publish workflow due to missing docker in runner
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-06 22:31:50 +03:00
spinline
ce9fb6781a ci: configure runner to use host mode with mips-builder label
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
Publish Runner Image / Push Docker image to Docker Hub (push) Failing after 3m18s
2026-02-06 22:22:03 +03:00
spinline
4855b193d4 ci: add workflow to publish runner image to docker hub
Some checks failed
Publish Runner Image / Push Docker image to Docker Hub (push) Has been cancelled
Build MIPS Binary / build (push) Has been cancelled
2026-02-06 22:18:01 +03:00
spinline
1d636d63fa feat: add self-contained gitea runner dockerfile with all build tools included
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-06 22:16:04 +03:00
spinline
db4edff957 ci: fix trunk binary arch download logic for arm64 runner
Some checks failed
Build MIPS Binary / build (push) Failing after 8m10s
2026-02-06 22:04:15 +03:00
spinline
921cba2cab ci: remove broken cache and use prebuilt trunk binary for speed
Some checks failed
Build MIPS Binary / build (push) Failing after 1m6s
2026-02-06 22:02:18 +03:00
spinline
c64c95233f ci: add action/cache to speed up builds
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-06 21:49:09 +03:00
spinline
d17bfc88ad ci: replace docker build with cargo-zigbuild for mips cross-compilation
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-06 21:45:35 +03:00
spinline
b646d0851c ci: remove --locked from trunk install to avoid yanked crate warnings
Some checks failed
Build MIPS Binary / build (push) Failing after 10m36s
2026-02-06 21:33:40 +03:00
spinline
42e03bd2e3 ci: manually install node 20 to path to fix structuredClone error
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-06 21:31:23 +03:00
spinline
67a1d96b26 ci: debug tailwind build failure by running explicitly
Some checks failed
Build MIPS Binary / build (push) Failing after 9m30s
2026-02-06 21:20:59 +03:00
spinline
3187ed76b0 ci: install wasm-bindgen-cli from source to fix glibc error
Some checks failed
Build MIPS Binary / build (push) Failing after 10m54s
2026-02-06 21:05:50 +03:00
spinline
4dfce1096e ci: set nightly default and install Node 20 for trunk
Some checks failed
Build MIPS Binary / build (push) Failing after 9m9s
2026-02-06 20:53:41 +03:00
spinline
e3cfc11b65 ci: source cargo env in trunk/frontend steps
Some checks failed
Build MIPS Binary / build (push) Failing after 8m12s
2026-02-06 20:42:22 +03:00
spinline
f579431098 ci: install rustup in runner container if missing
Some checks failed
Build MIPS Binary / build (push) Failing after 1m2s
2026-02-06 20:41:05 +03:00
spinline
6014ec64e8 ci: replace checkout action with direct git fetch
Some checks failed
Build MIPS Binary / build (push) Failing after 1s
2026-02-06 20:39:46 +03:00
spinline
bdb30f33d8 ci: trigger server runner
Some checks failed
Build MIPS Binary / build (push) Failing after 34s
2026-02-06 20:33:16 +03:00
spinline
2ea2894664 ci: test build speed with cached image
All checks were successful
Build MIPS Binary / build (push) Successful in 4m47s
2026-02-06 19:22:37 +03:00
spinline
e329275956 ci: use cross-rs image with Rust for proper sysroot, cache builder image
All checks were successful
Build MIPS Binary / build (push) Successful in 7m39s
2026-02-06 19:10:57 +03:00
spinline
1f7af5fe30 ci: remove RUSTFLAGS, musl target is static by default
All checks were successful
Build MIPS Binary / build (push) Successful in 13m49s
2026-02-06 18:51:49 +03:00
spinline
bf54d2699e ci: force static linking with crt-static and -static flags
Some checks failed
Build MIPS Binary / build (push) Failing after 10m7s
2026-02-06 18:39:40 +03:00
spinline
46d61b5562 fix: suppress unused_assignments warning for got_first_message
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-06 18:33:18 +03:00
spinline
103d353422 ci: use rust:latest + rustup nightly (rust:nightly tag doesn't exist)
All checks were successful
Build MIPS Binary / build (push) Successful in 12m22s
2026-02-06 18:08:40 +03:00
spinline
b8f8c60c22 ci: use rust:nightly image with musl.cc cross-compiler for self-contained MIPS build
Some checks failed
Build MIPS Binary / build (push) Failing after 30s
2026-02-06 18:07:33 +03:00
spinline
03a07a9075 ci: use Docker directly instead of cross to avoid host toolchain resolution
Some checks failed
Build MIPS Binary / build (push) Failing after 31s
2026-02-06 18:04:41 +03:00
spinline
861ad445fc ci: use +nightly flag instead of RUSTUP_TOOLCHAIN env var
Some checks failed
Build MIPS Binary / build (push) Failing after 30s
2026-02-06 18:00:53 +03:00
spinline
1aeb33fbf4 ci: use shell commands instead of GitHub Actions for host runner
Some checks failed
Build MIPS Binary / build (push) Failing after 30s
2026-02-06 17:59:17 +03:00
spinline
dd025bd185 fix: use RELEASE_TOKEN secret (GITEA_ prefix reserved), add .runner to gitignore
Some checks failed
Build MIPS Binary / build (push) Failing after 45s
2026-02-06 17:57:25 +03:00
spinline
6554c4248c ci: add Gitea Actions workflow for MIPS build
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-06 17:31:32 +03:00
spinline
14f811fd79 fix: convert sort dropdown to signal-based for iOS Safari 2026-02-06 17:19:47 +03:00
spinline
2a919211d9 fix: fixed statusbar at bottom, fix dropdown toggle race, fix theme buttons on iOS
- StatusBar now uses fixed positioning at viewport bottom (z-[99])
- Dropdown toggle uses skip_next_close guard to prevent close-then-reopen
- Global close listener uses click instead of pointerdown (fires after toggle)
- Theme/limit buttons use on:pointerdown instead of on:click for iOS compat
- Main content has pb-8 to prevent overlap with fixed statusbar
- Rebuilt tailwind.css
2026-02-06 17:17:07 +03:00
spinline
2c25d3bdf1 chore: rebuild tailwind.css 2026-02-06 14:21:17 +03:00
spinline
1d06906475 fix: wrap toolbar+main in overflow-hidden, free statusbar from clipping 2026-02-06 14:19:46 +03:00
spinline
e4cc6322bf fix: dropdown clipped by overflow-hidden, change to overflow-x-hidden 2026-02-06 14:09:34 +03:00
spinline
d1604fb8fb frontend(statusbar): use signal-based dropdowns to fix iOS Safari toggle 2026-02-06 13:57:26 +03:00
spinline
3b34b0e61a fix: prevent toast spam on server disconnect - show once, reconnect silently with backoff 2026-02-06 13:37:12 +03:00
spinline
19d9d7f30f fix: use details-based dropdowns for iOS Safari toggle behavior 2026-02-06 13:34:07 +03:00
spinline
a3321c7cf1 fix: properly check notification permission result, show toast feedback on Safari 2026-02-06 13:26:37 +03:00
23 changed files with 2324 additions and 606 deletions

View File

@@ -0,0 +1,80 @@
name: Build MIPS Binary
on:
push:
branches: [ "main" ]
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: mips-builder
steps:
- name: Checkout
env:
GIT_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
rm -rf .git
git init .
git remote add origin https://admin:$\{GIT_TOKEN\}@git.karatatar.com/admin/vibetorrent.git
git fetch --depth=1 origin ${{ gitea.sha }}
git checkout FETCH_HEAD
- name: Build Frontend
run: |
cd frontend
npm install
# Run Tailwind manually first
npx @tailwindcss/cli -i input.css -o public/tailwind.css
trunk build --release
- name: Build Backend (MIPS)
env:
# Ensure we are building a fully static binary
# -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"
run: |
cd backend
cargo zigbuild --target mips-unknown-linux-musl --release -Z build-std=std,panic_abort
file target/mips-unknown-linux-musl/release/backend
- name: Rename Binary
run: mv target/mips-unknown-linux-musl/release/backend target/mips-unknown-linux-musl/release/vibetorrent-mips
- name: Generate Release Tag
id: tag
run: echo "release_tag=release-$(date +'%Y%m%d-%H%M')" >> "$GITHUB_OUTPUT"
- name: Create Release
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
TAG="${{ steps.tag.outputs.release_tag }}"
REPO="admin/vibetorrent"
API_URL="${{ gitea.server_url }}/api/v1"
# Create release
RELEASE_RESPONSE=$(curl -s -X POST "${API_URL}/repos/${REPO}/releases" -H "Authorization: token ${RELEASE_TOKEN}" -H "Content-Type: application/json" -d "{
\"tag_name\": \"${TAG}\",
\"name\": \"Release ${TAG}\",
\"body\": \"Automated build from commit ${{ gitea.sha }}\",
\"draft\": false,
\"prerelease\": false
}")
RELEASE_ID=$(echo "$RELEASE_RESPONSE" | jq -r '.id')
echo "Release ID: $RELEASE_ID"
if [ "$RELEASE_ID" = "null" ] || [ -z "$RELEASE_ID" ]; then
echo "Failed to create release:"
echo "$RELEASE_RESPONSE"
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

@@ -1,80 +0,0 @@
name: Build MIPS Binary
on:
push:
branches: [ "main" ]
workflow_dispatch:
permissions:
contents: write
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Debug - List Files (Pre-Build)
run: ls -R
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Rust
uses: dtolnay/rust-toolchain@nightly
with:
targets: wasm32-unknown-unknown
components: rust-src
- name: Rust Cache
uses: Swatinem/rust-cache@v2
- name: Install Trunk
uses: jetli/trunk-action@v0.5.0
with:
version: 'latest'
- name: Build Frontend
run: |
cd frontend
npm install
trunk build --release
- name: Install Cross
run: cargo install cross
- name: Build Backend (MIPS)
env:
RUSTUP_TOOLCHAIN: nightly
CROSS_NO_WARNINGS: 0
run: |
cd backend
cross build --target mips-unknown-linux-musl --release -Z build-std=std,panic_abort
- name: Debug - List Files
run: |
echo "Listing target directory..."
find target -maxdepth 5 || true
ls -R target/mips-unknown-linux-musl/release || true
- name: Rename Binary
run: mv target/mips-unknown-linux-musl/release/backend target/mips-unknown-linux-musl/release/vibetorrent-mips
- name: Generate Tag
id: tag
run: echo "release_tag=release-$(date +'%Y%m%d-%H%M')" >> $GITHUB_OUTPUT
- name: Create Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ steps.tag.outputs.release_tag }}
name: Release ${{ steps.tag.outputs.release_tag }}
files: target/mips-unknown-linux-musl/release/vibetorrent-mips
draft: false
prerelease: false

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ result.xml
**/node_modules
frontend/dist
backend.log
.runner

690
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -33,3 +33,9 @@ utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] }
web-push = { version = "0.10", default-features = false, features = ["hyper-client"], optional = true }
base64 = "0.22"
openssl = { version = "0.10", features = ["vendored"], optional = true }
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
bcrypt = "0.17.0"
axum-extra = { version = "0.10", features = ["cookie"] }
rand = "0.8"
anyhow = "1.0.101"
time = { version = "0.3.47", features = ["serde", "formatting", "parsing"] }

106
backend/src/db.rs Normal file
View File

@@ -0,0 +1,106 @@
use sqlx::{sqlite::SqlitePoolOptions, Pool, Sqlite, Row};
use std::time::Duration;
use anyhow::Result;
#[derive(Clone)]
pub struct Db {
pool: Pool<Sqlite>,
}
impl Db {
pub async fn new(db_url: &str) -> Result<Self> {
let pool = SqlitePoolOptions::new()
.max_connections(5)
.acquire_timeout(Duration::from_secs(3))
.connect(db_url)
.await?;
let db = Self { pool };
db.init().await?;
Ok(db)
}
async fn init(&self) -> Result<()> {
// Create users table
sqlx::query(
"CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)",
)
.execute(&self.pool)
.await?;
// Create sessions table
sqlx::query(
"CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
expires_at DATETIME NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id)
)",
)
.execute(&self.pool)
.await?;
Ok(())
}
// --- User Operations ---
pub async fn create_user(&self, username: &str, password_hash: &str) -> Result<()> {
sqlx::query("INSERT INTO users (username, password_hash) VALUES (?, ?)")
.bind(username)
.bind(password_hash)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn get_user_by_username(&self, username: &str) -> Result<Option<(i64, String)>> {
let row = sqlx::query("SELECT id, password_hash FROM users WHERE username = ?")
.bind(username)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|r| (r.get(0), r.get(1))))
}
pub async fn has_users(&self) -> Result<bool> {
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
.fetch_one(&self.pool)
.await?;
Ok(row.0 > 0)
}
// --- Session Operations ---
pub async fn create_session(&self, user_id: i64, token: &str, expires_at: i64) -> Result<()> {
sqlx::query("INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, datetime(?, 'unixepoch'))")
.bind(token)
.bind(user_id)
.bind(expires_at)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn get_session_user(&self, token: &str) -> Result<Option<i64>> {
let row = sqlx::query("SELECT user_id FROM sessions WHERE token = ? AND expires_at > datetime('now')")
.bind(token)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|r| r.get(0)))
}
pub async fn delete_session(&self, token: &str) -> Result<()> {
sqlx::query("DELETE FROM sessions WHERE token = ?")
.bind(token)
.execute(&self.pool)
.await?;
Ok(())
}
}

View File

@@ -0,0 +1,123 @@
use crate::{db::Db, AppState};
use axum::{
extract::{State, Json},
http::StatusCode,
response::IntoResponse,
};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use time::Duration;
#[derive(Deserialize, ToSchema)]
pub struct LoginRequest {
username: String,
password: String,
}
#[derive(Serialize, ToSchema)]
pub struct UserResponse {
username: String,
}
#[utoipa::path(
post,
path = "/api/auth/login",
request_body = LoginRequest,
responses(
(status = 200, description = "Login successful"),
(status = 401, description = "Invalid credentials"),
(status = 500, description = "Internal server error")
)
)]
pub async fn login_handler(
State(state): State<AppState>,
jar: CookieJar,
Json(payload): Json<LoginRequest>,
) -> impl IntoResponse {
let user = match state.db.get_user_by_username(&payload.username).await {
Ok(Some(u)) => u,
Ok(None) => return (StatusCode::UNAUTHORIZED, "Invalid credentials").into_response(),
Err(e) => {
tracing::error!("DB error during login: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response();
}
};
let (user_id, password_hash) = user;
match bcrypt::verify(&payload.password, &password_hash) {
Ok(true) => {
// Create session
let token: String = (0..32).map(|_| {
use rand::{distributions::Alphanumeric, Rng};
rand::thread_rng().sample(Alphanumeric) as char
}).collect();
// Expires in 30 days
let expires_in = 60 * 60 * 24 * 30;
let expires_at = time::OffsetDateTime::now_utc().unix_timestamp() + expires_in;
if let Err(e) = state.db.create_session(user_id, &token, expires_at).await {
tracing::error!("Failed to create session: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to create session").into_response();
}
let cookie = Cookie::build(("auth_token", token))
.path("/")
.http_only(true)
.same_site(SameSite::Strict)
.max_age(Duration::seconds(expires_in))
.build();
(StatusCode::OK, jar.add(cookie), "Login successful").into_response()
}
_ => (StatusCode::UNAUTHORIZED, "Invalid credentials").into_response(),
}
}
#[utoipa::path(
post,
path = "/api/auth/logout",
responses(
(status = 200, description = "Logged out")
)
)]
pub async fn logout_handler(
State(state): State<AppState>,
jar: CookieJar,
) -> impl IntoResponse {
if let Some(token) = jar.get("auth_token") {
let _ = state.db.delete_session(token.value()).await;
}
let cookie = Cookie::build(("auth_token", ""))
.path("/")
.http_only(true)
.max_age(Duration::seconds(-1)) // Expire immediately
.build();
(StatusCode::OK, jar.add(cookie), "Logged out").into_response()
}
#[utoipa::path(
get,
path = "/api/auth/check",
responses(
(status = 200, description = "Authenticated"),
(status = 401, description = "Not authenticated")
)
)]
pub async fn check_auth_handler(
State(state): State<AppState>,
jar: CookieJar,
) -> impl IntoResponse {
if let Some(token) = jar.get("auth_token") {
match state.db.get_session_user(token.value()).await {
Ok(Some(_)) => return StatusCode::OK.into_response(),
_ => {} // Invalid session
}
}
StatusCode::UNAUTHORIZED.into_response()
}

View File

@@ -18,6 +18,9 @@ use shared::{
};
use utoipa::ToSchema;
pub mod auth;
pub mod setup;
#[derive(RustEmbed)]
#[folder = "../frontend/dist"]
pub struct Asset;

View File

@@ -0,0 +1,84 @@
use crate::AppState;
use axum::{
extract::{State, Json},
http::StatusCode,
response::IntoResponse,
};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Deserialize, ToSchema)]
pub struct SetupRequest {
username: String,
password: String,
}
#[derive(Serialize, ToSchema)]
pub struct SetupStatusResponse {
completed: bool,
}
#[utoipa::path(
get,
path = "/api/setup/status",
responses(
(status = 200, description = "Setup status", body = SetupStatusResponse)
)
)]
pub async fn get_setup_status_handler(State(state): State<AppState>) -> impl IntoResponse {
let completed = match state.db.has_users().await {
Ok(has) => has,
Err(e) => {
tracing::error!("DB error checking users: {}", e);
false
}
};
Json(SetupStatusResponse { completed }).into_response()
}
#[utoipa::path(
post,
path = "/api/setup",
request_body = SetupRequest,
responses(
(status = 200, description = "Setup completed"),
(status = 400, description = "Invalid request"),
(status = 403, description = "Setup already completed"),
(status = 500, description = "Internal server error")
)
)]
pub async fn setup_handler(
State(state): State<AppState>,
Json(payload): Json<SetupRequest>,
) -> impl IntoResponse {
// 1. Check if setup is already completed (i.e., users exist)
match state.db.has_users().await {
Ok(true) => return (StatusCode::FORBIDDEN, "Setup already completed").into_response(),
Err(e) => {
tracing::error!("DB error checking users: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response();
}
Ok(false) => {} // Proceed
}
// 2. Validate input
if payload.username.len() < 3 || payload.password.len() < 6 {
return (StatusCode::BAD_REQUEST, "Username must be at least 3 chars, password at least 6").into_response();
}
// 3. Create User
let password_hash = match bcrypt::hash(&payload.password, bcrypt::DEFAULT_COST) {
Ok(h) => h,
Err(e) => {
tracing::error!("Failed to hash password: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to process password").into_response();
}
};
if let Err(e) = state.db.create_user(&payload.username, &password_hash).await {
tracing::error!("Failed to create user: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to create user").into_response();
}
(StatusCode::OK, "Setup completed successfully").into_response()
}

View File

@@ -1,3 +1,4 @@
mod db;
mod diff;
mod handlers;
#[cfg(feature = "push-notifications")]
@@ -10,7 +11,12 @@ use axum::error_handling::HandleErrorLayer;
use axum::{
routing::{get, post},
Router,
middleware::{self, Next},
response::Response,
http::{StatusCode, Request},
body::Body,
};
use axum_extra::extract::cookie::CookieJar;
use clap::Parser;
use dotenvy::dotenv;
use shared::{AppEvent, Torrent};
@@ -32,10 +38,39 @@ pub struct AppState {
pub tx: Arc<watch::Sender<Vec<Torrent>>>,
pub event_bus: broadcast::Sender<AppEvent>,
pub scgi_socket_path: String,
pub db: db::Db,
#[cfg(feature = "push-notifications")]
pub push_store: push::PushSubscriptionStore,
}
async fn auth_middleware(
state: axum::extract::State<AppState>,
jar: CookieJar,
request: Request<Body>,
next: Next,
) -> Result<Response, StatusCode> {
// Skip auth for public paths
let path = request.uri().path();
if path.starts_with("/api/auth/login")
|| path.starts_with("/api/auth/check") // Used by frontend to decide where to go
|| path.starts_with("/api/setup")
|| path.starts_with("/swagger-ui")
|| path.starts_with("/api-docs")
|| !path.starts_with("/api/") // Allow static files (frontend)
{
return Ok(next.run(request).await);
}
// Check token
if let Some(token) = jar.get("auth_token") {
match state.db.get_session_user(token.value()).await {
Ok(Some(_)) => return Ok(next.run(request).await),
_ => {} // Invalid
}
}
Err(StatusCode::UNAUTHORIZED)
}
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
@@ -51,6 +86,10 @@ struct Args {
/// Port to listen on
#[arg(short, long, env = "PORT", default_value_t = 3000)]
port: u16,
/// Database URL
#[arg(long, env = "DATABASE_URL", default_value = "sqlite:vibetorrent.db")]
db_url: String,
}
#[cfg(feature = "push-notifications")]
@@ -68,7 +107,12 @@ struct Args {
handlers::get_global_limit_handler,
handlers::set_global_limit_handler,
handlers::get_push_public_key_handler,
handlers::subscribe_push_handler
handlers::subscribe_push_handler,
handlers::auth::login_handler,
handlers::auth::logout_handler,
handlers::auth::check_auth_handler,
handlers::setup::setup_handler,
handlers::setup::get_setup_status_handler
),
components(
schemas(
@@ -83,7 +127,10 @@ struct Args {
shared::SetLabelRequest,
shared::GlobalLimitRequest,
push::PushSubscription,
push::PushKeys
push::PushKeys,
handlers::auth::LoginRequest,
handlers::setup::SetupRequest,
handlers::setup::SetupStatusResponse
)
),
tags(
@@ -105,7 +152,12 @@ struct ApiDoc;
handlers::set_file_priority_handler,
handlers::set_label_handler,
handlers::get_global_limit_handler,
handlers::set_global_limit_handler
handlers::set_global_limit_handler,
handlers::auth::login_handler,
handlers::auth::logout_handler,
handlers::auth::check_auth_handler,
handlers::setup::setup_handler,
handlers::setup::get_setup_status_handler
),
components(
schemas(
@@ -118,7 +170,10 @@ struct ApiDoc;
shared::TorrentTracker,
shared::SetFilePriorityRequest,
shared::SetLabelRequest,
shared::GlobalLimitRequest
shared::GlobalLimitRequest,
handlers::auth::LoginRequest,
handlers::setup::SetupRequest,
handlers::setup::SetupStatusResponse
)
),
tags(
@@ -146,6 +201,29 @@ async fn main() {
tracing::info!("Socket: {}", args.socket);
tracing::info!("Port: {}", args.port);
// Initialize Database
tracing::info!("Connecting to database: {}", args.db_url);
// Ensure the db file exists if it's sqlite
if args.db_url.starts_with("sqlite:") {
let path = args.db_url.trim_start_matches("sqlite:");
if !std::path::Path::new(path).exists() {
tracing::info!("Database file not found, creating: {}", path);
match std::fs::File::create(path) {
Ok(_) => tracing::info!("Created empty database file"),
Err(e) => tracing::error!("Failed to create database file: {}", e),
}
}
}
let db: db::Db = match db::Db::new(&args.db_url).await {
Ok(db) => db,
Err(e) => {
tracing::error!("Failed to connect to database: {}", e);
std::process::exit(1);
}
};
tracing::info!("Database connected successfully.");
// Startup Health Check
let socket_path = std::path::Path::new(&args.socket);
if !socket_path.exists() {
@@ -181,6 +259,7 @@ async fn main() {
tx: tx.clone(),
event_bus: event_bus.clone(),
scgi_socket_path: args.socket.clone(),
db: db.clone(),
#[cfg(feature = "push-notifications")]
push_store: push::PushSubscriptionStore::new(),
};
@@ -308,6 +387,13 @@ async fn main() {
let app = Router::new()
.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi()))
// Setup & Auth Routes
.route("/api/setup/status", get(handlers::setup::get_setup_status_handler))
.route("/api/setup", post(handlers::setup::setup_handler))
.route("/api/auth/login", post(handlers::auth::login_handler))
.route("/api/auth/logout", post(handlers::auth::logout_handler))
.route("/api/auth/check", get(handlers::auth::check_auth_handler))
// App Routes
.route("/api/events", get(sse::sse_handler))
.route("/api/torrents/add", post(handlers::add_torrent_handler))
.route(
@@ -344,6 +430,7 @@ async fn main() {
.route("/api/push/subscribe", post(handlers::subscribe_push_handler));
let app = app
.layer(middleware::from_fn_with_state(app_state.clone(), auth_middleware))
.layer(TraceLayer::new_for_http())
.layer(
CompressionLayer::new()

94
docker/runner/Dockerfile Normal file
View File

@@ -0,0 +1,94 @@
# Use a base image that supports multi-arch (x64 and arm64)
# We use debian:bookworm-slim as base to install everything manually
# and then install the act_runner binary.
FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive
ENV PATH="/root/.cargo/bin:/root/.node/bin:/root/zig:${PATH}"
# 1. Install Basic Dependencies
RUN apt-get update && apt-get install -y \
curl \
git \
build-essential \
ca-certificates \
wget \
xz-utils \
libssl-dev \
pkg-config \
file \
jq \
# Needed for some crate compilations
protobuf-compiler \
&& rm -rf /var/lib/apt/lists/*
# 2. Install Node.js v20 (Manual install to support multi-arch cleanly)
RUN ARCH=$(dpkg --print-architecture) && \
if [ "$ARCH" = "amd64" ]; then NODE_ARCH="x64"; \
elif [ "$ARCH" = "arm64" ]; then NODE_ARCH="arm64"; fi && \
NODE_VER="v20.11.1" && \
curl -fsSL "https://nodejs.org/dist/$NODE_VER/node-$NODE_VER-linux-$NODE_ARCH.tar.xz" -o node.tar.xz && \
mkdir -p /root/.node && \
tar -xJf node.tar.xz -C /root/.node --strip-components=1 && \
rm node.tar.xz
# 3. Install Rust (Nightly) + Targets
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain nightly --profile minimal && \
. "$HOME/.cargo/env" && \
rustup target add wasm32-unknown-unknown && \
rustup component add rust-src
# 4. Install Zig (for Cross Compilation)
RUN ARCH=$(dpkg --print-architecture) && \
if [ "$ARCH" = "amd64" ]; then ZIG_ARCH="x86_64"; \
elif [ "$ARCH" = "arm64" ]; then ZIG_ARCH="aarch64"; fi && \
ZIG_VER="0.13.0" && \
curl -fsSL "https://ziglang.org/download/$ZIG_VER/zig-linux-$ZIG_ARCH-$ZIG_VER.tar.xz" -o zig.tar.xz && \
tar -xf zig.tar.xz && \
mv "zig-linux-$ZIG_ARCH-$ZIG_VER" /root/zig && \
rm zig.tar.xz
# 5. Fix: Create libatomic.a with __atomic_is_lock_free for MIPS
# MIPS musl static build misses __atomic_is_lock_free.
# We provide it via pure assembly to avoid Clang builtin conflicts.
RUN . "$HOME/.cargo/env" && \
printf '.text\n.globl __atomic_is_lock_free\n.type __atomic_is_lock_free, @function\n__atomic_is_lock_free:\n sltiu $v0, $a0, 5\n jr $ra\n nop\n.size __atomic_is_lock_free, .-__atomic_is_lock_free\n' > atomic.s && \
/root/zig/zig cc -target mips-linux-musl -msoft-float -c -o atomic.o atomic.s && \
/root/zig/zig ar rcs libatomic.a atomic.o && \
RUST_SYSROOT=$(rustc --print sysroot) && \
TARGET_LIB_DIR="$RUST_SYSROOT/lib/rustlib/mips-unknown-linux-musl/lib" && \
mkdir -p "$TARGET_LIB_DIR" && \
cp libatomic.a "$TARGET_LIB_DIR/" && \
rm atomic.s atomic.o libatomic.a
# 6. Install Tools (Trunk, cargo-zigbuild, wasm-bindgen protocol aligned)
# We install trunk binary to save time, others via cargo
RUN . "$HOME/.cargo/env" && \
# Install cargo-zigbuild
cargo install cargo-zigbuild && \
# Install trunk (Binary)
ARCH=$(dpkg --print-architecture) && \
if [ "$ARCH" = "amd64" ]; then TRUNK_ARCH="x86_64-unknown-linux-gnu"; \
elif [ "$ARCH" = "arm64" ]; then TRUNK_ARCH="aarch64-unknown-linux-gnu"; fi && \
wget -qO- "https://github.com/trunk-rs/trunk/releases/download/v0.21.5/trunk-$TRUNK_ARCH.tar.gz" | tar -xzf - -C /root/.cargo/bin/ && \
chmod +x /root/.cargo/bin/trunk && \
# Install wasm-bindgen-cli (Compiling from source to avoid glibc issues, doing it ONCE here)
cargo install wasm-bindgen-cli --version 0.2.108
# 7. Install Gitea Act Runner Binary
# We fetch the binary directly to run as the entrypoint
RUN ARCH=$(dpkg --print-architecture) && \
VERSION="0.2.11" && \
curl -fsSL -o /usr/local/bin/act_runner "https://dl.gitea.com/act_runner/$VERSION/act_runner-$VERSION-linux-$ARCH" && \
chmod +x /usr/local/bin/act_runner
# Create a volume for registration data
VOLUME /data
WORKDIR /data
# Define entrypoint to run the registration or daemon
# We will use a script to handle auto-registration if env vars are present
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -0,0 +1,23 @@
#!/bin/bash
set -e
# If GITEA_INSTANCE_URL and GITEA_RUNNER_TOKEN are provided, try to register
if [ -n "$GITEA_INSTANCE_URL" ] && [ -n "$GITEA_RUNNER_TOKEN" ]; then
if [ ! -f ".runner" ]; then
echo "Registering runner..."
# Register with label 'mips-builder' valid for host execution
# plus 'ubuntu-latest' mapped to host for convenience if needed
act_runner register \
--instance "$GITEA_INSTANCE_URL" \
--token "$GITEA_RUNNER_TOKEN" \
--name "vibetorrent-mips-runner-$(hostname)" \
--labels "mips-builder:host,ubuntu-latest:host" \
--no-interactive
else
echo "Runner already registered."
fi
fi
# Run the daemon
echo "Starting act_runner daemon..."
exec act_runner daemon

View File

@@ -10,7 +10,7 @@
@layer base {
html,
body {
@apply min-h-[100dvh] w-full overflow-hidden bg-base-100 text-base-content overscroll-y-none;
@apply min-h-dvh w-full overflow-hidden bg-base-100 text-base-content overscroll-y-none;
}
}

View File

@@ -767,6 +767,141 @@
}
}
}
.toggle {
@layer daisyui.l1.l2.l3 {
border: var(--border) solid currentColor;
color: var(--input-color);
position: relative;
display: inline-grid;
flex-shrink: 0;
cursor: pointer;
appearance: none;
place-content: center;
vertical-align: middle;
webkit-user-select: none;
user-select: none;
grid-template-columns: 0fr 1fr 1fr;
--radius-selector-max: calc(
var(--radius-selector) + var(--radius-selector) + var(--radius-selector)
);
border-radius: calc( var(--radius-selector) + min(var(--toggle-p), var(--radius-selector-max)) + min(var(--border), var(--radius-selector-max)) );
padding: var(--toggle-p);
box-shadow: 0 1px currentColor inset;
@supports (color: color-mix(in lab, red, red)) {
box-shadow: 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000) inset;
}
transition: color 0.3s, grid-template-columns 0.2s;
--input-color: var(--color-base-content);
@supports (color: color-mix(in lab, red, red)) {
--input-color: color-mix(in oklab, var(--color-base-content) 50%, #0000);
}
--toggle-p: calc(var(--size) * 0.125);
--size: calc(var(--size-selector, 0.25rem) * 6);
width: calc((var(--size) * 2) - (var(--border) + var(--toggle-p)) * 2);
height: var(--size);
> * {
z-index: 1;
grid-column: span 1 / span 1;
grid-column-start: 2;
grid-row-start: 1;
height: 100%;
cursor: pointer;
appearance: none;
background-color: transparent;
padding: calc(0.25rem * 0.5);
transition: opacity 0.2s, rotate 0.4s;
border: none;
&:focus {
--tw-outline-style: none;
outline-style: none;
@media (forced-colors: active) {
outline: 2px solid transparent;
outline-offset: 2px;
}
}
&:nth-child(2) {
color: var(--color-base-100);
rotate: 0deg;
}
&:nth-child(3) {
color: var(--color-base-100);
opacity: 0%;
rotate: -15deg;
}
}
&:has(:checked) {
> :nth-child(2) {
opacity: 0%;
rotate: 15deg;
}
> :nth-child(3) {
opacity: 100%;
rotate: 0deg;
}
}
&:before {
position: relative;
inset-inline-start: calc(0.25rem * 0);
grid-column-start: 2;
grid-row-start: 1;
aspect-ratio: 1 / 1;
height: 100%;
border-radius: var(--radius-selector);
background-color: currentcolor;
translate: 0;
--tw-content: "";
content: var(--tw-content);
transition: background-color 0.1s, translate 0.2s, inset-inline-start 0.2s;
box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px currentColor;
@supports (color: color-mix(in lab, red, red)) {
box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000);
}
background-size: auto, calc(var(--noise) * 100%);
background-image: none, var(--fx-noise);
}
@media (forced-colors: active) {
&:before {
outline-style: var(--tw-outline-style);
outline-width: 1px;
outline-offset: calc(1px * -1);
}
}
@media print {
&:before {
outline: 0.25rem solid;
outline-offset: -1rem;
}
}
&:focus-visible, &:has(:focus-visible) {
outline: 2px solid currentColor;
outline-offset: 2px;
}
&:checked, &[aria-checked="true"], &:has(> input:checked) {
grid-template-columns: 1fr 1fr 0fr;
background-color: var(--color-base-100);
--input-color: var(--color-base-content);
&:before {
background-color: currentcolor;
}
@starting-style {
&:before {
opacity: 0;
}
}
}
&:indeterminate {
grid-template-columns: 0.5fr 1fr 0.5fr;
}
&:disabled {
cursor: not-allowed;
opacity: 30%;
&:before {
background-color: transparent;
border: var(--border) solid currentColor;
}
}
}
}
.input {
@layer daisyui.l1.l2.l3 {
cursor: text;
@@ -1171,6 +1306,9 @@
}
}
}
.absolute {
position: absolute;
}
.fixed {
position: fixed;
}
@@ -1190,72 +1328,6 @@
--toast-x: 0;
}
}
.dropdown-end {
@layer daisyui.l1.l2 {
--anchor-h: span-left;
:where(.dropdown-content) {
inset-inline-end: calc(0.25rem * 0);
translate: 0 0;
[dir="rtl"] & {
translate: 0 0;
}
}
&.dropdown-left {
--anchor-h: left;
--anchor-v: span-top;
.dropdown-content {
top: auto;
bottom: calc(0.25rem * 0);
}
}
&.dropdown-right {
--anchor-h: right;
--anchor-v: span-top;
.dropdown-content {
top: auto;
bottom: calc(0.25rem * 0);
}
}
}
}
.dropdown-start {
@layer daisyui.l1.l2 {
--anchor-h: span-right;
:where(.dropdown-content) {
inset-inline-end: auto;
translate: 0 0;
[dir="rtl"] & {
translate: 0 0;
}
}
&.dropdown-left {
--anchor-h: left;
--anchor-v: span-bottom;
.dropdown-content {
top: calc(0.25rem * 0);
bottom: auto;
}
}
&.dropdown-right {
--anchor-h: right;
--anchor-v: span-bottom;
.dropdown-content {
top: calc(0.25rem * 0);
bottom: auto;
}
}
}
}
.dropdown-top {
@layer daisyui.l1.l2 {
--anchor-v: top;
.dropdown-content {
top: auto;
bottom: 100%;
transform-origin: bottom;
}
}
}
.toast-bottom {
@layer daisyui.l1.l2 {
top: auto;
@@ -1263,6 +1335,21 @@
--toast-y: 0;
}
}
.top-full {
top: 100%;
}
.right-0 {
right: calc(var(--spacing) * 0);
}
.bottom-0 {
bottom: calc(var(--spacing) * 0);
}
.bottom-full {
bottom: 100%;
}
.left-0 {
left: calc(var(--spacing) * 0);
}
.modal-backdrop {
@layer daisyui.l1.l2.l3 {
grid-column-start: 1;
@@ -1484,6 +1571,9 @@
gap: calc(0.25rem * 2);
}
}
.mt-1 {
margin-top: calc(var(--spacing) * 1);
}
.mt-2 {
margin-top: calc(var(--spacing) * 2);
}
@@ -1678,6 +1768,9 @@
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.block {
display: block;
}
.flex {
display: flex;
}
@@ -1738,8 +1831,8 @@
.h-10 {
height: calc(var(--spacing) * 10);
}
.h-14 {
height: calc(var(--spacing) * 14);
.h-auto {
height: auto;
}
.h-full {
height: 100%;
@@ -2060,6 +2153,9 @@
.pt-1 {
padding-top: calc(var(--spacing) * 1);
}
.pb-8 {
padding-bottom: calc(var(--spacing) * 8);
}
.pb-20 {
padding-bottom: calc(var(--spacing) * 20);
}
@@ -2255,10 +2351,6 @@
}
}
}
.blur {
--tw-blur: blur(8px);
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
}
.filter {
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
}

View File

@@ -3,15 +3,66 @@ use crate::components::layout::statusbar::StatusBar;
use crate::components::layout::toolbar::Toolbar;
use crate::components::toast::ToastContainer;
use crate::components::torrent::table::TorrentTable;
use crate::components::auth::login::Login;
use crate::components::auth::setup::Setup;
use leptos::*;
use leptos_router::*;
use serde::Deserialize;
#[derive(Deserialize)]
struct SetupStatus {
completed: bool,
}
#[component]
pub fn App() -> impl IntoView {
crate::store::provide_torrent_store();
// Initialize push notifications after user grants permission
// Auth State
let (is_loading, set_is_loading) = create_signal(true);
let (is_authenticated, set_is_authenticated) = create_signal(false);
// Check Auth & Setup Status on load
create_effect(move |_| {
spawn_local(async move {
// 1. Check Setup Status
let setup_res = gloo_net::http::Request::get("/api/setup/status").send().await;
if let Ok(resp) = setup_res {
if let Ok(status) = resp.json::<SetupStatus>().await {
if !status.completed {
// Redirect to setup if not completed
let navigate = use_navigate();
navigate("/setup", Default::default());
set_is_loading.set(false);
return;
}
}
}
// 2. Check Auth Status
let auth_res = gloo_net::http::Request::get("/api/auth/check").send().await;
if let Ok(resp) = auth_res {
if resp.status() == 200 {
set_is_authenticated.set(true);
// Initialize push notifications logic only if authenticated
// ... (Push notification logic moved here or kept global but guarded)
} else {
let navigate = use_navigate();
// If we are already on login or setup, don't redirect loop
let pathname = window().location().pathname().unwrap_or_default();
if pathname != "/login" && pathname != "/setup" {
navigate("/login", Default::default());
}
}
}
set_is_loading.set(false);
});
});
// Initialize push notifications after user grants permission (Only if authenticated)
create_effect(move |_| {
if is_authenticated.get() {
spawn_local(async {
// Wait a bit for service worker to be ready
gloo_timers::future::TimeoutFuture::new(2000).await;
@@ -19,8 +70,6 @@ pub fn App() -> impl IntoView {
// Check if running on iOS and not standalone
if let Some(ios_message) = crate::utils::platform::get_ios_notification_info() {
log::warn!("iOS detected: {}", ios_message);
// Show toast to inform user
if let Some(store) = use_context::<crate::store::TorrentStore>() {
crate::store::show_toast_with_signal(
store.notifications,
@@ -31,16 +80,11 @@ pub fn App() -> impl IntoView {
return;
}
// Check if push notifications are supported
if !crate::utils::platform::supports_push_notifications() {
log::warn!("Push notifications not supported on this platform");
return;
}
// Safari requires user gesture for notification permission
// Don't auto-request on Safari - user should click a button
if crate::utils::platform::is_safari() {
log::info!("Safari detected - notification permission requires user interaction");
if let Some(store) = use_context::<crate::store::TorrentStore>() {
crate::store::show_toast_with_signal(
store.notifications,
@@ -51,33 +95,40 @@ pub fn App() -> impl IntoView {
return;
}
// For non-Safari browsers (Chrome, Firefox, Edge), attempt auto-subscribe
log::info!("Attempting to subscribe to push notifications...");
crate::store::subscribe_to_push_notifications().await;
});
}
});
view! {
// Main app wrapper - ensures proper stacking context
<div class="relative w-full h-screen" style="height: 100dvh;">
// Drawer layout
<Router>
<Routes>
<Route path="/login" view=move || view! { <Login /> } />
<Route path="/setup" view=move || view! { <Setup /> } />
<Route path="/*" view=move || {
view! {
<Show when=move || !is_loading.get() fallback=|| view! {
<div class="flex items-center justify-center h-screen bg-base-100">
<span class="loading loading-spinner loading-lg"></span>
</div>
}>
<Show when=move || is_authenticated.get() fallback=|| view! { <Login /> }>
// Protected Layout
<div class="drawer lg:drawer-open h-full w-full">
<input id="my-drawer" type="checkbox" class="drawer-toggle" />
<div class="drawer-content flex flex-col h-full overflow-hidden bg-base-100 text-base-content text-sm select-none">
// Toolbar at the top
<Toolbar />
<main class="flex-1 flex flex-col min-w-0 bg-base-100 overflow-hidden">
<Router>
<main class="flex-1 flex flex-col min-w-0 bg-base-100 overflow-hidden pb-8">
<Routes>
<Route path="/" view=move || view! { <TorrentTable /> } />
<Route path="/settings" view=move || view! { <div class="p-4">"Settings Page (Coming Soon)"</div> } />
</Routes>
</Router>
</main>
// Status Bar at the bottom
<StatusBar />
</div>
@@ -88,8 +139,13 @@ pub fn App() -> impl IntoView {
</div>
</div>
</div>
</Show>
</Show>
}
}/>
</Routes>
</Router>
// Toast container - fixed positioning relative to viewport
<ToastContainer />
</div>
}

View File

@@ -0,0 +1,110 @@
use leptos::*;
use leptos_router::*;
use serde::{Deserialize, Serialize};
#[derive(Serialize)]
struct LoginRequest {
username: String,
password: String,
}
#[component]
pub fn Login() -> impl IntoView {
let (username, set_username) = create_signal(String::new());
let (password, set_password) = create_signal(String::new());
let (error, set_error) = create_signal(Option::<String>::None);
let (loading, set_loading) = create_signal(false);
let handle_login = move |ev: web_sys::SubmitEvent| {
ev.prevent_default();
set_loading.set(true);
set_error.set(None);
spawn_local(async move {
let req = LoginRequest {
username: username.get(),
password: password.get(),
};
let client = gloo_net::http::Request::post("/api/auth/login")
.json(&req)
.expect("Failed to create request");
match client.send().await {
Ok(resp) => {
if resp.ok() {
// Redirect to home on success
let navigate = use_navigate();
navigate("/", Default::default());
} else {
set_error.set(Some("Kullanıcı adı veya şifre hatalı".to_string()));
}
}
Err(_) => {
set_error.set(Some("Bağlantı hatası".to_string()));
}
}
set_loading.set(false);
});
};
view! {
<div class="flex items-center justify-center min-h-screen bg-base-200">
<div class="card w-full max-w-sm shadow-xl bg-base-100">
<div class="card-body">
<h2 class="card-title justify-center mb-4">"VibeTorrent Giriş"</h2>
<form on:submit=handle_login>
<div class="form-control w-full">
<label class="label">
<span class="label-text">"Kullanıcı Adı"</span>
</label>
<input
type="text"
placeholder="Kullanıcı adınız"
class="input input-bordered w-full"
prop:value=username
on:input=move |ev| set_username.set(event_target_value(&ev))
disabled=move || loading.get()
/>
</div>
<div class="form-control w-full mt-4">
<label class="label">
<span class="label-text">"Şifre"</span>
</label>
<input
type="password"
placeholder="******"
class="input input-bordered w-full"
prop:value=password
on:input=move |ev| set_password.set(event_target_value(&ev))
disabled=move || loading.get()
/>
</div>
<Show when=move || error.get().is_some()>
<div class="alert alert-error mt-4 text-sm py-2">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span>{move || error.get()}</span>
</div>
</Show>
<div class="card-actions justify-end mt-6">
<button
class="btn btn-primary w-full"
type="submit"
disabled=move || loading.get()
>
<Show when=move || loading.get() fallback=|| "Giriş Yap">
<span class="loading loading-spinner"></span>
"Giriş Yapılıyor..."
</Show>
</button>
</div>
</form>
</div>
</div>
</div>
}
}

View File

@@ -0,0 +1,2 @@
pub mod login;
pub mod setup;

View File

@@ -0,0 +1,145 @@
use leptos::*;
use leptos_router::*;
use serde::{Deserialize, Serialize};
#[derive(Serialize)]
struct SetupRequest {
username: String,
password: String,
}
#[component]
pub fn Setup() -> impl IntoView {
let (username, set_username) = create_signal(String::new());
let (password, set_password) = create_signal(String::new());
let (confirm_password, set_confirm_password) = create_signal(String::new());
let (error, set_error) = create_signal(Option::<String>::None);
let (loading, set_loading) = create_signal(false);
let handle_setup = move |ev: web_sys::SubmitEvent| {
ev.prevent_default();
set_loading.set(true);
set_error.set(None);
let pass = password.get();
let confirm = confirm_password.get();
if pass != confirm {
set_error.set(Some("Şifreler eşleşmiyor".to_string()));
set_loading.set(false);
return;
}
if pass.len() < 6 {
set_error.set(Some("Şifre en az 6 karakter olmalıdır".to_string()));
set_loading.set(false);
return;
}
spawn_local(async move {
let req = SetupRequest {
username: username.get(),
password: pass,
};
let client = gloo_net::http::Request::post("/api/setup")
.json(&req)
.expect("Failed to create request");
match client.send().await {
Ok(resp) => {
if resp.ok() {
// Redirect to login after setup
let navigate = use_navigate();
navigate("/login", Default::default());
} else {
let text = resp.text().await.unwrap_or_default();
set_error.set(Some(format!("Hata: {}", text)));
}
}
Err(_) => {
set_error.set(Some("Bağlantı hatası".to_string()));
}
}
set_loading.set(false);
});
};
view! {
<div class="flex items-center justify-center min-h-screen bg-base-200">
<div class="card w-full max-w-md shadow-xl bg-base-100">
<div class="card-body">
<h2 class="card-title justify-center mb-2">"VibeTorrent Kurulumu"</h2>
<p class="text-center text-sm opacity-70 mb-4">"Yönetici hesabınızı oluşturun"</p>
<form on:submit=handle_setup>
<div class="form-control w-full">
<label class="label">
<span class="label-text">"Kullanıcı Adı"</span>
</label>
<input
type="text"
placeholder="admin"
class="input input-bordered w-full"
prop:value=username
on:input=move |ev| set_username.set(event_target_value(&ev))
disabled=move || loading.get()
required
/>
</div>
<div class="form-control w-full mt-4">
<label class="label">
<span class="label-text">"Şifre"</span>
</label>
<input
type="password"
placeholder="******"
class="input input-bordered w-full"
prop:value=password
on:input=move |ev| set_password.set(event_target_value(&ev))
disabled=move || loading.get()
required
/>
</div>
<div class="form-control w-full mt-4">
<label class="label">
<span class="label-text">"Şifre Tekrar"</span>
</label>
<input
type="password"
placeholder="******"
class="input input-bordered w-full"
prop:value=confirm_password
on:input=move |ev| set_confirm_password.set(event_target_value(&ev))
disabled=move || loading.get()
required
/>
</div>
<Show when=move || error.get().is_some()>
<div class="alert alert-error mt-4 text-sm py-2">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span>{move || error.get()}</span>
</div>
</Show>
<div class="card-actions justify-end mt-6">
<button
class="btn btn-primary w-full"
type="submit"
disabled=move || loading.get()
>
<Show when=move || loading.get() fallback=|| "Kurulumu Tamamla">
<span class="loading loading-spinner"></span>
"İşleniyor..."
</Show>
</button>
</div>
</form>
</div>
</div>
</div>
}
}

View File

@@ -1,6 +1,5 @@
use leptos::*;
use shared::GlobalLimitRequest;
use wasm_bindgen::JsCast;
fn format_bytes(bytes: i64) -> String {
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
@@ -110,43 +109,44 @@ pub fn StatusBar() -> impl IntoView {
});
};
// Helper to close dropdowns by blurring the active element
let close_dropdown = move || {
if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
if let Some(active) = doc.active_element() {
let _ = active
.dyn_into::<web_sys::HtmlElement>()
.map(|el| el.blur());
}
// Signal-based dropdown state: 0=none, 1=download, 2=upload, 3=theme
let (active_dropdown, set_active_dropdown) = create_signal(0u8);
// Toggle a specific dropdown
let toggle = move |id: u8| {
let current = active_dropdown.get_untracked();
if current == id {
set_active_dropdown.set(0);
} else {
set_active_dropdown.set(id);
}
};
// Global listener to force blur on touchstart (for iOS "tap outside" closing)
let force_blur = move |_| {
if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
if let Some(active) = doc.active_element() {
// If something is focused, blur it to close dropdowns
let _ = active
.dyn_into::<web_sys::HtmlElement>()
.map(|el| el.blur());
}
}
// Close all dropdowns
let close_all = move || {
set_active_dropdown.set(0);
};
let _ = window_event_listener(ev::touchstart, force_blur);
view! {
<div class="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">
// 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]">
// --- DOWNLOAD SPEED DROPDOWN ---
<div class="relative">
<div
class="dropdown dropdown-top dropdown-start"
on:touchstart=move |e| e.stop_propagation()
>
<div
tabindex="0"
role="button"
class="flex items-center gap-2 cursor-pointer hover:text-primary transition-colors select-none"
title="Global Download Speed - Click to Set Limit"
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">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 13.5L12 21m0 0l-7.5-7.5M12 21V3" />
@@ -160,8 +160,8 @@ pub fn StatusBar() -> impl IntoView {
</div>
<ul
tabindex="0"
class="dropdown-content z-[100] menu p-2 shadow bg-base-200 rounded-box w-40 mb-2 border border-base-300"
class="absolute bottom-full left-0 z-[100] menu p-2 shadow bg-base-200 rounded-box w-40 mb-2 border border-base-300"
style=move || if active_dropdown.get() == 1 { "display: block" } else { "display: none" }
>
{
limits.clone().into_iter().map(|(val, label)| {
@@ -169,14 +169,14 @@ pub fn StatusBar() -> impl IntoView {
let current = stats.get().down_limit.unwrap_or(0);
(current - val).abs() < 1024
};
let close = close_dropdown.clone();
view! {
<li>
<button
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);
close();
close_all();
}
>
{label}
@@ -192,15 +192,14 @@ pub fn StatusBar() -> impl IntoView {
</div>
// --- UPLOAD SPEED DROPDOWN ---
<div class="relative">
<div
class="dropdown dropdown-top dropdown-start"
on:touchstart=move |e| e.stop_propagation()
>
<div
tabindex="0"
role="button"
class="flex items-center gap-2 cursor-pointer hover:text-primary transition-colors select-none"
title="Global Upload Speed - Click to Set Limit"
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">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 10.5L12 3m0 0l7.5 7.5M12 3v18" />
@@ -214,8 +213,8 @@ pub fn StatusBar() -> impl IntoView {
</div>
<ul
tabindex="0"
class="dropdown-content z-[100] menu p-2 shadow bg-base-200 rounded-box w-40 mb-2 border border-base-300"
class="absolute bottom-full left-0 z-[100] menu p-2 shadow bg-base-200 rounded-box w-40 mb-2 border border-base-300"
style=move || if active_dropdown.get() == 2 { "display: block" } else { "display: none" }
>
{
limits.clone().into_iter().map(|(val, label)| {
@@ -223,14 +222,14 @@ pub fn StatusBar() -> impl IntoView {
let current = stats.get().up_limit.unwrap_or(0);
(current - val).abs() < 1024
};
let close = close_dropdown.clone();
view! {
<li>
<button
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);
close();
close_all();
}
>
{label}
@@ -246,15 +245,14 @@ pub fn StatusBar() -> impl IntoView {
</div>
<div class="ml-auto flex items-center gap-4">
<div class="relative">
<div
class="dropdown dropdown-top dropdown-end"
on:touchstart=move |e| e.stop_propagation()
>
<div
tabindex="0"
role="button"
class="btn btn-ghost btn-xs btn-square"
title="Change Theme"
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">
<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" />
@@ -262,21 +260,20 @@ pub fn StatusBar() -> impl IntoView {
</div>
<ul
tabindex="0"
class="dropdown-content z-[100] menu p-2 shadow bg-base-200 rounded-box w-52 mb-2 border border-base-300 max-h-96 overflow-y-auto"
class="absolute bottom-full right-0 z-[100] menu p-2 shadow bg-base-200 rounded-box w-52 mb-2 border border-base-300 max-h-96 overflow-y-auto"
style=move || if active_dropdown.get() == 3 { "display: block" } else { "display: none" }
>
{
let themes = vec![
"light", "dark", "dim", "nord", "cupcake", "dracula", "cyberpunk", "emerald", "sunset", "abyss"
];
let close = close_dropdown.clone();
themes.into_iter().map(|theme| {
let close = close.clone();
view! {
<li>
<button
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());
if let Some(win) = web_sys::window() {
if let Some(doc) = win.document() {
@@ -286,7 +283,7 @@ pub fn StatusBar() -> impl IntoView {
let _ = storage.set_item("vibetorrent_theme", theme);
}
}
close();
close_all();
}
>
{theme}
@@ -297,7 +294,6 @@ pub fn StatusBar() -> impl IntoView {
}
</ul>
</div>
<button
class="btn btn-ghost btn-xs btn-square"
title="Settings & Notification Permissions"
@@ -305,7 +301,45 @@ pub fn StatusBar() -> impl IntoView {
// Request push notification permission when settings button is clicked
spawn_local(async {
log::info!("Settings button clicked - requesting push notification permission");
// Check current permission state before requesting
let window = web_sys::window().expect("window should exist");
let _current_perm = js_sys::Reflect::get(&window, &"Notification".into())
.ok()
.and_then(|n| js_sys::Reflect::get(&n, &"permission".into()).ok())
.and_then(|p| p.as_string())
.unwrap_or_default();
crate::store::subscribe_to_push_notifications().await;
// Check permission after request
let new_perm = js_sys::Reflect::get(&window, &"Notification".into())
.ok()
.and_then(|n| js_sys::Reflect::get(&n, &"permission".into()).ok())
.and_then(|p| p.as_string())
.unwrap_or_default();
if let Some(store) = use_context::<crate::store::TorrentStore>() {
if new_perm == "granted" {
crate::store::show_toast_with_signal(
store.notifications,
shared::NotificationLevel::Success,
"Bildirimler etkinleştirildi! Torrent tamamlandığında bildirim alacaksınız.".to_string(),
);
} else if new_perm == "denied" {
crate::store::show_toast_with_signal(
store.notifications,
shared::NotificationLevel::Error,
"Bildirim izni reddedildi. Tarayıcı ayarlarından izin verebilirsiniz.".to_string(),
);
} else {
crate::store::show_toast_with_signal(
store.notifications,
shared::NotificationLevel::Warning,
"Bildirim izni verilemedi. Açılan izin penceresinde 'İzin Ver' seçeneğini seçin.".to_string(),
);
}
}
});
}
>

View File

@@ -3,3 +3,4 @@ pub mod layout;
pub mod modal;
pub mod toast;
pub mod torrent;
pub mod auth;

View File

@@ -152,6 +152,9 @@ pub fn TorrentTable() -> impl IntoView {
}
};
// Signal-based sort dropdown for mobile
let (sort_open, set_sort_open) = create_signal(false);
let sort_arrow = move |col: SortColumn| {
if sort_col.get() == col {
match sort_dir.get() {
@@ -321,25 +324,38 @@ pub fn TorrentTable() -> impl IntoView {
</table>
</div>
<div class="md:hidden flex flex-col h-full bg-base-200">
<div class="md:hidden flex flex-col h-full bg-base-200 relative">
// Transparent overlay to close sort dropdown
<Show when=move || sort_open.get()>
<div
class="fixed inset-0 z-[98] cursor-default"
on:pointerdown=move |_| set_sort_open.set(false)
></div>
</Show>
<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>
<div class="relative">
<div
class="dropdown dropdown-end"
on:touchstart=move |e| e.stop_propagation()
>
<div
tabindex="0"
role="button"
class="btn btn-ghost btn-xs gap-1 opacity-70 font-normal"
on:pointerdown=move |e| {
e.stop_propagation();
let cur = sort_open.get_untracked();
set_sort_open.set(!cur);
}
>
<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="M3 7.5L7.5 3m0 0L12 7.5M7.5 3v13.5m13.5 0L16.5 21m0 0L12 16.5m4.5 4.5V7.5" />
</svg>
"Sort"
</div>
<ul tabindex="0" class="dropdown-content z-[100] menu p-2 shadow bg-base-100 rounded-box w-48 border border-base-200 text-xs">
<ul
class="absolute top-full right-0 z-[100] menu p-2 shadow bg-base-100 rounded-box w-48 mt-1 border border-base-200 text-xs"
style=move || if sort_open.get() { "display: block" } else { "display: none" }
on:pointerdown=move |e| e.stop_propagation()
>
<li class="menu-title px-2 py-1 opacity-50 text-[10px] uppercase font-bold">"Sort By"</li>
{
let columns = vec![
@@ -352,26 +368,18 @@ pub fn TorrentTable() -> impl IntoView {
(SortColumn::ETA, "ETA"),
];
let close_dropdown = move || {
if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
if let Some(active) = doc.active_element() {
let _ = active.dyn_into::<web_sys::HtmlElement>().map(|el| el.blur());
}
}
};
columns.into_iter().map(|(col, label)| {
let is_active = move || sort_col.get() == col;
let current_dir = move || sort_dir.get();
let close = close_dropdown.clone();
view! {
<li>
<button
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);
close();
set_sort_open.set(false);
}
>
{label}
@@ -392,8 +400,7 @@ pub fn TorrentTable() -> impl IntoView {
</div>
</div>
<div class="overflow-y-auto p-3 pb-20 flex-1 grid grid-cols-1 content-start gap-3">
{move || filtered_torrents().into_iter().map(|t| {
<div class="overflow-y-auto p-3 pb-20 flex-1 grid grid-cols-1 content-start gap-3"> {move || filtered_torrents().into_iter().map(|t| {
let progress_class = if t.percent_complete >= 100.0 { "progress-success" } else { "progress-primary" };
let status_str = format!("{:?}", t.status);
let status_badge_class = match t.status {

View File

@@ -144,6 +144,8 @@ pub fn provide_torrent_store() {
let mut backoff_ms: u32 = 1000; // Start with 1 second
let max_backoff_ms: u32 = 30000; // Max 30 seconds
let mut was_connected = false;
let mut disconnect_notified = false; // Track if we already showed disconnect toast
let mut got_first_message; // Only count as "connected" after receiving data
loop {
let es_result = EventSource::new("/api/events");
@@ -152,20 +154,28 @@ pub fn provide_torrent_store() {
Ok(mut es) => {
match es.subscribe("message") {
Ok(mut stream) => {
// Connection established
if was_connected {
// We were previously connected and lost connection, now reconnected
// Don't show "connected" toast yet - wait for first real message
got_first_message = false;
// Process messages
while let Some(Ok((_, msg))) = stream.next().await {
// First successful message = truly connected
if !got_first_message {
got_first_message = true;
backoff_ms = 1000; // Reset backoff on real data
if was_connected && disconnect_notified {
// We were previously connected, lost connection, and now truly reconnected
show_toast_with_signal(
notifications,
NotificationLevel::Success,
"Sunucu bağlantısı yeniden kuruldu",
);
disconnect_notified = false;
}
was_connected = true;
backoff_ms = 1000; // Reset backoff on successful connection
}
// Process messages
while let Some(Ok((_, msg))) = stream.next().await {
if let Some(data_str) = msg.data().as_string() {
if let Ok(event) = serde_json::from_str::<AppEvent>(&data_str) {
match event {
@@ -218,9 +228,6 @@ pub fn provide_torrent_store() {
// Show browser notification for critical events
let is_critical = n.message.contains("tamamlandı")
|| n.message.contains("Reconnected")
|| n.message.contains("yeniden kuruldu")
|| n.message.contains("Lost connection")
|| n.level == shared::NotificationLevel::Error;
if is_critical {
@@ -243,34 +250,37 @@ pub fn provide_torrent_store() {
}
// Stream ended - connection lost
if was_connected {
if was_connected && !disconnect_notified {
show_toast_with_signal(
notifications,
NotificationLevel::Warning,
"Sunucu bağlantısı kesildi, yeniden bağlanılıyor...",
);
disconnect_notified = true;
}
}
Err(_) => {
// Failed to subscribe
if was_connected {
// Failed to subscribe - only notify once
if was_connected && !disconnect_notified {
show_toast_with_signal(
notifications,
NotificationLevel::Warning,
"Sunucu bağlantısı kesildi, yeniden bağlanılıyor...",
);
disconnect_notified = true;
}
}
}
}
Err(_) => {
// Failed to create EventSource
if was_connected {
// Failed to create EventSource - only notify once
if was_connected && !disconnect_notified {
show_toast_with_signal(
notifications,
NotificationLevel::Warning,
"Sunucu bağlantısı kesildi, yeniden bağlanılıyor...",
);
disconnect_notified = true;
}
}
}
@@ -309,34 +319,71 @@ pub async fn subscribe_to_push_notifications() {
// First, request notification permission if not already granted
let window = web_sys::window().expect("window should exist");
if let Ok(notification_class) = js_sys::Reflect::get(&window, &"Notification".into()) {
if !notification_class.is_undefined() {
let permission_granted = if let Ok(notification_class) = js_sys::Reflect::get(&window, &"Notification".into()) {
if notification_class.is_undefined() {
log::error!("Notification API not available");
return;
}
// Check current permission
let current_permission = js_sys::Reflect::get(&notification_class, &"permission".into())
.ok()
.and_then(|p| p.as_string())
.unwrap_or_default();
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(
web_sys::js_sys::Promise::from(promise_val)
js_sys::Promise::from(promise_val)
);
match request_future.await {
Ok(_) => {
log::info!("Notification permission requested");
Ok(result) => {
let result_str = result.as_string().unwrap_or_default();
log::info!("Permission request result: {}", result_str);
result_str == "granted"
}
Err(e) => {
log::warn!("Failed to request notification permission: {:?}", e);
log::error!("Failed to request notification permission: {:?}", e);
false
}
}
}
Err(e) => {
log::warn!("Failed to call requestPermission: {:?}", e);
}
log::error!("Failed to call requestPermission: {:?}", e);
false
}
}
} else {
false
}
} else {
false
}
}
} else {
log::error!("Cannot access Notification class");
return;
};
if !permission_granted {
log::warn!("Notification permission not granted, cannot subscribe to push");
return;
}
log::info!("Notification permission granted! Proceeding with push subscription...");
// Get VAPID public key from backend
let public_key_response = match Request::get("/api/push/public-key").send().await {
Ok(resp) => resp,

View File

@@ -1,69 +1,99 @@
const CACHE_NAME = 'vibetorrent-v1';
const CACHE_NAME = "vibetorrent-v2";
const ASSETS_TO_CACHE = [
'/',
'/index.html',
'/manifest.json',
'/icon-192.png',
'/icon-512.png'
"/",
"/index.html",
"/manifest.json",
"/icon-192.png",
"/icon-512.png",
];
// Install event - cache assets
self.addEventListener('install', (event) => {
console.log('[Service Worker] Installing...');
self.addEventListener("install", (event) => {
console.log("[Service Worker] Installing...");
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
console.log('[Service Worker] Caching static assets');
caches
.open(CACHE_NAME)
.then((cache) => {
console.log("[Service Worker] Caching static assets");
return cache.addAll(ASSETS_TO_CACHE);
}).then(() => {
console.log('[Service Worker] Skip waiting');
return self.skipWaiting();
})
.then(() => {
console.log("[Service Worker] Skip waiting");
return self.skipWaiting();
}),
);
});
// Activate event - clean old caches
self.addEventListener('activate', (event) => {
console.log('[Service Worker] Activating...');
self.addEventListener("activate", (event) => {
console.log("[Service Worker] Activating...");
event.waitUntil(
caches.keys().then((cacheNames) => {
caches
.keys()
.then((cacheNames) => {
return Promise.all(
cacheNames.map((key) => {
if (key !== CACHE_NAME) {
console.log('[Service Worker] Deleting old cache:', key);
console.log("[Service Worker] Deleting old cache:", key);
return caches.delete(key);
}
})
}),
);
}).then(() => {
console.log('[Service Worker] Claiming clients');
return self.clients.claim();
})
.then(() => {
console.log("[Service Worker] Claiming clients");
return self.clients.claim();
}),
);
});
// Fetch event - network first, cache fallback for API calls
self.addEventListener('fetch', (event) => {
// Fetch event - network first for HTML, cache fallback for API calls
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
// Network-first strategy for API calls
if (url.pathname.startsWith('/api/')) {
if (url.pathname.startsWith("/api/")) {
event.respondWith(
fetch(event.request)
.catch(() => {
fetch(event.request).catch(() => {
// Could return cached API response or offline page
return new Response(
JSON.stringify({ error: 'Offline' }),
{ headers: { 'Content-Type': 'application/json' } }
);
})
return new Response(JSON.stringify({ error: "Offline" }), {
headers: { "Content-Type": "application/json" },
});
}),
);
return;
}
// Cache-first strategy for static assets
// Network-first strategy for HTML pages (entry points)
// This ensures users always get the latest version of the app
if (
event.request.mode === "navigate" ||
url.pathname.endsWith("index.html") ||
url.pathname === "/"
) {
event.respondWith(
fetch(event.request)
.then((response) => {
// Cache the latest version of the HTML
const responseToCache = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
return response;
})
.catch(() => {
return caches.match(event.request);
}),
);
return;
}
// Cache-first strategy for static assets (JS, CSS, Images)
event.respondWith(
caches.match(event.request).then((response) => {
return response || fetch(event.request).then((fetchResponse) => {
return (
response ||
fetch(event.request).then((fetchResponse) => {
// Optionally cache new requests
if (fetchResponse && fetchResponse.status === 200) {
const responseToCache = fetchResponse.clone();
@@ -72,56 +102,57 @@ self.addEventListener('fetch', (event) => {
});
}
return fetchResponse;
});
})
);
}),
);
});
// Notification click event - focus or open app
self.addEventListener('notificationclick', (event) => {
console.log('[Service Worker] Notification clicked:', event.notification.tag);
self.addEventListener("notificationclick", (event) => {
console.log("[Service Worker] Notification clicked:", event.notification.tag);
event.notification.close();
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
clients
.matchAll({ type: "window", includeUncontrolled: true })
.then((clientList) => {
// If app is already open, focus it
for (let client of clientList) {
if (client.url === '/' && 'focus' in client) {
if (client.url === "/" && "focus" in client) {
return client.focus();
}
}
// Otherwise open new window
if (clients.openWindow) {
return clients.openWindow('/');
return clients.openWindow("/");
}
})
}),
);
});
// Push notification event
self.addEventListener('push', (event) => {
console.log('[Service Worker] Push notification received');
self.addEventListener("push", (event) => {
console.log("[Service Worker] Push notification received");
const data = event.data ? event.data.json() : {};
const title = data.title || 'VibeTorrent';
const title = data.title || "VibeTorrent";
const options = {
body: data.body || 'New notification',
icon: data.icon || '/icon-192.png',
badge: data.badge || '/icon-192.png',
tag: data.tag || 'vibetorrent-notification',
body: data.body || "New notification",
icon: data.icon || "/icon-192.png",
badge: data.badge || "/icon-192.png",
tag: data.tag || "vibetorrent-notification",
requireInteraction: false,
// iOS-specific: vibrate pattern (if supported)
vibrate: [200, 100, 200],
// Add data for notification click handling
data: {
url: data.url || '/',
timestamp: Date.now()
}
url: data.url || "/",
timestamp: Date.now(),
},
};
console.log('[Service Worker] Showing notification:', title, options);
console.log("[Service Worker] Showing notification:", title, options);
event.waitUntil(
self.registration.showNotification(title, options)
);
event.waitUntil(self.registration.showNotification(title, options));
});