Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
"dialog:default",
"fs:default",
"fs:allow-write-file",
"fs:allow-copy-file",
"fs:scope-temp-recursive",
"shell:default"
]
}
6 changes: 4 additions & 2 deletions src-tauri/src/conversion/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ use serde::Serialize;
pub enum ErrorCode {
ConversionCancelled,
InvalidOptions,
InputWriteFailed,
FfmpegUnavailable,
FfmpegStartFailed,
ConversionFailed,
OutputReadFailed,
ProbeFailed,
InputFileNotFound,
TempDirCreationFailed,
TimestampFetchFailed,
OutputFileNotGenerated
}

#[derive(Debug, Serialize)]
Expand Down
123 changes: 78 additions & 45 deletions src-tauri/src/conversion/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,33 +16,60 @@ use tauri_plugin_shell::ShellExt;
pub use error::{ApiError, ConversionError, ErrorCode};
pub use types::{ConversionRequest, MediaDimensions, MediaProbeRequest};

/// 指定された一時ファイルを明示的に削除する関数
pub fn remove_temp_file(path_str: &str) {
let path = PathBuf::from(path_str);
let temp_dir = std::env::temp_dir().join("henkanhakase");

// セキュリティ対策: 作成した一時ディレクトリ配下のファイルのみ削除を許可
if path.starts_with(&temp_dir) && path.exists() {
let _ = fs::remove_file(path);
}
}

/// アプリ起動時などに一時ディレクトリ内をすべて破棄・再作成する関数
pub fn cleanup_temp_dir() {
let temp_dir = std::env::temp_dir().join("henkanhakase");
if temp_dir.exists() {
let _ = fs::remove_dir_all(&temp_dir);
}
}

pub async fn convert(
app: &AppHandle,
request: ConversionRequest,
cancel_flag: Arc<AtomicBool>,
) -> Result<Vec<u8>, ConversionError> {
let workspace = TempWorkspace::new()?;
) -> Result<String, ConversionError> {
let input_path = PathBuf::from(&request.input_path);
if !input_path.exists() {
return Err(ConversionError::new(ErrorCode::InputFileNotFound));
}

let temp_dir = std::env::temp_dir().join("henkanhakase");
fs::create_dir_all(&temp_dir)
.map_err(|_| ConversionError::new(ErrorCode::TempDirCreationFailed))?;

let input_path = workspace
.path()
.join(format!("input.{}", request.input_format.extension()));
let output_path = workspace
.path()
.join(format!("output.{}", request.output_format.extension()));
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| ConversionError::new(ErrorCode::TimestampFetchFailed))?
.as_nanos();

fs::write(&input_path, request.data)
.map_err(|_| ConversionError::new(ErrorCode::InputWriteFailed))?;
let output_file = TempFile::new(temp_dir.join(format!(
"{}_{}.{}",
request.stem,
timestamp,
request.output_format.extension()
)));

let args = ffmpeg::build_args(
&input_path,
&output_path,
output_file.path(),
request.input_format,
request.output_format,
&request.options,
)
.map_err(|_| ConversionError::new(ErrorCode::InvalidOptions))?;

// 1. .output() ではなく .spawn() を使用してプロセスを起動する
let (mut rx, child) = app
.shell()
.sidecar("ffmpeg")
Expand All @@ -51,48 +78,56 @@ pub async fn convert(
.spawn()
.map_err(|_| ConversionError::new(ErrorCode::FfmpegStartFailed))?;

// 2. FFmpeg の実行完了、またはキャンセルフラグの変更を非同期にループ監視する
loop {
// フロントエンドから cancel_conversion が呼ばれたかをチェック
if cancel_flag.load(Ordering::Relaxed) {
// FFmpeg プロセスを強制終了する
let _ = child.kill();
return Err(ConversionError::new(ErrorCode::ConversionCancelled));
}

// FFmpeg からのイベント(出力ログや終了通知)を確認する
if let Ok(Some(tauri_plugin_shell::process::CommandEvent::Terminated(payload))) =
tokio::time::timeout(Duration::from_millis(100), rx.recv()).await
{
if payload.code != Some(0) {
return Err(ConversionError::new(ErrorCode::ConversionFailed));
}
// 正常終了したためループを抜ける
break;
}
}

fs::read(&output_path).map_err(|_| ConversionError::new(ErrorCode::OutputReadFailed))
if !output_file.path().exists() {
return Err(ConversionError::new(ErrorCode::OutputFileNotGenerated));
}

let final_path = output_file.disarm();
Ok(final_path.to_string_lossy().into_owned())
}

pub async fn probe_dimensions(
app: &AppHandle,
request: MediaProbeRequest,
) -> Result<MediaDimensions, ConversionError> {
let workspace = TempWorkspace::new()?;
let input_path = workspace
.path()
.join(format!("input.{}", request.input_format.extension()));
let probe_path = workspace.path().join("probe.png");
let input_path = PathBuf::from(&request.input_path);
if !input_path.exists() {
return Err(ConversionError::new(ErrorCode::InputFileNotFound));
}

let temp_dir = std::env::temp_dir().join("henkanhakase");
fs::create_dir_all(&temp_dir)
.map_err(|_| ConversionError::new(ErrorCode::TempDirCreationFailed))?;

let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| ConversionError::new(ErrorCode::TimestampFetchFailed))?
.as_nanos();

fs::write(&input_path, request.data)
.map_err(|_| ConversionError::new(ErrorCode::InputWriteFailed))?;
let probe_file = TempFile::new(temp_dir.join(format!("probe_{}.png", timestamp)));

let output = app
.shell()
.sidecar("ffmpeg")
.map_err(|_| ConversionError::new(ErrorCode::FfmpegUnavailable))?
.args([
"-y".to_string(),
"-i".to_string(),
input_path.to_string_lossy().into_owned(),
"-frames:v".to_string(),
Expand All @@ -101,7 +136,7 @@ pub async fn probe_dimensions(
"image2pipe".to_string(),
"-vcodec".to_string(),
"png".to_string(),
probe_path.to_string_lossy().into_owned(),
probe_file.path().to_string_lossy().into_owned(),
])
.output()
.await
Expand All @@ -112,7 +147,7 @@ pub async fn probe_dimensions(
}

let probe_data =
fs::read(&probe_path).map_err(|_| ConversionError::new(ErrorCode::ProbeFailed))?;
fs::read(probe_file.path()).map_err(|_| ConversionError::new(ErrorCode::ProbeFailed))?;

parse_png_dimensions(&probe_data)
}
Expand All @@ -134,33 +169,31 @@ fn parse_png_dimensions(data: &[u8]) -> Result<MediaDimensions, ConversionError>
Ok(MediaDimensions { width, height })
}

struct TempWorkspace {
/// スコープを抜けた際(エラー時やキャンセル時含む)に一時ファイルを自動削除するRAIIガード
pub struct TempFile {
path: PathBuf,
keep: bool,
}

impl TempWorkspace {
fn new() -> Result<Self, ConversionError> {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| ConversionError::new(ErrorCode::InputWriteFailed))?
.as_nanos();

let name = format!("henkanhakase-{}-{timestamp}", std::process::id());

let path = std::env::temp_dir().join(name);

fs::create_dir_all(&path).map_err(|_| ConversionError::new(ErrorCode::InputWriteFailed))?;

Ok(Self { path })
impl TempFile {
pub fn new(path: PathBuf) -> Self {
Self { path, keep: false }
}

fn path(&self) -> &Path {
pub fn path(&self) -> &Path {
&self.path
}

pub fn disarm(mut self) -> PathBuf {
self.keep = true;
self.path.clone()
}
}

impl Drop for TempWorkspace {
impl Drop for TempFile {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
if !self.keep && self.path.exists() {
let _ = fs::remove_file(&self.path);
}
}
}
35 changes: 20 additions & 15 deletions src-tauri/src/conversion/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ impl FileFormat {
pub fn is_video(self) -> bool {
matches!(self, Self::Mp4 | Self::Webm | Self::Avi | Self::Mov)
}

pub fn is_audio(self) -> bool {
matches!(
self,
Expand Down Expand Up @@ -250,20 +251,6 @@ pub struct ConversionOptions {
pub audio: Option<AudioOptions>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MediaProbeRequest {
pub data: Vec<u8>,
pub input_format: FileFormat,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MediaDimensions {
pub width: u32,
pub height: u32,
}

impl ConversionOptions {
pub fn validate(&self, output_format: FileFormat) -> Result<(), String> {
if let Some(width) = self.width {
Expand Down Expand Up @@ -333,10 +320,28 @@ impl ConversionOptions {
}
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MediaProbeRequest {
/// 入力ファイルの絶対パス
pub input_path: String,
pub _input_format: FileFormat,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MediaDimensions {
pub width: u32,
pub height: u32,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConversionRequest {
pub data: Vec<u8>,
/// 入力ファイルの絶対パス
pub input_path: String,
/// ファイル名 (拡張子なし)
pub stem: String,
pub input_format: FileFormat,
pub output_format: FileFormat,

Expand Down
25 changes: 18 additions & 7 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ mod conversion;
use conversion::{ApiError, ConversionRequest, MediaDimensions, MediaProbeRequest};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tauri::State;
use tauri::{State, WindowEvent};

#[derive(Default)]
pub struct AppState {
Expand All @@ -15,19 +15,15 @@ async fn convert_file(
app: tauri::AppHandle,
state: State<'_, AppState>,
request: ConversionRequest,
) -> Result<Vec<u8>, ApiError> {
// 変換開始時にキャンセルフラグを「false(未キャンセル)」にリセット
) -> Result<String, ApiError> {
state.cancel_flag.store(false, Ordering::Relaxed);

// conversion モジュールに cancel_flag (Arc<AtomicBool>) を渡して処理を実行
conversion::convert(&app, request, state.cancel_flag.clone())
.await
.map_err(ApiError::from)
}

#[tauri::command]
fn cancel_conversion(state: State<'_, AppState>) {
// フラグを「true(キャンセル済み)」に更新
state.cancel_flag.store(true, Ordering::Relaxed);
}

Expand All @@ -41,8 +37,16 @@ async fn probe_media_dimensions(
.map_err(ApiError::from)
}

#[tauri::command]
fn cleanup_temp_file(path: String) {
conversion::remove_temp_file(&path);
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// アプリ起動時に前回の古い一時ディレクトリを初期化・全削除
conversion::cleanup_temp_dir();

tauri::Builder::default()
.manage(AppState::default())
.plugin(tauri_plugin_shell::init())
Expand All @@ -52,8 +56,15 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![
convert_file,
probe_media_dimensions,
cancel_conversion
cancel_conversion,
cleanup_temp_file
])
// ウィンドウイベントの監視を追加(アプリ終了時に一時ディレクトリごと削除)
.on_window_event(|_window, event| {
if let WindowEvent::CloseRequested { .. } = event {
conversion::cleanup_temp_dir();
}
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
Loading