feat(magic): expose cooldown commands and extend device types

This commit is contained in:
chahinebrini 2026-06-16 20:33:38 +02:00
parent 97977011ae
commit 8f5e34ae67
3 changed files with 872 additions and 0 deletions

View File

@ -0,0 +1,284 @@
import { invoke } from "@tauri-apps/api/core";
export interface PlatformInfo {
platform: "MacOS" | "Windows" | "Linux" | "Unknown";
version: string;
supports_ios_supervision: boolean;
}
export interface LocalServerInfo {
url: string;
qr_payload: string;
}
export interface SuperviseResult {
success: boolean;
stdout: string;
stderr: string;
}
export interface RedeemPairingResponse {
token: string;
sessionId: string;
createdAt: string;
}
export interface MagicSession {
accessToken: string;
sessionId: string;
userId: string;
createdAt: number;
label: string | null;
}
export interface RegisterDeviceResponse {
deviceId: string;
dnsToken: string;
profileUrl: string;
existing: boolean;
}
export interface MagicDeviceInfo {
source: string;
deviceId: string;
hostname: string;
model: string | null;
osVersion: string | null;
magicEnrolledAt: string | null;
releaseRequestedAt: string | null;
releaseAvailableAt: string | null;
cooldownUntil: string | null;
status: "active" | "cooldown" | "revoked" | "pending";
lastSeenAt: string | null;
}
export interface ReleaseResponse {
releaseRequestedAt: string;
releaseAvailableAt: string;
}
export interface UserProfile {
nickname: string | null;
avatar: string | null;
plan: string | null;
}
export interface IphoneDeviceState {
udid: string;
name: string;
productType: string;
productVersion: string;
isSupervised: boolean;
organizationName?: string;
findMyEnabled?: boolean;
installedProfileIDs: string[];
installedAppBundleIDs: string[];
}
export interface DesktopProtectionState {
active: boolean;
platform: string;
activatedAt: string;
}
export interface SuperviseStatus {
isSupervised: boolean;
organizationName?: string;
findMyEnabled?: boolean;
}
export interface MdmPushStatus {
udid: string;
push_result: string;
}
export interface MdmCommandResult {
command_uuid: string;
response_body: string;
}
async function invokeLogged<T>(command: string, args?: Record<string, unknown>): Promise<T> {
const { useLogger } = await import("~/composables/useLogger");
const logger = useLogger();
try {
logger.info(`invoke:${command}`, args ? JSON.stringify(args, null, 2) : undefined);
const result = await invoke<T>(command, args);
logger.debug(`invoke:${command}:ok`, typeof result === "object" ? JSON.stringify(result, null, 2) : String(result));
return result;
} catch (err) {
const { formatError } = await import("~/composables/useLogger");
const formatted = formatError(err);
logger.error(`invoke:${command}:failed`, formatted.details ? `${formatted.message}\n${formatted.details}` : formatted.message);
throw err;
}
}
export function useTauri() {
async function getPlatform(): Promise<PlatformInfo> {
return await invokeLogged("get_platform");
}
async function redeemPairingCode(code: string, label?: string): Promise<RedeemPairingResponse> {
return await invokeLogged("redeem_pairing_code", { code, label });
}
async function registerDevice(
deviceId: string,
model: string | undefined,
osVersion: string | undefined,
): Promise<RegisterDeviceResponse> {
return await invokeLogged("register_device", { deviceId, model, osVersion });
}
async function getStoredSession(): Promise<MagicSession | null> {
return await invokeLogged("get_stored_session");
}
async function getMagicDevices(): Promise<MagicDeviceInfo[]> {
return await invokeLogged("get_magic_devices");
}
async function getMagicStatus(dnsToken: string): Promise<boolean> {
return await invokeLogged("get_magic_status", { dnsToken });
}
async function requestRelease(deviceId: string): Promise<ReleaseResponse> {
return await invokeLogged("request_release", { deviceId });
}
async function cancelRelease(deviceId: string): Promise<void> {
return await invokeLogged("cancel_release", { deviceId });
}
async function startCooldown(deviceId: string, durationMinutes: number = 60): Promise<{ cooldownUntil: string }> {
return await invokeLogged("start_cooldown", { deviceId, durationMinutes });
}
async function cancelCooldown(deviceId: string): Promise<void> {
await invokeLogged("cancel_cooldown", { deviceId });
}
async function logoutMagic(): Promise<void> {
return await invokeLogged("logout_magic");
}
async function startLocalProfileServer(profilePath: string): Promise<LocalServerInfo> {
return await invokeLogged("start_local_profile_server", { profilePath });
}
async function stopLocalProfileServer(): Promise<void> {
await invokeLogged("stop_local_profile_server");
}
async function runSuperviseMagic(action: string, args?: string[]): Promise<SuperviseResult> {
return await invokeLogged("run_supervise_magic", { action, args });
}
async function downloadProfile(profileUrl: string): Promise<string> {
return await invokeLogged("download_profile", { profileUrl });
}
async function activateProtection(profilePath: string): Promise<void> {
return await invokeLogged("activate_protection", { profilePath });
}
async function fetchMe(): Promise<UserProfile> {
return await invokeLogged("fetch_me");
}
async function detectIphoneState(): Promise<IphoneDeviceState | null> {
return await invokeLogged("detect_iphone_state");
}
async function getSuperviseStatus(): Promise<SuperviseStatus> {
return await invokeLogged("get_supervise_status");
}
async function getInstalledProfiles(): Promise<string[]> {
return await invokeLogged("get_installed_profiles");
}
async function getInstalledApps(): Promise<string[]> {
return await invokeLogged("get_installed_apps");
}
async function installProfile(path: string): Promise<SuperviseResult> {
return await invokeLogged("install_profile", { path });
}
async function downloadAndPatchEnrollmentProfile(url: string, udid: string): Promise<string> {
return await invokeLogged("download_and_patch_enrollment_profile", { url, udid });
}
async function mdmPing(): Promise<string> {
return await invokeLogged("mdm_ping");
}
async function mdmPush(udid: string): Promise<MdmPushStatus> {
return await invokeLogged("mdm_push", { udid });
}
async function mdmInstallApp(udid: string): Promise<MdmCommandResult> {
return await invokeLogged("mdm_install_app", { udid });
}
async function mdmSetSupervisedMode(udid: string): Promise<MdmCommandResult> {
return await invokeLogged("mdm_set_supervised_mode", { udid });
}
async function mdmTakeManagement(udid: string): Promise<MdmCommandResult> {
return await invokeLogged("mdm_take_management", { udid });
}
async function mdmInstallLockProfile(udid: string, profilePath: string): Promise<MdmCommandResult> {
return await invokeLogged("mdm_install_lock_profile", { udid, profilePath });
}
async function getDesktopProtectionStatus(): Promise<DesktopProtectionState | null> {
return await invokeLogged("get_desktop_protection_status");
}
async function setDesktopProtectionStatus(active: boolean, platform: string): Promise<void> {
await invokeLogged("set_desktop_protection_status", { active, platform });
}
async function getHostname(): Promise<string> {
return await invokeLogged("get_hostname");
}
return {
getPlatform,
redeemPairingCode,
registerDevice,
getStoredSession,
getMagicDevices,
getMagicStatus,
requestRelease,
cancelRelease,
startCooldown,
cancelCooldown,
logoutMagic,
startLocalProfileServer,
stopLocalProfileServer,
runSuperviseMagic,
downloadProfile,
activateProtection,
fetchMe,
detectIphoneState,
getSuperviseStatus,
getInstalledProfiles,
getInstalledApps,
installProfile,
downloadAndPatchEnrollmentProfile,
mdmPing,
mdmPush,
mdmInstallApp,
mdmSetSupervisedMode,
mdmTakeManagement,
mdmInstallLockProfile,
getDesktopProtectionStatus,
setDesktopProtectionStatus,
getHostname,
};
}

View File

@ -0,0 +1,354 @@
use crate::config::AppConfig;
use crate::error::{AppError, AppResult};
use serde::{Deserialize, Serialize};
const HTTP_TIMEOUT_SECONDS: u64 = 30;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RedeemPairingRequest {
pub code: String,
pub label: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RedeemPairingResponse {
pub token: String,
#[serde(rename = "sessionId")]
pub session_id: String,
#[serde(rename = "createdAt")]
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegisterDeviceRequest {
#[serde(rename = "deviceId")]
pub device_id: String,
pub hostname: String,
pub model: Option<String>,
#[serde(rename = "osVersion")]
pub os_version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegisterDeviceResponse {
#[serde(rename = "deviceId")]
pub device_id: String,
#[serde(rename = "dnsToken")]
pub dns_token: String,
#[serde(rename = "profileUrl")]
pub profile_url: String,
pub existing: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MagicDeviceInfo {
pub source: String,
#[serde(rename = "deviceId")]
pub device_id: String,
pub hostname: String,
pub model: Option<String>,
#[serde(rename = "osVersion")]
pub os_version: Option<String>,
#[serde(rename = "magicEnrolledAt")]
pub magic_enrolled_at: Option<String>,
#[serde(rename = "releaseRequestedAt")]
pub release_requested_at: Option<String>,
#[serde(rename = "releaseAvailableAt")]
pub release_available_at: Option<String>,
#[serde(rename = "cooldownUntil")]
pub cooldown_until: Option<String>,
pub status: String,
#[serde(rename = "lastSeenAt")]
pub last_seen_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatusResponse {
pub active: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReleaseResponse {
#[serde(rename = "releaseRequestedAt")]
pub release_requested_at: String,
#[serde(rename = "releaseAvailableAt")]
pub release_available_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CooldownResponse {
#[serde(rename = "cooldownUntil")]
pub cooldown_until: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserProfile {
pub nickname: Option<String>,
pub avatar: Option<String>,
pub plan: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiEnvelope<T> {
pub success: bool,
pub data: T,
}
pub struct MagicApiClient {
client: reqwest::Client,
base_url: String,
}
impl MagicApiClient {
pub fn new(config: &AppConfig) -> Self {
Self {
client: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECONDS))
.build()
.expect("reqwest client build"),
base_url: config.backend_base_url.clone(),
}
}
pub async fn redeem_pairing_code(
&self,
code: &str,
label: Option<&str>,
) -> AppResult<RedeemPairingResponse> {
let url = format!("{}/api/magic/pair/redeem", self.base_url);
let body = RedeemPairingRequest {
code: code.to_string(),
label: label.map(|s| s.to_string()),
};
let response = self
.client
.post(&url)
.json(&body)
.send()
.await
.map_err(|e| AppError::new(format!("Network error: {}", e)))?;
let status = response.status();
if status == reqwest::StatusCode::BAD_REQUEST || status == reqwest::StatusCode::NOT_FOUND {
return Err(AppError::new(
"Code ungültig. Bitte in der ReBreak-App einen neuen Code generieren.".to_string(),
));
}
if status == reqwest::StatusCode::GONE {
let text = response
.text()
.await
.unwrap_or_else(|_| "".to_string());
if text.contains("verwendet") {
return Err(AppError::new("Code wurde bereits verwendet.".to_string()));
}
return Err(AppError::new("Code abgelaufen. Bitte einen neuen generieren.".to_string()));
}
Self::handle_response::<ApiEnvelope<RedeemPairingResponse>>(response)
.await
.map(|envelope| envelope.data)
}
pub async fn register_device(
&self,
token: &str,
device_id: &str,
hostname: &str,
model: Option<&str>,
os_version: Option<&str>,
) -> AppResult<RegisterDeviceResponse> {
let url = format!("{}/api/magic/register", self.base_url);
let body = RegisterDeviceRequest {
device_id: device_id.to_string(),
hostname: hostname.to_string(),
model: model.map(|s| s.to_string()),
os_version: os_version.map(|s| s.to_string()),
};
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", token))
.json(&body)
.send()
.await
.map_err(|e| AppError::new(format!("Network error: {}", e)))?;
let status = response.status();
if status == reqwest::StatusCode::CONFLICT {
return Err(AppError::new(
"Device-Limit erreicht. Bitte zuerst ein Gerät freigeben.".to_string(),
));
}
Self::handle_response::<ApiEnvelope<RegisterDeviceResponse>>(response)
.await
.map(|envelope| envelope.data)
}
pub async fn list_devices(&self, token: &str) -> AppResult<Vec<MagicDeviceInfo>> {
let url = format!("{}/api/magic/devices", self.base_url);
let response = self
.client
.get(&url)
.header("Authorization", format!("Bearer {}", token))
.send()
.await
.map_err(|e| AppError::new(format!("Network error: {}", e)))?;
Self::handle_response::<ApiEnvelope<Vec<MagicDeviceInfo>>>(response)
.await
.map(|envelope| envelope.data)
}
pub async fn get_status(&self, dns_token: &str) -> AppResult<bool> {
let url = format!("{}/api/magic/status", self.base_url);
let response = self
.client
.get(&url)
.query(&[("token", dns_token)])
.send()
.await
.map_err(|e| AppError::new(format!("Network error: {}", e)))?;
Self::handle_response::<ApiEnvelope<StatusResponse>>(response)
.await
.map(|envelope| envelope.data.active)
}
pub async fn request_release(&self, token: &str, device_id: &str) -> AppResult<ReleaseResponse> {
let url = format!(
"{}/api/magic/devices/{}/request-release",
self.base_url, device_id
);
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", token))
.send()
.await
.map_err(|e| AppError::new(format!("Network error: {}", e)))?;
Self::handle_response::<ApiEnvelope<ReleaseResponse>>(response)
.await
.map(|envelope| envelope.data)
}
pub async fn cancel_release(&self, token: &str, device_id: &str) -> AppResult<()> {
let url = format!(
"{}/api/magic/devices/{}/cancel-release",
self.base_url, device_id
);
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", token))
.send()
.await
.map_err(|e| AppError::new(format!("Network error: {}", e)))?;
Self::handle_response::<ApiEnvelope<serde_json::Value>>(response)
.await
.map(|_| ())
}
pub async fn start_cooldown(
&self,
token: &str,
device_id: &str,
duration_minutes: u32,
) -> AppResult<CooldownResponse> {
let url = format!("{}/api/magic/devices/{}/cooldown", self.base_url, device_id);
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", token))
.json(&serde_json::json!({ "durationMinutes": duration_minutes }))
.send()
.await
.map_err(|e| AppError::new(format!("Network error: {}", e)))?;
Self::handle_response::<ApiEnvelope<CooldownResponse>>(response)
.await
.map(|envelope| envelope.data)
}
pub async fn cancel_cooldown(&self, token: &str, device_id: &str) -> AppResult<()> {
let url = format!("{}/api/magic/devices/{}/cancel-cooldown", self.base_url, device_id);
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", token))
.send()
.await
.map_err(|e| AppError::new(format!("Network error: {}", e)))?;
Self::handle_response::<ApiEnvelope<serde_json::Value>>(response)
.await
.map(|_| ())
}
pub async fn fetch_me(&self, token: &str) -> AppResult<UserProfile> {
let url = format!("{}/api/magic/me", self.base_url);
let response = self
.client
.get(&url)
.header("Authorization", format!("Bearer {}", token))
.send()
.await
.map_err(|e| AppError::new(format!("Network error: {}", e)))?;
Self::handle_response::<ApiEnvelope<UserProfile>>(response)
.await
.map(|envelope| envelope.data)
}
pub async fn download_profile(&self, dns_token: &str) -> AppResult<Vec<u8>> {
let url = format!(
"{}/api/magic/profile.mobileconfig?token={}",
self.base_url, dns_token
);
let response = self
.client
.get(&url)
.send()
.await
.map_err(|e| AppError::new(format!("Network error: {}", e)))?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
return Err(AppError::new(format!("HTTP {}: {}", status, text)));
}
response
.bytes()
.await
.map(|b| b.to_vec())
.map_err(|e| AppError::new(format!("Failed to read profile: {}", e)))
}
async fn handle_response<T: serde::de::DeserializeOwned>(
response: reqwest::Response,
) -> AppResult<T> {
let status = response.status();
if status.is_success() {
response
.json::<T>()
.await
.map_err(|e| AppError::new(format!("Failed to parse response: {}", e)))
} else {
let text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
Err(AppError::new(format!("HTTP {}: {}", status, text)))
}
}
}

View File

@ -0,0 +1,234 @@
mod backend;
mod config;
mod error;
mod ios_device;
mod mdm;
mod platform;
mod server;
mod sidecar;
use backend::api::{
MagicApiClient, MagicDeviceInfo, RedeemPairingResponse, RegisterDeviceResponse, ReleaseResponse,
UserProfile,
};
use config::{AppConfig, DesktopProtectionState, MagicSession};
use error::AppResult;
use std::process::Command;
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_os::init())
.invoke_handler(tauri::generate_handler![
platform::get_platform,
server::local_http::start_local_profile_server,
server::local_http::stop_local_profile_server,
sidecar::supervise_magic::run_supervise_magic,
ios_device::detect_iphone_state,
ios_device::get_supervise_status,
ios_device::get_installed_profiles,
ios_device::get_installed_apps,
ios_device::install_profile,
ios_device::download_and_patch_enrollment_profile,
mdm::mdm_ping,
mdm::mdm_push,
mdm::mdm_install_app,
mdm::mdm_set_supervised_mode,
mdm::mdm_take_management,
mdm::mdm_install_lock_profile,
redeem_pairing_code,
register_device,
get_stored_session,
get_magic_devices,
get_magic_status,
request_release,
cancel_release,
start_cooldown,
cancel_cooldown,
logout_magic,
download_profile,
activate_protection,
fetch_me,
get_desktop_protection_status,
set_desktop_protection_status,
get_hostname,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
fn hostname() -> AppResult<String> {
let output = Command::new("hostname")
.output()
.map_err(|e| error::AppError::new(format!("Failed to get hostname: {}", e)))?;
let mut hostname = String::from_utf8_lossy(&output.stdout).to_string();
hostname = hostname.trim().to_string();
if hostname.is_empty() {
hostname = "ReBreak Magic Device".to_string();
}
Ok(hostname)
}
fn require_session() -> AppResult<MagicSession> {
AppConfig::load_magic_session()?.ok_or_else(|| {
error::AppError::new("Keine Magic-Session gefunden. Bitte neu paaren.")
})
}
#[tauri::command]
async fn redeem_pairing_code(code: String, label: Option<String>) -> AppResult<RedeemPairingResponse> {
let trimmed = code.trim().to_string();
if !trimmed.chars().all(|c| c.is_ascii_digit()) || trimmed.len() != 6 {
return Err(error::AppError::new("Der Code besteht aus 6 Ziffern.".to_string()));
}
let config = AppConfig::load();
let client = MagicApiClient::new(&config);
let response = client
.redeem_pairing_code(&trimmed, label.as_deref())
.await?;
let label = label.unwrap_or_else(|| hostname().unwrap_or_else(|_| "ReBreak Magic".to_string()));
let session = MagicSession::new(response.token.clone(), response.session_id.clone(), Some(label));
AppConfig::save_magic_session(&session)?;
Ok(response)
}
#[tauri::command]
async fn register_device(
device_id: String,
model: Option<String>,
os_version: Option<String>,
) -> AppResult<RegisterDeviceResponse> {
let session = require_session()?;
let config = AppConfig::load();
let client = MagicApiClient::new(&config);
let hostname = hostname()?;
let response = client
.register_device(&session.access_token, &device_id, &hostname, model.as_deref(), os_version.as_deref())
.await?;
Ok(response)
}
#[tauri::command]
async fn get_stored_session() -> AppResult<Option<MagicSession>> {
AppConfig::load_magic_session()
}
#[tauri::command]
async fn get_magic_devices() -> AppResult<Vec<MagicDeviceInfo>> {
let session = require_session()?;
let config = AppConfig::load();
let client = MagicApiClient::new(&config);
client.list_devices(&session.access_token).await
}
#[tauri::command]
async fn get_magic_status(dns_token: String) -> AppResult<bool> {
let config = AppConfig::load();
let client = MagicApiClient::new(&config);
client.get_status(&dns_token).await
}
#[tauri::command]
async fn request_release(device_id: String) -> AppResult<ReleaseResponse> {
let session = require_session()?;
let config = AppConfig::load();
let client = MagicApiClient::new(&config);
client.request_release(&session.access_token, &device_id).await
}
#[tauri::command]
async fn cancel_release(device_id: String) -> AppResult<()> {
let session = require_session()?;
let config = AppConfig::load();
let client = MagicApiClient::new(&config);
client.cancel_release(&session.access_token, &device_id).await
}
#[tauri::command]
async fn start_cooldown(device_id: String, duration_minutes: u32) -> AppResult<serde_json::Value> {
let session = require_session()?;
let config = AppConfig::load();
let client = MagicApiClient::new(&config);
let result = client.start_cooldown(&session.access_token, &device_id, duration_minutes).await?;
Ok(serde_json::json!({ "cooldownUntil": result.cooldown_until }))
}
#[tauri::command]
async fn cancel_cooldown(device_id: String) -> AppResult<()> {
let session = require_session()?;
let config = AppConfig::load();
let client = MagicApiClient::new(&config);
client.cancel_cooldown(&session.access_token, &device_id).await
}
#[tauri::command]
fn logout_magic() -> AppResult<()> {
AppConfig::clear_magic_session()
}
#[tauri::command]
fn activate_protection(profile_path: String) -> AppResult<()> {
platform::activate_protection(&profile_path)
}
#[tauri::command]
async fn fetch_me() -> AppResult<UserProfile> {
let session = require_session()?;
let config = AppConfig::load();
let client = MagicApiClient::new(&config);
client.fetch_me(&session.access_token).await
}
#[tauri::command]
async fn download_profile(profile_url: String) -> AppResult<String> {
let session = require_session()?;
let config = AppConfig::load();
let client = MagicApiClient::new(&config);
// Try to extract dns_token from profileUrl, fallback to direct token lookup
let dns_token = extract_dns_token(&profile_url)
.or_else(|| Some(session.access_token.clone()))
.ok_or_else(|| error::AppError::new("Kein DNS-Token verfügbar".to_string()))?;
let bytes = client.download_profile(&dns_token).await?;
let config_dir = AppConfig::config_dir()?;
std::fs::create_dir_all(&config_dir)?;
let profile_path = config_dir.join("rebreak-iphone-protect.mobileconfig");
std::fs::write(&profile_path, bytes)?;
Ok(profile_path.to_string_lossy().to_string())
}
fn extract_dns_token(profile_url: &str) -> Option<String> {
profile_url.split("token=").nth(1).map(|s| s.split('&').next().unwrap_or(s).to_string())
}
#[tauri::command]
fn get_desktop_protection_status() -> AppResult<Option<DesktopProtectionState>> {
AppConfig::load_desktop_protection()
}
#[tauri::command]
fn get_hostname() -> AppResult<String> {
hostname()
}
#[tauri::command]
fn set_desktop_protection_status(active: bool, platform: String) -> AppResult<()> {
if active {
let state = DesktopProtectionState {
active,
platform,
activated_at: chrono::Utc::now(),
};
AppConfig::save_desktop_protection(&state)
} else {
AppConfig::clear_desktop_protection()
}
}