Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,11 @@ panic = "abort"

[dependencies]
risuko-engine = { workspace = true }
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri = { version = "2", features = [
"tray-icon",
"image-png",
"macos-private-api",
] }
tauri-plugin-store = "2"
tauri-plugin-deep-link = "2"
tauri-plugin-dialog = "2"
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"identifier": "default",
"description": "Default capabilities for Risuko",
"windows": ["main"],
"windows": ["main", "tray-panel"],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Inspect tray/flyout renderer invoke surface (what permissions are actually needed).
fd -i 'tray\.ts|Flyout\.vue|FlyoutTaskItem\.vue' src/renderer \
  | xargs -r rg -n -C2 '\binvoke\s*\('

# 2) Inspect backend command exposure list for mapping.
rg -n -C2 'generate_handler!\[' src-tauri/src/lib.rs

# 3) Review current capability grants applied to tray-panel.
cat src-tauri/capabilities/default.json

Repository: YueMiyuki/Risuko

Length of output: 2430


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Get full list of handlers exposed by generate_handler!
rg -n 'generate_handler!\[' src-tauri/src/lib.rs -A 100

# 2) Search all invoke calls in Flyout and related tray components (broader search)
find src/renderer -type f \( -name "*.vue" -o -name "*.ts" \) -path "*[Ff]lyout*" -o -path "*[Tt]ray*" | head -20

# 3) Check if other capability profiles exist
find src-tauri/capabilities -type f -name "*.json"

# 4) Search for all invoke calls across tray-related files more thoroughly
rg -n 'invoke\(' src/renderer --glob '*[Tt]ray*' -B 2 -A 2

Repository: YueMiyuki/Risuko

Length of output: 5773


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Check desktop.json to understand capability profiles
cat src-tauri/capabilities/desktop.json

# 2) Check tray.ts for all invoke calls (may have more than Flyout.vue)
cat src/renderer/pages/index/tray.ts

# 3) Check tray.worker.ts for invoke calls
cat src/renderer/workers/tray.worker.ts

# 4) Check if main/tauri.conf.json shows which capability applies to which window
find src-tauri -name "tauri.conf.json" -o -name "tauri.conf.*.json" | xargs -r cat 2>/dev/null | head -200

Repository: YueMiyuki/Risuko

Length of output: 6698


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Search for tray-panel window creation/registration
rg -n 'tray-panel' src-tauri --type rust -B 3 -A 3

# 2) Check where tray window/panel is spawned
rg -n 'tray' src-tauri/src --type rust -i | grep -i 'window\|spawn\|create' | head -20

# 3) Check store methods to understand what commands are invoked
rg -n 'fetchGlobalStat\|fetchList' src/renderer --type ts -B 2 -A 5 | head -50

Repository: YueMiyuki/Risuko

Length of output: 686


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Find and read the store files to see what commands are actually invoked
find src/renderer/store -type f -name "*.ts" | head -10

# 2) Check app store specifically for what commands are invoked
rg -n 'fetchGlobalStat|fetchList' src/renderer/store --type ts -B 2 -A 8 | head -80

# 3) Check complete WebviewWindowBuilder setup for tray-panel to confirm capability assignment
cat src-tauri/src/managers/flyout.rs

Repository: YueMiyuki/Risuko

Length of output: 10200


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the api object definition to understand what commands are invoked
find src/renderer -name "api.ts" -o -name "api.js" | head -5

# Check what fetchGlobalStat and fetchList actually call
rg -n 'fetchGlobalStat|fetchList' src/renderer/store/app.ts -B 2 -A 8

# Verify the api calls for these methods
rg -n 'fetchTaskList\|fetchGlobalStat' src/renderer -A 3 -B 1 | grep -E '(invoke|api\.)' | head -30

Repository: YueMiyuki/Risuko

Length of output: 734


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find api definitions
find src/renderer -type f \( -name "*.ts" -o -name "*.js" \) | xargs rg -l "getGlobalStat\|fetchTaskList" | head -5

# Look for the api module/service
find src/renderer -type d -name "api*" -o -name "service*" | head -5

# Search for where getGlobalStat is defined
rg -n "getGlobalStat|fetchTaskList" src/renderer --type ts | grep "=\|:" | head -20

Repository: YueMiyuki/Risuko

Length of output: 44


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Simple search for where api is imported/defined
rg -n "const api\|export.*api\|import.*api" src/renderer/store/app.ts

# Also check the imports at top of app.ts
head -30 src/renderer/store/app.ts

# Find all files with "api" in the name
fd -i "api" src/renderer --type f

Repository: YueMiyuki/Risuko

Length of output: 42


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the beginning of app.ts to see imports
head -50 src/renderer/store/app.ts

# Simple list of files in store directory
ls -la src/renderer/store/

# Try to find where "api" variable comes from
rg "import.*api\|const api" src/renderer/store/ --type ts -n

Repository: YueMiyuki/Risuko

Length of output: 1902


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the api directory/file
find src/renderer -path "*api*" -type f | grep -v node_modules

# Check if it's a directory
ls -la src/renderer/api/ 2>/dev/null || ls -la src/renderer/api.ts 2>/dev/null || echo "Not found, searching..."

# Search for `@/api` alias definition and api files
rg -n "getGlobalStat|get_global_stat" src/renderer --type ts -B 1 -A 3 | head -50

Repository: YueMiyuki/Risuko

Length of output: 967


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the complete Api.ts file to see all commands tray-panel can invoke
cat src/renderer/api/Api.ts

Repository: YueMiyuki/Risuko

Length of output: 20797


Scope tray-panel to a minimal capability set.

Line 4 adds tray-panel to the default capability, which grants it all 80+ exposed commands and broad permissions including shell execution, file access, process control, and OS operations. However, the flyout only calls 4 read-only backend commands: show_window, get_global_stat, tell_active, tell_waiting, and tell_stopped. If the tray panel webview is compromised, an attacker gains access to file operations, process management, and shell execution across the entire application. Create a dedicated minimal capability profile for tray-panel with only the window show/hide operations and the specific read-only query commands it uses.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src-tauri/capabilities/default.json` at line 4, The default "tray-panel"
entry currently inherits the full "windows" capability; create a new minimal
capability object (e.g., "tray-panel-minimal") in the JSON that only grants the
specific window operations and read-only backend commands used by the
flyout—include the window show/hide APIs and the exact commands show_window,
get_global_stat, tell_active, tell_waiting, and tell_stopped—and remove all
broad permissions (filesystem, shell, process, OS) from that profile, then
replace the "tray-panel" reference in the top-level capabilities list with the
new minimal profile name so the tray webview is scoped to those limited commands
only.

"permissions": [
"core:default",
"core:window:default",
Expand Down
13 changes: 13 additions & 0 deletions src-tauri/capabilities/tray-panel.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Misleading security description: tray-panel.json claims "No filesystem, shell, process, OS, or dialog access", but default.json already includes "tray-panel" in its windows list and grants all of those permissions. In Tauri v2, capability permissions are unioned — so the restrictive intent is entirely defeated. Remove "tray-panel" from default.json's windows array if the intent is genuine restriction, or update the description to match reality.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src-tauri/capabilities/tray-panel.json:

<comment>Misleading security description: `tray-panel.json` claims "No filesystem, shell, process, OS, or dialog access", but `default.json` already includes `"tray-panel"` in its `windows` list and grants all of those permissions. In Tauri v2, capability permissions are unioned — so the restrictive intent is entirely defeated. Remove `"tray-panel"` from `default.json`'s windows array if the intent is genuine restriction, or update the description to match reality.</comment>

<file context>
@@ -0,0 +1,13 @@
+{
+	"identifier": "tray-panel",
+	"description": "Minimal capabilities for the tray flyout panel. Grants only window management, store access, and the invoke channel needed for flyout commands (show_window, get_global_stat, tell_active, tell_waiting, tell_stopped, pause/unpause/remove task, add_uri, open_path, reveal_in_folder). No filesystem, shell, process, OS, or dialog access.",
+	"windows": ["tray-panel"],
+	"permissions": [
+		"core:default",
+		"core:window:allow-show",
+		"core:window:allow-hide",
+		"core:window:allow-set-focus",
</file context>

"identifier": "tray-panel",
"description": "Minimal capabilities for the tray flyout panel. Grants only window management, store access, and the invoke channel needed for flyout commands (show_window, get_global_stat, tell_active, tell_waiting, tell_stopped, pause/unpause/remove task, add_uri, open_path, reveal_in_folder). No filesystem, shell, process, OS, or dialog access.",
"windows": ["tray-panel"],
"permissions": [
"core:default",
"core:window:allow-show",
"core:window:allow-hide",
"core:window:allow-set-focus",
"core:window:allow-start-dragging",
"store:default"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
]
}
13 changes: 8 additions & 5 deletions src-tauri/risuko-bt/src/torrent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1700,7 +1700,6 @@ fn maybe_clear_endgame(chunk_tracker: &mut ChunkTracker) {
}

/// Handle an inbound BEP-55 ut_holepunch message

fn handle_holepunch(
hp: HolepunchMsg,
from_addr: SocketAddr,
Expand Down Expand Up @@ -1791,10 +1790,14 @@ fn try_initiate_holepunch(
let Some(relay_hp) = relay.their_ut_holepunch_id else {
return; // relay doesn't support holepunch
};
if relay.cmd_tx.try_send(PeerCommand::Send(Message::Extended {
ext_id: relay_hp,
payload: build_holepunch(holepunch_type::RENDEZVOUS, target, 0),
})).is_ok() {
if relay
.cmd_tx
.try_send(PeerCommand::Send(Message::Extended {
ext_id: relay_hp,
payload: build_holepunch(holepunch_type::RENDEZVOUS, target, 0),
}))
.is_ok()
{
holepunch_attempted.insert(target);
log::debug!(
target: "diag",
Expand Down
5 changes: 4 additions & 1 deletion src-tauri/src/commands/file_cmds.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1204,7 +1204,10 @@ fn trash_generated_torrent_sidecars_in_dir(dir: &Path, normalized_info_hash: Opt
// dict and comparing to this task's info-hash identifies the sidecar
// precisely, and never touches an unrelated torrent (different hash).
let hex_stem = generated_torrent_hex_stem(file_name);
let matched_by_name = hex_stem.as_deref().map(|stem| stem == hash).unwrap_or(false);
let matched_by_name = hex_stem
.as_deref()
.map(|stem| stem == hash)
.unwrap_or(false);
// Only probe content when the filename has no hex-infohash stem.
// A hex-named .torrent whose stem differs from our hash is definitively
// a different torrent; content-matching it would be a false positive that
Expand Down
22 changes: 22 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,8 @@ pub fn run() {

managers::tray::setup_tray(app)?;

managers::flyout::setup_flyout(app)?;

// Start RSS background polling
if let Ok(guard) = app.state::<state::AppState>().rss.lock() {
if let Some(rss) = guard.clone() {
Expand All @@ -353,6 +355,15 @@ pub fn run() {
})
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
#[cfg(not(target_os = "android"))]
if window.label() == managers::flyout::FLYOUT_LABEL {
api.prevent_close();
let _ = window.hide();
return;
}
if window.label() != "main" {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let quitting = window
.app_handle()
.state::<state::AppState>()
Expand All @@ -364,6 +375,17 @@ pub fn run() {
api.prevent_close();
let _ = commands::app_cmds::hide_main_window(window.app_handle());
}

if let tauri::WindowEvent::Focused(false) = event {
#[cfg(not(target_os = "android"))]
if window.label() == managers::flyout::FLYOUT_LABEL {
#[cfg(target_os = "macos")]
let _ = window
.app_handle()
.set_activation_policy(tauri::ActivationPolicy::Accessory);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
let _ = window.hide();
}
}
})
.invoke_handler(tauri::generate_handler![
commands::config_cmds::get_app_config,
Expand Down
173 changes: 173 additions & 0 deletions src-tauri/src/managers/flyout.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
//! Tray "Quick Panel" flyout window

#[cfg(not(target_os = "android"))]
use tauri::{
AppHandle, Emitter, Manager, PhysicalPosition, WebviewUrl, WebviewWindow, WebviewWindowBuilder,
};

#[cfg(target_os = "android")]
use tauri::AppHandle;

#[cfg(not(target_os = "android"))]
pub const FLYOUT_LABEL: &str = "tray-panel";

/// Logical size of the flyout window
#[cfg(not(target_os = "android"))]
const FLYOUT_WIDTH: f64 = 396.0;
#[cfg(not(target_os = "android"))]
const FLYOUT_HEIGHT: f64 = 540.0;

/// Gap in physical pixels between the tray icon and the flyout edge
#[cfg(not(target_os = "android"))]
const FLYOUT_GAP: i32 = 0;

/// Create the flyout window
#[cfg(not(target_os = "android"))]
pub fn setup_flyout(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
let handle = app.handle();
// Skip if it somehow already exists
if handle.get_webview_window(FLYOUT_LABEL).is_some() {
return Ok(());
}

let mut builder =
WebviewWindowBuilder::new(app, FLYOUT_LABEL, WebviewUrl::App("tray.html".into()))
.title("Risuko Quick Panel")
.inner_size(FLYOUT_WIDTH, FLYOUT_HEIGHT)
.decorations(false)
.always_on_top(true)
.skip_taskbar(true)
.resizable(false)
.visible(false)
.focused(false)
.transparent(true)
.shadow(false)
.accept_first_mouse(true);

#[cfg(target_os = "macos")]
{
builder = builder.visible_on_all_workspaces(true);
}

builder.build()?;
Ok(())
}

#[cfg(target_os = "android")]
pub fn setup_flyout(_app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}

/// Cache the tray icon rect
#[cfg(not(target_os = "android"))]
pub fn cache_tray_rect(app: &AppHandle, rect: &tauri::Rect, scale_factor: f64) {
let pos = rect.position.to_physical::<f64>(scale_factor);
let size = rect.size.to_physical::<f64>(scale_factor);
if let Some(state) = app.try_state::<crate::state::AppState>() {
if let Ok(mut anchor) = state.tray_anchor.lock() {
*anchor = Some((pos.x, pos.y, size.width, size.height));
}
}
}

/// Toggle the flyout
#[cfg(not(target_os = "android"))]
pub fn toggle_flyout(app: &AppHandle) {
let Some(window) = app.get_webview_window(FLYOUT_LABEL) else {
log::warn!("[Risuko] flyout window not found");
return;
};

if window.is_visible().unwrap_or(false) {
#[cfg(target_os = "macos")]
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
let _ = app.set_activation_policy(tauri::ActivationPolicy::Accessory);
let _ = window.hide();
return;
}

position_flyout(app, &window);
let _ = window.show();
let _ = window.set_focus();
// Wake the webview's polling loop so the panel is fresh on open.
let _ = window.emit("flyout:show", ());
}

#[cfg(target_os = "android")]
pub fn toggle_flyout(_app: &AppHandle) {}

/// Position the flyout near the cached tray anchor
#[cfg(not(target_os = "android"))]
fn position_flyout(app: &AppHandle, window: &WebviewWindow) {
// Resolve the anchor point (icon center) in physical pixels
let anchor = app
.try_state::<crate::state::AppState>()
.and_then(|state| state.tray_anchor.lock().ok().and_then(|guard| *guard));

let (icon_x, icon_y, icon_w, icon_h) = match anchor {
Some(rect) => rect,
None => {
// No tray rect was ever delivered
match app.cursor_position() {
Ok(pos) => (pos.x, pos.y, 0.0, 0.0),
Err(_) => (0.0, 0.0, 0.0, 0.0),
}
}
};

let icon_center_x = icon_x + icon_w / 2.0;
let icon_center_y = icon_y + icon_h / 2.0;

// Find the monitor under the icon; fall back to primary
let monitor = app
.monitor_from_point(icon_center_x, icon_center_y)
.ok()
.flatten()
.or_else(|| app.primary_monitor().ok().flatten());

let Some(monitor) = monitor else {
// Last resort: drop it at the anchor with no clamping
let _ = window.set_position(PhysicalPosition::new(icon_x as i32, icon_y as i32));
return;
};

let scale = monitor.scale_factor();
let work = monitor.work_area();
let wa_x = work.position.x as f64;
let wa_y = work.position.y as f64;
let wa_w = work.size.width as f64;
let wa_h = work.size.height as f64;

// Window size in physical pixels
let win_w = FLYOUT_WIDTH * scale;
let win_h = FLYOUT_HEIGHT * scale;
let gap = FLYOUT_GAP as f64 * scale;

// Decide above vs below using the icon center relative to the work area
let in_top_half = icon_center_y < wa_y + wa_h / 2.0;
let mut y = if in_top_half {
// Place below the icon
icon_y + icon_h + gap
} else {
// Place above the icon
icon_y - win_h - gap
};

// Horizontally center on the icon
let mut x = icon_center_x - win_w / 2.0;

// Clamp into the work area
let max_x = wa_x + wa_w - win_w;
let max_y = wa_y + wa_h - win_h;
if x < wa_x {
x = wa_x;
} else if x > max_x {
x = max_x.max(wa_x);
}
if y < wa_y {
y = wa_y;
} else if y > max_y {
y = max_y.max(wa_y);
}

let _ = window.set_position(PhysicalPosition::new(x.round() as i32, y.round() as i32));
}
1 change: 1 addition & 0 deletions src-tauri/src/managers/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod flyout;
pub mod menu;
pub mod tray;
pub mod vault;
Expand Down
42 changes: 31 additions & 11 deletions src-tauri/src/managers/tray.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,24 @@ use tauri::{
image::Image,
menu::{Menu, MenuBuilder, MenuItemBuilder, PredefinedMenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
App, AppHandle, Emitter, Manager,
App, AppHandle, Emitter,
};

#[cfg(target_os = "android")]
use tauri::{App, AppHandle};

#[cfg(not(target_os = "android"))]
use super::{emit_command, show_and_emit};
use super::{emit_command, flyout, show_and_emit};

#[cfg(not(target_os = "android"))]
fn toggle_main_window(app: &AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let is_visible = window.is_visible().unwrap_or(false);
if is_visible {
let _ = crate::commands::app_cmds::hide_main_window(app);
} else {
let _ = crate::commands::app_cmds::show_main_window(app);
}
fn tray_event_rect(event: &TrayIconEvent) -> Option<(&tauri::Rect, (f64, f64))> {
match event {
TrayIconEvent::Click { rect, position, .. }
| TrayIconEvent::DoubleClick { rect, position, .. }
| TrayIconEvent::Enter { rect, position, .. }
| TrayIconEvent::Move { rect, position, .. }
| TrayIconEvent::Leave { rect, position, .. } => Some((rect, (position.x, position.y))),
_ => None,
}
}

Expand Down Expand Up @@ -62,6 +62,11 @@ fn build_tray_menu(
get_tray_menu_text(labels, "tray-show", "Show Risuko"),
)
.build(handle)?;
let quick_panel = MenuItemBuilder::with_id(
"tray-quick-panel",
get_tray_menu_text(labels, "tray-quick-panel", "Quick Panel"),
)
.build(handle)?;
let manual = MenuItemBuilder::with_id(
"tray-manual",
get_tray_menu_text(labels, "tray-manual", "Manual"),
Expand Down Expand Up @@ -95,6 +100,7 @@ fn build_tray_menu(
&open_file,
&sep1,
&show,
&quick_panel,
&manual,
&check_updates,
&sep2,
Expand Down Expand Up @@ -123,13 +129,26 @@ pub fn setup_tray(app: &App) -> Result<(), Box<dyn std::error::Error>> {
.icon_as_template(true)
.show_menu_on_left_click(false)
.on_tray_icon_event(|tray, event| {
let app = tray.app_handle();
// Cache the tray icon rect from any positional event so the flyout
// can anchor near the tray even when opened from the menu item
if let Some((rect, point)) = tray_event_rect(&event) {
let scale = app
.monitor_from_point(point.0, point.1)
.ok()
.flatten()
.map(|m| m.scale_factor())
.unwrap_or(1.0);
flyout::cache_tray_rect(app, rect, scale);
}

if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
toggle_main_window(tray.app_handle());
flyout::toggle_flyout(app);
}
})
.on_menu_event(move |app, event| {
Expand All @@ -141,6 +160,7 @@ pub fn setup_tray(app: &App) -> Result<(), Box<dyn std::error::Error>> {
"tray-show" => {
let _ = crate::commands::app_cmds::show_main_window(app);
}
"tray-quick-panel" => flyout::toggle_flyout(app),
"tray-manual" => {
let _ = open::that("https://github.com/YueMiyuki/Risuko/wiki");
}
Expand Down
Loading
Loading