feat(magic): identify current device via hardwareId, migrate existing devices
This commit is contained in:
parent
e4b28be5be
commit
5b0a4d03d2
@ -36,7 +36,7 @@ function normalizeHostname(value: string): string {
|
||||
function mapToComputedDevice(d: MagicDeviceInfo, isCurrent: boolean): ComputedDevice {
|
||||
return {
|
||||
deviceId: d.deviceId,
|
||||
name: d.model ?? d.hostname,
|
||||
name: d.hostname ?? d.model ?? "Unbenanntes Gerät",
|
||||
platform: normalizePlatform(d.model ?? d.hostname),
|
||||
model: d.model,
|
||||
osVersion: d.osVersion,
|
||||
@ -53,14 +53,16 @@ export function useDeviceStatus(
|
||||
localHostname: Ref<string | null>,
|
||||
iphone: Ref<IphoneDeviceState | null>,
|
||||
currentDeviceId?: Ref<string | null>,
|
||||
currentHardwareId?: Ref<string | null>,
|
||||
) {
|
||||
function isCurrentDevice(d: MagicDeviceInfo): boolean {
|
||||
if (currentHardwareId?.value && d.hardwareId) {
|
||||
return d.hardwareId === currentHardwareId.value;
|
||||
}
|
||||
if (currentDeviceId?.value) {
|
||||
return d.deviceId === currentDeviceId.value;
|
||||
}
|
||||
if (!localHostname.value) return false;
|
||||
const local = normalizeHostname(localHostname.value);
|
||||
return normalizeHostname(d.hostname) === local;
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentBackendDevice = computed<ComputedDevice | null>(() => {
|
||||
|
||||
32
apps/rebreak-magic/app/composables/useMagicState.ts
Normal file
32
apps/rebreak-magic/app/composables/useMagicState.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import type { MagicDeviceInfo, RegisterDeviceResponse, IphoneDeviceState } from "./useTauri";
|
||||
|
||||
export interface MagicSessionState {
|
||||
token?: string;
|
||||
deviceId: string;
|
||||
hardwareId?: string;
|
||||
dnsToken: string;
|
||||
profileUrl: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function useMagicSession() {
|
||||
return useState<MagicSessionState | null>("magic-session", () => null);
|
||||
}
|
||||
|
||||
export function useMagicDevices() {
|
||||
return useState<MagicDeviceInfo[]>("magic-devices", () => []);
|
||||
}
|
||||
|
||||
export function useCurrentMagicDevice() {
|
||||
const session = useMagicSession();
|
||||
const devices = useMagicDevices();
|
||||
|
||||
return computed(() => {
|
||||
if (!session.value) return null;
|
||||
return devices.value.find((d) => d.deviceId === session.value!.deviceId) ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
export function useIphoneDevice() {
|
||||
return useState<IphoneDeviceState | null>("iphone-device", () => null);
|
||||
}
|
||||
@ -43,9 +43,15 @@ export function useProtectionStatus() {
|
||||
return iphone.value.isSupervised && hasEnrollment && hasLock;
|
||||
});
|
||||
const currentBackendDevice = computed<MagicDeviceInfo | null>(() => {
|
||||
if (!localHostname.value) return null;
|
||||
const local = normalizeHostname(localHostname.value);
|
||||
return devices.value.find((d) => normalizeHostname(d.hostname) === local) ?? null;
|
||||
if (!session.value) return null;
|
||||
const hardwareId = session.value.hardwareId;
|
||||
const deviceId = session.value.deviceId;
|
||||
return (
|
||||
devices.value.find((d) => {
|
||||
if (hardwareId && d.hardwareId) return d.hardwareId === hardwareId;
|
||||
return d.deviceId === deviceId;
|
||||
}) ?? null
|
||||
);
|
||||
});
|
||||
const desktopProtected = computed(() => !!desktopProtection.value?.active || !!currentBackendDevice.value);
|
||||
const anyBackendDevice = computed(() => devices.value.length > 0);
|
||||
|
||||
@ -41,6 +41,7 @@ export interface RegisterDeviceResponse {
|
||||
export interface MagicDeviceInfo {
|
||||
source: string;
|
||||
deviceId: string;
|
||||
hardwareId: string | null;
|
||||
hostname: string;
|
||||
model: string | null;
|
||||
osVersion: string | null;
|
||||
@ -131,11 +132,18 @@ export function useTauri() {
|
||||
}
|
||||
|
||||
async function registerDevice(
|
||||
deviceId: string,
|
||||
model: string | undefined,
|
||||
osVersion: string | undefined,
|
||||
model?: string,
|
||||
osVersion?: string,
|
||||
): Promise<RegisterDeviceResponse> {
|
||||
return await invokeLogged("register_device", { deviceId, model, osVersion });
|
||||
return await invokeLogged("register_device", { model, osVersion });
|
||||
}
|
||||
|
||||
async function getHardwareId(): Promise<string> {
|
||||
return await invokeLogged("get_hardware_id");
|
||||
}
|
||||
|
||||
async function getDeviceId(): Promise<string | null> {
|
||||
return await invokeLogged("get_device_id");
|
||||
}
|
||||
|
||||
async function getStoredSession(): Promise<MagicSession | null> {
|
||||
@ -287,5 +295,7 @@ export function useTauri() {
|
||||
getDesktopProtectionStatus,
|
||||
setDesktopProtectionStatus,
|
||||
getHostname,
|
||||
getHardwareId,
|
||||
getDeviceId,
|
||||
};
|
||||
}
|
||||
|
||||
@ -149,6 +149,9 @@ const {
|
||||
fetchMe,
|
||||
setDesktopProtectionStatus,
|
||||
getPlatform,
|
||||
getHardwareId,
|
||||
getDeviceId,
|
||||
registerDevice,
|
||||
} = useTauri();
|
||||
|
||||
const session = useMagicSession();
|
||||
@ -156,7 +159,8 @@ const devices = useMagicDevices();
|
||||
const iphone = useIphoneDevice();
|
||||
const protection = useProtectionStatus();
|
||||
|
||||
const currentDeviceId = computed(() => session.value?.deviceId ?? null);
|
||||
const currentDeviceId = ref<string | null>(null);
|
||||
const currentHardwareId = ref<string | null>(null);
|
||||
|
||||
const profile = ref<UserProfile | null>(null);
|
||||
const loading = ref(false);
|
||||
@ -172,7 +176,7 @@ const subscriptionInGracePeriod = ref(false);
|
||||
// Share localHostname from protection composable with device status logic.
|
||||
const localHostname = protection.localHostname;
|
||||
const { currentBackendDevice, iosDevices, desktopDevices, iosStars } =
|
||||
useDeviceStatus(devices, localHostname, iphone, currentDeviceId);
|
||||
useDeviceStatus(devices, localHostname, iphone, currentDeviceId, currentHardwareId);
|
||||
|
||||
const selectedDeviceStars = computed(() => {
|
||||
if (!selectedDevice.value || selectedDevice.value.platform !== "ios") return null;
|
||||
@ -193,8 +197,33 @@ onMounted(async () => {
|
||||
} catch (e) {
|
||||
platformInfo.value = null;
|
||||
}
|
||||
await initCurrentDevice();
|
||||
});
|
||||
|
||||
async function initCurrentDevice() {
|
||||
try {
|
||||
const hardwareId = await getHardwareId();
|
||||
currentHardwareId.value = hardwareId;
|
||||
|
||||
// Ensure the backend knows this hardware ID.
|
||||
// For existing devices this performs the one-time hardwareId migration.
|
||||
const response = await registerDevice(
|
||||
platformInfo.value?.platform,
|
||||
undefined,
|
||||
);
|
||||
currentDeviceId.value = response.deviceId;
|
||||
|
||||
session.value = {
|
||||
deviceId: response.deviceId,
|
||||
hardwareId,
|
||||
dnsToken: response.dnsToken,
|
||||
profileUrl: response.profileUrl,
|
||||
};
|
||||
} catch (e: any) {
|
||||
console.error("Failed to initialize current device:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProfile() {
|
||||
try {
|
||||
profile.value = await fetchMe();
|
||||
|
||||
@ -21,8 +21,10 @@ pub struct RedeemPairingResponse {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RegisterDeviceRequest {
|
||||
#[serde(rename = "deviceId")]
|
||||
pub device_id: String,
|
||||
#[serde(rename = "deviceId", skip_serializing_if = "Option::is_none")]
|
||||
pub device_id: Option<String>,
|
||||
#[serde(rename = "hardwareId", skip_serializing_if = "Option::is_none")]
|
||||
pub hardware_id: Option<String>,
|
||||
pub hostname: String,
|
||||
pub model: Option<String>,
|
||||
#[serde(rename = "osVersion")]
|
||||
@ -45,6 +47,8 @@ pub struct MagicDeviceInfo {
|
||||
pub source: String,
|
||||
#[serde(rename = "deviceId")]
|
||||
pub device_id: String,
|
||||
#[serde(rename = "hardwareId")]
|
||||
pub hardware_id: Option<String>,
|
||||
pub hostname: String,
|
||||
pub model: Option<String>,
|
||||
#[serde(rename = "osVersion")]
|
||||
@ -159,14 +163,16 @@ impl MagicApiClient {
|
||||
pub async fn register_device(
|
||||
&self,
|
||||
token: &str,
|
||||
device_id: &str,
|
||||
device_id: Option<&str>,
|
||||
hardware_id: Option<&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(),
|
||||
device_id: device_id.map(|s| s.to_string()),
|
||||
hardware_id: hardware_id.map(|s| s.to_string()),
|
||||
hostname: hostname.to_string(),
|
||||
model: model.map(|s| s.to_string()),
|
||||
os_version: os_version.map(|s| s.to_string()),
|
||||
|
||||
223
apps/rebreak-magic/src-tauri/src/config.rs
Normal file
223
apps/rebreak-magic/src-tauri/src/config.rs
Normal file
@ -0,0 +1,223 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
pub backend_base_url: String,
|
||||
pub mdm_server_url: String,
|
||||
pub supabase_url: String,
|
||||
pub supabase_anon_key: String,
|
||||
/// Stable device identity known to the backend (UserDevice.deviceId).
|
||||
/// Filled after first successful device registration.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub device_id: Option<String>,
|
||||
/// Hardware-bound identity (UserDevice.hardwareId).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hardware_id: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
backend_base_url: "https://staging.rebreak.org".to_string(),
|
||||
mdm_server_url: "https://mdm.rebreak.org".to_string(),
|
||||
supabase_url: String::new(),
|
||||
supabase_anon_key: String::new(),
|
||||
device_id: None,
|
||||
hardware_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const KEYRING_SERVICE: &str = "org.rebreak.magic";
|
||||
const KEYRING_ACCOUNT: &str = "magic-session";
|
||||
const SESSION_FILE: &str = "session.json";
|
||||
const DESKTOP_PROTECTION_FILE: &str = "desktop-protection.json";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MagicSession {
|
||||
pub access_token: String,
|
||||
pub session_id: String,
|
||||
pub user_id: String,
|
||||
#[serde(with = "chrono::serde::ts_seconds")]
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
impl MagicSession {
|
||||
pub fn new(access_token: String, session_id: String, label: Option<String>) -> Self {
|
||||
Self {
|
||||
access_token,
|
||||
session_id,
|
||||
user_id: String::new(),
|
||||
created_at: chrono::Utc::now(),
|
||||
label,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BinderConfig {
|
||||
#[serde(rename = "mdmServer")]
|
||||
pub mdm_server: String,
|
||||
#[serde(rename = "mdmUser")]
|
||||
pub mdm_user: String,
|
||||
#[serde(rename = "mdmApiKey")]
|
||||
pub mdm_api_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DesktopProtectionState {
|
||||
pub active: bool,
|
||||
pub platform: String,
|
||||
#[serde(with = "chrono::serde::ts_seconds")]
|
||||
pub activated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
pub fn config_dir() -> AppResult<PathBuf> {
|
||||
let dir = dirs::config_dir()
|
||||
.ok_or_else(|| AppError::new("Could not find config directory"))?
|
||||
.join("org.rebreak.magic");
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
pub fn config_path() -> AppResult<PathBuf> {
|
||||
Ok(Self::config_dir()?.join("config.json"))
|
||||
}
|
||||
|
||||
pub fn binder_config_path() -> AppResult<PathBuf> {
|
||||
Ok(dirs::config_dir()
|
||||
.ok_or_else(|| AppError::new("Could not find config directory"))?
|
||||
.join("rebreak-binder")
|
||||
.join("config.json"))
|
||||
}
|
||||
|
||||
pub fn load_binder_config() -> AppResult<BinderConfig> {
|
||||
let path = Self::binder_config_path()?;
|
||||
if !path.exists() {
|
||||
return Err(AppError::new(
|
||||
"rebreak-binder config nicht gefunden. Bitte README → 'Config (lokal)'.".to_string(),
|
||||
));
|
||||
}
|
||||
let json = std::fs::read_to_string(&path)?;
|
||||
serde_json::from_str(&json)
|
||||
.map_err(|e| AppError::new(format!("rebreak-binder config kaputt: {}", e)))
|
||||
}
|
||||
|
||||
pub fn load() -> Self {
|
||||
match Self::config_path() {
|
||||
Ok(path) if path.exists() => {
|
||||
std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
_ => Self::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self) -> AppResult<()> {
|
||||
let path = Self::config_path()?;
|
||||
let contents = serde_json::to_string_pretty(self)?;
|
||||
std::fs::write(path, contents)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn session_path() -> AppResult<PathBuf> {
|
||||
Ok(Self::config_dir()?.join(SESSION_FILE))
|
||||
}
|
||||
|
||||
pub fn save_magic_session(session: &MagicSession) -> AppResult<()> {
|
||||
let path = Self::session_path()?;
|
||||
let json = serde_json::to_string_pretty(session)
|
||||
.map_err(|e| AppError::new(format!("Failed to serialize session: {}", e)))?;
|
||||
std::fs::write(&path, json)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_magic_session() -> AppResult<Option<MagicSession>> {
|
||||
let path = match Self::session_path() {
|
||||
Ok(p) => p,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let json = std::fs::read_to_string(&path)?;
|
||||
if json.trim().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let session = serde_json::from_str(&json)
|
||||
.map_err(|e| AppError::new(format!("Failed to parse session: {}", e)))?;
|
||||
Ok(Some(session))
|
||||
}
|
||||
|
||||
pub fn clear_magic_session() -> AppResult<()> {
|
||||
let path = match Self::session_path() {
|
||||
Ok(p) => p,
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
if path.exists() {
|
||||
std::fs::remove_file(&path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn desktop_protection_path() -> AppResult<PathBuf> {
|
||||
Ok(Self::config_dir()?.join(DESKTOP_PROTECTION_FILE))
|
||||
}
|
||||
|
||||
pub fn save_desktop_protection(state: &DesktopProtectionState) -> AppResult<()> {
|
||||
let path = Self::desktop_protection_path()?;
|
||||
let json = serde_json::to_string_pretty(state)
|
||||
.map_err(|e| AppError::new(format!("Failed to serialize desktop protection state: {}", e)))?;
|
||||
std::fs::write(&path, json)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_desktop_protection() -> AppResult<Option<DesktopProtectionState>> {
|
||||
let path = match Self::desktop_protection_path() {
|
||||
Ok(p) => p,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let json = std::fs::read_to_string(&path)?;
|
||||
if json.trim().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let state = serde_json::from_str(&json)
|
||||
.map_err(|e| AppError::new(format!("Failed to parse desktop protection state: {}", e)))?;
|
||||
Ok(Some(state))
|
||||
}
|
||||
|
||||
pub fn clear_desktop_protection() -> AppResult<()> {
|
||||
let path = match Self::desktop_protection_path() {
|
||||
Ok(p) => p,
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
if path.exists() {
|
||||
std::fs::remove_file(&path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_device_id(&mut self, id: String) -> AppResult<()> {
|
||||
self.device_id = Some(id);
|
||||
self.save()
|
||||
}
|
||||
|
||||
pub fn set_hardware_id(&mut self, id: String) -> AppResult<()> {
|
||||
self.hardware_id = Some(id);
|
||||
self.save()
|
||||
}
|
||||
}
|
||||
@ -54,6 +54,8 @@ pub fn run() {
|
||||
get_desktop_protection_status,
|
||||
set_desktop_protection_status,
|
||||
get_hostname,
|
||||
get_hardware_id,
|
||||
get_device_id,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
@ -71,6 +73,26 @@ fn hostname() -> AppResult<String> {
|
||||
Ok(hostname)
|
||||
}
|
||||
|
||||
fn get_or_init_hardware_id(config: &mut AppConfig) -> AppResult<String> {
|
||||
if let Some(id) = config.hardware_id.clone() {
|
||||
return Ok(id);
|
||||
}
|
||||
let id = platform::get_hardware_id()?;
|
||||
config.set_hardware_id(id.clone())?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_hardware_id() -> AppResult<String> {
|
||||
let mut config = AppConfig::load();
|
||||
get_or_init_hardware_id(&mut config)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_device_id() -> AppResult<Option<String>> {
|
||||
Ok(AppConfig::load().device_id)
|
||||
}
|
||||
|
||||
fn require_session() -> AppResult<MagicSession> {
|
||||
AppConfig::load_magic_session()?.ok_or_else(|| {
|
||||
error::AppError::new("Keine Magic-Session gefunden. Bitte neu paaren.")
|
||||
@ -99,17 +121,28 @@ async fn redeem_pairing_code(code: String, label: Option<String>) -> AppResult<R
|
||||
|
||||
#[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 mut config = AppConfig::load();
|
||||
let client = MagicApiClient::new(&config);
|
||||
let hostname = hostname()?;
|
||||
|
||||
// Hardware-ID ist der stabile Identifikator; deviceId wird vom Backend zurückgeliefert.
|
||||
let hardware_id = get_or_init_hardware_id(&mut config)?;
|
||||
let response = client
|
||||
.register_device(&session.access_token, &device_id, &hostname, model.as_deref(), os_version.as_deref())
|
||||
.register_device(
|
||||
&session.access_token,
|
||||
config.device_id.as_deref(),
|
||||
Some(&hardware_id),
|
||||
&hostname,
|
||||
model.as_deref(),
|
||||
os_version.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
config.set_device_id(response.device_id.clone())?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
|
||||
74
apps/rebreak-magic/src-tauri/src/platform/macos.rs
Normal file
74
apps/rebreak-magic/src-tauri/src/platform/macos.rs
Normal file
@ -0,0 +1,74 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use super::{Platform, PlatformInfo};
|
||||
use crate::error::AppResult;
|
||||
|
||||
pub fn get_platform_info() -> AppResult<PlatformInfo> {
|
||||
Ok(PlatformInfo {
|
||||
platform: Platform::MacOS,
|
||||
version: "14.0".to_string(), // TODO: Read actual macOS version
|
||||
supports_ios_supervision: true,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn install_dns_profile(profile_path: &str) -> AppResult<()> {
|
||||
use std::process::Command;
|
||||
let status = Command::new("open")
|
||||
.arg(profile_path)
|
||||
.status()
|
||||
.map_err(|e| crate::error::AppError::new(format!("Failed to open profile: {}", e)))?;
|
||||
|
||||
if !status.success() {
|
||||
return Err(crate::error::AppError::new(
|
||||
"System Settings konnte das Profil nicht öffnen".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn store_token(_token: &str) -> AppResult<()> {
|
||||
// TODO: Implement using macOS Keychain via keyring crate
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn read_token() -> AppResult<Option<String>> {
|
||||
// TODO: Implement using macOS Keychain via keyring crate
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub fn activate_protection(profile_path: &str) -> AppResult<()> {
|
||||
install_dns_profile(profile_path)
|
||||
}
|
||||
|
||||
pub fn deactivate_protection(_token: &str) -> AppResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the macOS system UUID (IOPlatformUUID).
|
||||
pub fn get_hardware_id() -> AppResult<String> {
|
||||
use std::process::Command;
|
||||
let output = Command::new("ioreg")
|
||||
.args([
|
||||
"-rd1",
|
||||
"-c",
|
||||
"IOPlatformExpertDevice",
|
||||
"-k",
|
||||
"IOPlatformUUID",
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| crate::error::AppError::new(format!("Failed to read hardware ID: {}", e)))?;
|
||||
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
for line in text.lines() {
|
||||
if line.contains("IOPlatformUUID") {
|
||||
let parts: Vec<&str> = line.split('"').collect();
|
||||
if let Some(uuid) = parts.iter().rev().find(|s| s.contains('-')) {
|
||||
return Ok(uuid.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(crate::error::AppError::new(
|
||||
"Could not parse macOS hardware UUID".to_string(),
|
||||
))
|
||||
}
|
||||
75
apps/rebreak-magic/src-tauri/src/platform/mod.rs
Normal file
75
apps/rebreak-magic/src-tauri/src/platform/mod.rs
Normal file
@ -0,0 +1,75 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::error::AppResult;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum Platform {
|
||||
MacOS,
|
||||
Windows,
|
||||
Linux,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlatformInfo {
|
||||
pub platform: Platform,
|
||||
pub version: String,
|
||||
pub supports_ios_supervision: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_platform() -> AppResult<PlatformInfo> {
|
||||
#[cfg(target_os = "macos")]
|
||||
return macos::get_platform_info();
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
return windows::get_platform_info();
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
Ok(PlatformInfo {
|
||||
platform: Platform::Unknown,
|
||||
version: String::new(),
|
||||
supports_ios_supervision: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub trait PlatformInterface {
|
||||
fn install_dns_profile(&self, profile_path: &str) -> AppResult<()>;
|
||||
fn store_token(&self, token: &str) -> AppResult<()>;
|
||||
fn read_token(&self) -> AppResult<Option<String>>;
|
||||
fn activate_protection(&self, token: &str) -> AppResult<()>;
|
||||
fn deactivate_protection(&self, token: &str) -> AppResult<()>;
|
||||
}
|
||||
|
||||
pub fn activate_protection(profile_or_token: &str) -> AppResult<()> {
|
||||
#[cfg(target_os = "macos")]
|
||||
return macos::activate_protection(profile_or_token);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
return windows::activate_protection(profile_or_token);
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
Err(crate::error::AppError::new(
|
||||
"Plattform wird nicht unterstützt".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn get_hardware_id() -> AppResult<String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
return macos::get_hardware_id();
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
return windows::get_hardware_id();
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
Err(crate::error::AppError::new(
|
||||
"Hardware-ID wird auf dieser Plattform nicht unterstützt".to_string(),
|
||||
))
|
||||
}
|
||||
94
apps/rebreak-magic/src-tauri/src/platform/windows.rs
Normal file
94
apps/rebreak-magic/src-tauri/src/platform/windows.rs
Normal file
@ -0,0 +1,94 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use super::{Platform, PlatformInfo};
|
||||
use crate::error::AppResult;
|
||||
|
||||
pub fn get_platform_info() -> AppResult<PlatformInfo> {
|
||||
Ok(PlatformInfo {
|
||||
platform: Platform::Windows,
|
||||
version: "11".to_string(), // TODO: Read actual Windows version
|
||||
supports_ios_supervision: true, // Once supervise-magic Windows build is verified
|
||||
})
|
||||
}
|
||||
|
||||
pub fn install_doh(dns_token: &str) -> AppResult<()> {
|
||||
use std::process::Command;
|
||||
|
||||
let doh_url = format!("https://dns.rebreak.org/dns-query/{}", dns_token);
|
||||
let script = format!(
|
||||
r#"
|
||||
$iface = Get-NetAdapter | Where-Object {{ $_.Status -eq 'Up' }} | Select-Object -First 1
|
||||
if (-not $iface) {{ exit 1 }}
|
||||
Set-DnsClientDohServerAddress -ServerAddress '142.132.245.42' -DohTemplate '{}' -AutoUpgrade $true
|
||||
Set-DnsClientServerAddress -InterfaceAlias $iface.Name -ServerAddresses ('142.132.245.42')
|
||||
"#,
|
||||
doh_url
|
||||
);
|
||||
|
||||
let status = Command::new("powershell.exe")
|
||||
.args([
|
||||
"-NonInteractive",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
&script,
|
||||
])
|
||||
.status()
|
||||
.map_err(|e| crate::error::AppError::new(format!("Failed to run PowerShell: {}", e)))?;
|
||||
|
||||
if !status.success() {
|
||||
return Err(crate::error::AppError::new(
|
||||
"DoH-Konfiguration fehlgeschlagen. Bitte ReBreak Magic mit Administratorrechten starten.".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn store_token(token: &str) -> AppResult<()> {
|
||||
// TODO: Implement using Windows Credential Manager via keyring crate
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn read_token() -> AppResult<Option<String>> {
|
||||
// TODO: Implement using Windows Credential Manager via keyring crate
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub fn activate_protection(token: &str) -> AppResult<()> {
|
||||
install_doh(token)
|
||||
}
|
||||
|
||||
pub fn deactivate_protection(_token: &str) -> AppResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the Windows machine GUID (Registry: HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid).
|
||||
pub fn get_hardware_id() -> AppResult<String> {
|
||||
use std::process::Command;
|
||||
let output = Command::new("reg")
|
||||
.args([
|
||||
"query",
|
||||
"HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography",
|
||||
"/v",
|
||||
"MachineGuid",
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| crate::error::AppError::new(format!("Failed to read hardware ID: {}", e)))?;
|
||||
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
for line in text.lines() {
|
||||
if line.contains("MachineGuid") {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if let Some(uuid) = parts.last() {
|
||||
if uuid.contains('-') {
|
||||
return Ok(uuid.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(crate::error::AppError::new(
|
||||
"Could not parse Windows machine GUID".to_string(),
|
||||
))
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
-- Hardware-ID für UserDevices
|
||||
-- Client liefert hardwaregebundene ID; Backend speichert sie separat zur bestehenden deviceId.
|
||||
|
||||
ALTER TABLE "rebreak"."user_devices"
|
||||
ADD COLUMN IF NOT EXISTS "hardware_id" TEXT;
|
||||
|
||||
-- Eindeutigkeit pro User + Hardware-ID (NULL-Werte sind bei UNIQUE erlaubt)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "user_devices_user_id_hardware_id_key"
|
||||
ON "rebreak"."user_devices"("user_id", "hardware_id");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "user_devices_hardware_id_idx"
|
||||
ON "rebreak"."user_devices"("hardware_id");
|
||||
@ -1102,6 +1102,9 @@ model UserDevice {
|
||||
/// Wird in com.apple.profileRemovalPassword injiziert — User sieht es NIE,
|
||||
/// nur nach Cooldown-Release (Offboarding). NULL → noch keins generiert.
|
||||
magicRemovalPassword String? @map("magic_removal_password")
|
||||
/// Hardware-gebundene ID (z. B. System-UUID, ANDROID_ID, IDFV).
|
||||
/// Wird vom Client geliefert, nicht vom Backend generiert.
|
||||
hardwareId String? @map("hardware_id")
|
||||
/// Wann der User die Entfernung des Magic-Profils beantragt hat.
|
||||
/// Removal-Passwort wird erst nach +MAGIC_RELEASE_COOLDOWN_H sichtbar.
|
||||
magicReleaseRequestedAt DateTime? @map("magic_release_requested_at")
|
||||
@ -1109,8 +1112,10 @@ model UserDevice {
|
||||
magicCooldownUntil DateTime? @map("magic_cooldown_until")
|
||||
|
||||
@@unique([userId, deviceId])
|
||||
@@unique([userId, hardwareId])
|
||||
@@index([userId])
|
||||
@@index([deviceId])
|
||||
@@index([hardwareId])
|
||||
@@map("user_devices")
|
||||
@@schema("rebreak")
|
||||
}
|
||||
|
||||
@ -32,6 +32,7 @@ export default defineEventHandler(async (event) => {
|
||||
select: {
|
||||
id: true,
|
||||
deviceId: true,
|
||||
hardwareId: true,
|
||||
platform: true,
|
||||
model: true,
|
||||
name: true,
|
||||
@ -64,6 +65,7 @@ export default defineEventHandler(async (event) => {
|
||||
return {
|
||||
source: "magic" as const,
|
||||
deviceId: d.deviceId,
|
||||
hardwareId: d.hardwareId,
|
||||
hostname: d.hostname ?? "Unbenanntes Ger\u00e4t",
|
||||
model: d.model,
|
||||
osVersion: d.osVersion,
|
||||
@ -86,6 +88,7 @@ export default defineEventHandler(async (event) => {
|
||||
return {
|
||||
source: "locked" as const,
|
||||
deviceId: d.deviceId,
|
||||
hardwareId: d.hardwareId,
|
||||
hostname: d.name ?? d.model ?? prettyPlatform(d.platform),
|
||||
model: d.model,
|
||||
osVersion: d.osVersion,
|
||||
|
||||
@ -22,21 +22,33 @@ import { generateRemovalPassword } from "../../utils/magic-lock";
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUser(event);
|
||||
const body = await readBody(event);
|
||||
const { deviceId, hostname, model, osVersion, platform } = body as {
|
||||
const { deviceId, hardwareId, hostname, model, osVersion, platform } = body as {
|
||||
deviceId?: string;
|
||||
hardwareId?: string;
|
||||
hostname?: string;
|
||||
model?: string;
|
||||
osVersion?: string;
|
||||
platform?: string;
|
||||
};
|
||||
|
||||
if (!deviceId || !hostname) {
|
||||
if (!hostname) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: "deviceId und hostname required",
|
||||
message: "hostname required",
|
||||
});
|
||||
}
|
||||
|
||||
if (!deviceId && !hardwareId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: "deviceId oder hardwareId required",
|
||||
});
|
||||
}
|
||||
|
||||
// Für neue Magic-Registrierungen: hardwareId wird gleichzeitig deviceId,
|
||||
// damit das Backend keine eigene ID generieren muss.
|
||||
const effectiveDeviceId = deviceId?.trim() || hardwareId!.trim();
|
||||
|
||||
// Plattform: Mac-App sendet nichts (legacy default), Windows-App sendet "windows"
|
||||
const devicePlatform =
|
||||
platform === "windows" ? "windows" : "macos";
|
||||
@ -44,17 +56,60 @@ export default defineEventHandler(async (event) => {
|
||||
const db = usePrisma();
|
||||
|
||||
// 1. Prüfe ob Device bereits als Magic-Client gebunden ist (idempotent)
|
||||
const existing = await db.userDevice.findUnique({
|
||||
where: { userId_deviceId: { userId: user.id, deviceId } },
|
||||
// Priorität: hardwareId → deviceId → Migration über Modell/Plattform/OS.
|
||||
let existing = null;
|
||||
|
||||
if (hardwareId) {
|
||||
existing = await db.userDevice.findFirst({
|
||||
where: { userId: user.id, hardwareId },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
deviceId: true,
|
||||
magicDnsToken: true,
|
||||
magicEnrolledAt: true,
|
||||
magicRevokedAt: true,
|
||||
magicRemovalPassword: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!existing && deviceId) {
|
||||
existing = await db.userDevice.findUnique({
|
||||
where: { userId_deviceId: { userId: user.id, deviceId } },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
deviceId: true,
|
||||
magicDnsToken: true,
|
||||
magicEnrolledAt: true,
|
||||
magicRevokedAt: true,
|
||||
magicRemovalPassword: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Migration: bestehendes Gerät ohne hardwareId anhand von Modell/Plattform/OS finden.
|
||||
if (!existing && hardwareId) {
|
||||
existing = await db.userDevice.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
hardwareId: null,
|
||||
platform: devicePlatform,
|
||||
model: model ?? null,
|
||||
osVersion: osVersion ?? null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
deviceId: true,
|
||||
magicDnsToken: true,
|
||||
magicEnrolledAt: true,
|
||||
magicRevokedAt: true,
|
||||
magicRemovalPassword: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Wenn Token existiert und nicht revoked → return existing
|
||||
if (
|
||||
@ -132,21 +187,26 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
// 5. Upsert UserDevice (platform="macos" | "windows")
|
||||
// Bei Migration behalten wir die bestehende deviceId bei.
|
||||
const upsertDeviceId = existing?.deviceId || effectiveDeviceId;
|
||||
|
||||
const device = await db.userDevice.upsert({
|
||||
where: { userId_deviceId: { userId: user.id, deviceId } },
|
||||
where: { userId_deviceId: { userId: user.id, deviceId: upsertDeviceId } },
|
||||
create: {
|
||||
userId: user.id,
|
||||
deviceId,
|
||||
deviceId: upsertDeviceId,
|
||||
platform: devicePlatform,
|
||||
model: model ?? null,
|
||||
name: hostname,
|
||||
osVersion: osVersion ?? null,
|
||||
hardwareId: hardwareId ?? null,
|
||||
magicDnsToken: dnsToken,
|
||||
magicEnrolledAt: new Date(),
|
||||
magicHostname: hostname,
|
||||
magicRemovalPassword: removalPassword,
|
||||
},
|
||||
update: {
|
||||
hardwareId: hardwareId ?? undefined,
|
||||
magicDnsToken: dnsToken,
|
||||
magicEnrolledAt: new Date(),
|
||||
magicRevokedAt: null, // Clear falls vorher revoked
|
||||
|
||||
@ -418,6 +418,7 @@ export async function deleteUserDevice(
|
||||
|
||||
export interface MagicDeviceRecord {
|
||||
deviceId: string;
|
||||
hardwareId: string | null;
|
||||
hostname: string | null;
|
||||
model: string | null;
|
||||
osVersion: string | null;
|
||||
@ -445,6 +446,7 @@ export async function listMagicDevices(
|
||||
orderBy: { magicEnrolledAt: "desc" },
|
||||
select: {
|
||||
deviceId: true,
|
||||
hardwareId: true,
|
||||
magicHostname: true,
|
||||
model: true,
|
||||
osVersion: true,
|
||||
@ -458,6 +460,7 @@ export async function listMagicDevices(
|
||||
|
||||
return devices.map((d) => ({
|
||||
deviceId: d.deviceId,
|
||||
hardwareId: d.hardwareId,
|
||||
hostname: d.magicHostname,
|
||||
model: d.model,
|
||||
osVersion: d.osVersion,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user