Migration Roadmap and Technical Documentation
Comprehensive guide for porting Motrix download manager to Aether project
- Project Overview
- Architecture Analysis
- Migration Roadmap
- Phase Implementation Details
- Technical Specifications
- Quality Assurance
- Deployment
- Summary
This document outlines the complete migration plan for porting Motrix (Electron-based download manager) to the Aether project (Tauri + Nuxt 3 architecture). The migration will transform a traditional Electron application into a modern, performant Tauri-based desktop application while maintaining all core functionality.
- ✅ Modernization: Migrate from Electron to Tauri for better performance and security
- ✅ Framework Upgrade: Vue 2 → Vue 3 with Composition API
- ✅ Type Safety: JavaScript → TypeScript throughout the application
- ✅ Architecture: Implement clean, maintainable code structure
- ✅ Performance: Leverage Rust backend for optimal performance
- ✅ Cross-platform: Maintain support for Windows, macOS, and Linux
Architecture Overview:
Motrix Architecture
├── Frontend: Electron + Vue.js 2 + Element UI
├── Backend: Node.js with Aria2 integration
├── State: Vuex store management
├── Build: Webpack + Electron Builder
└── Language: JavaScript
Key Components:
- Aria2 Engine: C++ binary for download management
- Electron Main Process: System integration and window management
- Vue.js Renderer: User interface and interaction
- Vuex Store: Application state management
- IPC Communication: Inter-process communication
Architecture Overview:
Aether Architecture
├── Frontend: Nuxt 3 + Vue.js 3 + Nuxt UI
├── Backend: Tauri (Rust) with Aria2 integration
├── State: Pinia store management
├── Build: Vite + Tauri bundler
└── Language: TypeScript + Rust
Key Improvements:
- Rust Backend: Better performance and memory safety
- Modern Frontend: Vue 3 Composition API with TypeScript
- Smaller Bundle: Tauri produces significantly smaller binaries
- Better Security: Rust's memory safety and Tauri's security model
- Native Performance: Direct system integration through Rust
Duration: Weeks 1-2
-
Package.json Updates
{ "name": "aether-download-manager", "description": "Modern download manager built with Tauri and Nuxt", "version": "1.0.0", "main": "src-tauri/target/release/aether-download-manager", "scripts": { "dev": "tauri dev", "build": "tauri build", "preview": "nuxt preview" } } -
Tauri Configuration
{ "package": { "productName": "Aether Download Manager", "version": "1.0.0" }, "tauri": { "allowlist": { "all": false, "fs": { "all": true, "scope": ["$DOWNLOAD/*", "$DOCUMENT/*"] }, "shell": { "all": false, "sidecar": true, "scope": ["aria2c"] } } } }
aether-download-manager/
├── docs/ # Documentation
│ ├── migration-plan.md # This document
│ ├── api-reference.md # API documentation
│ └── development-guide.md # Development setup
├── src-tauri/ # Rust backend
│ ├── src/
│ │ ├── main.rs # Entry point
│ │ ├── lib.rs # Library exports
│ │ ├── commands/ # Tauri commands
│ │ │ ├── mod.rs
│ │ │ ├── download.rs # Download operations
│ │ │ ├── config.rs # Configuration management
│ │ │ └── system.rs # System integration
│ │ ├── aria2/ # Aria2 integration
│ │ │ ├── mod.rs
│ │ │ ├── client.rs # Aria2 RPC client
│ │ │ ├── config.rs # Aria2 configuration
│ │ │ └── process.rs # Process management
│ │ ├── models/ # Data models
│ │ │ ├── mod.rs
│ │ │ ├── download.rs # Download task models
│ │ │ └── config.rs # Configuration models
│ │ └── utils/ # Utility functions
│ ├── binaries/ # Aria2 binaries
│ │ ├── aria2c-macos-x64
│ │ ├── aria2c-macos-arm64
│ │ ├── aria2c-windows-x64.exe
│ │ ├── aria2c-linux-x64
│ │ └── aria2c-linux-arm64
│ └── icons/ # Application icons
├── app/ # Nuxt frontend
│ ├── components/ # Vue components
│ │ ├── Download/ # Download management
│ │ │ ├── TaskList.vue # Task list component
│ │ │ ├── TaskItem.vue # Individual task
│ │ │ ├── AddDialog.vue # Add download dialog
│ │ │ └── ProgressBar.vue # Progress visualization
│ │ ├── Layout/ # Layout components
│ │ │ ├── Sidebar.vue # Navigation sidebar
│ │ │ ├── Header.vue # Application header
│ │ │ └── StatusBar.vue # Status information
│ │ └── Settings/ # Settings interface
│ │ ├── General.vue # General settings
│ │ ├── Aria2.vue # Aria2 configuration
│ │ └── Advanced.vue # Advanced options
│ ├── composables/ # Vue composables
│ │ ├── useDownload.ts # Download management
│ │ ├── useAria2.ts # Aria2 client
│ │ ├── useConfig.ts # Configuration
│ │ └── useNotifications.ts # Notification system
│ ├── layouts/ # Nuxt layouts
│ │ ├── default.vue # Default layout
│ │ └── minimal.vue # Minimal layout
│ ├── pages/ # Application pages
│ │ ├── index.vue # Home/Downloads page
│ │ ├── downloads/ # Download management
│ │ │ ├── index.vue # Active downloads
│ │ │ ├── completed.vue # Completed downloads
│ │ │ └── [id].vue # Task details
│ │ ├── settings/ # Settings pages
│ │ │ ├── index.vue # Settings overview
│ │ │ ├── general.vue # General settings
│ │ │ └── advanced.vue # Advanced settings
│ │ └── about.vue # About page
│ ├── stores/ # Pinia stores
│ │ ├── download.ts # Download state
│ │ ├── config.ts # Configuration state
│ │ └── ui.ts # UI state
│ └── types/ # TypeScript types
│ ├── download.ts # Download types
│ ├── config.ts # Configuration types
│ └── aria2.ts # Aria2 types
└── public/ # Static assets
├── aria2/ # Aria2 configurations
│ ├── aria2.conf # Default configuration
│ └── dht.dat # DHT nodes
└── icons/ # UI icons
Duration: Weeks 3-5
Aria2 Process Management:
// src-tauri/src/aria2/process.rs
use std::process::{Child, Command, Stdio};
use tauri::api::process::{Command as TauriCommand, CommandEvent};
pub struct Aria2Process {
child: Option<Child>,
rpc_port: u16,
rpc_secret: String,
}
impl Aria2Process {
pub fn new(port: u16, secret: String) -> Self {
Self {
child: None,
rpc_port: port,
rpc_secret: secret,
}
}
pub async fn start(&mut self, config_path: &str) -> Result<(), Box<dyn std::error::Error>> {
let binary_path = self.get_aria2_binary_path()?;
let child = Command::new(binary_path)
.args(&[
"--enable-rpc",
&format!("--rpc-listen-port={}", self.rpc_port),
&format!("--rpc-secret={}", self.rpc_secret),
"--rpc-allow-origin-all",
&format!("--conf-path={}", config_path),
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
self.child = Some(child);
Ok(())
}
pub fn stop(&mut self) -> Result<(), Box<dyn std::error::Error>> {
if let Some(mut child) = self.child.take() {
child.kill()?;
child.wait()?;
}
Ok(())
}
fn get_aria2_binary_path(&self) -> Result<String, Box<dyn std::error::Error>> {
let platform = std::env::consts::OS;
let arch = std::env::consts::ARCH;
let binary_name = match platform {
"windows" => format!("aria2c-{}-{}.exe", platform, arch),
_ => format!("aria2c-{}-{}", platform, arch),
};
// Get binary path from Tauri resources
let resource_path = tauri::api::path::resource_dir(&tauri::generate_context!())?
.join("binaries")
.join(binary_name);
Ok(resource_path.to_string_lossy().to_string())
}
}Aria2 RPC Client:
// src-tauri/src/aria2/client.rs
use serde_json::{json, Value};
use reqwest::Client;
pub struct Aria2Client {
base_url: String,
secret: String,
client: Client,
}
impl Aria2Client {
pub fn new(host: &str, port: u16, secret: String) -> Self {
Self {
base_url: format!("http://{}:{}/jsonrpc", host, port),
secret,
client: Client::new(),
}
}
pub async fn add_uri(&self, uris: Vec<String>, options: Value) -> Result<String, Box<dyn std::error::Error>> {
let params = json!([
format!("token:{}", self.secret),
uris,
options
]);
let response = self.call_method("aria2.addUri", params).await?;
Ok(response["result"].as_str().unwrap_or("").to_string())
}
pub async fn get_download_status(&self, gid: &str) -> Result<Value, Box<dyn std::error::Error>> {
let params = json!([
format!("token:{}", self.secret),
gid
]);
let response = self.call_method("aria2.tellStatus", params).await?;
Ok(response["result"].clone())
}
pub async fn pause_download(&self, gid: &str) -> Result<(), Box<dyn std::error::Error>> {
let params = json!([
format!("token:{}", self.secret),
gid
]);
self.call_method("aria2.pause", params).await?;
Ok(())
}
async fn call_method(&self, method: &str, params: Value) -> Result<Value, Box<dyn std::error::Error>> {
let payload = json!({
"jsonrpc": "2.0",
"id": "1",
"method": method,
"params": params
});
let response = self.client
.post(&self.base_url)
.json(&payload)
.send()
.await?;
let result: Value = response.json().await?;
Ok(result)
}
}// src-tauri/src/commands/download.rs
use crate::aria2::Aria2Client;
use crate::models::download::{DownloadTask, DownloadOptions};
use tauri::State;
use std::sync::Mutex;
#[tauri::command]
pub async fn add_download_uri(
client: State<'_, Mutex<Aria2Client>>,
uris: Vec<String>,
options: Option<DownloadOptions>,
) -> Result<String, String> {
let client = client.lock().map_err(|e| e.to_string())?;
let opts = options.unwrap_or_default();
client
.add_uri(uris, serde_json::to_value(opts).map_err(|e| e.to_string())?)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn pause_download(
client: State<'_, Mutex<Aria2Client>>,
gid: String,
) -> Result<(), String> {
let client = client.lock().map_err(|e| e.to_string())?;
client
.pause_download(&gid)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn resume_download(
client: State<'_, Mutex<Aria2Client>>,
gid: String,
) -> Result<(), String> {
let client = client.lock().map_err(|e| e.to_string())?;
client
.resume_download(&gid)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_all_downloads(
client: State<'_, Mutex<Aria2Client>>,
) -> Result<Vec<DownloadTask>, String> {
let client = client.lock().map_err(|e| e.to_string())?;
client
.get_all_downloads()
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn remove_download(
client: State<'_, Mutex<Aria2Client>>,
gid: String,
) -> Result<(), String> {
let client = client.lock().map_err(|e| e.to_string())?;
client
.remove_download(&gid)
.await
.map_err(|e| e.to_string())
}Duration: Weeks 6-8
Download Management Composable:
// app/composables/useDownload.ts
import { ref, computed } from 'vue'
import { invoke } from '@tauri-apps/api/tauri'
export interface DownloadTask {
gid: string
status: 'active' | 'waiting' | 'paused' | 'error' | 'complete' | 'removed'
totalLength: number
completedLength: number
downloadSpeed: number
uploadSpeed: number
connections: number
files: DownloadFile[]
dir: string
createdTime: number
}
export interface DownloadFile {
index: number
path: string
length: number
completedLength: number
selected: boolean
}
export interface DownloadOptions {
dir?: string
out?: string
maxConnectionPerServer?: number
split?: number
minSplitSize?: string
userAgent?: string
}
export const useDownload = () => {
const downloads = ref<DownloadTask[]>([])
const selectedDownloads = ref<string[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
const activeDownloads = computed(() =>
downloads.value.filter(d => d.status === 'active')
)
const completedDownloads = computed(() =>
downloads.value.filter(d => d.status === 'complete')
)
const pausedDownloads = computed(() =>
downloads.value.filter(d => d.status === 'paused')
)
const addDownload = async (uris: string[], options?: DownloadOptions): Promise<string> => {
try {
isLoading.value = true
error.value = null
const gid = await invoke<string>('add_download_uri', { uris, options })
await refreshDownloads()
return gid
} catch (err) {
error.value = err as string
throw err
} finally {
isLoading.value = false
}
}
const pauseDownload = async (gid: string): Promise<void> => {
try {
await invoke('pause_download', { gid })
await refreshDownloads()
} catch (err) {
error.value = err as string
throw err
}
}
const resumeDownload = async (gid: string): Promise<void> => {
try {
await invoke('resume_download', { gid })
await refreshDownloads()
} catch (err) {
error.value = err as string
throw err
}
}
const removeDownload = async (gid: string): Promise<void> => {
try {
await invoke('remove_download', { gid })
downloads.value = downloads.value.filter(d => d.gid !== gid)
selectedDownloads.value = selectedDownloads.value.filter(id => id !== gid)
} catch (err) {
error.value = err as string
throw err
}
}
const refreshDownloads = async (): Promise<void> => {
try {
const result = await invoke<DownloadTask[]>('get_all_downloads')
downloads.value = result
} catch (err) {
error.value = err as string
throw err
}
}
const selectDownload = (gid: string, selected: boolean = true): void => {
if (selected && !selectedDownloads.value.includes(gid)) {
selectedDownloads.value.push(gid)
} else if (!selected) {
selectedDownloads.value = selectedDownloads.value.filter(id => id !== gid)
}
}
const selectAllDownloads = (): void => {
selectedDownloads.value = downloads.value.map(d => d.gid)
}
const clearSelection = (): void => {
selectedDownloads.value = []
}
// Auto-refresh downloads every 2 seconds
const { pause: pauseAutoRefresh, resume: resumeAutoRefresh } = useIntervalFn(
refreshDownloads,
2000,
{ immediate: true }
)
return {
// State
downloads: readonly(downloads),
selectedDownloads: readonly(selectedDownloads),
isLoading: readonly(isLoading),
error: readonly(error),
// Computed
activeDownloads,
completedDownloads,
pausedDownloads,
// Actions
addDownload,
pauseDownload,
resumeDownload,
removeDownload,
refreshDownloads,
selectDownload,
selectAllDownloads,
clearSelection,
pauseAutoRefresh,
resumeAutoRefresh,
}
}Task List Component:
<!-- app/components/Download/TaskList.vue -->
<template>
<div class="task-list">
<div v-if="isLoading" class="loading-state">
<USkeleton class="h-20 w-full" v-for="i in 3" :key="i" />
</div>
<div v-else-if="tasks.length === 0" class="empty-state">
<div class="text-center py-12">
<Icon name="lucide:download" class="w-16 h-16 mx-auto mb-4 text-gray-400" />
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
No downloads
</h3>
<p class="text-gray-500 dark:text-gray-400">
Add a new download to get started
</p>
</div>
</div>
<div v-else class="space-y-2">
<TaskItem
v-for="task in tasks"
:key="task.gid"
:task="task"
:selected="selectedDownloads.includes(task.gid)"
@select="selectDownload"
@pause="pauseDownload"
@resume="resumeDownload"
@remove="removeDownload"
@click="$emit('taskClick', task)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import type { DownloadTask } from '~/composables/useDownload'
interface Props {
tasks: DownloadTask[]
selectedDownloads: string[]
isLoading?: boolean
}
interface Emits {
taskClick: [task: DownloadTask]
}
defineProps<Props>()
defineEmits<Emits>()
const { selectDownload, pauseDownload, resumeDownload, removeDownload } = useDownload()
</script>
<style scoped>
.task-list {
@apply min-h-full;
}
.loading-state {
@apply space-y-4 p-4;
}
.empty-state {
@apply flex items-center justify-center min-h-96;
}
</style>Task Item Component:
<!-- app/components/Download/TaskItem.vue -->
<template>
<UCard
:class="[
'task-item cursor-pointer transition-all duration-200',
{ 'ring-2 ring-primary-500': selected }
]"
@click="$emit('click')"
>
<div class="flex items-center space-x-4">
<!-- Selection Checkbox -->
<UCheckbox
:model-value="selected"
@update:model-value="$emit('select', task.gid, $event)"
@click.stop
/>
<!-- File Icon -->
<div class="flex-shrink-0">
<Icon :name="getFileIcon(task)" class="w-8 h-8" />
</div>
<!-- Task Info -->
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{{ getFileName(task) }}
</h3>
<TaskStatus :status="task.status" />
</div>
<!-- Progress Bar -->
<ProgressBar
:total="task.totalLength"
:completed="task.completedLength"
:status="task.status"
class="mb-2"
/>
<!-- Stats -->
<div class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
<span>{{ formatFileSize(task.completedLength) }} / {{ formatFileSize(task.totalLength) }}</span>
<span v-if="task.status === 'active'" class="flex items-center space-x-4">
<span>↓ {{ formatSpeed(task.downloadSpeed) }}</span>
<span v-if="task.uploadSpeed > 0">↑ {{ formatSpeed(task.uploadSpeed) }}</span>
</span>
<span v-else-if="task.status === 'complete'">
{{ $dayjs(task.createdTime * 1000).format('MMM D, YYYY') }}
</span>
</div>
</div>
<!-- Actions -->
<div class="flex items-center space-x-2" @click.stop>
<UButton
v-if="task.status === 'active'"
variant="ghost"
size="sm"
icon="lucide:pause"
@click="$emit('pause', task.gid)"
/>
<UButton
v-else-if="task.status === 'paused'"
variant="ghost"
size="sm"
icon="lucide:play"
@click="$emit('resume', task.gid)"
/>
<UDropdown :items="getActionItems(task)">
<UButton variant="ghost" size="sm" icon="lucide:more-horizontal" />
</UDropdown>
</div>
</div>
</UCard>
</template>
<script setup lang="ts">
import type { DownloadTask } from '~/composables/useDownload'
interface Props {
task: DownloadTask
selected: boolean
}
interface Emits {
click: []
select: [gid: string, selected: boolean]
pause: [gid: string]
resume: [gid: string]
remove: [gid: string]
}
defineProps<Props>()
defineEmits<Emits>()
const getFileName = (task: DownloadTask): string => {
if (task.files.length > 0) {
return task.files[0].path.split('/').pop() || 'Unknown'
}
return 'Unknown'
}
const getFileIcon = (task: DownloadTask): string => {
const fileName = getFileName(task)
const ext = fileName.split('.').pop()?.toLowerCase()
const iconMap: Record<string, string> = {
mp4: 'lucide:video',
avi: 'lucide:video',
mkv: 'lucide:video',
mp3: 'lucide:music',
flac: 'lucide:music',
wav: 'lucide:music',
pdf: 'lucide:file-text',
doc: 'lucide:file-text',
docx: 'lucide:file-text',
zip: 'lucide:archive',
rar: 'lucide:archive',
'7z': 'lucide:archive',
}
return iconMap[ext || ''] || 'lucide:file'
}
const getActionItems = (task: DownloadTask) => {
const items = [
{
label: 'Open folder',
icon: 'lucide:folder-open',
click: () => openFolder(task.dir),
},
{
label: 'Copy download link',
icon: 'lucide:copy',
click: () => copyDownloadLink(task),
},
]
if (task.status !== 'active') {
items.push({
label: 'Remove',
icon: 'lucide:trash-2',
click: () => $emit('remove', task.gid),
})
}
return [items]
}
const formatFileSize = (bytes: number): string => {
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
if (bytes === 0) return '0 B'
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`
}
const formatSpeed = (bytesPerSec: number): string => {
return `${formatFileSize(bytesPerSec)}/s`
}
</script>Duration: Weeks 9-11
Settings Store:
// app/stores/config.ts
export const useConfigStore = defineStore('config', () => {
const config = ref<AppConfig>({
general: {
downloadPath: '',
maxConcurrentDownloads: 5,
maxConnectionPerServer: 16,
minSplitSize: '20M',
enableNotifications: true,
autoStartDownloads: true,
},
aria2: {
rpcPort: 6800,
rpcSecret: '',
userAgent: 'Mozilla/5.0 (compatible; Aether Download Manager)',
retryTimes: 5,
retryDelay: 10,
timeout: 60,
checkCertificate: false,
},
ui: {
theme: 'auto',
language: 'en',
showSpeedInTitle: true,
minimizeToTray: true,
},
advanced: {
enableProxy: false,
proxyType: 'http',
proxyHost: '',
proxyPort: 8080,
proxyUsername: '',
proxyPassword: '',
enableDHT: true,
enablePEX: true,
enableLPD: true,
},
})
const loadConfig = async (): Promise<void> => {
try {
const savedConfig = await invoke<AppConfig>('get_config')
config.value = { ...config.value, ...savedConfig }
} catch (error) {
console.error('Failed to load config:', error)
}
}
const saveConfig = async (): Promise<void> => {
try {
await invoke('save_config', { config: config.value })
} catch (error) {
console.error('Failed to save config:', error)
throw error
}
}
const updateConfig = async <T extends keyof AppConfig>(
section: T,
updates: Partial<AppConfig[T]>
): Promise<void> => {
config.value[section] = { ...config.value[section], ...updates }
await saveConfig()
}
const resetConfig = async (): Promise<void> => {
try {
await invoke('reset_config')
await loadConfig()
} catch (error) {
console.error('Failed to reset config:', error)
throw error
}
}
return {
config: readonly(config),
loadConfig,
saveConfig,
updateConfig,
resetConfig,
}
})
export interface AppConfig {
general: {
downloadPath: string
maxConcurrentDownloads: number
maxConnectionPerServer: number
minSplitSize: string
enableNotifications: boolean
autoStartDownloads: boolean
}
aria2: {
rpcPort: number
rpcSecret: string
userAgent: string
retryTimes: number
retryDelay: number
timeout: number
checkCertificate: boolean
}
ui: {
theme: 'auto' | 'light' | 'dark'
language: string
showSpeedInTitle: boolean
minimizeToTray: boolean
}
advanced: {
enableProxy: boolean
proxyType: 'http' | 'https' | 'socks5'
proxyHost: string
proxyPort: number
proxyUsername: string
proxyPassword: string
enableDHT: boolean
enablePEX: boolean
enableLPD: boolean
}
}WebSocket Integration:
// app/composables/useAria2WebSocket.ts
export const useAria2WebSocket = () => {
const { config } = storeToRefs(useConfigStore())
const { refreshDownloads } = useDownload()
const ws = ref<WebSocket | null>(null)
const isConnected = ref(false)
const reconnectAttempts = ref(0)
const maxReconnectAttempts = 5
const connect = (): void => {
try {
const wsUrl = `ws://localhost:${config.value.aria2.rpcPort}/jsonrpc`
ws.value = new WebSocket(wsUrl)
ws.value.onopen = () => {
console.log('WebSocket connected')
isConnected.value = true
reconnectAttempts.value = 0
// Subscribe to aria2 notifications
subscribeToNotifications()
}
ws.value.onmessage = (event) => {
try {
const message = JSON.parse(event.data)
handleNotification(message)
} catch (error) {
console.error('Failed to parse WebSocket message:', error)
}
}
ws.value.onclose = () => {
console.log('WebSocket disconnected')
isConnected.value = false
// Attempt to reconnect
if (reconnectAttempts.value < maxReconnectAttempts) {
setTimeout(() => {
reconnectAttempts.value++
connect()
}, 2000 * reconnectAttempts.value)
}
}
ws.value.onerror = (error) => {
console.error('WebSocket error:', error)
}
} catch (error) {
console.error('Failed to connect WebSocket:', error)
}
}
const disconnect = (): void => {
if (ws.value) {
ws.value.close()
ws.value = null
}
}
const subscribeToNotifications = (): void => {
if (!ws.value || ws.value.readyState !== WebSocket.OPEN) return
const notifications = [
'aria2.onDownloadStart',
'aria2.onDownloadPause',
'aria2.onDownloadStop',
'aria2.onDownloadComplete',
'aria2.onDownloadError',
'aria2.onBtDownloadComplete',
]
notifications.forEach(method => {
ws.value?.send(JSON.stringify({
jsonrpc: '2.0',
id: Date.now(),
method: 'system.multicall',
params: [[['system.listNotifications']]]
}))
})
}
const handleNotification = (message: any): void => {
if (message.method && message.method.startsWith('aria2.')) {
const event = message.method.replace('aria2.', '')
const params = message.params || []
console.log(`Aria2 event: ${event}`, params)
// Refresh downloads on any notification
refreshDownloads()
// Handle specific events
switch (event) {
case 'onDownloadStart':
showNotification('Download started', 'A new download has started')
break
case 'onDownloadComplete':
showNotification('Download completed', 'A download has finished')
break
case 'onDownloadError':
showNotification('Download error', 'A download has encountered an error')
break
}
}
}
const showNotification = (title: string, body: string): void => {
if ('Notification' in window && Notification.permission === 'granted') {
new Notification(title, { body })
}
}
// Auto-connect when composable is used
onMounted(() => {
connect()
})
onUnmounted(() => {
disconnect()
})
return {
isConnected: readonly(isConnected),
connect,
disconnect,
}
}Duration: Weeks 12-13
Unit Tests:
// tests/composables/useDownload.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useDownload } from '~/composables/useDownload'
// Mock Tauri invoke
vi.mock('@tauri-apps/api/tauri', () => ({
invoke: vi.fn(),
}))
describe('useDownload', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should add download successfully', async () => {
const { invoke } = await import('@tauri-apps/api/tauri')
vi.mocked(invoke).mockResolvedValueOnce('test-gid-123')
const { addDownload } = useDownload()
const gid = await addDownload(['https://example.com/file.zip'])
expect(gid).toBe('test-gid-123')
expect(invoke).toHaveBeenCalledWith('add_download_uri', {
uris: ['https://example.com/file.zip'],
options: undefined,
})
})
it('should handle download errors', async () => {
const { invoke } = await import('@tauri-apps/api/tauri')
vi.mocked(invoke).mockRejectedValueOnce(new Error('Network error'))
const { addDownload } = useDownload()
await expect(addDownload(['invalid-url'])).rejects.toThrow('Network error')
})
})Integration Tests:
// src-tauri/src/tests/integration.rs
#[cfg(test)]
mod tests {
use super::*;
use crate::aria2::Aria2Client;
use tokio_test;
#[tokio_test]
async fn test_aria2_client_connection() {
let client = Aria2Client::new("localhost", 6800, "test-secret".to_string());
// This would require a running aria2 instance for full integration test
// For now, we test the client creation
assert_eq!(client.base_url, "http://localhost:6800/jsonrpc");
}
#[tokio_test]
async fn test_download_commands() {
// Mock test for download commands
let result = add_download_uri(
vec!["https://httpbin.org/uuid".to_string()],
None,
).await;
// This would test the actual command in a test environment
assert!(result.is_ok());
}
}Benchmark Tests:
// src-tauri/benches/aria2_performance.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use aether_download_manager::aria2::Aria2Client;
fn benchmark_aria2_calls(c: &mut Criterion) {
let rt = tokio::runtime::Runtime::new().unwrap();
let client = Aria2Client::new("localhost", 6800, "".to_string());
c.bench_function("get_version", |b| {
b.to_async(&rt).iter(|| async {
black_box(client.get_version().await)
})
});
}
criterion_group!(benches, benchmark_aria2_calls);
criterion_main!(benches);Duration: Week 14
Tauri Configuration:
{
"package": {
"productName": "Aether Download Manager",
"version": "1.0.0"
},
"build": {
"beforeBuildCommand": "npm run build",
"beforeDevCommand": "npm run dev",
"devPath": "http://localhost:3000",
"distDir": "../dist"
},
"tauri": {
"allowlist": {
"all": false,
"fs": {
"all": true,
"scope": [
"$DOWNLOAD/*",
"$DOCUMENT/*",
"$HOME/.config/aether-download-manager/*"
]
},
"shell": {
"all": false,
"sidecar": true,
"scope": ["aria2c"]
},
"window": {
"all": false,
"create": false,
"center": true,
"requestUserAttention": true,
"setResizable": true,
"setTitle": true,
"maximize": true,
"minimize": true,
"show": true,
"close": true,
"hide": true,
"setSize": true,
"setMinSize": true,
"setMaxSize": true,
"setPosition": true,
"setFullscreen": true,
"setFocus": true,
"setIcon": true,
"setSkipTaskbar": true,
"setCursorGrab": true,
"setCursorVisible": true,
"setCursorIcon": true,
"setCursorPosition": true,
"setIgnoreCursorEvents": true,
"startDragging": true,
"print": true
},
"notification": {
"all": true
},
"app": {
"all": false,
"show": true,
"hide": true
}
},
"bundle": {
"active": true,
"targets": "all",
"identifier": "com.aether.download-manager",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"resources": [
"binaries/*",
"aria2/*"
],
"externalBin": [
"binaries/aria2c-macos-x64",
"binaries/aria2c-macos-arm64",
"binaries/aria2c-windows-x64.exe",
"binaries/aria2c-linux-x64",
"binaries/aria2c-linux-arm64"
],
"copyright": "Copyright (c) 2025 Aether Team",
"category": "Utility",
"shortDescription": "Modern download manager",
"longDescription": "A modern, fast, and reliable download manager built with Tauri and Nuxt.",
"macOS": {
"entitlements": null,
"exceptionDomain": "",
"frameworks": [],
"providerShortName": null,
"signingIdentity": null
},
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": ""
},
"linux": {
"deb": {
"depends": []
}
}
},
"security": {
"csp": null
},
"updater": {
"active": true,
"endpoints": [
"https://releases.aether-dm.com/{{target}}/{{current_version}}"
],
"dialog": true,
"pubkey": "YOUR_PUBLIC_KEY_HERE"
},
"windows": [
{
"fullscreen": false,
"height": 720,
"resizable": true,
"title": "Aether Download Manager",
"width": 1200,
"minWidth": 800,
"minHeight": 600
}
]
}
}GitHub Actions Workflow:
# .github/workflows/build.yml
name: Build and Release
on:
push:
tags:
- 'v*'
pull_request:
branches: [main]
jobs:
build:
strategy:
matrix:
platform: [macos-latest, ubuntu-latest, windows-latest]
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 18
cache: 'npm'
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Install dependencies (Ubuntu)
if: matrix.platform == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
- name: Install Node dependencies
run: npm ci
- name: Install Tauri dependencies
run: npm install -g @tauri-apps/cli
- name: Build application
run: npm run tauri build
- name: Upload artifacts
uses: actions/upload-artifact@v3
with:
name: ${{ matrix.platform }}-build
path: src-tauri/target/release/bundle/
release:
needs: build
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- uses: actions/checkout@v3
- name: Download artifacts
uses: actions/download-artifact@v3
- name: Create Release
uses: softprops/action-gh-release@v1
with:
files: |
**/*.dmg
**/*.exe
**/*.AppImage
**/*.deb
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}This comprehensive migration plan provides a structured approach to transforming Motrix into a modern Tauri-based application. The plan emphasizes:
- Incremental Migration: Phased approach minimizing risk
- Modern Architecture: Leveraging Rust backend with Vue 3 frontend
- Type Safety: Full TypeScript implementation
- Performance: Optimized with Tauri's efficiency
- Maintainability: Clean, modular code structure
- Cross-platform: Support for all major desktop platforms
The migration will result in a faster, more secure, and maintainable download manager while preserving all the functionality that makes Motrix popular among users.
Key Benefits:
- 🚀 50-70% smaller binary size compared to Electron
- ⚡ Better performance with Rust backend
- 🔒 Enhanced security with Tauri's security model
- 🛠️ Modern development experience with Vue 3 + TypeScript
- 📱 Future-ready architecture for potential mobile support
This roadmap serves as a comprehensive guide for the development team to execute a successful migration from Motrix to Aether Download Manager.