diff --git a/.gitignore b/.gitignore index 2cc9cf8..eccee69 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,7 @@ local.properties *.zip *.so -*.sh +b.sh test* magisk/system/bin/* fast-memmem diff --git a/cli/src/main.rs b/cli/src/main.rs index 7fe3286..3ee3539 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,12 +1,15 @@ #![feature(iter_intersperse, print_internals)] -use std::fmt::Display; +use std::error::Error; +use std::fmt::{Debug, Display}; use std::fs::{self, OpenOptions}; use std::io::{self, Seek}; use std::io::{BufWriter, Read, Write}; use std::mem::size_of; use std::ops::Range; +use std::panic::Location; use std::process::{Command, ExitCode}; +use termion::raw::IntoRawMode; use termion::event::Key; use termion::{clear, cursor}; @@ -15,7 +18,7 @@ mod colorize; use colorize::ToColored; mod menus; -use menus::{cursor_hide, cursor_show, select_menu, select_menu_numbered, select_menu_with_input}; +use menus::Menus; #[cfg(target_os = "android")] const MODULE_DETACH: &str = "/data/adb/modules/zygisk-detach/detach.bin"; @@ -31,9 +34,35 @@ extern "C" { fn kill(pid: i32, sig: i32) -> i32; } +struct CLIErr { + source: E, + loc: &'static Location<'static>, +} +impl Error for CLIErr {} +impl Debug for CLIErr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "{}\nat {}", self.source, self.loc) + } +} +impl Display for CLIErr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Debug::fmt(self, f) + } +} +impl From for CLIErr { + #[track_caller] + fn from(err: io::Error) -> Self { + Self { + source: err, + loc: Location::caller(), + } + } +} + +type IOError = Result>; + fn main() -> ExitCode { std::panic::set_hook(Box::new(|panic| { - use termion::raw::IntoRawMode; if let Ok(mut stderr) = io::stderr().into_raw_mode() { let _ = writeln!(stderr, "\r\n{panic}\r\n"); let _ = writeln!(stderr, "This should not have happened.\r"); @@ -44,6 +73,7 @@ fn main() -> ExitCode { let _ = write!(stderr, "{}", cursor::Show); } })); + let mut menus = Menus::new(); let mut args = std::env::args().skip(1); if matches!(args.next().as_deref(), Some("--serialize")) { @@ -64,14 +94,14 @@ fn main() -> ExitCode { } } - let ret = match interactive() { + let ret = match interactive(&mut menus) { Ok(()) => ExitCode::SUCCESS, Err(err) => { eprintln!("\rERROR: {err}"); ExitCode::FAILURE } }; - cursor_show().unwrap(); + menus.cursor_show().unwrap(); ret } @@ -80,7 +110,7 @@ fn detach_bin_changed() { let _ = kill_store(); } -fn serialize_txt(path: &str) -> io::Result<()> { +fn serialize_txt(path: &str) -> IOError<()> { let detach_bin = OpenOptions::new() .create(true) .truncate(true) @@ -97,19 +127,19 @@ fn serialize_txt(path: &str) -> io::Result<()> { Ok(()) } -fn interactive() -> io::Result<()> { - cursor_hide()?; +fn interactive(menus: &mut Menus) -> IOError<()> { + menus.cursor_hide()?; print!("zygisk-detach cli by github.com/j-hc\r\n\n"); loop { - match main_menu()? { - Op::DetachSelect => detach_menu()?, - Op::ReattachSelect => reattach_menu()?, + match main_menu(menus)? { + Op::DetachSelect => detach_menu(menus)?, + Op::ReattachSelect => reattach_menu(menus)?, Op::Reset => { if fs::remove_file(MODULE_DETACH).is_ok() { let _ = kill_store(); - text!("Reset"); + text!(menus, "Reset"); } else { - text!("Already empty"); + text!(menus, "Already empty"); } } Op::CopyToSd => { @@ -118,11 +148,11 @@ fn interactive() -> io::Result<()> { #[cfg(target_os = "linux")] const SDCARD_DETACH: &str = "detach_sdcard.bin"; match fs::copy(MODULE_DETACH, SDCARD_DETACH) { - Ok(_) => text!("Copied"), + Ok(_) => text!(menus, "Copied"), Err(err) if err.kind() == io::ErrorKind::NotFound => { - text!("detach.bin not found"); + text!(menus, "detach.bin not found"); } - Err(err) => return Err(err), + Err(err) => return Err(err.into()), } } Op::Quit => return Ok(()), @@ -131,26 +161,30 @@ fn interactive() -> io::Result<()> { } } -fn reattach_menu() -> io::Result<()> { - let Ok(mut detach_txt) = fs::OpenOptions::new() +fn reattach_menu(menus: &mut Menus) -> IOError<()> { + let mut detach_txt = match fs::OpenOptions::new() .write(true) .read(true) .open(MODULE_DETACH) - else { - text!("No detach.bin was found!"); - return Ok(()); + { + Ok(v) => v, + Err(e) if e.kind() == io::ErrorKind::NotFound => { + text!(menus, "detach.bin not found"); + return Ok(()); + } + Err(e) => return Err(e.into()), }; let mut content = Vec::new(); detach_txt.read_to_end(&mut content)?; detach_txt.seek(io::SeekFrom::Start(0))?; - let detached_apps = get_detached_apps(&content); + let detached_apps = get_detached_apps(menus, &content); let detach_len = detached_apps.len(); if detach_len == 0 { - text!("detach.bin is empty"); + text!(menus, "detach.bin is empty"); return Ok(()); } let list = detached_apps.iter().map(|e| e.0.as_str()); - let Some(i) = select_menu( + let Some(i) = menus.select_menu( list, "Select the app to re-attach ('q' to leave):", "✖".red(), @@ -160,7 +194,7 @@ fn reattach_menu() -> io::Result<()> { return Ok(()); }; - textln!("{}: {}", "re-attach".red(), detached_apps[i].0); + textln!(menus, "{}: {}", "re-attach".red(), detached_apps[i].0); content.drain(detached_apps[i].1.clone()); detach_txt.set_len(0)?; detach_txt.write_all(&content)?; @@ -168,7 +202,7 @@ fn reattach_menu() -> io::Result<()> { Ok(()) } -fn get_detached_apps(detach_txt: &[u8]) -> Vec<(String, Range)> { +fn get_detached_apps(menus: &mut Menus, detach_txt: &[u8]) -> Vec<(String, Range)> { let mut i = 0; let mut detached = Vec::new(); while i < detach_txt.len() { @@ -177,7 +211,7 @@ fn get_detached_apps(detach_txt: &[u8]) -> Vec<(String, Range)> { i += SZ_LEN; let Some(encoded_name) = &detach_txt.get(i..i + len as usize) else { eprintln!("Corrupted detach.bin. Reset and try again."); - let _ = cursor_show(); + let _ = menus.cursor_show(); std::process::exit(1); }; let name = String::from_utf8(encoded_name.iter().step_by(2).cloned().collect()).unwrap(); @@ -188,12 +222,12 @@ fn get_detached_apps(detach_txt: &[u8]) -> Vec<(String, Range)> { } #[cfg(target_os = "linux")] -fn get_installed_apps() -> io::Result> { +fn get_installed_apps() -> IOError> { Ok("package:com.app1\npackage:org.xxx2".as_bytes().to_vec()) } #[cfg(target_os = "android")] -fn get_installed_apps() -> io::Result> { +fn get_installed_apps() -> IOError> { Ok(Command::new("pm") .args(["list", "packages"]) .stdout(std::process::Stdio::piped()) @@ -211,7 +245,7 @@ enum Op { Nop, } -fn main_menu() -> io::Result { +fn main_menu(menus: &mut Menus) -> IOError { struct OpText { desc: &'static str, op: Op, @@ -232,16 +266,16 @@ fn main_menu() -> io::Result { OpText::new("Reset detached apps", Op::Reset), OpText::new("Copy detach.bin to /sdcard", Op::CopyToSd), ]; - let i = select_menu_numbered(ops.iter(), Key::Char('q'), "- Selection:")?; + let i = menus.select_menu_numbered(ops.iter(), Key::Char('q'), "- Selection:")?; use menus::SelectNumberedResp as SN; match i { SN::Index(i) => Ok(ops[i].op), SN::UndefinedKey(Key::Char(c)) => { - text!("Undefined key {c:?}"); + text!(menus, "Undefined key {c:?}"); Ok(Op::Nop) } SN::UndefinedKey(k @ (Key::Down | Key::Up | Key::Left | Key::Right)) => { - text!("Undefined key {k:?}"); + text!(menus, "Undefined key {k:?}"); Ok(Op::Nop) } SN::Quit => Ok(Op::Quit), @@ -249,7 +283,7 @@ fn main_menu() -> io::Result { } } -fn bin_serialize(app: &str, sink: impl Write) -> io::Result<()> { +fn bin_serialize(app: &str, sink: impl Write) -> IOError<()> { let w = app .as_bytes() .iter() @@ -267,7 +301,7 @@ fn bin_serialize(app: &str, sink: impl Write) -> io::Result<()> { Ok(()) } -fn detach_menu() -> io::Result<()> { +fn detach_menu(menus: &mut Menus) -> IOError<()> { let installed_apps = get_installed_apps()?; let apps: Vec<&str> = installed_apps[..installed_apps.len() - 1] .split(|&e| e == b'\n') @@ -277,8 +311,8 @@ fn detach_menu() -> io::Result<()> { }) .map(|e| std::str::from_utf8(e).expect("non utf-8 package names?")) .collect(); - cursor_show()?; - let selected = select_menu_with_input( + menus.cursor_show()?; + let selected = menus.select_menu_with_input( |input| { let input = input.trim(); if !input.is_empty() { @@ -297,7 +331,7 @@ fn detach_menu() -> io::Result<()> { "- app: ", None, )?; - cursor_hide()?; + menus.cursor_hide()?; if let Some(detach_app) = selected { let mut f = fs::OpenOptions::new() .create(true) @@ -306,19 +340,22 @@ fn detach_menu() -> io::Result<()> { .open(MODULE_DETACH)?; let mut buf: Vec = Vec::new(); f.read_to_end(&mut buf)?; - if !get_detached_apps(&buf).iter().any(|(s, _)| s == detach_app) { + if !get_detached_apps(menus, &buf) + .iter() + .any(|(s, _)| s == detach_app) + { bin_serialize(detach_app, f)?; - textln!("{} {}", "detach:".green(), detach_app); - textln!("Changes are applied. No need for a reboot!"); + textln!(menus, "{} {}", "detach:".green(), detach_app); + textln!(menus, "Changes are applied. No need for a reboot!"); detach_bin_changed(); } else { - textln!("{} {}", "already detached:".green(), detach_app); + textln!(menus, "{} {}", "already detached:".green(), detach_app); } } Ok(()) } -fn _kill_store_am() -> io::Result<()> { +fn _kill_store_am() -> IOError<()> { Command::new("am") .args(["force-stop", "com.android.vending"]) .spawn()? @@ -326,7 +363,7 @@ fn _kill_store_am() -> io::Result<()> { Ok(()) } -fn kill_store() -> io::Result<()> { +fn kill_store() -> IOError<()> { const PKG: &str = "com.android.vending"; let mut buf = [0u8; PKG.len()]; for proc in fs::read_dir("/proc")? { diff --git a/cli/src/menus.rs b/cli/src/menus.rs index 8f6b2d8..4a8ccc5 100644 --- a/cli/src/menus.rs +++ b/cli/src/menus.rs @@ -1,224 +1,255 @@ use crate::colorize::ToColored; use std::fmt::Display; -use std::io::{self, BufWriter, Write}; +use std::io::Write; +use std::io::{self, BufWriter, Stdout}; use termion::input::TermRead; -use termion::raw::IntoRawMode; +use termion::raw::{IntoRawMode, RawTerminal}; use termion::{clear, cursor, event::Key}; #[macro_export] macro_rules! text { - ($($arg:tt)*) => {{ - write!( - ::std::io::stdout(), - "{}{}{}{}\r", - cursor::Up(1), - clear::CurrentLine, - format_args!($($arg)*), - cursor::Down(1) - )?; - ::std::io::stdout().flush()?; + ($dst:expr, $($arg:tt)*) => {{ + write!( + $dst.stdout, + "{}{}{}{}\r", + cursor::Up(1), + clear::CurrentLine, + format_args!($($arg)*), + cursor::Down(1) + )?; + $dst.stdout.flush()?; }}; } #[macro_export] macro_rules! textln { - ($($arg:tt)*) => {{ - text!("{}\n", format_args!($($arg)*)); + ($dst:expr, $($arg:tt)*) => {{ + text!($dst, "{}\n", format_args!($($arg)*)); }}; } -pub fn cursor_hide() -> io::Result<()> { - let mut stdout = io::stdout().into_raw_mode()?; - write!(stdout, "{}", cursor::Hide)?; - Ok(()) +pub enum SelectNumberedResp { + Index(usize), + UndefinedKey(Key), + Quit, } - -pub fn cursor_show() -> io::Result<()> { - let mut stdout = io::stdout().into_raw_mode()?; - write!(stdout, "{}", cursor::Show)?; - Ok(()) +pub struct Menus { + pub(crate) stdout: BufWriter>, } - -pub fn select_menu + Clone>( - list: I, - title: impl Display, - prompt: impl Display, - quit: Option, -) -> io::Result> { - let mut stdout = BufWriter::new(io::stdout().lock().into_raw_mode()?); - let mut select_idx = 0; - let list_len = list.clone().count(); - let mut keys = io::stdin().lock().keys(); - write!(stdout, "{}\r\n", title)?; - let ret = loop { - for (i, selection) in list.clone().enumerate() { - if i == select_idx { - write!(stdout, "{} {}\r\n", prompt, selection.black().white_bg())?; - } else { - write!(stdout, "{}\r\n", selection.faint())?; - } +impl Menus { + pub fn new() -> Self { + Self { + stdout: BufWriter::new(io::stdout().into_raw_mode().unwrap()), } - stdout.flush()?; + } - let key = keys - .next() - .expect("keys() should block") - .expect("faulty keyboard?"); - write!( - stdout, - "\r{}{}", - cursor::Up(list_len as u16), - clear::AfterCursor - )?; - match key { - Key::Char('\n') => { - break Ok(Some(select_idx)); - } - Key::Up => select_idx = select_idx.saturating_sub(1), - Key::Down => { - if select_idx + 1 < list_len { - select_idx += 1; + pub fn cursor_hide(&mut self) -> io::Result<()> { + write!(self.stdout, "{}", cursor::Hide)?; + Ok(()) + } + + pub fn cursor_show(&mut self) -> io::Result<()> { + write!(self.stdout, "{}", cursor::Show)?; + Ok(()) + } + + pub fn select_menu + Clone>( + &mut self, + list: I, + title: impl Display, + prompt: impl Display, + quit: Option, + ) -> io::Result> { + let mut select_idx = 0; + let list_len = list.clone().count(); + let mut keys = io::stdin().lock().keys(); + write!(self.stdout, "{}\r\n", title)?; + let ret = loop { + for (i, selection) in list.clone().enumerate() { + if i == select_idx { + write!( + self.stdout, + "{} {}\r\n", + prompt, + selection.black().white_bg() + )?; + } else { + write!(self.stdout, "{}\r\n", selection.faint())?; } } - k if k == Key::Ctrl('c') || quit.is_some_and(|q| q == key) => { - break Ok(None); - } - _ => {} - } - }; - write!(stdout, "{}{}", cursor::Up(1), clear::CurrentLine)?; - stdout.flush()?; - ret -} + self.stdout.flush()?; -pub fn select_menu_with_input Vec, L: Display>( - lister: F, - prompt: impl Display, - input_prompt: &str, - quit: Option, -) -> io::Result> { - let mut stdout = BufWriter::new(io::stdout().lock().into_raw_mode()?); - let mut select_idx = 0; - let mut cursor = 0; - let mut input = String::new(); + let key = keys + .next() + .expect("keys() should block") + .expect("faulty keyboard?"); + write!( + self.stdout, + "\r{}{}", + cursor::Up(list_len as u16), + clear::AfterCursor + )?; + match key { + Key::Char('\n') => { + break Ok(Some(select_idx)); + } + Key::Up => select_idx = select_idx.saturating_sub(1), + Key::Down => { + if select_idx + 1 < list_len { + select_idx += 1; + } + } + k if k == Key::Ctrl('c') || quit.is_some_and(|q| q == key) => { + break Ok(None); + } + _ => {} + } + }; + write!(self.stdout, "{}{}", cursor::Up(1), clear::CurrentLine)?; + self.stdout.flush()?; + ret + } - let mut keys = io::stdin().lock().keys(); - let ret = loop { - write!( - stdout, - "\r{}{}{}", - clear::AfterCursor, - input_prompt.magenta(), - input, - )?; - let mut list = lister(&input); - let list_len = list.len(); + pub fn select_menu_with_input Vec, L: Display>( + &mut self, + lister: F, + prompt: impl Display, + input_prompt: &str, + quit: Option, + ) -> io::Result> { + let mut select_idx = 0; + let mut cursor = 0; + let mut input = String::new(); - select_idx = select_idx.min(list_len); - if list_len > 0 { - write!(stdout, "\r\n\n↑ and ↓ to navigate")?; - write!(stdout, "\n\rENTER to select\r\n")?; - } + let mut keys = io::stdin().lock().keys(); + let ret = loop { + write!( + self.stdout, + "\r{}{}{}", + clear::AfterCursor, + input_prompt.magenta(), + input, + )?; + let mut list = lister(&input); + let list_len = list.len(); - for (i, selection) in list.iter().enumerate() { - if i == select_idx { - write!(stdout, "{} {}\r\n", prompt, selection.black().white_bg())?; - } else { - write!(stdout, "{}\r\n", selection.faint())?; + select_idx = select_idx.min(list_len); + if list_len > 0 { + write!(self.stdout, "\r\n\n↑ and ↓ to navigate")?; + write!(self.stdout, "\n\rENTER to select\r\n")?; } - } - if list_len > 0 { - write!(stdout, "{}", cursor::Up(list_len as u16 + 4))?; - } - write!( - stdout, - "\r{}", - cursor::Right(input_prompt.len() as u16 + cursor as u16) - )?; - stdout.flush()?; - write!(stdout, "\r{}", clear::AfterCursor)?; - match keys - .next() - .expect("keys() should block") - .expect("faulty keyboard?") - { - Key::Char('\n') => { - break Ok(if list_len > select_idx { - Some(list.remove(select_idx)) + for (i, selection) in list.iter().enumerate() { + if i == select_idx { + write!( + self.stdout, + "{} {}\r\n", + prompt, + selection.black().white_bg() + )?; } else { - None - }); - } - Key::Up => select_idx = select_idx.saturating_sub(1), - Key::Down => { - if select_idx + 1 < list_len { - select_idx += 1; - } - } - Key::Backspace => { - if cursor > 0 { - cursor -= 1; - input.remove(cursor); + write!(self.stdout, "{}\r\n", selection.faint())?; } } - Key::Char(c) => { - input.insert(cursor, c); - cursor += 1; + if list_len > 0 { + write!(self.stdout, "{}", cursor::Up(list_len as u16 + 4))?; } - Key::Right => { - if cursor < input.len() { - cursor += 1 + write!( + self.stdout, + "\r{}", + cursor::Right(input_prompt.len() as u16 + cursor as u16) + )?; + self.stdout.flush()?; + write!(self.stdout, "\r{}", clear::AfterCursor)?; + + match keys + .next() + .expect("keys() should block") + .expect("faulty keyboard?") + { + Key::Char('\n') => { + break Ok(if list_len > select_idx { + Some(list.remove(select_idx)) + } else { + None + }); } + Key::Up => select_idx = select_idx.saturating_sub(1), + Key::Down => { + if select_idx + 1 < list_len { + select_idx += 1; + } + } + Key::Backspace => { + if cursor > 0 { + cursor -= 1; + input.remove(cursor); + } + } + Key::Char(c) => { + if c.is_ascii() { + input.insert(cursor, c); + cursor += 1; + } else { + write!( + self.stdout, + "{}{}{}{}\r", + cursor::Up(1), + clear::CurrentLine, + format_args!("Only ASCII characters"), + cursor::Down(1) + )?; + } + } + Key::Right => { + if cursor < input.len() { + cursor += 1 + } + } + Key::Left => cursor = cursor.saturating_sub(1), + k if k == Key::Ctrl('c') || quit.is_some_and(|q| q == k) => { + break Ok(None); + } + _ => {} } - Key::Left => cursor = cursor.saturating_sub(1), - k if k == Key::Ctrl('c') || quit.is_some_and(|q| q == k) => { - break Ok(None); - } - _ => {} - } - }; - stdout.flush()?; - ret -} - -pub enum SelectNumberedResp { - Index(usize), - UndefinedKey(Key), - Quit, -} - -pub fn select_menu_numbered + Clone>( - list: I, - quit: Key, - title: &str, -) -> io::Result { - let mut stdout = BufWriter::new(io::stdout().lock().into_raw_mode()?); - let list_len = list.clone().count(); - write!(stdout, "\r{title}\r\n")?; - for (i, s) in list.enumerate() { - write!(stdout, "{}. {}\r\n", (i + 1).green(), s)?; + }; + write!(self.stdout, "\r{}{}\r\n", cursor::Up(1), clear::AfterCursor)?; + self.stdout.flush()?; + ret } - write!(stdout, "{}. Quit\r\n", 'q'.green())?; - stdout.flush()?; - let key = io::stdin() - .lock() - .keys() - .next() - .expect("keys() should block") - .expect("faulty keyboard?"); - write!( - stdout, - "\r{}{}", - cursor::Up(list_len as u16 + 2), - clear::AfterCursor, - )?; - stdout.flush()?; - match key { - Key::Char(c) if c.to_digit(10).is_some_and(|c| c as usize <= list_len) => Ok( - SelectNumberedResp::Index(c.to_digit(10).unwrap() as usize - 1), - ), - k if k == Key::Ctrl('c') || k == quit => Ok(SelectNumberedResp::Quit), - k => Ok(SelectNumberedResp::UndefinedKey(k)), + + pub fn select_menu_numbered + Clone>( + &mut self, + list: I, + quit: Key, + title: &str, + ) -> io::Result { + let list_len = list.clone().count(); + write!(self.stdout, "\r{title}\r\n")?; + for (i, s) in list.enumerate() { + write!(self.stdout, "{}. {}\r\n", (i + 1).green(), s)?; + } + write!(self.stdout, "{}. Quit\r\n", 'q'.green())?; + self.stdout.flush()?; + let key = io::stdin() + .lock() + .keys() + .next() + .expect("keys() should block") + .expect("faulty keyboard?"); + write!( + self.stdout, + "\r{}{}", + cursor::Up(list_len as u16 + 2), + clear::AfterCursor, + )?; + self.stdout.flush()?; + match key { + Key::Char(c) if c.to_digit(10).is_some_and(|c| c as usize <= list_len) => Ok( + SelectNumberedResp::Index(c.to_digit(10).unwrap() as usize - 1), + ), + k if k == Key::Ctrl('c') || k == quit => Ok(SelectNumberedResp::Quit), + k => Ok(SelectNumberedResp::UndefinedKey(k)), + } } } diff --git a/magisk/customize.sh b/magisk/customize.sh index eec0227..fe30f21 100644 --- a/magisk/customize.sh +++ b/magisk/customize.sh @@ -1,13 +1,5 @@ #!/system/bin/sh -[ "$MAGISK_VER_CODE" -lt 26100 ] && { - ui_print - ui_print "*******************************" - ui_print "This module is only supported in Magisk >= v26.1" - ui_print "*******************************" - abort -} - mv -f "$MODPATH/system/bin/detach-${ARCH}" "$MODPATH/system/bin/detach" rm "$MODPATH"/system/bin/detach-* diff --git a/magisk/service.sh b/magisk/service.sh new file mode 100644 index 0000000..84fbb2a --- /dev/null +++ b/magisk/service.sh @@ -0,0 +1,3 @@ +#!/system/bin/sh +MODDIR=${0%/*} +[ -f "$MODDIR/detach.txt" ] && "$MODDIR"/system/bin/detach --serialize "$MODDIR/detach.txt" diff --git a/update.json b/update.json index a1d6484..8292209 100644 --- a/update.json +++ b/update.json @@ -1,6 +1,6 @@ { "version": "v1.5", "versionCode": 6, - "zipUrl": "https://github.com/kazimmt/zygisk-detach/releases/latest/download/zygisk-detach-v1.5.zip", - "changelog": "https://raw.githubusercontent.com/kazimmt/zygisk-detach/master/README.md" + "zipUrl": "https://github.com/j-hc/zygisk-detach/releases/latest/download/zygisk-detach-v1.5.zip", + "changelog": "https://raw.githubusercontent.com/j-hc/zygisk-detach/master/README.md" }