Files
backup_script/tools/tools.sh
Yawasau 05130a9898 feat: 流式備份/恢復、遠端預掃優化、增量邏輯強化
新功能
- 流式備份/恢復: remote_stream=1 支援 SMB+WebDAV 邊壓邊傳不佔本機空間
- 遠端預掃: 單次連線取全部列表 + 批量 json 下載 (120 往返→6 連線)
- 主循環增量比對零網路開銷 (讀本地快取)
- 最終計數核驗 verify_backup_manifest (三態顯示)
- conf 重構: smb_url/webdav_url 按協議分組,切換 remote_type 免重輸

修復
- 流式 staging 以遠端 json 快取為種子,解決權限/SSAID 無變化仍重備
- 上傳 json 前合併遠端版本,防 version 等欄位被部分覆蓋丟失
- 版本比對讀本地同步副本,修復流式模式每輪誤報版本變化
- 清單偵測版本更新強制重備 apk,防 json 被失敗輪汙染後 apk 缺檔
- apk 版本相符仍核對遠端檔存在性,污染數據自動自愈
- 流式失敗不傳 json,確保下輪整體重備不殘留壞數據
- smbclient cd 失敗停留根目錄導致列表/大小統計污染 → 前綴過濾
- WebDAV href URL 編碼改通用 %XX awk 查表解碼 (中文/空格/+ 全解)
- json 預掃改全量批量抓,不再靠列表交集 (修 smbclient 中文名轉碼毀名)
- 殺舊進程改單次 ps 快照整樹殺 (修 busybox ps 無 PPID / 誤殺自身)
- smbclient stdin 命令還原內嵌換行 (修中文多層路徑建目錄失敗)
- 連線失敗轉本地備份時補顯本地分區統計
- ms_to_readable 對永不休眠值特判顯示
- 單 app 備份入口補遠端預掃流程
- .changed_apps 去重、TMPDIR 殘留全清、tab 縮排統一

優化
- 遠端預掃: 120 連線→6 連線,主循環零網路開銷
- 殺舊進程: ~15 秒→< 1 秒 (逐 /proc 遞迴→單次 ps 快照 awk)
- 批量權限: JVM fork 3N→3
2026-06-13 12:29:18 +08:00

7423 lines
299 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/system/bin/sh
# ============================================================
# SpeedBackup tools.sh
# ============================================================
# 區塊索引:
# L30 ── conf 模板函數 (backup_settings / restore_settings)
# L286 ── 基礎工具函數 (echoRgb / jq_inplace / size / ...)
# L841 ── 遠端功能函數 (upload / download / smb / webdav)
# L2750 ── 系統初始化 (環境檢測 / 路徑 / 用戶 / 語言)
# L3266 ── 備份路徑 / 預掃 / app_details
# L3567 ── 備份核心函數 (Backup_apk / Backup_data / ...)
# L4509 ── backup() 主函數
# L5139 ── Restore() 主函數
# L5576 ── Getlist / Check_json / backup_media / wifi
# L5926 ── 主選單入口
# ============================================================
if [ "$(whoami)" != root ]; then
echo "你是憨批不給Root用你媽 爬"
exit 1
fi
_dex_debug=1
[[ -d /data/cache ]] && set -x 2> /data/cache/debug_output.log
shell_language="zh-TW"
MODDIR_NAME="${MODDIR##*/}"
tools_path="$MODDIR/tools"
script="${0##*/}"
backup_version="202606131225"
[[ $SHELL = *mt* ]] && echo "請勿使用MT管理器拓展包環境執行,請更換系統環境" && exit 2
# 產生 backup_settings.conf 的內容模板 (寫到 stdout)
# 透過重定向到檔案來生成或更新備份設定檔
update_backup_settings_conf() {
echo "#0關閉音量鍵選擇 (如選項未設置,則強制使用音量鍵選擇)
#1開啟音量鍵選擇 (如選項已設置,則跳過該選項提示)
#2使用鍵盤輸入適用於無音量鍵可用設備選擇 (如選項未設置,則強制使用鍵盤輸入)
Lo="${Lo:-0}"
#後台執行腳本
#0不能關閉當前終端有壓縮速率
#1終端有可能完全無顯示但是log會持續刷新可直接完全關閉終端
background_execution="${background_execution:-0}"
#腳本語言設置 留空則自動識別系統語言環境並翻譯
#1簡體中文 0繁體中文
Shell_LANG="$Shell_LANG"
#備份開始後偽裝亮屏
#1開啟 0關閉
setDisplayPowerMode="${setDisplayPowerMode:-0}"
#自定義備份文件輸出位置 支持相對路徑(留空則默認當前路徑)
Output_path=\""$Output_path"\"
#自定義備份目錄後綴(留空則不添加後綴)
#支持日期時間變量:%yyyymmdd %hhmmss %yyyymmddhhmmss %yyyy %mm %dd
#例_daily → Backup_zstd_0_daily
#例_%yyyymmdd → Backup_zstd_0_20260522
Backup_suffix=\""$Backup_suffix"\"
#自定義applist.txt位置 支持相對路徑(留空則默認當前路徑)
list_location=\""$list_location"\"
#自動更新腳本(留空強制選擇)
#1開啟 0關閉
update="${update:-1}"
#自動更新的cdn節點針對國內用戶使用無牆或是使用VPN請設置0
#0 直鏈下載
#1 https://ghfast.top
#2 https://shrill-pond-3e81.hunsh.workers.dev
cdn=${cdn:-1}
#自定義屏蔽外部掛載點 例OTG 虛擬SD等 多個掛載點請使用 | 區隔
#屏蔽後不會提示音量鍵選擇不影響Output_path指定外置存儲位置
mount_point=\""${mount_point:-rannki|0000-1}"\"
#使用者(如0 999等用戶如存在多個用戶留空強制選擇無多個用戶則默認用戶0不詢問)
user="$user"
#備份模式
#1包含數據+安裝包0僅包安裝包
#此選項設置1時Backup_obb_dataBackup_user_datablacklist_mode將可設置 0時Backup_user_dataBackup_obb_datablacklist_mode選項不生效
#此外設置0時將同時忽略appList.txt的!與任何黑名單設置(包括黑名單列表)
Backup_Mode="${Backup_Mode:-1}"
#是否備份使用者數據 (1備份 0不備份 留空強制選擇)
Backup_user_data="${Backup_user_data:-1}"
#是否備份外部數據 例:原神的數據包(1備份 0不備份 留空強制選擇)
Backup_obb_data="${Backup_obb_data:-1}"
#是否在應用數據備份完成後備份自定義目錄
#1開啟 0關閉
backup_media="${backup_media:-0}"
#存在進程忽略備份(1忽略0備份)
Background_apps_ignore="${Background_apps_ignore:-0}"
#添加自定義備份路徑 例Download DCIM等文件夾 請使用絕對路徑,請勿刪除\"\"
Custom_path=\""$Custom_path"\"
#黑名單模式(1完全忽略不備份 0僅備份安裝包注意此選項Backup_Mode=1時黑名單模式才能使用)
blacklist_mode="${blacklist_mode:-0}"
#備份黑名單(備份策略由「黑名單模式」控制,此處只作為黑名單應用列表)
blacklist=\""${blacklist:-
#com.esunbank
#com.chailease.tw.app.android.ccfappcust}"\"
#位於data的預裝應用白名單 例:相冊 錄音機 天氣 計算器等(默認屏蔽備份預裝應用,如需備份請添加預裝應用白名單)
whitelist=\""${whitelist:-
com.xiaomi.xmsf
com.xiaomi.xiaoailite
com.xiaomi.hm.health
com.duokan.phone.remotecontroller
com.miui.weather2
com.milink.service
com.android.soundrecorder
com.miui.virtualsim
com.xiaomi.vipaccount
com.miui.fm
com.xiaomi.shop
com.xiaomi.smarthome
com.miui.notes
com.xiaomi.router
com.xiaomi.mico
dev.miuiicons.pedroz}"\"
#可被備份的系統應用白名單(默認屏蔽備份系統應用,如需備份請添加系統應用白名單)
system=\""${system:-
com.google.android.calendar
com.google.android.gm
com.google.android.googlequicksearchbox
com.google.android.tts
com.google.android.apps.maps
com.google.android.apps.messaging
com.google.android.inputmethod.latin
com.instagram.android
com.facebook.orca
sh.siava.AOSPMods
com.facebook.katana
com.android.chrome}"\"
#壓縮算法(可用zstd tartar為僅打包 有什麼好用的壓縮算法請聯系我
#zstd擁有良好的壓縮率與速度
Compression_method=${Compression_method:-zstd}
#色彩設定 (256 色 ANSI 編號)
#常用值: 39藍 51青 82綠 196紅 208橘 213粉 220黃 165紫
#主色 (一般資訊, 預設亮黃)
rgb_a="${rgb_a:-220}"
#輔色1 (提示/進度, 預設亮青)
rgb_b="${rgb_b:-51}"
#輔色2 (強調/變數值, 預設粉紅)
rgb_c="${rgb_c:-213}"
#遠程備份類型 (留空不啟用)
#推薦 webdav (穩定)
#smb 支援 SMB2/SMB3 (本腳本拒絕 SMB1/CIFS, 會自動協商到伺服器支援的最高版本)
remote_type="${remote_type:-}"
# 保存原始值, 供連線失敗後 (如中途開 WiFi) 重新檢測用
_remote_type_orig="$remote_type"
#遠程地址 (兩種協議分開設定, 切換 remote_type 免重輸)
#SMB例: smb://192.168.1.100/backup/
smb_url="${smb_url:-}"
#認證用戶名
smb_remote_user="${smb_remote_user:-}"
#認證密碼
smb_remote_pass=\""$smb_remote_pass"\"
#WebDAV例: http://192.168.1.100:8080/dav/
webdav_url="${webdav_url:-}"
#認證用戶名
webdav_remote_user="${webdav_remote_user:-}"
#認證密碼
webdav_remote_pass=\""$webdav_remote_pass"\"
#流式上傳 (邊壓邊傳, 不佔本機空間)
#1 開啟流式: 數據直接壓縮→管道傳到遠端, 本機不留 tar (省空間, 全量上傳, 不做本機校驗/增量)
#0 關閉(預設): 先壓到本機→校驗→再上傳 (保留本機檔案, 支援增量)
#支援 smb / webdav 兩種 remote_type
remote_stream="${remote_stream:-0}"
#流式上傳除錯 (1=失敗時印出 smbclient/curl 的具體錯誤, 用於排查流式失敗原因)
_stream_debug="${_stream_debug:-0}"
#遠程備份完成後是否保留本地檔案
#1保留本地檔案(上傳後不刪除) 0上傳成功後刪除本地檔案
remote_keep_local="${remote_keep_local:-0}"
#邊備份邊上傳 (每備份完一個應用立即上傳,然後刪除本機檔案再備份下一個,以節省本機空間)
#1 開啟 0 關閉
#開啟後:每個應用備份完成 → 立即上傳遠端 → 上傳成功後刪除本機檔案 → 繼續備份下一個
#關閉後:先備份所有應用 → 全部備份完再統一上傳
remote_upload_per_app="${remote_upload_per_app:-0}"
#log 目錄大小上限 (單位 MB), 達到上限會在啟動時自動清空 log/
#留空或設 0 = 關閉自動清理
log_max_size_mb="${log_max_size_mb:-}"
" | sed '
/^Custom_path/ s/ /\n/g;
/^blacklist/ s/ /\n/g;
/^whitelist/ s/ /\n/g;
/^system/ s/ /\n/g;
/^am_start/ s/ /\n/g;
s/true/1/g;
s/false/0/g'
}
# 產生 restore_settings.conf 的內容模板 (寫到 stdout)
# 備份完成時呼叫此函數寫入備份目錄,讓恢復端有獨立的設定檔
update_Restore_settings_conf() {
echo "#0關閉音量鍵選擇 (如選項未設置,則強制使用音量鍵選擇)
#1開啟音量鍵選擇 (如選項已設置,則跳過該選項提示)
#2使用鍵盤輸入適用於無音量鍵可用設備選擇 (如選項未設置,則強制使用鍵盤輸入)
Lo="${Lo:-0}"
#後台執行腳本
#0不能關閉當前終端有壓縮速率
#1終端有可能完全無顯示但是log會持續刷新可直接完全關閉終端
background_execution="${background_execution:-0}"
#恢復開始後偽裝亮屏
#1開啟 0關閉
setDisplayPowerMode="${setDisplayPowerMode:-0}"
#腳本語言設置 為空自動針對當前系統語言環境自動翻譯
#1簡體中文 0繁體中文
Shell_LANG="$Shell_LANG"
#自動更新腳本(留空強制選擇)
update="${update:-1}"
#自動更新的cdn節點針對國內用戶使用無牆或是使用VPN請設置0
#0 直鏈下載
#1 https://ghfast.top
#2 https://shrill-pond-3e81.hunsh.workers.dev
cdn=${cdn:-1}
#恢復模式(1恢復未安裝應用 0全恢復)
recovery_mode="${recovery_mode:-0}"
#恢復資料夾
media_recovery="${media_recovery:-0}"
#存在進程忽略恢復(1忽略0恢復)
Background_apps_ignore="${Background_apps_ignore:-0}"
#使用者(如0 999等用戶留空如存在多個用戶強制音量鍵選擇無多用戶則默認0不詢問)
user="$user"
#log 目錄大小上限 (單位 MB), 達到上限會在啟動時自動清空 log/
#留空或設 0 = 關閉自動清理
log_max_size_mb="${log_max_size_mb:-}"
#色彩設定 (256 色 ANSI 編號)
#常用值: 39藍 51青 82綠 196紅 208橘 213粉 220黃 165紫
#主色 (一般資訊, 預設亮黃)
rgb_a="${rgb_a:-220}"
#輔色1 (提示/進度, 預設亮青)
rgb_b="${rgb_b:-51}"
#輔色2 (強調/變數值, 預設粉紅)
rgb_c="${rgb_c:-213}"" | sed 's/true/1/g ; s/false/0/g'
}
if [[ ! -d $tools_path ]]; then
tools_path="${MODDIR%/*}/tools"
[[ ! -d $tools_path ]] && echo "$tools_path二進制目錄遺失" && EXIT="true"
fi
# 根據當前 conf_path 判斷類型,觸發對應模板重新寫入
# 用於腳本版本升級時自動補齊新增的設定欄位
_update_conf() {
case $conf_path in
*backup_settings.conf) update_backup_settings_conf>"$conf_path" ;;
*restore_settings.conf) update_Restore_settings_conf>"$conf_path" ;;
*) echo "$conf_path配置遺失" && exit 1 ;;
esac
}
if [[ ! -f $conf_path ]]; then
_update_conf
echo "因腳本找不到\n$conf_path\n故重新生成默認列表\n請重新配置後重新執行腳本" && exit 0
fi
[[ ! -f $conf_path ]] && echo "$conf_path遺失" && exit 2
. "$conf_path" &>/dev/null
_update_conf
# 依 remote_type 取對應遠端位址/帳密 (smb_*/webdav_* 由 conf 設定)
case $remote_type in
smb) remote_url="$smb_url"; remote_user="$smb_remote_user"; remote_pass="$smb_remote_pass" ;;
webdav) remote_url="$webdav_url"; remote_user="$webdav_remote_user"; remote_pass="$webdav_remote_pass" ;;
*) remote_url=""; remote_user=""; remote_pass="" ;;
esac
case $Shell_LANG in
1) LANG="CN" ;;
0) LANG="TW" ;;
*)
_l="$(settings get system system_locales 2>/dev/null | head -1)"
[[ -z $_l || $_l = null ]] && _l="$(getprop persist.sys.locale)"
case $_l in
zh-Hant*|zh_Hant*|zh-TW*|zh-HK*|zh-MO*) LANG="TW" ;;
zh-Hans*|zh_Hans*|zh-CN*|zh-SG*|zh*) LANG="CN" ;;
esac
;;
esac
# ======================================================
# 基礎工具函數
# ======================================================
# 帶色彩輸出, 用法: echoRgb "訊息" [色碼]
# 色碼:
# 0 = 紅色 (197) - 錯誤/警告
# 1 = 亮綠 (121) - 成功
# 2 = rgb_c (213) - 強調/變數值 (粉紅, 預設)
# 3 = rgb_b (51) - 提示/進度 (亮青, 預設)
# 其他/省略 = rgb_a (220) - 一般資訊 (亮黃, 預設)
# rgb_a/b/c 可在 conf 自訂, 全部都是 256 色 ANSI 編號
echoRgb() {
local color
case $2 in
0) color=197 ;;
1) color=121 ;;
2) color=$rgb_c ;;
3) color=$rgb_b ;;
*) color=$rgb_a ;;
esac
echo -e "\e[38;5;${color}m -$1\e[0m"
}
# JSON 原地更新 helper (取代 jq...>tmp...cat>...rm 模式)
# 用法: jq_inplace <檔案> <jq 表達式> [額外參數...]
# 例: jq_inplace "$app_details" --arg k "key" '.[$k] = "value"'
# 注意: 用 cat 寫回而不是 mv, 因為 Android 跨檔案系統 mv 會嘗試 setfilecon
# (sdcard 不支援會印 "Operation not supported on transport endpoint" 錯誤)
jq_inplace() {
local file="$1"; shift
local tmp="$TMPDIR/.jq_$$"
if jq "$@" "$file" > "$tmp"; then
cat "$tmp" > "$file"
rm -f "$tmp"
else
rm -f "$tmp"
return 1
fi
}
# 計算目錄總大小 (bytes), 純文件字節和 (對應電腦端「大小」)
# 用法: calc_dir_size <目錄路徑>
# 依功能類型顯示相關 conf 設定
# 遠端狀態片段 (供備份類附加顯示); 有啟用才顯示細節
remote_conf_line() {
if [[ -n $remote_type ]]; then
if [[ $remote_stream = 1 ]]; then
echo "\n -遠端上傳:$remote_type ($remote_url)\n -流式上傳:開啟 (不佔本機)"
else
echo "\n -遠端上傳:$remote_type ($remote_url)\n -保留本地檔:$remote_keep_local"
fi
else
echo "\n -遠端上傳:未啟用"
fi
}
show_conf() {
case $1 in
backup)
echoRgb "配置詳細:\n -壓縮方式:$Compression_method\n -音量鍵確認:$Lo\n -更新:$update\n -備份模式:$Backup_Mode\n -備份外部數據:$Backup_obb_data\n -備份user數據:$Backup_user_data\n -黑名單模式:$blacklist_mode\n -黑名單:$(awk '!/[#]/ && NF' <<< "$blacklist" | grep -c . 2>/dev/null)個\n -白名單:$(awk '!/[#]/ && NF' <<< "$whitelist" | grep -c . 2>/dev/null)個\n -自定義目錄備份:$backup_media\n -存在進程忽略備份:$Background_apps_ignore\n -關閉螢幕:$setDisplayPowerMode$(remote_conf_line)" ;;
media)
echoRgb "配置詳細:\n -壓縮方式:$Compression_method\n -音量鍵確認:$Lo\n -自定義路徑:$Custom_path\n -關閉螢幕:$setDisplayPowerMode$(remote_conf_line)" ;;
wifi)
echoRgb "配置詳細:\n -音量鍵確認:$Lo\n -關閉螢幕:$setDisplayPowerMode$(remote_conf_line)" ;;
remote)
echoRgb "配置詳細:\n -音量鍵確認:$Lo\n -遠端類型:${remote_type:-未設定}\n -遠端位址:${remote_url:-未設定}\n -保留本地檔:$remote_keep_local" ;;
restore)
echoRgb "配置詳細:\n -音量鍵確認:$Lo\n -壓縮方式:$Compression_method\n -關閉螢幕:$setDisplayPowerMode" ;;
esac
}
calc_dir_size() {
# 純文件字節總和 (對應電腦端「大小」, 不含目錄項佔用); 單一 find 進程
find "$1" -type f -printf '%s\n' 2>/dev/null | awk '{s+=$1}END{print s+0}'
}
# 查預掃的目錄大小表 (.dir_sizes, 由 prepare_dir_size_map 並行算好)
# 用法: _dir_size <pkg> <type> <目錄路徑> — 表中有則秒回, 無則現算(兜底)
_dir_size() {
local _p="$1" _t="$2" _path="$3" _vn
_DIR_SIZE_RET=""
# 零 fork 組變量名 (內建展開轉義)
_vn="_sz_${_p//[!a-zA-Z0-9]/_}_${_t//[!a-zA-Z0-9]/_}"
# 防呆: 變量名只含合法字元才 eval (避免殘留 . 等造成 bad substitution); 否則現算
case $_vn in
*[!a-zA-Z0-9_]*) ;; # 含非法字元 → 跳過查表, 走 fallback
*) eval "_DIR_SIZE_RET=\${$_vn:-}" 2>/dev/null ;;
esac
[[ -n $_DIR_SIZE_RET ]] && return
_DIR_SIZE_RET="$(calc_dir_size "$_path")"
}
# 把 .dir_sizes 一次載入成動態變量 _sz_<pkg轉義>_<type>=size (取代每次 fork awk, 手機 fork 成本高)
load_dir_size_map() {
[[ ! -f $TMPDIR/.dir_sizes ]] && return
local _pk _ty _sz _vn
while IFS=$'\t' read -r _pk _ty _sz; do
[[ -z $_pk ]] && continue
_vn="_sz_${_pk//[!a-zA-Z0-9]/_}_$_ty"
eval "$_vn=\$_sz"
done < "$TMPDIR/.dir_sizes"
}
# 通用: 把 pkg<TAB>value 表載入成動態變量 <prefix>_<pkg轉義>=value (零 fork 查詢)
# 用法: load_kv_map <檔案> <變量前綴>
load_kv_map() {
[[ ! -f $1 ]] && return
local _pk _val _vn _pfx="$2"
while IFS=$'\t' read -r _pk _val; do
[[ -z $_pk ]] && continue
_vn="${_pfx}_${_pk//[!a-zA-Z0-9]/_}"
eval "$_vn=\$_val"
done < "$1"
}
# 通用查詢: _kv_get <變量前綴> <pkg> → 印出值 (零 fork)
_kv_get() {
local _vn="$1_${2//[!a-zA-Z0-9]/_}" _v
eval "_v=\${$_vn}"
echo "$_v"
}
# 壓縮 helper (取代散落各處的 tar/zstd case 分支)
# 用法 1 (目錄打包): tar_compress_dir <輸出檔基礎名> <切到目錄> <要打包名> [tar 額外參數...]
# 例: tar_compress_dir "$folder/user" "${dp%/*}" "${dp##*/}" --exclude=cache
# 用法 2 (glob 打包): tar_compress_glob <輸出檔基礎名> <切到目錄> <glob 模式>
# 例: tar_compress_glob "$folder/apk" "$apk_path2" "*.apk"
# 自動依 $Compression_method 決定輸出 .tar 還是 .tar.zst
tar_compress_dir() {
local out_base="$1" cd_to="$2" pack_name="$3"
shift 3
# 流式模式 (remote_stream=1): 直接管道到遠端, 不寫本機 (省空間)
# _STREAM_DEST 由呼叫端設為遠端目標目錄 (相對遠端根)
if [[ $remote_stream = 1 && -n $_STREAM_DEST ]]; then
echoRgb "流式傳輸中 (邊壓邊傳, 不佔本機)..." "3" >&2
local _rb="$_STREAM_DEST/${out_base##*/}"
[[ ! -d ${out_base%/*} ]] && mkdir -p "${out_base%/*}" 2>/dev/null
case $Compression_method in
tar|Tar|TAR)
tar --checkpoint-action="ttyout=%T\r" --warning=no-file-changed \
"$@" -cpf - -C "$cd_to" "$pack_name" | _stream_upload "$_rb.tar"
;;
zstd|Zstd|ZSTD)
tar --checkpoint-action="ttyout=%T\r" --warning=no-file-changed \
"$@" -cpf - -C "$cd_to" "$pack_name" | \
zstd --ultra -3 -T0 -q --priority=rt | _stream_upload "$_rb.tar.zst"
;;
esac
result=$?
if [[ $result != 0 ]]; then
echoRgb "流式上傳失敗 ($_rb) 遠端可能未寫入完整, 建議重試" "0" >&2
echo "${_rb%%/*}" >> "$TMPDIR/.stream_failed"
else
_manifest_add "$_rb"
fi
return $result
fi
case $Compression_method in
tar|Tar|TAR)
tar --checkpoint-action="ttyout=%T\r" --warning=no-file-changed \
"$@" -cpf "$out_base.tar" -C "$cd_to" "$pack_name"
;;
zstd|Zstd|ZSTD)
tar --checkpoint-action="ttyout=%T\r" --warning=no-file-changed \
"$@" -cpf - -C "$cd_to" "$pack_name" | \
zstd --ultra -3 -T0 -q --priority=rt > "$out_base.tar.zst"
;;
esac
result=$?
chmod 0777 "$out_base.tar" "$out_base.tar.zst" 2>/dev/null
[[ $result = 0 ]] && _manifest_add "${out_base#$Backup/}"
return $result
}
# 記錄本次成功備份的檔案 (相對路徑不含副檔名, 例 1DM+/apk), 供結尾計數核驗
_manifest_add() {
[[ -z $1 ]] && return
if ! awk -v p="$1" '$0==p{f=1} END{exit !f}' "$TMPDIR/.backup_manifest" 2>/dev/null; then
echo "$1" >> "$TMPDIR/.backup_manifest"
fi
}
# 最終檔案計數核驗: 本次備份的檔案逐一確認存在 (本地 [[ -f ]] / 遠端流式下載驗證), 顯示數量
verify_backup_manifest() {
[[ ! -s $TMPDIR/.backup_manifest ]] && return
local _mf="$TMPDIR/.backup_manifest" _ext _expect _found=0 _miss=""
case $Compression_method in
zstd|Zstd|ZSTD) _ext=".tar.zst" ;;
*) _ext=".tar" ;;
esac
_expect="$(grep -vc '^$' "$_mf")"
echoRgb "—————— 最終檔案計數核驗 ——————" "3"
local _remote_chk=0
if [[ $remote_stream = 1 ]]; then
_remote_chk=1
elif [[ -n $remote_type && $remote_keep_local != true ]]; then
_remote_chk=1
fi
if [[ $_remote_chk = 1 ]]; then
# 遠端核驗: 重抓一次遠端列表 (單連線), 逐項比對存在性
echoRgb " -核驗遠端檔案 (單次列表)..." "3"
local _vlist="$TMPDIR/.verify_files"
remote_list_files "$(get_backup_dirname)" > "$_vlist" 2>/dev/null
local _rel _head
while read -r _rel; do
[[ -z $_rel ]] && continue
if ! awk -v a="$_rel$_ext" -v b="$_rel.tar" '$0==a||$0==b{f=1;exit} END{exit !f}' "$_vlist" 2>/dev/null; then
# 列表沒找到: 單檔下載開頭再確認一次 (smbclient 列表對中文名轉碼毀名, 避免誤報)
_head="$(_stream_download "$(get_backup_dirname)/$_rel$_ext" 2>/dev/null | head -c 60)"
case $_head in
""|*NT_STATUS*) _miss="$_miss$_rel$_ext\n" ;;
esac
fi
done <<EOF3
$(cat "$_mf")
EOF3
_miss="$(echo -e "$_miss" | grep -v '^$')"
rm -f "$_vlist"
else
# 本地核驗
local _rel
while read -r _rel; do
[[ -z $_rel ]] && continue
if [[ ! -f $Backup/$_rel.tar.zst && ! -f $Backup/$_rel.tar ]]; then
_miss="$_miss$_rel$_ext\n"
fi
done <<EOF3
$(cat "$_mf")
EOF3
_miss="$(echo -e "$_miss" | grep -v '^$')"
fi
local _misscnt
_misscnt="$(echo "$_miss" | grep -vc '^$')"
_found=$((_expect - _misscnt))
if [[ $_misscnt -eq 0 ]]; then
echoRgb " ✅ 應有 $_expect 個檔案, 實際存在 $_expect" "1"
else
echoRgb " ⚠️ 應有 $_expect 個檔案, 實際存在 $_found 個, 缺失 $_misscnt 個:" "0"
echo "$_miss" | while read -r _m; do [[ -n $_m ]] && echoRgb " - $_m" "0"; done
fi
rm -f "$_mf"
}
tar_compress_glob() {
local out_base="$1" cd_to="$2" pattern="$3"
# 流式模式
if [[ $remote_stream = 1 && -n $_STREAM_DEST ]]; then
echoRgb "流式傳輸中 (邊壓邊傳, 不佔本機)..." "3" >&2
local _rb="$_STREAM_DEST/${out_base##*/}"
[[ ! -d ${out_base%/*} ]] && mkdir -p "${out_base%/*}" 2>/dev/null
(
cd "$cd_to" || return 1
case $Compression_method in
tar|Tar|TAR)
tar --checkpoint-action="ttyout=%T\r" -cf - $pattern | _stream_upload "$_rb.tar"
;;
zstd|Zstd|ZSTD)
tar --checkpoint-action="ttyout=%T\r" -cf - $pattern | \
zstd --ultra -3 -T0 -q --priority=rt | _stream_upload "$_rb.tar.zst"
;;
esac
)
result=$?
if [[ $result != 0 ]]; then
echoRgb "流式上傳失敗 ($_rb) 遠端可能未寫入完整, 建議重試" "0" >&2
echo "${_rb%%/*}" >> "$TMPDIR/.stream_failed"
else
_manifest_add "$_rb"
fi
return $result
fi
(
cd "$cd_to" || return 1
case $Compression_method in
tar|Tar|TAR)
tar --checkpoint-action="ttyout=%T\r" -cf "$out_base.tar" $pattern
;;
zstd|Zstd|ZSTD)
tar --checkpoint-action="ttyout=%T\r" -cf - $pattern | \
zstd --ultra -3 -T0 -q --priority=rt > "$out_base.tar.zst"
;;
esac
)
result=$?
chmod 0777 "$out_base.tar" "$out_base.tar.zst" 2>/dev/null
[[ $result = 0 ]] && _manifest_add "${out_base#$Backup/}"
return $result
}
rgb_a="${rgb_a:=220}"
abi="$(getprop ro.product.cpu.abi)"
sdk="$(getprop ro.build.version.sdk)"
release="$(getprop ro.build.version.release)"
case $abi in
arm64*)
if [[ $sdk -lt 24 ]]; then
echoRgb "設備Android ${release}版本過低 請升級至Android 8+" "0"
exit 1
else
case $sdk in
26|27|28)
echoRgb "設備Android ${release}版本偏低,無法確定腳本能正確的使用" "0"
;;
esac
fi
;;
*)
echoRgb "未知的架構: $abi" "0"
exit 1
;;
esac
get_mv="$(which mv)"
PATH="/system/bin:/system/xbin:/data/adb/ksu/bin:/sbin/.magisk/busybox:/sbin/.magisk:/sbin:/system_ext/bin:/vendor/bin:/vendor/xbin:/data/data/com.omarea.vtools/files/toolkit:/data/user/0/com.termux/files/usr/bin"
# 先查 magisk 二進制是否存在, 避免直接呼叫導致 libc 雜訊 (小米系統 vendor 屬性權限警告)
if command -v magisk >/dev/null 2>&1; then
_magisk_path="$(magisk --path 2>/dev/null)"
if [[ -d $_magisk_path ]]; then
PATH="$_magisk_path/.magisk/busybox:$PATH"
fi
elif ! command -v ksud >/dev/null 2>&1; then
echo "Magisk busybox Path does not exist"
fi
export PATH="$PATH"
filepath="/data/backup_tools"
busybox="$filepath/busybox"
busybox2="$tools_path/busybox"
#排除自身
exclude="
update
soc.json
classes.dex
Device_List"
if [[ ! -d $filepath ]]; then
mkdir -p "$filepath"
[[ $? = 0 ]] && echoRgb "設置busybox環境中"
fi
#刪除無效軟連結
find -L "$filepath" -maxdepth 1 -type l -exec rm -rf {} \;
if [[ -f $busybox && -f $busybox2 ]]; then
filesha256="$(sha256sum "$busybox" | cut -d" " -f1)"
filesha256_1="$(sha256sum "$busybox2" | cut -d" " -f1)"
if [[ $filesha256 != $filesha256_1 ]]; then
echoRgb "busybox sha256不一致 重新創立環境中"
rm -rf "$filepath"/*
fi
fi
find "$tools_path" -maxdepth 1 ! -path "$tools_path/tools.sh" -type f | grep -Ev "$(echo $exclude | sed 's/ /\|/g')" | while read -r; do
File_name="${REPLY##*/}"
if [[ ! -f $filepath/$File_name ]]; then
cp -r "$REPLY" "$filepath"
chmod 0777 "$filepath/$File_name"
echoRgb "$File_name > $filepath/$File_name"
else
filesha256="$(sha256sum "$filepath/$File_name" | cut -d" " -f1)"
filesha256_1="$(sha256sum "$tools_path/$File_name" | cut -d" " -f1)"
if [[ $filesha256 != $filesha256_1 ]]; then
echoRgb "$File_name sha256不一致 重新創建"
cp -r "$REPLY" "$filepath"
chmod 0777 "$filepath/$File_name"
echoRgb "$File_name > $filepath/$File_name"
fi
fi
done
if [[ -f $busybox ]]; then
"$busybox" --list | while read -r; do
if [[ $REPLY != tar && $REPLY != bc && ! -f $filepath/$REPLY ]]; then
ln -fs "$busybox" "$filepath/$REPLY"
fi
done
fi
[[ ! -f $filepath/zstd ]] && echoRgb "$filepath缺少zstd" && exit 2
export PATH="$filepath:$PATH"
export TZ=Asia/Taipei
ln -fs "$tools_path/classes.dex" "$filepath/classes.dex"
export CLASSPATH="$filepath/classes.dex"
quit=0
while read -r file expected_hash; do
if [[ -f $tools_path/$file ]]; then
computed_hash="$(sha256sum "$tools_path/$file" | awk '{print $1}')"
if [[ $computed_hash = $expected_hash ]]; then
echoRgb "$file: 驗證通過"
else
echoRgb "$tools_path/$file: SHA-256 不一致\n -\"$computed_hash\""
# smbclient/curl 不一致 → 只是不能用遠端, 不致命
case $file in
smbclient|curl) ;;
*) quit=2; break ;;
esac
fi
else
# smbclient/curl 缺失 → 只是不能用遠端, 不致命
case $file in
smbclient)
echoRgb "⚠️ 檔案 $tools_path/$file 不存在 (僅影響 SMB 遠端備份)" "0"
;;
curl)
echoRgb "⚠️ 檔案 $tools_path/$file 不存在 (僅影響 WebDAV 遠端備份)" "0"
;;
*)
echoRgb "⚠️ 檔案 $tools_path/$file 不存在"
quit=1
break
;;
esac
fi
done <<EOF
zstd 9ef4b54148699c9874cfd45aaf38e5cc950e5d168afdcf2edf58a2463f5561ed
tar 882639ac310a7eb4052c68c21cea02633307700f9cc8c7c469c2dd18d734a112
classes.dex 7308f8d1499179b6248c8279ce5cb41380c453e0b86e3161e2124a5e481f0557
busybox 4d60ab3f5a59ebb2ca863f2f514e6924401b581e9b64f602665c008177626651
find 7fa812e58aafa29679cf8b50fc617ecf9fec2cfb2e06ea491e0a2d6bf79b903b
jq 6bc62f25981328edd3cfcfe6fe51b073f2d7e7710d7ef7fcdac28d4e384fc3d4
keycheck 50645ee0e0d2a7d64fb4a1286446df7a4445f3d11aefd49eeeb88515b314c363
cmd 08da8ac23b6e99788fd3ce6c19c7b5a083b2ad48be35963a48d01d6ee7f3bb6d
smbclient 0fe8aa0abcf2ab81387d25dfb4a47369925e475bcf0c32acc9846753775ec35e
curl c78079c0239f0a6c44aa7e9180f97d4c3d175495d1ccf565a8854abd15f68b60
EOF
# log 目錄超過上限就清空 (避免長期累積佔空間)
# 上限由 conf 的 log_max_size_mb 控制 (預設 2MB, 0=關閉)
# 清理範圍:
# - ${logfile%/*}/ (主腳本)
# - $MODDIR/Backup_*/log/ (備份模式)
# - $MODDIR/Backup_*/*/log/ (子目錄)
# - ${logfile%/*}/ (恢復模式, MODDIR 是 Backup_zstd_X)
cleanup_log_if_oversize() {
# conf 沒設置 (空值) 也不清, 只有明確設正整數才啟用
local max="$log_max_size_mb"
[[ -z $max || $max = 0 ]] && return 0
case $max in
*[!0-9]*) return 0 ;; # 非純數字直接跳過
esac
local max_kb=$((max * 1024))
local d size_kb
for d in "$MODDIR/log" "$MODDIR"/Backup_*/log "$MODDIR"/Backup_*/*/log; do
[[ ! -d $d ]] && continue
[[ -z $(ls -A "$d" 2>/dev/null) ]] && continue
size_kb=$(du -sk "$d" 2>/dev/null | awk '{print $1}')
if [[ ${size_kb:-0} -ge $max_kb ]]; then
rm -rf "$d"/*
echoRgb "log 目錄 $d 超過 ${max}MB, 已清空" "3"
fi
done
}
# 打印 tools 目錄內所有二進制版本到 log/tools_version.log
# 啟動時跑一次, 方便除錯時知道用戶用什麼版本工具
print_tools_version() {
local _ver_log="${logfile%/*}/tools_version.log"
mkdir -p "${logfile%/*}" 2>/dev/null
{
echo "===== Tools version on $(date '+%Y-%m-%d %H:%M:%S') ====="
echo "abi=$abi sdk=$sdk release=$release"
echo ""
# zstd
which zstd >/dev/null 2>&1 && {
echo "[zstd]"
zstd --version 2>&1 | head -2
echo ""
}
# tar
which tar >/dev/null 2>&1 && {
echo "[tar]"
tar --version 2>&1 | head -2
echo ""
}
# busybox
which busybox >/dev/null 2>&1 && {
echo "[busybox]"
busybox 2>&1 | head -1
echo ""
}
# jq
which jq >/dev/null 2>&1 && {
echo "[jq]"
jq --version 2>&1
echo ""
}
# find
which find >/dev/null 2>&1 && {
echo "[find]"
find --version 2>&1 | head -1
echo ""
}
# curl
which curl >/dev/null 2>&1 && {
echo "[curl]"
curl --version 2>&1 | head -3
echo ""
}
# smbclient
which smbclient >/dev/null 2>&1 && {
echo "[smbclient]"
smbclient --version 2>&1 | head -1
echo ""
}
# keycheck (沒 --version, 記 sha256)
which keycheck >/dev/null 2>&1 && {
echo "[keycheck]"
echo "sha256: $(sha256sum "$(which keycheck)" 2>/dev/null | awk '{print $1}')"
echo ""
}
# classes.dex
[[ -f $tools_path/classes.dex ]] && {
echo "[classes.dex]"
echo "sha256: $(sha256sum "$tools_path/classes.dex" 2>/dev/null | awk '{print $1}')"
echo ""
}
# script 自己版本
echo "[backup_script]"
echo "backup_version=$backup_version"
} > "$_ver_log" 2>&1
echoRgb "工具版本已記錄: $_ver_log" "2"
}
if [[ $background_execution = 1 || $setDisplayPowerMode = 1 ]]; then
notification() { app_process /system/bin com.xayah.dex.NotificationUtil notify -t 'SpeedBackup' "$@"; }
else
notification() { :; }
fi
if [[ $quit -ne 0 ]]; then
exit "$quit"
fi
cleanup_log_if_oversize
print_tools_version
# Logo
echo -e "\e[38;5;51m"
cat <<'LOGO'
░██████╗██████╗░███████╗███████╗██████╗░
██╔════╝██╔══██╗██╔════╝██╔════╝██╔══██╗
╚█████╗░██████╔╝█████╗░░█████╗░░██║░░██║
░╚═══██╗██╔═══╝░██╔══╝░░██╔══╝░░██║░░██║
██████╔╝██║░░░░░███████╗███████╗██████╔╝
╚═════╝░╚═╝░░░░░╚══════╝╚══════╝╚═════╝░
██████╗░░█████╗░░█████╗░██╗░░██╗██╗░░░██╗██████╗░
██╔══██╗██╔══██╗██╔══██╗██║░██╔╝██║░░░██║██╔══██╗
██████╦╝███████║██║░░╚═╝█████═╝░██║░░░██║██████╔╝
██╔══██╗██╔══██║██║░░██╗██╔═██╗░██║░░░██║██╔═══╝░
██████╦╝██║░░██║╚█████╔╝██║░╚██╗╚██████╔╝██║░░░░░
╚═════╝░╚═╝░░╚═╝░╚════╝░╚═╝░░╚═╝░╚═════╝░╚═╝░░░░░
LOGO
echo -e "\e[38;5;213m » RESTORE // SYNC «\e[0m"
sleep 1 && clear
TMPDIR="/data/local/tmp"
[[ -n $TMPDIR ]] && rm -rf "$TMPDIR"/* 2>/dev/null
[[ ! -d $TMPDIR ]] && mkdir "$TMPDIR"
chmod 771 "$TMPDIR"
chown '2000:2000' "$TMPDIR"
# DNS 解析: 解決部分 ARM curl 二進位檔域名解析失敗 (尤其網盤 WebDAV)
# 用法: _dns_resolve "host.example.com" → 輸出 IP 或空字串
# 快取放在 $TMPDIR/.dns_cache, 格式: <host><TAB><ip>
_dns_resolve() {
local host="$1"
# 若已是 IP 直接返回
case $host in
*[!0-9.]*) ;;
*) echo "$host"; return 0 ;;
esac
# 查快取 (mksh 兼容: 用檔案而非 here-string)
if [[ -f $TMPDIR/.dns_cache ]]; then
local _cached
_cached=$(awk -v h="$host" -F'\t' '$1 == h {print $2; exit}' "$TMPDIR/.dns_cache" 2>/dev/null)
[[ -n $_cached ]] && { echo "$_cached"; return 0; }
fi
# 解析: 依可用工具 fallback
local ip=""
if command -v nslookup >/dev/null 2>&1; then
ip=$(nslookup "$host" 2>/dev/null | awk '/^(Address|Name):/ {if (NR>1 && $0 ~ /^Address/) {print $NF; exit}}')
# 備援: 抓任何 IPv4
[[ -z $ip ]] && ip=$(nslookup "$host" 2>/dev/null | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | grep -v '^127\.' | tail -1)
fi
if [[ -z $ip ]] && command -v ping >/dev/null 2>&1; then
ip=$(ping -c 1 -W 1 "$host" 2>/dev/null | sed -n 's/.*(\([0-9.]*\)).*/\1/p' | head -1)
fi
# 寫入快取
[[ -n $ip ]] && printf '%s\t%s\n' "$host" "$ip" >> "$TMPDIR/.dns_cache"
echo "$ip"
}
# 覆蓋 curl: 自動透過 --resolve 繞過內建 DNS (解決 ARM curl 二進制解析失敗)
# 對 URL 內的域名先解析成 IP, 再傳 --resolve <host>:<port>:<ip> 給 curl
# 只處理 URL 參數中的域名, 純 IP 跳過
curl() {
# mksh 不支援 args=(), 改用暫存檔記錄
local extra_resolve="" _arg _rest _hp _host _port _ip
for _arg in "$@"; do
case $_arg in
http://*|https://*|ftp://*)
# 解出 host:port
_rest="${_arg#*://}"
_hp="${_rest%%/*}"
_host="${_hp%%:*}"
_port="${_hp#*:}"
[[ $_port = $_hp ]] && {
case $_arg in
http://*) _port=80 ;;
https://*) _port=443 ;;
ftp://*) _port=21 ;;
esac
}
# 僅對域名 (非純 IP) 處理
case $_host in
*[!0-9.]*) ;; # 含非數字/點 → 是域名
*) continue ;; # 純 IP → 跳過
esac
_ip=$(_dns_resolve "$_host")
if [[ -n $_ip && $_ip != "$_host" ]]; then
extra_resolve="$extra_resolve --resolve $_host:$_port:$_ip"
fi
;;
esac
done
# 用 command curl 避免遞迴, 加上預先解析的 resolve 參數
if [[ -n $extra_resolve ]]; then
command curl $extra_resolve "$@"
else
command curl "$@"
fi
}
if [[ $(which busybox) = "" ]]; then
echoRgb "環境變量中沒有找到busybox 請在tools內添加一個\narm64可用的busybox\n或是安裝搞機助手 scene或是Magisk busybox模塊...." "0"
exit 1
fi
if [[ $(which toybox | grep -Eo "system") != system ]]; then
echoRgb "系統變量中沒有找到toybox" "0"
exit 1
fi
#下列為自定義函數
alias down="app_process /system/bin com.xayah.dex.HttpUtil get $@"
case $LANG in
*CN* | *cn*)
alias ts="app_process /system/bin com.xayah.dex.CCUtil t2s $@" ;;
*)
alias ts="app_process /system/bin com.xayah.dex.CCUtil s2t $@" ;;
esac
alias LS="toybox ls -Zd"
alias mv="$get_mv"
# 給 trap / 流程控制用的 return 函數
# Set_back_0 永遠回 0 (成功), Set_back_1 永遠回 1 (失敗)
Set_back_0() {
return 0
}
Set_back_1() {
return 1
}
# 計算並輸出某段流程的耗時
# 用法: endtime <計時編號> <名稱>
# 計時編號: 1=讀 starttime1, 2=讀 starttime2 (需先在外面 set 變數)
# 輸出格式: " -<名稱>用時:X天X時X分X秒"
endtime() {
case $1 in
1) starttime="$starttime1" ;;
2) starttime="$starttime2" ;;
esac
endtime="$(date -u "+%s")"
duration="$(echo $((endtime - starttime)) | awk '{t=split("60 秒 60 分 24 時 999 天",a);for(n=1;n<t;n+=2){if($1==0)break;s=$1%a[n]a[n+1]s;$1=int($1/a[n])}print s}')"
[[ $duration != "" ]] && echo " -$2用時:$duration" || echo " -$2用時:0秒"
}
nskg=1
# 從 GitHub Release API 取得腳本最新版本號
# 透過 CDN (ghfast / cloudflare worker) 加速避免被牆
get_version() {
while :; do
keycheck
case $? in
42)
[[ $Select_user = true ]] && branch="$1" || branch=true
echoRgb "$1" "1"
;;
41)
[[ $Select_user = true ]] && branch="$2" || branch=false
echoRgb "$2" "0"
;;
*)
echoRgb "keycheck錯誤" "0"
continue
;;
esac
sleep 0.3
break
done
}
# 規範化布林值,將 1/true/yes 等變成 true,其他變成 false
# 用於 conf 讀進來的開關項統一格式
isBoolean() {
unset nsx
nsx="$1"
case $1 in
1|true|True|TRUE)
nsx=true ;;
0|false|False|FALSE)
nsx=false ;;
*)
echoRgb "$conf_path $2=$1填寫錯誤正確值1or0" "0"
exit 2 ;;
esac
}
# 根據上一條命令的退出碼輸出成功/失敗訊息
# 用法: echo_log "操作名稱" [skip_success_msg]
# 第二個參數非空時, 成功不輸出訊息 (只設變數)
echo_log() {
if [[ $? = 0 ]]; then
[[ $2 = "" ]] && echoRgb "$1成功" "1"
result=0
Set_back_0
else
echoRgb "$1失敗,過世了" "0"
notification "$RANDOM" "$name1: $1失敗,過世"
result=1
Set_back_1
fi
}
# 殺死先前殘留的腳本進程,並設置 lock 防止重複執行
# trap EXIT 會清 lock 並觸發 remote_cleanup (若有遠端設定)
kill_Serve() {
local LOCK_DIR="/data/.backup_lock"
local MY_PID="$$"
# 使用 mkdir 作為原子鎖操作,避免 TOCTOU 競態條件
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
if [[ -f $LOCK_DIR/pid ]]; then
OLD_PID="$(cat "$LOCK_DIR/pid")"
if kill -0 "$OLD_PID" 2>/dev/null; then
echo "發現先前的備份程序 (PID=$OLD_PID),將其終止"
# 單次 ps 快照 + awk 一次算出待殺清單: 舊程序整棵子孫樹 + 殘留 start.sh/tools.sh (排除自己祖先鏈)
local _kp _psbin="/system/bin/ps"
[[ -x $_psbin ]] || _psbin="ps"
# self 整條祖先鏈 + 自己的子孫樹 一律保護 (避免殺到自己 / 自己起的 ps 子進程)
for _kp in $($_psbin -e -o pid=,ppid=,args= 2>/dev/null | awk -v root="$OLD_PID" -v self="$$" -v me="$MY_PID" '
{ ppid[$1]=$2; cmd[$1]=$0 }
END {
# 保護: self 與 MY_PID 的祖先鏈
for (start_pid in ppid) {}
split(self" "me, seeds, " ")
for (k in seeds) { p=seeds[k]; while (p>1 && (p in ppid)) { safe[p]=1; p=ppid[p] } safe[seeds[k]]=1 }
# 保護: self/me 的子孫樹
sm[self]=1; sm[me]=1; ch=1
while (ch) { ch=0; for (x in ppid) if (!(x in sm) && (ppid[x] in sm)) { sm[x]=1; ch=1 } }
for (x in sm) safe[x]=1
# 待殺: 舊程序子孫樹 + 殘留 start.sh/tools.sh, 扣除保護集
mark[root]=1; ch=1
while (ch) { ch=0; for (x in ppid) if (!(x in mark) && (ppid[x] in mark)) { mark[x]=1; ch=1 } }
for (x in cmd) if (cmd[x] ~ /start\.sh|tools\.sh/) mark[x]=1
for (x in mark) if (!(x in safe)) print x
}'); do
kill -KILL "$_kp" 2>/dev/null
done
echo "結束自身,避免重複執行"
exit 1
else
echo "發現 lock 但程序已不存在,視為殘留 lock"
rm -rf "$LOCK_DIR"
mkdir "$LOCK_DIR" 2>/dev/null || exit 1
fi
else
rm -rf "$LOCK_DIR"
mkdir "$LOCK_DIR" 2>/dev/null || exit 1
fi
fi
echo "$MY_PID" > "$LOCK_DIR/pid"
trap "rm -rf '$LOCK_DIR'; rm -f \"\$TMPDIR/.pkg_uid\" \"\$TMPDIR/.pkg_ver\" \"\$TMPDIR/.pkg_perms\" \"\$TMPDIR/.dir_sizes\" \"\$TMPDIR/.pkg_installer\" \"\$TMPDIR/.battery_wl\" \"\$TMPDIR/.installed_pkgs\" \"\$TMPDIR/.smb_scan_results\" \"\$TMPDIR/.backup_done\" \"\$TMPDIR/.update_apks\" \"\$TMPDIR/.add_apks\" \"\$TMPDIR/.ssaid_apks\" \"\$TMPDIR/.changed_apps\" \"\$TMPDIR/.batch_grant\" \"\$TMPDIR/.batch_revoke\" \"\$TMPDIR/.batch_ops\" \"\$TMPDIR/.batch_opsreset\" \"\$TMPDIR/.restore_ssaid\" \"\$TMPDIR/.dns_cache\" \"\$TMPDIR/.backup_manifest\" \"\$TMPDIR/.remote_scripts\" \"\$TMPDIR/.remote_files\" \"\$TMPDIR/.dex_call_log\" \"\$TMPDIR/.stream_restore_list\" \"\$TMPDIR/.json_fetch\" \"\$TMPDIR/.verify_files\" \"\$TMPDIR/.stream_failed\" \"\$TMPDIR/.listver_changed\" \$TMPDIR/.remote_app_details_* 2>/dev/null; rm -rf \"\$TMPDIR/.remote_json\" 2>/dev/null; remote_cleanup" EXIT
}
kill_Serve
# ======================================================
# 遠端功能函數 (upload / download / smb / webdav)
# ======================================================
# 預連線測試 (避免後續操作卡住)
# 用法: remote_precheck <host> <port>
# 三層 fallback: nc → /dev/tcp → curl, 失敗會寫 log/remote_precheck.log
remote_precheck() {
local host="$1" port="$2"
[[ -z $host ]] && { echoRgb "remote_precheck: host為空" "0"; return 1; }
local dbg="${logfile:+${logfile%/*}/}remote_precheck.log"
[[ -z $logfile ]] && dbg="$TMPDIR/remote_precheck.log"
mkdir -p "${dbg%/*}" 2>/dev/null
{
echo "===== precheck $(date '+%Y-%m-%d %H:%M:%S') ====="
echo "host=$host port=$port"
} >> "$dbg"
# 1. nc
if command -v nc >/dev/null 2>&1; then
nc -z -w 3 "$host" "$port" >/dev/null 2>&1 && {
echo "[OK] nc passed" >> "$dbg"
return 0
}
echo "[FAIL] nc -z -w 3 $host $port → 失敗" >> "$dbg"
fi
# 2. /dev/tcp
if command -v timeout >/dev/null 2>&1; then
timeout 3 sh -c "echo > /dev/tcp/$host/$port" >/dev/null 2>&1 && {
echo "[OK] /dev/tcp passed" >> "$dbg"
return 0
}
echo "[FAIL] timeout 3 /dev/tcp/$host/$port → 失敗" >> "$dbg"
fi
# 3. curl --connect-timeout (對 https 通常更可靠, 也能用 --resolve)
if command -v curl >/dev/null 2>&1; then
# 構造 url; 不知 https/http 直接試 telnet 風格
local _scheme=http
[[ $port = 443 || $port = 30 ]] && _scheme=https
local curl_err
curl_err=$(curl -sS --connect-timeout 3 -o /dev/null -w '%{http_code}' "$_scheme://$host:$port/" 2>&1)
case $curl_err in
[0-9][0-9][0-9])
echo "[OK] curl returned HTTP $curl_err" >> "$dbg"
return 0 ;;
*)
echo "[FAIL] curl err: $curl_err" >> "$dbg"
;;
esac
fi
echoRgb "連線失敗詳情: $dbg" "3"
return 1
}
# 寫入遠端上傳 log (帶時間戳)
# 用法: remote_log "訊息"
remote_log() {
[[ -z $MODDIR ]] && return
local _up_log="${logfile%/*}/remote_upload.log"
mkdir -p "${logfile%/*}" 2>/dev/null
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$_up_log"
}
# 上傳結束時統一輸出總結並決定是否刪本地
# 參數: $1=協議名 $2=成功清單檔 $3=失敗清單檔
upload_summary() {
local proto="$1" ok_list="$2" fail_list="$3"
local ok_count=0 fail_count=0
[[ -f $ok_list ]] && ok_count="$(wc -l < "$ok_list" 2>/dev/null)"
[[ -f $fail_list ]] && fail_count="$(wc -l < "$fail_list" 2>/dev/null)"
ok_count=${ok_count:-0}
fail_count=${fail_count:-0}
# 計算總耗時
local elapsed_str=""
if [[ -n $UPLOAD_START_TS ]]; then
local elapsed=$(( $(date +%s) - UPLOAD_START_TS ))
elapsed_str=" 用時${elapsed}"
fi
echoRgb "_______________________________________" "2"
echoRgb "$proto 上傳完成: 成功 $ok_count / 失敗 $fail_count${elapsed_str}" "3"
remote_log "$proto 上傳結束: 成功 $ok_count / 失敗 $fail_count${elapsed_str}"
if [[ $fail_count -gt 0 ]]; then
echoRgb "失敗清單(已記錄到 ${logfile%/*}/remote_upload.log):" "0"
local n=0
while read -r line && [[ $n -lt 5 ]]; do
echoRgb " $line" "0"
let n++
done < "$fail_list"
[[ $fail_count -gt 5 ]] && echoRgb " ...還有 $((fail_count - 5)) 個,請看 log" "0"
fi
# 刪本地檔案的策略: remote_keep_local=true 或 1 永遠保留
# 否則: 必須「全部成功」才刪除所有上傳過的檔案
case $remote_keep_local in
1|true|True|TRUE)
echoRgb "remote_keep_local=$remote_keep_local 本地檔案保留" "3"
;;
*)
if [[ $fail_count -eq 0 && $ok_count -gt 0 ]]; then
echoRgb "全部上傳成功,清除本地已上傳檔案 (保留 tools/ 跟入口腳本)" "1"
while read -r f; do
[[ -z $f ]] && continue
# 保留: tools/ 目錄下檔案 / start.sh / backup.sh / recover.sh / upload.sh
case $f in
*/tools/*) continue ;;
esac
case ${f##*/} in
start.sh|backup.sh|recover.sh|upload.sh) continue ;;
esac
rm -f "$f"
done < "$ok_list"
elif [[ $fail_count -gt 0 ]]; then
echoRgb "部分上傳失敗,本地檔案全部保留 (含已上傳的)" "0"
remote_log "部分失敗,本地檔案全部保留"
fi
;;
esac
rm -f "$ok_list" "$fail_list" 2>/dev/null
unset UPLOAD_START_TS
[[ $fail_count -eq 0 ]]
}
# URL 編碼 (處理 UTF-8 多 byte, 保留 / 不編碼以保持路徑結構)
# 用法: url_encode_path <string>
url_encode_path() {
local s="$1"
# 用 od 把每個 byte 印成 hex, awk 處理 hex 字串轉換
# 避免 strtonum (busybox awk 不支援)
printf '%s' "$s" | od -An -tx1 -v | tr -s ' \n' ' ' | awk '
BEGIN {
# 建立 hex → dec 對照表
for (i=0; i<10; i++) hex2int[sprintf("%d",i)] = i
hex2int["a"]=10; hex2int["b"]=11; hex2int["c"]=12
hex2int["d"]=13; hex2int["e"]=14; hex2int["f"]=15
}
{
n = split($0, a, " ")
for (i=1; i<=n; i++) {
h = a[i]
if (h == "") continue
# 把兩個 hex 字元轉成 dec
val = hex2int[substr(h,1,1)] * 16 + hex2int[substr(h,2,1)]
# 不編碼: A-Z a-z 0-9 - _ . ~ /
if ((val>=48 && val<=57) || (val>=65 && val<=90) || (val>=97 && val<=122) \
|| val==45 || val==95 || val==46 || val==126 || val==47) {
printf "%c", val
} else {
printf "%%%s", toupper(h)
}
}
}'
}
# URL 解碼 (處理 %XX, 含 UTF-8 多 byte)
url_decode_path() {
local s="$1"
local converted
converted=$(echo "$s" | sed 's/%\([0-9a-fA-F][0-9a-fA-F]\)/\\x\1/g')
printf '%b' "$converted"
}
# 計算速度顯示字串
# 用法: speed_calc <總bytes> <用時秒數>
# 輸出: "1.23 GB/s" 或 "8.5 MB/s" 或 "512 KB/s" 或 "" (時間=0時)
speed_calc() {
local bytes="$1" secs="$2"
[[ -z $bytes || -z $secs ]] && return
[[ $secs -le 0 ]] && return
# 依速率(bytes/secs)分級, awk 一次處理 (無 32-bit 溢位, 四捨五入)
awk -v b="$bytes" -v s="$secs" 'BEGIN{
r=b/s
if(r>=1073741824) printf "%.2f GB/s", r/1073741824
else if(r>=1048576) printf "%.2f MB/s", r/1048576
else if(r>=1024) printf "%.1f KB/s", r/1024
else if(r>0) printf "%d B/s", r
}'
}
# 計算清單檔案總大小 (bytes)
list_total_size() {
local list="$1"
[[ ! -f $list ]] && { echo 0; return; }
# 一次批量 stat (xargs 分批) 取代逐行 fork, 大量檔案時快很多; 精確位元組
tr '\n' '\0' < "$list" | xargs -0 -r stat -c%s 2>/dev/null | awk '{s+=$1} END{print s+0}'
}
# 收集本次需要上傳的清單 (而非整個Backup)
# 結果寫入 $1 指定的list_file
# 範圍由以下變數控制 (在各備份入口設定,只反映「本次執行」):
# REMOTE_APPLIST : 字串,本次備份的 app 清單 (跟 $txt 同格式)
# REMOTE_UPLOAD_MEDIA=1 : 本次有跑 Media 備份, 要上傳 $Backup/Media
# REMOTE_UPLOAD_WIFI=1 : 本次有跑 wifi 備份, 要上傳 $Backup/wifi
# app 上傳條件:
# 1. 該行未被 #//! 註解
# 2. $Backup/$name1 目錄存在
# 3. 目錄內至少有一個有效檔案
remote_collect_targets() {
local list_file="$1"
local tmp_collect="$TMPDIR/.rcollect"
: > "$list_file"
# 全目錄模式: 上傳整個 Backup 下所有檔案 (排除 log/), 不依清單
if [[ $REMOTE_FULL_DIR = 1 ]]; then
[[ $REMOTE_QUIET != 1 ]] && echoRgb "全目錄模式: 收集整個備份目錄" "2"
find "$Backup" -type f -not -path "$Backup/log/*" >> "$list_file" 2>/dev/null
rm -f "$tmp_collect" 2>/dev/null
return 0
fi
# 如果設置了 REMOTE_SKIP_APPDATA跳過應用數據上傳
if [[ $REMOTE_SKIP_APPDATA != 1 && -n $REMOTE_APPLIST ]]; then
[[ $REMOTE_QUIET != 1 ]] && echoRgb "讀取本次備份名單" "2"
echo "$REMOTE_APPLIST" | grep -Ev '^[[:space:]]*[#!]|^[[:space:]]*$' | while read -r line; do
local name1="${line%% *}"
[[ -z $name1 ]] && continue
local full="$Backup/$name1"
[[ -d $full ]] || continue
if [[ $REMOTE_APPDETAILS_SKIP = 1 ]]; then
find "$full" -type f ! -name "app_details.json" > "$tmp_collect" 2>/dev/null
else
find "$full" -type f > "$tmp_collect" 2>/dev/null
fi
[[ -s $tmp_collect ]] && cat "$tmp_collect" >> "$list_file"
done
fi
if [[ $REMOTE_UPLOAD_MEDIA = 1 && -d $Backup/Media ]]; then
find "$Backup/Media" -type f > "$tmp_collect" 2>/dev/null
[[ -s $tmp_collect ]] && cat "$tmp_collect" >> "$list_file"
fi
if [[ $REMOTE_UPLOAD_WIFI = 1 && -d $Backup/wifi ]]; then
find "$Backup/wifi" -type f > "$tmp_collect" 2>/dev/null
[[ -s $tmp_collect ]] && cat "$tmp_collect" >> "$list_file"
fi
# 固定附加: tools/ 資料夾、start.sh、restore_settings.conf、appList.txt、mediaList.txt
# 只要 list_file 已經有內容(代表本次有東西要上傳)就一併帶上,讓遠端目錄能獨立還原
# REMOTE_SKIP_FIXED=1 時跳過 (逐應用上傳模式,避免重複上傳)
# REMOTE_SKIP_APPDATA=1 時也需要上傳依賴文件
if [[ ($REMOTE_SKIP_APPDATA = 1 || -s $list_file) && $REMOTE_SKIP_FIXED != 1 ]]; then
[[ -d $Backup/tools ]] && find "$Backup/tools" -type f >> "$list_file" 2>/dev/null
[[ -f $Backup/start.sh ]] && echo "$Backup/start.sh" >> "$list_file"
[[ -f $Backup/restore_settings.conf ]] && echo "$Backup/restore_settings.conf" >> "$list_file"
[[ -f $Backup/appList.txt ]] && echo "$Backup/appList.txt" >> "$list_file"
[[ -f $Backup/mediaList.txt ]] && echo "$Backup/mediaList.txt" >> "$list_file"
[[ -f "$Backup/MT管理器.apk" ]] && echo "$Backup/MT管理器.apk" >> "$list_file"
fi
rm -f "$tmp_collect" 2>/dev/null
}
# 掃描核心: 找出區網內所有開放 445 的主機, 寫入 $TMPDIR/.smb_scan_results (一行一 IP, 已排序)
# 成功(有結果) return 0; 無結果或無法掃描 return 1. 供 scan_smb / smb_autodetect_url 複用
_smb_scan_hosts() {
local my_ip
my_ip="$(ip route get 1 2>/dev/null | awk '{print $7; exit}')"
[[ -z $my_ip ]] && my_ip="$(ifconfig 2>/dev/null | grep -m1 'inet addr:192' | awk '{print $2}' | cut -d: -f2)"
[[ -z $my_ip ]] && { echoRgb "無法取得本機 IP" "0"; return 1; }
SMB_SCAN_SUBNET="${my_ip%.*}"
if ! command -v nc >/dev/null 2>&1; then
echoRgb "未找到 nc 命令,無法掃描" "0"; return 1
fi
echoRgb "本機 IP: $my_ip" "2"
echoRgb "掃描 $SMB_SCAN_SUBNET.0/24 上的 SMB 主機 (445 port)..." "3"
local results="$TMPDIR/.smb_scan_results"; : > "$results"
local i pids=""
for i in $(seq 1 254); do
( nc -z -w 1 "$SMB_SCAN_SUBNET.$i" 445 >/dev/null 2>&1 && echo "$SMB_SCAN_SUBNET.$i" >> "$results" ) &
pids="$pids $!"
if [[ $((i % 50)) -eq 0 ]]; then
wait $pids 2>/dev/null; pids=""
printf '\r掃描 %d/254 %s' "$i" "$(progress_bar $((i * 100 / 254)))" >&2
fi
done
wait $pids 2>/dev/null
printf '\r掃描 254/254 %s\n' "$(progress_bar 100)" >&2
[[ ! -s $results ]] && return 1
sort -t. -k4 -n "$results" -o "$results"
return 0
}
# 自動偵測區網 SMB 並設定 remote_url (取第一台有可用共享的主機)
# 不論 remote_stream/remote_user/remote_pass 是否填寫都會探測
smb_autodetect_url() {
_smb_scan_hosts || return 1
local _auth
if [[ -n $remote_user ]]; then _auth="-U $remote_user%$remote_pass"; else _auth="-N"; fi
local target share
while read -r target; do
echoRgb "發現 SMB: $target" "1"
share="$(command smbclient -L "//$target" $_auth -t 5 -s /dev/null -m SMB3 2>/dev/null \
| awk '/Disk/ && $1!~/\$$/ {print $1; exit}')"
if [[ -n $share ]]; then
remote_url="smb://$target/$share"
rm -f "$TMPDIR/.smb_scan_results"
return 0
fi
echoRgb " $target 無可用共享 (或需認證)" "2"
done < "$TMPDIR/.smb_scan_results"
rm -f "$TMPDIR/.smb_scan_results"
return 1
}
# 掃描區網內所有開放 SMB (445 port) 的主機 (菜單功能: 顯示所有主機與共享)
scan_smb() {
if ! _smb_scan_hosts; then
echoRgb "未發現 SMB 主機" "0"
rm -f "$TMPDIR/.smb_scan_results"
return 1
fi
echoRgb "------- 掃描完成 -------" "3"
local _auth
if [[ -n $remote_user ]]; then _auth="-U $remote_user%$remote_pass"; else _auth="-N"; fi
while read -r target; do
echoRgb "發現 SMB: $target" "1"
# 查主機名 (有 nmblookup 才查)
if command -v nmblookup >/dev/null 2>&1; then
local hn
hn="$(nmblookup -A "$target" 2>/dev/null | awk 'NR==2{print $1}' | tr -d '<>\t ')"
[[ -n $hn ]] && echoRgb " 主機名: $hn" "2"
fi
# 列 share — 用 awk 不用 grep,避開 busybox grep regex 限制
command smbclient -L "//$target" $_auth -t 3 -s /dev/null -m SMB3 2>/dev/null \
| awk '/Disk/ {print " 共享: "$1}' \
| while read -r line; do echoRgb "$line" "2"; done
done < "$TMPDIR/.smb_scan_results"
rm -f "$TMPDIR/.smb_scan_results"
}
# SMB 上傳實作 (使用 smbclient)
# 流程: 解析 URL → 預檢 → 收集檔案 → 按目錄分組 → 每組一次 smbclient 批次傳輸
# 跟 upload_remote 的差別: SMB 用獨立的 smbclient 二進制, 不走 curl
upload_smb() {
[[ -z $remote_url ]] && { echoRgb "remote_url未設置" "0"; return 1; }
UPLOAD_START_TS=$(date +%s)
[[ $REMOTE_QUIET != 1 ]] && echoRgb "使用: $filepath/smbclient" "2"
# 解析 smb://server/share/remotepath
remote_parse_smb_url
local share="$SMB_SHARE"
local rem_path="$SMB_REM_PATH"
# 自動加上備份目錄前綴 (跟本地結構一致)
local backup_subdir="$(get_backup_dirname)"
rem_path="${rem_path}/${backup_subdir}"
# 拆出 host 和 port (從 share 反推)
local _hp="${share#//}"; _hp="${_hp%%/*}"
local host="${_hp%%:*}"
local port="${_hp#*:}"
[[ $port = $_hp ]] && port=445
echoRgb "SMB: $share (路徑: ${rem_path:-/})" "2"
# 連線預檢
if ! remote_precheck "$host" "$port"; then
echoRgb "SMB伺服器無法連線: $host:$port (請檢查WiFi/位址/伺服器狀態)" "0"
echoRgb "本地檔案已保留" "0"
return 1
fi
local list_file="$TMPDIR/.slist"
local ok_list="$TMPDIR/.sok"
local fail_list="$TMPDIR/.sfail"
: > "$ok_list"; : > "$fail_list"
remote_collect_targets "$list_file"
if [[ ! -s $list_file ]]; then
echoRgb "無檔案需上傳" "3"
rm -f "$list_file" "$ok_list" "$fail_list" 2>/dev/null
return 0
fi
local total
total="$(wc -l < "$list_file")"
[[ $REMOTE_QUIET != 1 ]] && echoRgb "準備上傳 $total 個檔案" "3"
if [[ $REMOTE_QUIET != 1 ]]; then
local _up_bytes
_up_bytes="$(list_total_size "$list_file")"
echoRgb "本次上傳總大小: $(size "$_up_bytes") (位元組:$_up_bytes)" "3"
fi
remote_log "SMB 開始: $share, 共 $total"
# smbclient 共用參數:
# -t 10 : 命令 timeout 秒數
# -s /dev/null : 跳過讀取 smb.conf (避免手動編譯版找不到 conf 噴警告)
# -p <port> : 指定 SMB 端口 (預設 445, 由 remote_parse_endpoint 設定)
# -m SMB3 : client max protocol = SMB3, 表示最高用到 SMB3.1.1
# min 維持 smbclient 預設 (SMB2_02), 故拒絕 SMB1 但允許協商到 SMB2.x ~ SMB3.x
local SMB_OPTS="-t 10 -s /dev/null${REMOTE_PORT:+ -p $REMOTE_PORT} -m SMB3"
# 收集所有需要建立的目錄
local mkdir_script="$TMPDIR/.smb_mkdir"
: > "$mkdir_script"
{
while read -r f; do
local d="${f#$Backup/}"
d="${d%/*}"
[[ -n $d && $d != "${f#$Backup/}" ]] && echo "${rem_path:+$rem_path/}$d"
done < "$list_file"
} | sort -u | while read -r d; do
# 對每層路徑都產生 mkdir 命令
# 注意: smbclient 內部命令不認 shell 引號, 不能加 '' 或 ""
local cur=""
local OLDIFS="$IFS"
IFS='/'
set -- $d
IFS="$OLDIFS"
for seg; do
[[ -z $seg ]] && continue
cur="$cur/$seg"
echo "mkdir $cur" >> "$mkdir_script"
done
done
# 一次連線執行所有 mkdir (比每個目錄重新連快很多)
if [[ -s $mkdir_script ]]; then
echo "exit" >> "$mkdir_script"
smbclient "$share" -U "$remote_user%$remote_pass" $SMB_OPTS < "$mkdir_script" 2>&1 \
| grep -Ev '^Domain=|^OS=|NT_STATUS_OBJECT_NAME_COLLISION|^Try "help"|^dos charset|^Can.t load' >&2
fi
rm -f "$mkdir_script" 2>/dev/null
# 按目錄分組上傳 (同一目錄的所有檔案,一次連線傳完)
# 先依遠端目錄分組
local group_dir="$TMPDIR/.smb_groups"
mkdir -p "$group_dir" && rm -f "$group_dir"/*
while read -r f; do
[[ -z $f ]] && continue
local rel="${f#$Backup/}"
local file_dir="$(dirname "$rel")"
local rem_dir="$rem_path"
[[ $file_dir != . ]] && rem_dir="${rem_dir:+$rem_dir/}$file_dir"
[[ -z $rem_dir ]] && rem_dir="/"
# 用 base64 或 hash 當分組 key,避免路徑裡的 / 影響檔名
local key="$(echo "$rem_dir|$(dirname "$f")" | md5sum 2>/dev/null | cut -c1-12)"
[[ -z $key ]] && key="$(echo "$rem_dir|$(dirname "$f")" | cksum | cut -d' ' -f1)"
local gf="$group_dir/$key"
[[ ! -f $gf ]] && {
echo "$rem_dir" > "$gf.meta"
echo "$(dirname "$f")" >> "$gf.meta"
}
echo "$f" >> "$gf"
done < "$list_file"
# 對每個分組執行批次上傳
local idx=0
# 算總目錄數 (用於進度計算; 不含 wifi, wifi 不參與百分比)
local total_dirs done_dirs=0
for gf in "$group_dir"/*; do
[[ -f $gf && $gf != *.meta ]] || continue
local rem_dir_check
rem_dir_check="$(sed -n 1p "$gf.meta")"
# wifi 目錄不算進總數
[[ $rem_dir_check = */wifi || $rem_dir_check = wifi || $rem_dir_check = */wifi/* ]] && continue
let total_dirs++
done
for gf in "$group_dir"/*; do
[[ -f $gf && $gf != *.meta ]] || continue
local meta="$gf.meta"
local rem_dir local_dir
rem_dir="$(sed -n 1p "$meta")"
local_dir="$(sed -n 2p "$meta")"
local file_count
file_count="$(wc -l < "$gf")"
# 判斷是否為 wifi (不計入進度)
local is_wifi=0
[[ $rem_dir = */wifi || $rem_dir = wifi || $rem_dir = */wifi/* ]] && is_wifi=1
echoRgb "上傳目錄 $rem_dir ($file_count 檔)" "3"
local dir_start
dir_start=$(date +%s)
# 建立 smbclient batch script
local batch="$TMPDIR/.smb_batch"
echo "cd $rem_dir" > "$batch"
echo "lcd $local_dir" >> "$batch"
while read -r f; do
local fname="$(basename "$f")"
echo "put $fname" >> "$batch"
done < "$gf"
echo "exit" >> "$batch"
# 跑 batch, 解析每個 put 的結果
local smb_out
smb_out="$(smbclient "$share" -U "$remote_user%$remote_pass" $SMB_OPTS < "$batch" 2>&1)"
# 對應每個檔案的成功/失敗
while read -r f; do
let idx++
local rel="${f#$Backup/}"
local fname="$(basename "$f")"
if echo "$smb_out" | grep -F "$fname" | grep -qE 'NT_STATUS|does not exist|ERR'; then
echo "$rel" >> "$fail_list"
echoRgb "[$idx/$total] ✗ $rel" "0"
remote_log "FAIL SMB $rel"
else
echo "$f" >> "$ok_list"
echoRgb "[$idx/$total] ✓ $rel" "1"
fi
done < "$gf"
rm -f "$batch"
# 此目錄完成,印整體進度 (wifi 不算)
if [[ $is_wifi = 0 && $total_dirs -gt 0 ]]; then
let done_dirs++
local dir_speed=""
local dir_bytes
dir_bytes=$(list_total_size "$gf")
local dir_elapsed=$(( $(date +%s) - dir_start ))
local sp
sp=$(speed_calc "$dir_bytes" "$dir_elapsed")
[[ -n $sp ]] && dir_speed=" ($sp)"
echoRgb "完成$((done_dirs * 100 / total_dirs))% $(progress_bar $((done_dirs * 100 / total_dirs)))${dir_speed}" "3"
fi
done
# REMOTE_APPDETAILS_FILE: 主體上傳完成後,若無失敗則上傳 app_details.json
if [[ -n $REMOTE_APPDETAILS_FILE && -f $REMOTE_APPDETAILS_FILE ]]; then
if [[ ! -s $fail_list ]]; then
let idx++
local _ad_rel="${REMOTE_APPDETAILS_FILE#$Backup/}"
local _ad_dir="$(dirname "$REMOTE_APPDETAILS_FILE")"
local _ad_fname="$(basename "$REMOTE_APPDETAILS_FILE")"
local _ad_smb_out
_ad_smb_out="$(smbclient "$share" -U "$remote_user%$remote_pass" -t 10 -s /dev/null \
-D "${rem_path:+$rem_path/}$backup_subdir/$(dirname "$_ad_rel")" \
-c "lcd $_ad_dir; put $_ad_fname; exit" 2>&1)"
if echo "$_ad_smb_out" | grep -F "$_ad_fname" | grep -qE 'NT_STATUS|does not exist|ERR'; then
echo "$_ad_rel" >> "$fail_list"
echoRgb "[$idx/$idx] ✗ $_ad_rel" "0"
remote_log "FAIL SMB $_ad_rel"
else
echo "$REMOTE_APPDETAILS_FILE" >> "$ok_list"
echoRgb "[$idx/$idx] ✓ $_ad_rel" "1"
fi
else
echoRgb "其他文件上傳失敗,跳過 app_details.json" "0"
fi
fi
rm -rf "$group_dir" 2>/dev/null
rm -f "$list_file" 2>/dev/null
upload_summary "SMB" "$ok_list" "$fail_list"
}
# 遠端上傳分派器 + WebDAV 實作
# $1=協議名 (webdav/smb), smb 會轉派給 upload_smb
# WebDAV: 用 curl 逐檔 PUT, 預先 MKCOL 建好目錄結構
upload_remote() {
local proto="$1"
[[ $proto = smb ]] && { upload_smb; return $?; }
[[ -z $remote_url ]] && { echoRgb "remote_url未設置" "0"; return 1; }
UPLOAD_START_TS=$(date +%s)
local base_url
case $proto in
webdav)
base_url="${remote_url%/}"
[[ $base_url != http://* && $base_url != https://* ]] && { echoRgb "WebDAV地址格式錯誤: $remote_url" "0"; return 1; }
;;
*) echoRgb "未支援的協議: $proto" "0"; return 1 ;;
esac
# 自動加上備份目錄前綴 (跟本地結構一致)
local backup_subdir="$(get_backup_dirname)"
base_url="$base_url/$backup_subdir"
# 連線預檢: 從 base_url 解出 host:port
local _hp="${base_url#*://}"
_hp="${_hp%%/*}"
local _host="${_hp%%:*}"
local _port="${_hp#*:}"
if [[ $_port = $_hp ]]; then
[[ $base_url = https://* ]] && _port=443 || _port=80
fi
if ! remote_precheck "$_host" "$_port"; then
echoRgb "$proto伺服器無法連線: $_host:$_port (請檢查WiFi/位址/伺服器狀態)" "0"
echoRgb "本地檔案已保留" "0"
return 1
fi
[[ $REMOTE_QUIET != 1 ]] && echoRgb "使用: $filepath/curl" "2"
local list_file="$TMPDIR/.rlist"
local ok_list="$TMPDIR/.rok"
local fail_list="$TMPDIR/.rfail"
: > "$ok_list"; : > "$fail_list"
[[ -z $Backup ]] && { echoRgb "Backup路徑為空" "0"; return 1; }
remote_collect_targets "$list_file"
if [[ ! -s $list_file ]]; then
echoRgb "無檔案需上傳" "3"
rm -f "$list_file" "$ok_list" "$fail_list" 2>/dev/null
return 0
fi
local total
total="$(wc -l < "$list_file")"
[[ $REMOTE_QUIET != 1 ]] && echoRgb "準備上傳 $total 個檔案" "3"
if [[ $REMOTE_QUIET != 1 ]]; then
local _up_bytes
_up_bytes="$(list_total_size "$list_file")"
echoRgb "本次上傳總大小: $(size "$_up_bytes") (位元組:$_up_bytes)" "3"
fi
remote_log "$proto 開始: $base_url, 共 $total"
# WebDAV: 先建初始目錄 (Backup_zstd_X 自己)
curl -sS -L --http1.1 -X MKCOL -u "$remote_user:$remote_pass" "$base_url" >/dev/null 2>&1
# WebDAV: 創建遠程子目錄 (MKCOL)
while read -r f; do
local d="${f#$Backup/}"
d="${d%/*}"
[[ -n $d && $d != "${f#$Backup/}" ]] && echo "$d"
done < "$list_file" | sort -u | while read -r d; do
local enc_d="$(url_encode_path "$d")"
local cur="$base_url"
local IFS='/'
set -- $enc_d
for seg; do
cur="$cur/$seg"
curl -sS -L --http1.1 -X MKCOL -u "$remote_user:$remote_pass" "$cur" >/dev/null 2>&1
done
done
# 預掃總目錄數 (排除 wifi, 不計入百分比)
local total_dirs done_dirs=0 last_dir="" cur_top_dir=""
local dir_start=0 dir_bytes_accum=0
while read -r f; do
local top="${f#$Backup/}"
top="${top%%/*}"
[[ $top = wifi ]] && continue
echo "$top"
done < "$list_file" | sort -u | while read -r d; do echo "$d"; done > "$TMPDIR/.dirs_count"
total_dirs="$(wc -l < "$TMPDIR/.dirs_count" 2>/dev/null)"
rm -f "$TMPDIR/.dirs_count"
# 上傳檔案
local idx=0
while read -r f; do
[[ -z $f ]] && continue
let idx++
local rel="${f#$Backup/}"
local cur_top="${rel%%/*}"
# 判斷是「目錄內檔案」還是「根目錄檔案」
# rel 含 / → 目錄內檔案, cur_top 是目錄名
# rel 不含 / → 根目錄檔案, cur_top 是檔案名本身
local is_root_file=0
[[ $rel = "$cur_top" ]] && is_root_file=1
# 目錄切換時印上一個目錄的進度
if [[ -n $last_dir && $cur_top != "$last_dir" ]]; then
if [[ $last_dir != wifi && $total_dirs -gt 0 ]]; then
let done_dirs++
local dir_speed=""
local dir_elapsed=$(( $(date +%s) - dir_start ))
local sp
sp=$(speed_calc "$dir_bytes_accum" "$dir_elapsed")
[[ -n $sp ]] && dir_speed=" ($sp)"
echoRgb "完成$((done_dirs * 100 / total_dirs))% $(progress_bar $((done_dirs * 100 / total_dirs)))${dir_speed}" "3"
fi
if [[ $is_root_file = 1 ]]; then
echoRgb "上傳檔案 $cur_top" "3"
else
echoRgb "上傳目錄 $cur_top" "3"
fi
dir_start=$(date +%s)
dir_bytes_accum=0
elif [[ -z $last_dir ]]; then
if [[ $is_root_file = 1 ]]; then
echoRgb "上傳檔案 $cur_top" "3"
else
echoRgb "上傳目錄 $cur_top" "3"
fi
dir_start=$(date +%s)
dir_bytes_accum=0
fi
last_dir="$cur_top"
# 累計這個目錄已上傳的 bytes
local _sz
_sz=$(stat -c%s "$f" 2>/dev/null)
dir_bytes_accum=$(( dir_bytes_accum + ${_sz:-0} ))
local target_url
if [[ $proto = webdav ]]; then
local enc_rel="$(url_encode_path "$rel")"
target_url="$base_url/$enc_rel"
else
target_url="$base_url/$rel"
fi
local http_code curl_exit
# 顯示上傳百分比: curl -# 進度 → awk 過濾只留百分比 → 同行刷新
local _sz_human
_sz_human=$(awk "BEGIN{s=${_sz:-0};if(s>=1073741824)printf\"%.2fGB\",s/1073741824;else if(s>=1048576)printf\"%.1fMB\",s/1048576;else if(s>=1024)printf\"%.0fKB\",s/1024;else printf\"%dB\",s}")
curl -# -S -L --http1.1 --retry 2 --retry-delay 3 --connect-timeout 10 \
-T "$f" -u "$remote_user:$remote_pass" -w '%{http_code}' \
-o /dev/null "$target_url" 2>&1 > "$TMPDIR/.curl_http" | \
awk -v idx="$idx" -v total="$total" -v rel="$rel" -v sz="$_sz_human" '
BEGIN{RS="\r"}
/[0-9]+%/{
match($0,/[0-9]+\.?[0-9]*%/)
pct=substr($0,RSTART,RLENGTH)
for(i=1;i<=NF;i++) if(index($i,"/s")) spd=$i
printf "\r\033[38;5;51m [%d/%d] %s (%s) %s",idx,total,rel,sz,pct
if(spd!="") printf " %s",spd
printf "\033[0m "
fflush()
}' > /dev/tty
curl_exit=$?
http_code="$(cat "$TMPDIR/.curl_http" 2>/dev/null)"
rm -f "$TMPDIR/.curl_http"
printf "\r\033[K" > /dev/tty
# http_code 2xx 視為成功
case $http_code in
2*)
echo "$f" >> "$ok_list"
echoRgb "[$idx/$total] ✓ $rel" "1"
;;
*)
echo "$rel (HTTP $http_code)" >> "$fail_list"
echoRgb "[$idx/$total] ✗ $rel (HTTP $http_code)" "0"
remote_log "FAIL $proto $rel HTTP=$http_code curl_exit=$curl_exit"
;;
esac
done < "$list_file"
# 最後一個目錄(非wifi)的進度
if [[ -n $last_dir && $last_dir != wifi && $total_dirs -gt 0 ]]; then
let done_dirs++
local dir_speed=""
local dir_elapsed=$(( $(date +%s) - dir_start ))
local sp
sp=$(speed_calc "$dir_bytes_accum" "$dir_elapsed")
[[ -n $sp ]] && dir_speed=" ($sp)"
echoRgb "完成$((done_dirs * 100 / total_dirs))% $(progress_bar $((done_dirs * 100 / total_dirs)))${dir_speed}" "3"
fi
# REMOTE_APPDETAILS_FILE: 主體上傳完成後,若無失敗則上傳 app_details.json
if [[ -n $REMOTE_APPDETAILS_FILE && -f $REMOTE_APPDETAILS_FILE ]]; then
if [[ ! -s $fail_list ]]; then
let idx++
local _ad_rel="${REMOTE_APPDETAILS_FILE#$Backup/}"
local _ad_url="$base_url/$(url_encode_path "$_ad_rel")"
local _ad_http
_ad_http="$(curl -sS -L --http1.1 --retry 2 --retry-delay 3 --connect-timeout 10 \
-T "$REMOTE_APPDETAILS_FILE" -u "$remote_user:$remote_pass" -w '%{http_code}' \
-o /dev/null "$_ad_url" 2>/dev/null)"
case $_ad_http in
2*)
echo "$REMOTE_APPDETAILS_FILE" >> "$ok_list"
echoRgb "[$idx/$idx] ✓ $_ad_rel" "1"
;;
*)
echo "$_ad_rel (HTTP $_ad_http)" >> "$fail_list"
echoRgb "[$idx/$idx] ✗ $_ad_rel (HTTP $_ad_http)" "0"
remote_log "FAIL $proto $_ad_rel HTTP=$_ad_http"
;;
esac
else
echoRgb "其他文件上傳失敗,跳過 app_details.json" "0"
fi
fi
rm -f "$list_file" 2>/dev/null
upload_summary "$proto" "$ok_list" "$fail_list"
}
# 從 remote_url 解析出 host 和 port (依 remote_type)
# 結果寫到全域變數 REMOTE_HOST 和 REMOTE_PORT
remote_parse_endpoint() {
REMOTE_HOST=""; REMOTE_PORT=""
case $remote_type in
smb)
local u="${remote_url#smb://}"; u="${u%%/*}"
REMOTE_HOST="${u%%:*}"; REMOTE_PORT="${u#*:}"; [[ $REMOTE_PORT = $u ]] && REMOTE_PORT=445
;;
webdav)
local u="${remote_url#*://}"; u="${u%%/*}"
REMOTE_HOST="${u%%:*}"; REMOTE_PORT="${u#*:}"
if [[ $REMOTE_PORT = $u ]]; then [[ $remote_url = https://* ]] && REMOTE_PORT=443 || REMOTE_PORT=80; fi
;;
esac
}
# 計算遠端某路徑 (相對遠端根) 下所有檔案總大小 (bytes), 對齊本地 calc_dir_size 的純檔案字節統計
# 用法: remote_dir_size "Backup_zstd_0/iQIYI" 或 "Backup_zstd_0"
# 依 remote_type 分發 smbclient(recurse ls) / curl(PROPFIND)
# 一次列出遠端 $1 (相對 share/url 的子目錄) 下所有檔案的相對路徑 (相對 $1), 一行一個
# SMB 用 recurse ls 單連線; WebDAV 用 PROPFIND Depth:infinity 解析 href
remote_list_files() {
local _path="$1"
case $remote_type in
smb)
local _auth
if [[ -n $remote_user ]]; then _auth="-U $remote_user%$remote_pass"; else _auth="-N"; fi
local SMB_OPTS="-t 300 -s /dev/null${REMOTE_PORT:+ -p $REMOTE_PORT} -m SMB3"
local _pref="$SMB_REM_PATH/$_path"; _pref="${_pref#/}"
local _p="$_pref"; _p="${_p//\//\\}"
# recurse ls: 目錄標頭是相對 share 根的完整路徑; 用前綴過濾,
# cd 失敗 (目錄不存在) 時 smbclient 停留根目錄, 不會混入他處檔案
command smbclient "$SMB_SHARE" $_auth $SMB_OPTS \
-c "recurse ON; prompt OFF; cd \"$_p\"; ls" 2>/dev/null \
| awk -v pref="$_pref/" '
/^\\/ { dir=$0; sub(/^\\/,"",dir); gsub(/\\/,"/",dir); next }
{
for (i=2; i<=NF; i++) {
if ($i ~ /^[AHSRN]+$/ && $(i+1) ~ /^[0-9]+$/) {
name=$1
for (j=2; j<i; j++) name=name" "$j
if (dir=="") { print name }
else {
full=dir"/"name
if (index(full, pref)==1) print substr(full, length(pref)+1)
}
break
}
}
}'
;;
webdav)
local _wauth=""
[[ -n $remote_user ]] && _wauth="-u $remote_user:$remote_pass"
local _wurl="${remote_url%/}/$_path"
# href 解碼後去掉 base 前綴, 過濾目錄 (以 / 結尾)
curl -fsS $_wauth -X PROPFIND -H "Depth: infinity" "$_wurl/" 2>/dev/null \
| sed 's/</\n</g' | sed -n 's|<[^>]*href[^>]*>\([^<]*\).*|\1|p' \
| awk -v base="$_path" '
BEGIN { for (i=0;i<256;i++) hex[sprintf("%02X",i)]=sprintf("%c",i) }
function urldec(s, out,k,h) {
out=""
while ((k=index(s,"%"))>0) {
h=toupper(substr(s,k+1,2))
if (h in hex) { out=out substr(s,1,k-1) hex[h]; s=substr(s,k+3) }
else { out=out substr(s,1,k); s=substr(s,k+1) }
}
return out s
}
{
$0=urldec($0)
if ($0 ~ /\/$/) next
idx=index($0, base"/")
if (idx==0) next
print substr($0, idx+length(base)+1)
}'
;;
esac
}
remote_dir_size() {
local _path="$1"
case $remote_type in
smb)
local _auth
if [[ -n $remote_user ]]; then _auth="-U $remote_user%$remote_pass"; else _auth="-N"; fi
local SMB_OPTS="-t 300 -s /dev/null${REMOTE_PORT:+ -p $REMOTE_PORT} -m SMB3"
local _pref="$SMB_REM_PATH/$_path"; _pref="${_pref#/}"
local _p="$_pref"; _p="${_p//\//\\}"
# recurse on + ls 累加檔案大小; 用目錄標頭前綴過濾,
# cd 失敗 (目錄不存在) 時不會把整個 share 根算進來
command smbclient "$SMB_SHARE" $_auth $SMB_OPTS \
-c "recurse ON; prompt OFF; cd \"$_p\"; ls" 2>/dev/null \
| awk -v pref="$_pref" '
/^\\/ { dir=$0; sub(/^\\/,"",dir); gsub(/\\/,"/",dir); ok=(index(dir,pref)==1); next }
ok || dir=="" {
for (i=2; i<=NF; i++) {
if ($i ~ /^[AHSRN]+$/ && $(i+1) ~ /^[0-9]+$/) { s += $(i+1); break }
}
}
END { print s+0 }'
;;
webdav)
local _wauth=""
[[ -n $remote_user ]] && _wauth="-u $remote_user:$remote_pass"
local _wurl="${remote_url%/}/$_path"
# PROPFIND Depth: infinity 遞迴, 抓所有 getcontentlength 數值累加
curl -fsS $_wauth -X PROPFIND -H "Depth: infinity" "$_wurl" 2>/dev/null \
| sed 's/</\n</g' \
| sed -n 's|.*getcontentlength[^>]*>\([0-9]\{1,\}\).*|\1|p' \
| awk '{s+=$1} END{print s+0}'
;;
*)
echo 0
;;
esac
}
# 流式模式: 上傳恢復必要的基礎設施到遠端 (tools/ 目錄、start.sh、restore_settings.conf)
# 讓遠端備份能獨立恢復 (功能8 檢查這些, 功能10 流式恢復需要)
# tools/ 較大(數十 MB 二進制), 遠端已有就跳過; start.sh/conf 小, 每次重傳確保最新
stream_upload_infra() {
local _stage="$TMPDIR/.stream_stage/.infra"
mkdir -p "$_stage" 2>/dev/null
# 1. start.sh (恢復模式入口, touch_shell "2")
touch_shell "2" "$_stage/start.sh"
_stream_upload "start.sh" < "$_stage/start.sh" && echoRgb "start.sh 已上傳遠端" "1" || echoRgb "start.sh 上傳失敗" "0"
# 2. restore_settings.conf
update_Restore_settings_conf > "$_stage/restore_settings.conf"
_stream_upload "restore_settings.conf" < "$_stage/restore_settings.conf" && echoRgb "restore_settings.conf 已上傳遠端" "1" || echoRgb "restore_settings.conf 上傳失敗" "0"
# 3. appList.txt (功能8/恢復需要應用清單)
if [[ -f $MODDIR/appList.txt ]]; then
_stream_upload "appList.txt" < "$MODDIR/appList.txt" && echoRgb "appList.txt 已上傳遠端" "1" || echoRgb "appList.txt 上傳失敗" "0"
fi
# 3b. MT管理器.apk (恢復時安裝用, 對齊非流式上傳清單)
if [[ -f $Backup/MT管理器.apk ]]; then
_stream_upload "MT管理器.apk" < "$Backup/MT管理器.apk" && echoRgb "MT管理器.apk 已上傳遠端" "1" || echoRgb "MT管理器.apk 上傳失敗" "0"
fi
# 4. tools/ 目錄: 遠端已有就跳過. 下載 tools/tools.sh 開頭, 必須是真 shell 腳本 (#!) 才算存在
# (smbclient get 不存在檔可能輸出錯誤訊息到 stdout, 故須驗證內容是腳本而非錯誤)
local _toolschk
_toolschk="$(_stream_download "$(get_backup_dirname)/tools/tools.sh" 2>/dev/null | head -c 30)"
case $_toolschk in
'#!'*|*'system/bin'*)
echoRgb "遠端已有 tools/ (跳過, 省流量)" "2" ;;
*)
echoRgb "遠端缺 tools/, 上傳工具目錄 (首次, 約數十 MB)..." "3"
local _tf _rel
find "$MODDIR/tools" -type f 2>/dev/null | while read -r _tf; do
_rel="tools/${_tf#$MODDIR/tools/}"
_stream_upload "$_rel" < "$_tf"
done
echoRgb "tools/ 已上傳遠端" "1"
;;
esac
rm -rf "$_stage" 2>/dev/null
}
# 通用流式上傳: 從 stdin 讀資料, 上傳到遠端 (相對遠端根的) 路徑
# 依 remote_type 分發到 smbclient / curl(webdav) / ssh
# 用法: <資料來源> | _stream_upload "相對路徑/file.tar.zst"
# 回傳: 0=成功
_stream_upload() {
local _rel="$1"
# 加上備份子目錄前綴 (Backup_zstd_X), 與 remote_download_single_file 路徑一致, 確保增量比對找得到
local _subdir="$(get_backup_dirname)"
_rel="$_subdir/$_rel"
case $remote_type in
smb)
# SMB 流式: 用 cd 切目錄 (對齊既有成功的 upload_smb, -D 會吃掉路徑字元) + put - 從 stdin
local _auth
if [[ -n $remote_user ]]; then _auth="-U $remote_user%$remote_pass"; else _auth="-N"; fi
local SMB_OPTS="-t 300 -s /dev/null${REMOTE_PORT:+ -p $REMOTE_PORT} -m SMB3"
local _smbpath="$SMB_REM_PATH/$_rel"; _smbpath="${_smbpath#/}"
local _smbdir="${_smbpath%/*}" _file="${_smbpath##*/}"
# 1. 先逐層建目錄 (smbclient 內部路徑用反斜線)
if [[ $_smbdir != $_smbpath ]]; then
local _mk="" _cur="" _seg _OLDIFS="$IFS"
IFS='/'; set -- $_smbdir; IFS="$_OLDIFS"
for _seg; do
[[ -z $_seg ]] && continue
if [[ -z $_cur ]]; then _cur="$_seg"; else _cur="$_cur\\$_seg"; fi
# smbclient stdin 餵命令必須一行一條 (分號在 stdin 模式不是分隔符),
# 故字串內嵌真換行 (下一行的 " 是字串收尾, 非贅字)
_mk="${_mk}mkdir \"$_cur\"
"
done
printf '%sexit\n' "$_mk" | command smbclient "$SMB_SHARE" $_auth $SMB_OPTS >/dev/null 2>&1
fi
# 2. 流式 put -: 用 -c 傳命令 (不佔 stdin!), stdin 留給 put - 讀管道資料
# (之前用 printf|smbclient 喂命令會佔住 stdin, 導致 put - 讀不到資料寫出 0KB)
local _cddir="${_smbdir//\//\\}"
local _out
_out="$(command smbclient "$SMB_SHARE" $_auth $SMB_OPTS \
-c "cd \"$_cddir\"; put - \"$_file\"" 2>&1)"
# smbclient 退出碼不可靠, 改看輸出有無錯誤關鍵字
local _rc=0
echo "$_out" | grep -qE 'NT_STATUS|does not exist|ERRbadpath|Server (stopped|exited)|Connection.*refused|tree connect failed' && _rc=1
if [[ $_rc != 0 && $_stream_debug = 1 ]]; then
echoRgb "[SMB流式失敗] dir=$_cddir file=$_file" "0" >&2
echo "$_out" | sed 's/^/ /' >&2
fi
return $_rc
;;
webdav)
# WebDAV: 先 MKCOL 逐層建父目錄 (rclone serve 不會自動建), 再 curl -T - 上傳
local _wbase="${remote_url%/}"
local _wauth=""
[[ -n $remote_user ]] && _wauth="-u $remote_user:$remote_pass"
# 逐層建目錄
local _wdir="${_rel%/*}"
if [[ $_wdir != $_rel ]]; then
local _IFS_old="$IFS"; IFS='/'; local _wp="" _wseg
for _wseg in $_wdir; do
_wp="$_wp$_wseg/"
curl -fsS $_wauth -X MKCOL "$_wbase/${_wp%/}" >/dev/null 2>&1
done
IFS="$_IFS_old"
fi
curl -fsS -o /dev/null --connect-timeout 30 --speed-time 300 --speed-limit 512 $_wauth -T - "$_wbase/$_rel" 2>"$TMPDIR/.stream_err"
local _rc=$?
if [[ $_rc != 0 && $_stream_debug = 1 ]]; then
echoRgb "[WebDAV流式失敗 rc=$_rc] url=$_wbase/$_rel" "0" >&2
sed 's/^/ /' "$TMPDIR/.stream_err" 2>/dev/null >&2
fi
rm -f "$TMPDIR/.stream_err"
return $_rc
;;
*)
return 1
;;
esac
}
# 通用流式下載: 把遠端 (相對遠端根的) 路徑檔案輸出到 stdout
# 依 remote_type 分發 smbclient(get -) / curl. 配合管道解壓: _stream_download "路徑" | zstd -d | tar -x
_stream_download() {
local _rel="$1"
case $remote_type in
smb)
local _auth
if [[ -n $remote_user ]]; then _auth="-U $remote_user%$remote_pass"; else _auth="-N"; fi
local SMB_OPTS="-t 300 -s /dev/null${REMOTE_PORT:+ -p $REMOTE_PORT} -m SMB3"
local _smbpath="$SMB_REM_PATH/$_rel"; _smbpath="${_smbpath#/}"
local _smbdir="${_smbpath%/*}" _file="${_smbpath##*/}"
local _cddir="${_smbdir//\//\\}"
# get "檔" - : 輸出到 stdout (smbclient 狀態訊息走 stderr, 丟棄)
command smbclient "$SMB_SHARE" $_auth $SMB_OPTS \
-c "cd \"$_cddir\"; get \"$_file\" -" 2>/dev/null
;;
webdav)
local _wauth=""
[[ -n $remote_user ]] && _wauth="-u $remote_user:$remote_pass"
curl -fsS --connect-timeout 30 --speed-time 300 --speed-limit 512 $_wauth "${remote_url%/}/$_rel" 2>/dev/null
;;
*)
return 1
;;
esac
}
# 解析 SMB URL 並設定 SMB_SHARE / SMB_REM_PATH 全域變數
# SMB_SHARE = //server/share_name (smbclient -L 用)
# SMB_REM_PATH = /sub/path (空字串代表 share 根目錄, 不含結尾斜線)
# 重複的 SMB URL 解析邏輯抽出 (原本有 4 個地方各自解析)
remote_parse_smb_url() {
local url="${remote_url#smb://}"; url="${url%/}"
local server="${url%%/*}"
local after_server="${url#$server/}"
local share_name="${after_server%%/*}"
# after_server 去掉 share_name 後可能是 "" 或 "/path/..."
# 直接用結果, 不再前綴 "/", 否則 "/path" 會變 "//path"
local rem_path="${after_server#$share_name}"
rem_path="${rem_path%/}"
[[ $rem_path = / ]] && rem_path=""
SMB_SHARE="//$server/$share_name"
SMB_REM_PATH="$rem_path"
}
# 從遠端下載單個文件 (用於備份前對比 app_details.json)
# 用法: remote_download_single_file <遠端相對路徑> <本地目標路徑>
# 回傳: 0=成功, 1=失敗
remote_download_single_file() {
local remote_rel="$1" local_dest="$2"
[[ -z $remote_type || -z $remote_url ]] && return 1
local backup_subdir="$(get_backup_dirname)"
case $remote_type in
webdav)
local base_url="${remote_url%/}/$backup_subdir"
local enc_rel="$(url_encode_path "$remote_rel")"
local target_url="$base_url/$enc_rel"
curl -sS -L --http1.1 --connect-timeout 10 -u "$remote_user:$remote_pass" \
-o "$local_dest" "$target_url" 2>/dev/null
[[ -s $local_dest ]]
;;
smb)
remote_parse_smb_url
local share="$SMB_SHARE"
local rem_path="$SMB_REM_PATH"
local base="${rem_path:+$rem_path/}$backup_subdir"
local dir_part="${remote_rel%/*}"
local file_part="${remote_rel##*/}"
local smb_dest="$TMPDIR/.smb_dl_$$"
mkdir -p "$smb_dest" 2>/dev/null
smbclient "$share" -U "$remote_user%$remote_pass" -t 10 -s /dev/null \
-D "$base/$dir_part" \
-c "lcd $smb_dest; get $file_part; exit" >/dev/null 2>&1
if [[ -f "$smb_dest/$file_part" ]]; then
mv "$smb_dest/$file_part" "$local_dest"
rm -rf "$smb_dest"
return 0
else
rm -rf "$smb_dest"
return 1
fi
;;
*)
return 1
;;
esac
}
# 過濾 smbclient 輸出的雜訊行 (Try help / dos charset / OS= 等橫幅文字)
# 用法: smb_filter_noise <輸入字串>
smb_filter_noise() {
echo "$1" | grep -Ev '^Try "help"|^dos charset|^Can.t load|^Domain=|^OS=|^directory_create_or_exist:|^$'
}
# 判斷目錄是否含任何檔案 (非空)
# 用法: dir_has_files <目錄路徑>
# 回傳: 0=有檔案, 1=空目錄或不存在
dir_has_files() {
[[ -d $1 ]] || return 1
[[ -n $(find "$1" -type f -print -quit 2>/dev/null) ]]
}
# 啟動時的遠端設定初始化
# 規範化 remote_keep_local 值, 驗證 remote_type, TCP 預檢
# 失敗時清空 remote_type 停用上傳但保留本地備份
remote_setup() {
# 若之前連線失敗清空了 remote_type, 用原始值恢復以重新檢測 (支援中途開 WiFi 後重試)
[[ -z $remote_type && -n $_remote_type_orig ]] && remote_type="$_remote_type_orig"
[[ -z $remote_type ]] && return
# 規範化 remote_keep_local 成 true/false
case $remote_keep_local in
1|true|True|TRUE) remote_keep_local=true ;;
*) remote_keep_local=false ;;
esac
echoRgb "遠程備份: $remote_type -> $remote_url" "3"
case $remote_type in
webdav|smb)
;;
*) echoRgb "未知遠程類型: $remote_type (可選: webdav/smb)" "0"; remote_type=""; return 1 ;;
esac
[[ -z $remote_url ]] && { echoRgb "遠端位址未設置 (請設 smb_url 或 webdav_url),停用遠端上傳" "0"; remote_type=""; return 1; }
# conf 防呆: 檢查 URL 格式跟協議匹配
case $remote_type in
webdav)
case $remote_url in
http://*|https://*) ;;
smb://*)
echoRgb "remote_type=webdav 但 remote_url 是 smb:// 開頭" "0"
echoRgb "請改成 http:// 或 https:// 開頭, 或把 remote_type 改成 smb" "3"
remote_type=""; return 1 ;;
*)
echoRgb "remote_url 必須以 http:// 或 https:// 開頭" "0"
echoRgb "目前: $remote_url" "0"
remote_type=""; return 1 ;;
esac
;;
smb)
case $remote_url in
smb://*) ;;
http://*|https://*)
echoRgb "remote_type=smb 但 remote_url 是 http(s):// 開頭" "0"
echoRgb "請改成 smb:// 開頭, 或把 remote_type 改成 webdav" "3"
remote_type=""; return 1 ;;
"")
# 未填地址: 自動掃描區網 SMB 並填入第一台的第一個共享
echoRgb "remote_url 未填, 自動掃描區網 SMB..." "3"
if smb_autodetect_url; then
echoRgb "自動填入: $remote_url" "1"
else
echoRgb "自動掃描未找到可用 SMB, 請手動填 remote_url" "0"
remote_type=""; return 1
fi
;;
*)
echoRgb "remote_url 必須以 smb:// 開頭" "0"
echoRgb "目前: $remote_url" "0"
remote_type=""; return 1 ;;
esac
;;
esac
# 帳密為空提醒 (非致命, 可能是匿名認證)
[[ -z $remote_user ]] && echoRgb "remote_user 未設定 (將以匿名嘗試連線)" "0"
# 事前連線測試: 從各協議解出 host:port 做快速 TCP 探測
remote_parse_endpoint
# 流式模式需要 SMB_SHARE/SMB_REM_PATH (平時在 upload 函數才解析, 流式不走那裡, 故這裡先解析)
[[ $remote_stream = 1 && $remote_type = smb ]] && remote_parse_smb_url
# 端口跟協議不一致警告 (常見錯誤: https 配 80 或 http 配 443)
if [[ $remote_type = webdav ]]; then
case "$remote_url:$REMOTE_PORT" in
https://*:80)
echoRgb "警告: HTTPS 通常用 443, 你設 80 (可能應改用 http://)" "0" ;;
http://*:443)
echoRgb "警告: HTTP 通常用 80, 你設 443 (可能應改用 https://)" "0" ;;
esac
fi
if remote_precheck "$REMOTE_HOST" "$REMOTE_PORT"; then
echoRgb "遠端連線測試通過 ($REMOTE_HOST:$REMOTE_PORT)" "1"
if [[ $remote_stream = 1 ]]; then
echoRgb "流式上傳模式 (邊壓邊傳, 不佔本機空間)" "3"
# WebDAV 流式需伺服器支援 chunked PUT (Synology 等 Apache WebDAV 常不支援, 回 411)
if [[ $remote_type = webdav ]]; then
local _wauth_t=""
[[ -n $remote_user ]] && _wauth_t="-u $remote_user:$remote_pass"
if ! echo "chunked_test" | curl -fsS -o /dev/null --connect-timeout 15 $_wauth_t -T - "${remote_url%/}/.stream_chunk_test" 2>/dev/null; then
echoRgb "此 WebDAV 伺服器不支援串流上傳 (chunked PUT, 如 Synology 內建 WebDAV)" "0"
echoRgb "流式模式無法使用, 請改用 SMB 或 rclone serve webdav, 或設 remote_stream=0" "3"
exit 1
fi
curl -fsS $_wauth_t -X DELETE "${remote_url%/}/.stream_chunk_test" >/dev/null 2>&1
fi
elif [[ $remote_keep_local = true ]]; then
echoRgb "備份完成後將自動上傳到遠端 (保留本地檔案)" "3"
else
echoRgb "備份完成後將自動上傳到遠端 (上傳成功後刪除本地檔案)" "3"
fi
else
echoRgb "遠端連線測試失敗: $REMOTE_HOST:$REMOTE_PORT" "0"
echoRgb "可能原因: 未開WiFi/位址錯誤/伺服器未啟動/協議端口不匹配" "0"
echoRgb "本次將停用遠端上傳,備份僅保留在本地" "0"
remote_type=""
fi
}
# 獨立的測試遠端入口 (給選單用)
# 1. 顯示 conf 設定
# 2. TCP 預檢
# 3. 嘗試認證 + list 遠端目錄
# 不會實際上傳任何東西
# 單獨上傳某個 app 的備份 (給子目錄 upload.sh 用)
# 假設呼叫時 Backup 是備份根目錄 (Backup_zstd_X)
# $1 = app 名 (子目錄名)
single_upload() {
local app_name="$1"
[[ -z $app_name ]] && { echoRgb "single_upload: 缺少 app 名" "0"; return 1; }
[[ -z $Backup ]] && Backup="$MODDIR"
local target="$Backup/$app_name"
[[ ! -d $target ]] && { echoRgb "找不到目錄: $target" "0"; return 1; }
dir_has_files "$target" || { echoRgb "$app_name 目錄為空,沒有東西可上傳" "0"; return 1; }
# 重置範圍 flag, 只標記這一個
unset REMOTE_APPLIST REMOTE_UPLOAD_MEDIA REMOTE_UPLOAD_WIFI
case $app_name in
Media) REMOTE_UPLOAD_MEDIA=1 ;;
wifi) REMOTE_UPLOAD_WIFI=1 ;;
*) REMOTE_APPLIST="$app_name" ;;
esac
REMOTE_TRIGGER=1
# 啟動時 remote_setup 已跑過, 這裡只檢查狀態
[[ -z $remote_type ]] && { echoRgb "遠端未設定或預檢失敗,終止" "0"; return 1; }
echoRgb "—————— 單獨上傳: $app_name ——————" "3"
case $remote_type in
smb) upload_smb ;;
webdav) upload_remote "webdav" ;;
esac
# 已主動上傳, 清旗標避免 trap EXIT 再跑一次
unset REMOTE_TRIGGER
}
# 邊備份邊上傳:每備份完一個應用後立即上傳並刪除本機檔案
# $1 = 應用名 (目錄名)
# 依賴 global: remote_type, remote_keep_local, Backup
per_app_upload_and_cleanup() {
local app_name="$1"
[[ -z $app_name ]] && return 1
[[ -z $Backup ]] && return 1
local target="$Backup/$app_name"
[[ ! -d $target ]] && return 0
dir_has_files "$target" || return 0
[[ -z $remote_type ]] && return 1
# 合併遠端 app_details.json 到本地,避免丢失遠端已有的字段
local local_app_details="$target/app_details.json"
local remote_app_details="$TMPDIR/.remote_app_details_merge_$$"
local remote_rel="${app_name}/app_details.json"
if [[ -f $local_app_details ]] && remote_download_single_file "$remote_rel" "$remote_app_details" 2>/dev/null; then
[[ -s $remote_app_details ]] && {
# 合併遠端數據到本地(本地數據優先,但保留遠端已有的字段)
local merged="$TMPDIR/.merged_app_details_$$"
jq -s '.[0] * .[1]' "$remote_app_details" "$local_app_details" > "$merged" 2>/dev/null && mv "$merged" "$local_app_details"
rm -f "$merged"
}
fi
rm -f "$remote_app_details"
# 設定上傳範圍:只上傳這一個 app, 跳過 tools/ 等固定項避免重複上傳
# REMOTE_APPDETAILS_FILE: 主體上傳完成後,若無失敗則由上傳函數自動處理
unset REMOTE_APPLIST REMOTE_UPLOAD_MEDIA REMOTE_UPLOAD_WIFI
REMOTE_APPLIST="$app_name"
REMOTE_SKIP_FIXED=1
REMOTE_APPDETAILS_SKIP=1
REMOTE_QUIET=1
REMOTE_TRIGGER=1
REMOTE_APPDETAILS_FILE="$Backup/$app_name/app_details.json"
case $remote_type in
smb) upload_smb ;;
webdav) upload_remote "webdav" ;;
esac
# 清除標記
unset REMOTE_TRIGGER REMOTE_SKIP_FIXED REMOTE_APPLIST REMOTE_APPDETAILS_SKIP REMOTE_QUIET REMOTE_APPDETAILS_FILE
}
# 主選單觸發: 讀 appList.txt + Custom_path, 直接上傳對應目錄
# 不互動,等同於跑完整備份後的自動上傳,但不重新備份
upload_current_backup() {
backup_path
[[ ! -d $Backup ]] && { echoRgb "本地備份目錄不存在: $Backup" "0"; return 1; }
echoRgb "本地備份: $Backup" "2"
show_conf remote
# remote_setup 連線失敗或未設定時會清空 remote_type → 直接終止, 不再詢問
# 用 remote_url 區分: 有設 URL 但 type 被清空 = 連線失敗; 沒設 URL = 未設定
if [[ -z $remote_type ]]; then
if [[ -n $remote_url ]]; then
echoRgb "遠端連線失敗 (請檢查 WiFi/伺服器), 無法上傳" "0"
else
echoRgb "未設定遠端, 無法上傳" "0"
fi
return 1
fi
# 選擇上傳模式: 1=按清單(appList) 2=整個目錄
unset REMOTE_FULL_DIR
unset _up_mode
if ! ask_yn "上傳模式" "按清單上傳" "上傳整個Backup目錄"; then
REMOTE_FULL_DIR=1
fi
if [[ $REMOTE_FULL_DIR = 1 ]]; then
echoRgb "已選擇: 上傳整個 Backup 目錄 (排除 log)" "2"
else
# 讀 appList.txt (跟備份用同一個解析邏輯)
local applist=""
if [[ -n $list_location ]]; then
if [[ ${list_location:0:1} = / ]]; then
[[ -f $list_location ]] && applist="$list_location"
else
[[ -f $MODDIR/$list_location ]] && applist="$MODDIR/$list_location"
fi
fi
[[ -z $applist && -f $MODDIR/appList.txt ]] && applist="$MODDIR/appList.txt"
# 組裝 REMOTE_APPLIST (跟 backup() 用同一個變數,讓 collect_targets 認得)
unset REMOTE_APPLIST REMOTE_UPLOAD_MEDIA REMOTE_UPLOAD_WIFI
if [[ -n $applist ]]; then
REMOTE_APPLIST="$(cat "$applist")"
local app_count
app_count=$(echo "$REMOTE_APPLIST" | grep -cEv '^[[:space:]]*[#!]|^[[:space:]]*$')
echoRgb "讀取 $applist (有效 $app_count 個 app)" "2"
else
echoRgb "找不到 appList.txt" "0"
fi
# 讀 Custom_path: 有設就帶上 Media
if [[ -n $Custom_path ]]; then
if dir_has_files "$Backup/Media"; then
REMOTE_UPLOAD_MEDIA=1
echoRgb "Custom_path 已設, 將上傳 Media" "2"
fi
fi
# wifi 目錄存在就一併上傳
if dir_has_files "$Backup/wifi"; then
REMOTE_UPLOAD_WIFI=1
echoRgb "wifi 目錄存在, 將上傳 wifi" "2"
fi
if [[ -z $REMOTE_APPLIST && $REMOTE_UPLOAD_MEDIA != 1 && $REMOTE_UPLOAD_WIFI != 1 ]]; then
echoRgb "沒有可上傳項目 (appList 為空, Custom_path 未設, 無 wifi)" "0"
return 1
fi
fi
# 上傳前統計大小並確認
local _pre_list="$TMPDIR/.precheck_list" _pre_bytes
remote_collect_targets "$_pre_list"
if [[ ! -s $_pre_list ]]; then
echoRgb "沒有可上傳的檔案" "0"; rm -f "$_pre_list" 2>/dev/null; return 1
fi
local _pre_count="$(wc -l < "$_pre_list")"
if [[ $REMOTE_FULL_DIR = 1 ]]; then
# 全目錄模式: 用 du 算整個目錄 (跟備份時 Calculate_size 同源), 減去 log
local _all _log
# 純文件字節, 排除根目錄 log (整體 - log, 兩者同算法相減精確)
local _all _log
_all="$(calc_dir_size "$Backup")"
_log="$(calc_dir_size "$Backup/log" 2>/dev/null)"
_pre_bytes=$(awk -v a="${_all:-0}" -v l="${_log:-0}" 'BEGIN{print a-l}')
else
_pre_bytes="$(list_total_size "$_pre_list")"
fi
echoRgb "本次上傳: $_pre_count 個檔案, 總大小 $(size "$_pre_bytes") (位元組:$_pre_bytes)" "3"
rm -f "$_pre_list" 2>/dev/null
if ! ask_yn "確認上傳?" "確認上傳" "取消"; then
echoRgb "已取消上傳" "1"; return 0
fi
REMOTE_TRIGGER=1
# 啟動時 remote_setup 已跑過, 這裡只檢查狀態
[[ -z $remote_type ]] && { echoRgb "遠端未設定或預檢失敗,終止" "0"; return 1; }
case $remote_type in
smb) upload_smb ;;
webdav) upload_remote "webdav" ;;
esac
# 已主動上傳, 清旗標避免 trap EXIT 再跑一次
unset REMOTE_TRIGGER
}
# 測試遠端連線完整性 (主選單第 6 項)
# 三層測試: TCP 預檢 → 認證 → 路徑訪問
# 每層失敗都有具體錯誤訊息 (帳密錯/路徑不存在/分享不存在等)
remote_test() {
echoRgb "============== 遠端連線測試 ==============" "3"
show_conf remote
if [[ -z $remote_type ]]; then
echoRgb "remote_type 未設定" "0"
echoRgb "請編輯 $conf_path 設定 remote_type/remote_url/remote_user/remote_pass" "3"
return 1
fi
echoRgb "類型: $remote_type" "2"
echoRgb "位址: $remote_url" "2"
echoRgb "帳號: ${remote_user:-(未設)}" "2"
[[ -n $remote_pass ]] && echoRgb "密碼: ********" "2" || echoRgb "密碼: (未設)" "2"
echoRgb "保留本地: ${remote_keep_local:-0}" "2"
case $remote_type in
webdav|smb) ;;
*) echoRgb "未知 remote_type: $remote_type (可選: webdav/smb)" "0"; return 1 ;;
esac
[[ -z $remote_url ]] && { echoRgb "remote_url 未設置" "0"; return 1; }
# 協議與 URL 一致性檢查
case $remote_type in
webdav)
case $remote_url in
http://*|https://*) ;;
smb://*)
echoRgb "remote_type=webdav 但 remote_url 是 smb:// 開頭" "0"
echoRgb "請改 remote_type=smb, 或把 remote_url 改成 http(s)://" "3"
return 1 ;;
*://*)
echoRgb "remote_url 協議 (${remote_url%%://*}://) 不被 webdav 支援" "0"
echoRgb "WebDAV 只支援 http:// 或 https://" "3"
return 1 ;;
*)
echoRgb "remote_url 必須以 http:// 或 https:// 開頭" "0"
echoRgb "目前: $remote_url" "0"
return 1 ;;
esac ;;
smb)
case $remote_url in
smb://*) ;;
http://*|https://*)
echoRgb "remote_type=smb 但 remote_url 是 http(s):// 開頭" "0"
echoRgb "請改 remote_type=webdav, 或把 remote_url 改成 smb://" "3"
return 1 ;;
*://*)
echoRgb "remote_url 協議 (${remote_url%%://*}://) 不被 smb 支援" "0"
echoRgb "SMB 只支援 smb://" "3"
return 1 ;;
*)
echoRgb "remote_url 必須以 smb:// 開頭" "0"
echoRgb "目前: $remote_url" "0"
return 1 ;;
esac ;;
esac
scan_smb
# 第一關: TCP 預檢
remote_parse_endpoint
echoRgb "—————— TCP 連線測試 ——————" "3"
echoRgb "目標: $REMOTE_HOST:$REMOTE_PORT" "2"
if remote_precheck "$REMOTE_HOST" "$REMOTE_PORT"; then
echoRgb "TCP 連線通過" "1"
else
echoRgb "TCP 連線失敗" "0"
echoRgb "可能原因:" "0"
echoRgb "WiFi 未開啟或不在同網段" "0"
echoRgb "伺服器 IP / port 寫錯" "0"
echoRgb "伺服器未啟動 / 防火牆阻擋" "0"
return 1
fi
# 第二關: 認證 + 列目錄
echoRgb "—————— 認證與列目錄測試 ——————" "3"
case $remote_type in
smb)
remote_parse_smb_url
local share="$SMB_SHARE"
local rem_path="$SMB_REM_PATH"
local out
out="$(smbclient "$share" -U "$remote_user%$remote_pass" -t 10 -s /dev/null${REMOTE_PORT:+ -p $REMOTE_PORT} -m SMB3 \
-c "cd ${rem_path:-/}; ls; exit" 2>&1)"
out="$(smb_filter_noise "$out")"
if echo "$out" | grep -qE 'NT_STATUS_LOGON_FAILURE'; then
echoRgb "認證失敗 (帳號或密碼錯誤)" "0"
return 1
elif echo "$out" | grep -qE 'NT_STATUS_ACCESS_DENIED'; then
echoRgb "存取被拒 (帳號權限不足)" "0"
return 1
elif echo "$out" | grep -qE 'NT_STATUS_BAD_NETWORK_NAME'; then
echoRgb "share 名稱錯誤: $share_name (請檢查伺服器是否有此分享)" "0"
return 1
elif echo "$out" | grep -qE 'NT_STATUS_OBJECT_(PATH|NAME)_NOT_FOUND'; then
echoRgb "遠端路徑不存在: $rem_path (將在首次上傳時建立)" "3"
elif echo "$out" | grep -qE 'NT_STATUS_(CONNECTION_REFUSED|IO_TIMEOUT|HOST_UNREACHABLE)'; then
echoRgb "網路不通 (伺服器無回應)" "0"
return 1
elif echo "$out" | grep -qE 'NT_STATUS_UNSUCCESSFUL'; then
echoRgb "SMB 連線失敗 (NT_STATUS_UNSUCCESSFUL)" "0"
echoRgb "常見原因: 端口錯誤 / SMB 協議版本不相容 / 伺服器拒絕" "3"
return 1
elif echo "$out" | grep -qE 'NT_STATUS|ERRSRV'; then
echoRgb "SMB 錯誤:" "0"
echo "$out" | head -5
return 1
else
echoRgb "認證通過, share 可存取" "1"
echoRgb "遠端路徑 $remote_url 可存取" "1"
# 抓實際協商出的 SMB 協議版本
# 不同 Samba 版本輸出格式不同, 先把 debug 輸出存檔再多路徑抓取
local _dbg="$TMPDIR/.smb_dbg_$$"
smbclient "$share" -U "$remote_user%$remote_pass" -t 5 -s /dev/null${REMOTE_PORT:+ -p $REMOTE_PORT} -m SMB3 -d 5 \
-c 'exit' >"$_dbg" 2>&1
local _proto
# 試多種關鍵字 (依不同 Samba 版本)
_proto="$(grep -oiE 'protocol negotiation: server.*\[(SMB[0-9_]+|NT[0-9]*)\]|Selected protocol \[?(SMB[0-9_]+|NT[0-9]*)|Negotiated dialect \[?(SMB[0-9_]+|NT[0-9]*)|protocol \[(SMB[0-9_]+|NT[0-9]*)\] negotiated|dialect.*(SMB[0-9_]+|NT[0-9]*)' "$_dbg" | grep -oE '(SMB[0-9_]+|NT[0-9]*)' | head -1)"
if [[ -n $_proto ]]; then
echoRgb "協議版本: $_proto" "1"
else
# fallback: 抓出所有看似協議版本的字串
_proto="$(grep -oiE 'SMB[123]_[0-9]{2}' "$_dbg" | sort -u | tail -1)"
[[ -n $_proto ]] && echoRgb "協議版本: $_proto (推測)" "1"
# 若仍抓不到, 保留 debug 輸出供排查
[[ -z $_proto ]] && {
echoRgb "無法解析協議版本, debug 輸出留在: $_dbg" "2"
return 0
}
fi
rm -f "$_dbg"
fi
;;
webdav)
local base_url="${remote_url%/}"
local code curl_err
# stderr 寫到檔案, 別污染 http_code
code="$(curl -sS -L --http1.1 --connect-timeout 10 -u "$remote_user:$remote_pass" \
-X PROPFIND -H "Depth: 0" -w '%{http_code}' -o /dev/null "$base_url" 2>"$TMPDIR/.curl_test_err")"
curl_err="$(cat "$TMPDIR/.curl_test_err" 2>/dev/null)"
rm -f "$TMPDIR/.curl_test_err"
case $code in
2*|207) echoRgb "WebDAV 認證通過 (HTTP $code)" "1" ;;
401) echoRgb "認證失敗 (HTTP 401, 帳號或密碼錯誤)" "0"; return 1 ;;
403) echoRgb "權限不足 (HTTP 403)" "0"; return 1 ;;
404) echoRgb "路徑不存在 (HTTP 404)" "0"; return 1 ;;
405) echoRgb "方法不允許 (HTTP 405, 此 URL 可能不是 WebDAV 端點)" "0"; return 1 ;;
408) echoRgb "請求逾時 (HTTP 408, 伺服器繁忙)" "0"; return 1 ;;
423) echoRgb "資源被鎖定 (HTTP 423, 有其他客戶端正在寫入)" "0"; return 1 ;;
429) echoRgb "請求過於頻繁 (HTTP 429, 觸發伺服器限流)" "0"; return 1 ;;
500) echoRgb "伺服器內部錯誤 (HTTP 500)" "0"; return 1 ;;
502) echoRgb "閘道錯誤 (HTTP 502, 反向代理 / 上游服務有問題)" "0"; return 1 ;;
503) echoRgb "服務不可用 (HTTP 503, 伺服器維護或過載)" "0"; return 1 ;;
504) echoRgb "閘道逾時 (HTTP 504)" "0"; return 1 ;;
3*) echoRgb "未處理的重定向 (HTTP $code, curl -L 已展開但仍失敗, 可能跳到非 WebDAV 端點)" "0"; return 1 ;;
000)
# curl 連 HTTP 都還沒走到, 看 stderr 判斷具體原因
echoRgb "curl 無法完成請求" "0"
case $curl_err in
*WRONG_VERSION_NUMBER*|*wrong\ version\ number*)
echoRgb "原因: 協議跟端口不匹配 (URL 寫 https 但伺服器是 http, 或反過來)" "0"
case $remote_url in
https://*) echoRgb "建議: 把 remote_url 改成 http://$REMOTE_HOST:$REMOTE_PORT/..." "3" ;;
http://*) echoRgb "建議: 把 remote_url 改成 https://$REMOTE_HOST:$REMOTE_PORT/..." "3" ;;
esac ;;
*"Could not resolve host"*|*"Couldn't resolve host"*)
echoRgb "原因: DNS 解析失敗 (域名不存在或 DNS 服務問題)" "0" ;;
*"Connection refused"*)
echoRgb "原因: 連線被拒 (端口未開或防火牆攔截)" "0" ;;
*"Connection timed out"*|*"timed out"*)
echoRgb "原因: 連線逾時 (網路或防火牆問題)" "0" ;;
*"SSL certificate"*|*"certificate verify"*|*"server certificate verification failed"*)
echoRgb "原因: SSL 證書驗證失敗 (自簽證書或過期)" "0"
echoRgb "建議: 若是自簽證書, 改用 http://, 或在 curl 加 -k (需改腳本)" "3" ;;
*"SSL_ERROR_SYSCALL"*|*"SSL connect error"*)
echoRgb "原因: SSL 握手失敗 (TLS 版本不相容 / 伺服器斷線)" "0" ;;
*"server certificate verification failed"*)
echoRgb "原因: 伺服器證書驗證失敗" "0"
echoRgb "建議: 若是自簽證書, 用 http:// 或在 curl 加 -k (需改腳本)" "3" ;;
*"Host requires authentication"*|*"401 Unauthorized"*)
echoRgb "原因: 需要認證但帳密為空或錯誤" "0" ;;
*"Operation too slow"*)
echoRgb "原因: 傳輸過慢被中斷" "0" ;;
*"Empty reply from server"*)
echoRgb "原因: 伺服器收到請求但沒回應 (服務未啟動 / 配置錯誤)" "0" ;;
*) echoRgb "詳細: $curl_err" "0" ;;
esac
return 1 ;;
*) echoRgb "WebDAV 異常 (HTTP $code)" "0"
[[ -n $curl_err ]] && echoRgb "詳細: $curl_err" "0"
return 1 ;;
esac
;;
esac
echoRgb "========================================" "3"
echoRgb "全部測試通過, 可以開始備份" "1"
return 0
}
# 列出遠端可用的備份目錄並產生 appList_network.txt
# 流程: 連遠端 → 列 Backup_*_* 目錄 → 讓使用者選 → 檢查必要檔案 → 掃 app 清單 → 輸出
remote_list_backups() {
show_conf remote
[[ -z $remote_type ]] && { echoRgb "remote_type 未設定" "0"; return 1; }
case $remote_type in
smb|webdav) ;;
*) echoRgb "下載功能僅支援 smb / webdav (目前 remote_type=$remote_type)" "0"; return 1 ;;
esac
# 規範化 remote_keep_local
case $remote_keep_local in
1|true|True|TRUE) remote_keep_local=true ;;
*) remote_keep_local=false ;;
esac
# 目標目錄 = 跟本地備份一樣的命名規則
local target_dir="$(get_backup_dirname)"
echoRgb "目標遠端目錄: $target_dir" "3"
# 連線預檢
remote_parse_endpoint
if ! remote_precheck "$REMOTE_HOST" "$REMOTE_PORT"; then
echoRgb "遠端連線失敗: $REMOTE_HOST:$REMOTE_PORT" "0"
return 1
fi
echoRgb "連線到 $remote_type://$REMOTE_HOST:$REMOTE_PORT" "1"
# 進入目標目錄, 列出檔案/子資料夾
local sub_listing="$TMPDIR/.remote_sub_listing"
: > "$sub_listing"
if [[ $remote_type = smb ]]; then
remote_parse_smb_url
local share="$SMB_SHARE"
local rem_path="$SMB_REM_PATH"
local smb_out
smb_out=$(smbclient "$share" -U "$remote_user%$remote_pass" -t 10 -s /dev/null${REMOTE_PORT:+ -p $REMOTE_PORT} -m SMB3 \
-c "cd ${rem_path:-/}/$target_dir; ls; exit" 2>&1)
if echo "$smb_out" | grep -qE 'NT_STATUS_OBJECT_(PATH|NAME)_NOT_FOUND'; then
echoRgb "遠端目錄不存在: $target_dir" "0"
echoRgb "請確認遠端有此備份,或備份過至少一次" "3"
rm -f "$sub_listing"
return 1
fi
if echo "$smb_out" | grep -qE 'NT_STATUS|ERRSRV'; then
echoRgb "讀取遠端失敗:" "0"
echo "$smb_out" | grep -E 'NT_STATUS|ERR' | head -3
rm -f "$sub_listing"
return 1
fi
# 格式: "D dirname" 或 "N filename"
echo "$smb_out" | awk 'NF>=5 && $1 != "." && $1 != ".." {print $2, $1}' > "$sub_listing"
elif [[ $remote_type = webdav ]]; then
local base_url="${remote_url%/}"
local http_code
http_code=$(curl -sS -L --http1.1 --connect-timeout 10 -u "$remote_user:$remote_pass" \
-X PROPFIND -H "Depth: 1" -w '%{http_code}' -o "$TMPDIR/.wdav_out" \
"$base_url/$target_dir/" 2>/dev/null)
# debug: 把 PROPFIND 原始回應寫到 log 供除錯
local dbg_log="${logfile%/*}/webdav_debug.log"
mkdir -p "${logfile%/*}" 2>/dev/null
{
echo "===== WebDAV PROPFIND $(date '+%Y-%m-%d %H:%M:%S') ====="
echo "URL: $base_url/$target_dir/"
echo "HTTP code: $http_code"
echo "----- Raw XML response -----"
cat "$TMPDIR/.wdav_out" 2>/dev/null
echo ""
echo "----- End -----"
} > "$dbg_log"
case $http_code in
2*) ;;
404)
echoRgb "遠端目錄不存在: $target_dir (HTTP 404)" "0"
echoRgb "請確認遠端有此備份,或備份過至少一次" "3"
# PROPFIND 根目錄看實際有什麼, 幫用戶確認路徑名
local root_code root_xml="$TMPDIR/.wdav_root"
root_code=$(curl -sS -L --http1.1 --connect-timeout 10 -u "$remote_user:$remote_pass" \
-X PROPFIND -H "Depth: 1" -w '%{http_code}' -o "$root_xml" \
"$base_url/" 2>/dev/null)
{
echo ""
echo "----- 根目錄探測 PROPFIND $base_url/ -----"
echo "HTTP code: $root_code"
cat "$root_xml" 2>/dev/null
echo ""
} >> "$dbg_log"
case $root_code in
2*)
# 抓 href 列表給用戶看
local found
found=$(cat "$root_xml" 2>/dev/null | tr '><' '\n' | awk '
/^(D:)?response$/ { in_resp=1; href="" }
/^\/(D:)?response$/ { if (in_resp && href != "") print href; in_resp=0 }
/^(D:)?href$/ { getline href }
' | grep -v '^/$' | grep -v "^${base_url#http*://*/}$")
if [[ -n $found ]]; then
echoRgb "遠端根目錄實際有以下項目:" "3"
echo "$found" | head -20
fi
;;
esac
rm -f "$root_xml"
echoRgb "原始回應已記錄: $dbg_log" "3"
rm -f "$sub_listing" "$TMPDIR/.wdav_out"; return 1 ;;
*) echoRgb "讀取遠端失敗 (HTTP $http_code)" "0"
echoRgb "原始回應已記錄: $dbg_log" "3"
rm -f "$sub_listing" "$TMPDIR/.wdav_out"; return 1 ;;
esac
local propfind_out
propfind_out=$(cat "$TMPDIR/.wdav_out" 2>/dev/null)
rm -f "$TMPDIR/.wdav_out"
# 解析每個 response, 過濾掉「目錄自己」(href 跟 base 同名)
# 收集成 "D|encoded_name" 或 "N|encoded_name"
# 兼容兩種 WebDAV XML 格式: 有 D: 名前綴 或無前綴
# 注意: busybox awk 對 ($|[..]) 解析有 bug, 改用 sub 去除前綴後字串比對
local raw_listing="$TMPDIR/.raw_wdav_listing"
echo "$propfind_out" | tr '><' '\n' | awk '
{
# 取每行第一個 token (去掉屬性)
tag = $1
# 去掉 D: 前綴, 處理 /D: 也變 /
sub(/^D:/, "", tag)
sub(/^\/D:/, "/", tag)
# 去掉自關閉的 / (collection/ → collection)
sub(/\/$/, "", tag)
}
tag == "response" { in_resp=1; href=""; is_dir=0; next }
tag == "/response" {
if (in_resp && href != "") {
n = split(href, a, "/")
name = a[n]
if (name == "" && n > 1) name = a[n-1]
if (name != "" && name != "/") {
print (is_dir ? "D" : "N") "|" name
}
}
in_resp=0
next
}
tag == "href" { getline href; next }
tag == "collection" { is_dir=1 }
' > "$raw_listing"
# 過濾掉目錄自己 (encoded 或非 encoded 都比對)
# target_dir 是 "Backup_zstd_0" 純 ASCII,不需要編碼
grep -vE "\|${target_dir}\$" "$raw_listing" > "$sub_listing"
rm -f "$raw_listing"
# URL 解碼 (支援 UTF-8 中文)
# 用 printf 將 %XX 轉成實際字元
local decoded="$TMPDIR/.decoded_listing"
: > "$decoded"
while IFS='|' read -r typ name; do
[[ -z $name ]] && continue
# printf '%b' 不認 %XX, 要先轉成 \xXX
local converted
converted=$(echo "$name" | sed 's/%\([0-9a-fA-F][0-9a-fA-F]\)/\\x\1/g')
# 用 printf 把 \xXX 還原成真實字元
local real
real=$(printf '%b' "$converted")
echo "$typ $real" >> "$decoded"
done < "$sub_listing"
mv "$decoded" "$sub_listing"
fi
if [[ ! -s $sub_listing ]]; then
echoRgb "遠端目錄為空或讀取失敗" "0"
# 列出 raw XML 跟解析後的結果到 log 方便除錯
if [[ $remote_type = webdav && -f ${logfile%/*}/webdav_debug.log ]]; then
{
echo ""
echo "----- Parsed listing (sub_listing) -----"
[[ -f $sub_listing ]] && cat "$sub_listing"
echo "(empty)"
} >> "${logfile%/*}/webdav_debug.log"
echoRgb "詳細回應已記錄: ${logfile%/*}/webdav_debug.log" "3"
fi
rm -f "$sub_listing"
return 1
fi
# 檢查必要檔案: tools/ start.sh restore_settings.conf
local has_tools=0 has_start=0 has_conf=0
while read -r type name; do
case "$name" in
tools) [[ $type = D ]] && has_tools=1 ;;
start.sh) [[ $type != D ]] && has_start=1 ;;
restore_settings.conf) [[ $type != D ]] && has_conf=1 ;;
esac
done < "$sub_listing"
local missing=""
[[ $has_tools = 0 ]] && missing="$missing tools/"
[[ $has_start = 0 ]] && missing="$missing start.sh"
[[ $has_conf = 0 ]] && missing="$missing restore_settings.conf"
if [[ -n $missing ]]; then
echoRgb "錯誤: 遠端 $target_dir 缺少必要檔案:$missing" "0"
echoRgb "此備份不完整,無法用於恢復" "0"
rm -f "$sub_listing"
return 1
fi
echoRgb "必要檔案檢查通過 (tools/ start.sh restore_settings.conf)" "1"
# 產生 appList_network.txt
# 規則:
# - 排除 tools, start.sh, restore_settings.conf (固定下載項)
# - 是目錄: wifi/Media → 特殊項; 其他 → app
# - 是檔案就忽略
local out="$MODDIR/appList_network.txt"
# 遠端 app 清單
local apps="$TMPDIR/.apps_list"
: > "$apps"
while read -r type name; do
[[ $type = D ]] || continue
case "$name" in
tools|wifi|Media) continue ;;
esac
echo "$name" >> "$apps"
done < "$sub_listing"
sort "$apps" > "$TMPDIR/.apps_sorted"
# 本地備份資料夾清單
local local_apps="$TMPDIR/.local_apps"
local _local_backup="$MODDIR/$(get_backup_dirname)"
: > "$local_apps"
for _d in "$_local_backup"/*/; do
_d="${_d%/}"; _d="${_d##*/}"
case "$_d" in tools|wifi|Media|log) continue ;; esac
[[ -f "$_local_backup/$_d/app_details.json" ]] && echo "$_d" >> "$local_apps"
done
sort "$local_apps" > "$TMPDIR/.local_sorted"
local only_remote only_local
only_remote="$(comm -23 "$TMPDIR/.apps_sorted" "$TMPDIR/.local_sorted")"
only_local="$(comm -13 "$TMPDIR/.apps_sorted" "$TMPDIR/.local_sorted")"
{
echo "# 遠端備份目錄: $target_dir"
echo "# 連線: $remote_type://$REMOTE_HOST/"
echo "# 用 # 註解掉不要下載的項目, 編輯完選 '從遠端下載備份' 即可"
echo ""
echo "# ---- 應用 (每行一個 app) ----"
cat "$TMPDIR/.apps_sorted"
echo ""
echo "# ---- 特殊項目 (非 app, 有就會下載) ----"
while read -r type name; do
[[ $type = D ]] || continue
case "$name" in
wifi|Media) echo "$name" ;;
esac
done < "$sub_listing"
echo ""
echo "# ---- 比對結果 ----"
if [[ -n $only_remote ]]; then
echo "# 遠端有、本地無 (可下載):"
echo "$only_remote" | while read -r _n; do echo "# $_n"; done
fi
if [[ -n $only_local ]]; then
echo "# 本地有、遠端無 (未上傳):"
echo "$only_local" | while read -r _n; do echo "# $_n"; done
fi
[[ -z $only_remote && -z $only_local ]] && echo "# 本地與遠端完全一致"
} > "$out"
rm -f "$apps" "$TMPDIR/.apps_sorted" "$local_apps" "$TMPDIR/.local_sorted"
rm -f "$sub_listing"
echoRgb "已輸出清單: $out" "1"
local _rc _lc
[[ -n $only_remote ]] && _rc="$(echo "$only_remote" | grep -c .)"
[[ -n $only_local ]] && _lc="$(echo "$only_local" | grep -c .)"
[[ -n $only_remote ]] && echoRgb "遠端有、本地無: ${_rc}" "3" && echo "$only_remote" | while read -r _n; do echoRgb " $_n" "3"; done
[[ -n $only_local ]] && echoRgb "本地有、遠端無: ${_lc}" "0" && echo "$only_local" | while read -r _n; do echoRgb " $_n" "0"; done
[[ -z $only_remote && -z $only_local ]] && echoRgb "本地與遠端完全一致" "1"
# 有差異時提供快速操作
if [[ -n $only_local ]]; then
if ask_yn "立即上傳本地多出的應用?" "上傳" "跳過"; then
backup_path
REMOTE_APPLIST="$only_local"
REMOTE_TRIGGER=1
case $remote_type in
smb) upload_smb ;;
webdav) upload_remote "webdav" ;;
esac
unset REMOTE_TRIGGER REMOTE_APPLIST
fi
fi
if [[ -n $only_remote ]]; then
if ask_yn "立即下載遠端多出的應用?" "下載" "跳過"; then
local _dl_items="$TMPDIR/.dl_diff_items"
echo "$only_remote" > "$_dl_items"
local chosen="$(get_backup_dirname)"
local dest="$MODDIR/$chosen"
mkdir -p "$dest" 2>/dev/null
remote_parse_endpoint
if remote_precheck "$REMOTE_HOST" "$REMOTE_PORT"; then
if [[ $remote_type = smb ]]; then
_remote_download_smb "$chosen" "$dest" "$_dl_items"
elif [[ $remote_type = webdav ]]; then
_remote_download_webdav "$chosen" "$dest" "$_dl_items"
fi
else
echoRgb "遠端連線失敗" "0"
fi
rm -f "$_dl_items"
fi
fi
echoRgb "請編輯該檔案,留下你要下載的項目,然後選 '從遠端下載備份'" "3"
}
# 依 appList_network.txt 下載備份到 $MODDIR/Backup_*_$user
remote_download_backup() {
show_conf remote
[[ -z $remote_type ]] && { echoRgb "remote_type 未設定" "0"; return 1; }
case $remote_type in
smb|webdav) ;;
*) echoRgb "下載功能僅支援 smb / webdav (目前 remote_type=$remote_type)" "0"; return 1 ;;
esac
local list="$MODDIR/appList_network.txt"
if [[ ! -f $list ]]; then
echoRgb "找不到 $list" "0"
echoRgb "請先執行 '列出遠端備份' 產生清單" "3"
return 1
fi
local dl_start
dl_start=$(date +%s)
# 目標目錄 = 跟本地備份一樣的命名規則
local chosen="$(get_backup_dirname)"
echoRgb "目標遠端目錄: $chosen" "3"
# 連線預檢
remote_parse_endpoint
if ! remote_precheck "$REMOTE_HOST" "$REMOTE_PORT"; then
echoRgb "遠端連線失敗: $REMOTE_HOST:$REMOTE_PORT" "0"
return 1
fi
# 解析清單 (去除註解/空行)
local items_file="$TMPDIR/.dl_items"
grep -Ev '^[[:space:]]*[#]|^[[:space:]]*$' "$list" > "$items_file"
if [[ ! -s $items_file ]]; then
echoRgb "清單為空,沒有東西需要下載" "0"
rm -f "$items_file"
return 1
fi
local item_count
item_count=$(wc -l < "$items_file")
echoRgb "將下載 $item_count 個項目 + 固定 3 項 (tools/ start.sh restore_settings.conf)" "3"
# 下載目標: $MODDIR/$chosen (例: $MODDIR/Backup_zstd_0)
local dest="$MODDIR/$chosen"
mkdir -p "$dest" 2>/dev/null
echoRgb "下載到: $dest" "2"
# 依協議分派
local fail=0
if [[ $remote_type = smb ]]; then
_remote_download_smb "$chosen" "$dest" "$items_file" || fail=1
elif [[ $remote_type = webdav ]]; then
_remote_download_webdav "$chosen" "$dest" "$items_file" || fail=1
fi
rm -f "$items_file"
local dl_elapsed=$(( $(date +%s) - dl_start ))
if [[ $fail -eq 0 ]]; then
echoRgb "_______________________________________" "2"
echoRgb "下載完成: $dest 用時${dl_elapsed}" "1"
echoRgb "可直接執行 $dest/start.sh 進行恢復" "3"
remote_log "下載完成: $dest 用時${dl_elapsed}"
else
echoRgb "下載過程有失敗,請檢查上方訊息 (用時${dl_elapsed}秒)" "0"
remote_log "下載失敗 用時${dl_elapsed}"
return 1
fi
}
# SMB 下載實作
# 每個 item 一次 smbclient (用 -D 直接切目錄,避免 cd 路徑解析問題)
# $1=遠端 Backup_zstd_X 目錄名, $2=本地目標, $3=要下載的項目清單檔
_remote_download_smb() {
local chosen="$1" dest="$2" items_file="$3"
remote_parse_smb_url
local share="$SMB_SHARE"
local rem_path="$SMB_REM_PATH"
local SMB_OPTS="-t 300 -s /dev/null${REMOTE_PORT:+ -p $REMOTE_PORT} -m SMB3"
local base="${rem_path:+$rem_path/}$chosen"
local total_items
total_items=$(wc -l < "$items_file")
local idx=0 fail_total=0
# 下載每個項目 (用 -D 切到指定目錄, 再 mget *)
while read -r item; do
[[ -z $item ]] && continue
let idx++
echoRgb "[$idx/$total_items] $(progress_bar $((idx * 100 / total_items))) 下載 $item" "3"
mkdir -p "$dest/$item" 2>/dev/null
local out
out=$(smbclient "$share" -U "$remote_user%$remote_pass" $SMB_OPTS \
-D "$base/$item" \
-c "lcd $dest/$item; prompt off; recurse on; mget *; exit" 2>&1)
out="$(smb_filter_noise "$out")"
if echo "$out" | grep -qE 'NT_STATUS_[A-Z_]+' \
|| [[ -z "$(ls -A "$dest/$item" 2>/dev/null)" ]]; then
echoRgb "$item" "0"
echo "$out" | grep -E 'NT_STATUS' | head -3
let fail_total++
else
echoRgb "$item" "1"
fi
done < "$items_file"
# 固定 3 項: tools/ (獨立連線)
echoRgb "下載固定項目: tools/ start.sh restore_settings.conf" "3"
mkdir -p "$dest/tools" 2>/dev/null
local tools_out
tools_out=$(smbclient "$share" -U "$remote_user%$remote_pass" $SMB_OPTS \
-D "$base/tools" \
-c "lcd $dest/tools; prompt off; recurse on; mget *; exit" 2>&1)
tools_out="$(smb_filter_noise "$tools_out")"
# 固定 3 項: start.sh / restore_settings.conf (獨立連線)
local fix_out
fix_out=$(smbclient "$share" -U "$remote_user%$remote_pass" $SMB_OPTS \
-D "$base" \
-c "lcd $dest; prompt off; get start.sh; get restore_settings.conf; exit" 2>&1)
fix_out="$(smb_filter_noise "$fix_out")"
# 驗證
local fix_fail=0
[[ -z "$(ls -A "$dest/tools" 2>/dev/null)" ]] && fix_fail=1
[[ ! -s $dest/start.sh ]] && fix_fail=1
[[ ! -s $dest/restore_settings.conf ]] && fix_fail=1
if [[ $fix_fail = 1 ]]; then
echoRgb " 固定項目下載有錯誤" "0"
echo "$tools_out
$fix_out" | grep -E 'NT_STATUS' | head -5
[[ -z "$(ls -A "$dest/tools" 2>/dev/null)" ]] && echoRgb " tools/ 為空" "0"
[[ ! -s $dest/start.sh ]] && echoRgb " start.sh 缺失或空檔" "0"
[[ ! -s $dest/restore_settings.conf ]] && echoRgb " restore_settings.conf 缺失或空檔" "0"
let fail_total++
else
echoRgb " ✓ 固定 3 項" "1"
fi
[[ -f $dest/start.sh ]] && chmod +x "$dest/start.sh" 2>/dev/null
[[ -d $dest/tools ]] && chmod -R +x "$dest/tools" 2>/dev/null
[[ $fail_total -eq 0 ]]
}
# WebDAV 下載實作 (並行模式: 先遞迴掃出所有檔案 url, 再 curl -Z 並行下載)
_remote_download_webdav() {
local chosen="$1" dest="$2" items_file="$3"
local base_url="${remote_url%/}/$chosen"
local total_items
total_items=$(wc -l < "$items_file")
local fail_total=0
# 遞迴掃描 WebDAV 路徑, 把所有檔案 (含子目錄內) 寫入清單檔
# 清單格式: <遠端編碼URL>\t<本地完整路徑>
# $1=遠端 base url (已編碼), $2=本地目錄, $3=清單檔
_webdav_scan_files() {
local r_url="$1" l_dir="$2" out_list="$3"
mkdir -p "$l_dir" 2>/dev/null
local out
out=$(curl -sS -L --http1.1 --connect-timeout 10 -u "$remote_user:$remote_pass" \
-X PROPFIND -H "Depth: 1" "$r_url/" 2>/dev/null)
# 用 mktemp 避免遞迴呼叫時不同層級共用同個檔案造成資料覆蓋
local parsed
parsed=$(mktemp "$TMPDIR/.wdav_scan_XXXXXX")
echo "$out" | tr '><' '\n' | awk '
{
tag = $1
sub(/^D:/, "", tag)
sub(/^\/D:/, "/", tag)
sub(/\/$/, "", tag)
}
tag == "response" { in_resp=1; href=""; is_dir=0; next }
tag == "/response" {
if (in_resp && href != "") {
print (is_dir ? "D" : "F") "\t" href
}
in_resp=0
next
}
tag == "href" { getline href; next }
tag == "collection" { is_dir=1 }
' > "$parsed"
local r_url_basename_encoded r_url_basename
r_url_basename_encoded="$(echo "$r_url" | sed 's|/$||; s|.*/||')"
r_url_basename=$(url_decode_path "$r_url_basename_encoded")
local rc=0
while IFS=$'\t' read -r typ href; do
[[ -z $href ]] && continue
local encoded_name name
encoded_name="$(echo "$href" | sed 's|/$||; s|.*/||')"
name=$(url_decode_path "$encoded_name")
[[ -z $name ]] && continue
[[ $name = "$r_url_basename" ]] && continue
if [[ $typ = D ]]; then
_webdav_scan_files "$r_url/$encoded_name" "$l_dir/$name" "$out_list" || rc=1
else
# 寫入清單: URL\t本地路徑
echo -e "$r_url/$encoded_name\t$l_dir/$name" >> "$out_list"
fi
done < "$parsed"
rm -f "$parsed"
return $rc
}
# 用 curl -Z (--parallel) 批次下載清單內的所有檔案
# 每行 "url\tlocal_path", 並行度預設 4
_webdav_parallel_get() {
local list="$1"
[[ ! -s $list ]] && return 0
# 組裝 curl --config 格式: url + output 一對一
local cfg="$TMPDIR/.curl_cfg_$$"
: > "$cfg"
while IFS=$'\t' read -r url lpath; do
# curl config 格式: 每組 url + output
# 路徑要用引號避開空白
echo "url = \"$url\"" >> "$cfg"
echo "output = \"$lpath\"" >> "$cfg"
done < "$list"
curl -sS -L --http1.1 --connect-timeout 10 --retry 2 -Z --parallel-max 4 \
-u "$remote_user:$remote_pass" -K "$cfg" 2>/dev/null
local rc=$?
rm -f "$cfg"
return $rc
}
# 1. 遞迴掃描所有要下載的檔案
local all_files="$TMPDIR/.wdav_all_files"
: > "$all_files"
local idx=0
local scan_fail=0
# 1a. items_file 內每個項目
while read -r item; do
[[ -z $item ]] && continue
let idx++
local encoded_item
encoded_item=$(url_encode_path "$item")
[[ -z $encoded_item ]] && encoded_item="$item"
echoRgb "[$idx/$total_items] $(progress_bar $((idx * 100 / total_items))) 掃描 $item" "3"
if ! _webdav_scan_files "$base_url/$encoded_item" "$dest/$item" "$all_files"; then
echoRgb " ✗ 掃描失敗: $item" "0"
scan_fail=1
let fail_total++
fi
done < "$items_file"
# 1b. 固定項目 tools/
echoRgb "掃描固定項目: tools/" "3"
if ! _webdav_scan_files "$base_url/tools" "$dest/tools" "$all_files"; then
echoRgb " ✗ 掃描失敗: tools/" "0"
scan_fail=1
let fail_total++
fi
# 1c. 固定檔案 start.sh / restore_settings.conf 直接加進清單
for f in start.sh restore_settings.conf; do
echo -e "$base_url/$f\t$dest/$f" >> "$all_files"
done
# 2. 並行下載
local total_files
total_files=$(wc -l < "$all_files")
echoRgb "並行下載 $total_files 個檔案 (4 路同時)" "3"
_webdav_parallel_get "$all_files"
rm -f "$all_files"
# 3. 事後驗證每個項目本地是否有檔案
local _vi=0
while read -r item; do
[[ -z $item ]] && continue
let _vi++
if [[ -z "$(ls -A "$dest/$item" 2>/dev/null)" ]]; then
echoRgb " [$_vi/$total_items] $(progress_bar $((_vi * 100 / total_items)))$item (本地為空)" "0"
let fail_total++
else
echoRgb " [$_vi/$total_items] $(progress_bar $((_vi * 100 / total_items)))$item" "1"
fi
done < "$items_file"
# 固定項目驗證
local fix_fail=0
[[ -z "$(ls -A "$dest/tools" 2>/dev/null)" ]] && fix_fail=1
[[ ! -s $dest/start.sh ]] && fix_fail=1
[[ ! -s $dest/restore_settings.conf ]] && fix_fail=1
if [[ $fix_fail = 1 ]]; then
echoRgb " 固定項目下載有錯誤" "0"
[[ -z "$(ls -A "$dest/tools" 2>/dev/null)" ]] && echoRgb " tools/ 為空" "0"
[[ ! -s $dest/start.sh ]] && echoRgb " start.sh 缺失或空檔" "0"
[[ ! -s $dest/restore_settings.conf ]] && echoRgb " restore_settings.conf 缺失或空檔" "0"
let fail_total++
else
echoRgb " ✓ 固定 3 項" "1"
fi
[[ -f $dest/start.sh ]] && chmod +x "$dest/start.sh" 2>/dev/null
[[ -d $dest/tools ]] && chmod -R +x "$dest/tools" 2>/dev/null
[[ $fail_total -eq 0 ]]
}
# trap EXIT 觸發的遠端上傳函數
# 只在 backup/backup_media/backup_update_apk 成功完成後才觸發上傳
# 其他選項 (測試/列出/下載/退出) 不觸發, 由 REMOTE_TRIGGER 旗標控制
remote_cleanup() {
# 只有在 backup / backup_media / backup_update_apk 跑完後才上傳
# 其他功能 (測試連線、生成列表、檢查壓縮等) 不觸發上傳
[[ $REMOTE_TRIGGER != 1 ]] && return 0
# 防雙重觸發 (backup 內直接呼叫 + trap EXIT 都可能呼叫)
[[ $REMOTE_DONE = 1 ]] && return 0
REMOTE_DONE=1
# 流式模式: 應用數據與 json 已在備份過程中逐個流式傳走, 此處只補傳結尾的 wifi (若有)
if [[ $remote_stream = 1 && -n $remote_type ]]; then
local _wifidir="$TMPDIR/.stream_stage/wifi"
if [[ $REMOTE_UPLOAD_WIFI = 1 && -d $_wifidir ]]; then
local _wf
for _wf in "$_wifidir"/*; do
[[ -f $_wf ]] && _stream_upload "wifi/${_wf##*/}" < "$_wf"
done
echoRgb "wifi 設定已上傳遠端" "1"
fi
echoRgb "流式上傳完成 (數據未佔用本機空間)" "1"
# 上傳恢復必要檔案到遠端 (tools/ start.sh restore_settings.conf), 讓遠端備份可獨立恢復 (功能8/10 需要)
stream_upload_infra
# 統計遠端備份資料夾大小 (對齊本地備份的 Calculate_size 顯示)
local _subdir="$(get_backup_dirname)"
local _rtotal _rnew
_rtotal="$(remote_dir_size "$_subdir")"
if [[ -n $_rtotal && $_rtotal != 0 ]]; then
echoRgb "遠端備份資料夾↓↓↓\n -$remote_url ($_subdir)" "2"
echoRgb "遠端備份總體大小$(size "$_rtotal") $_rtotal" "3"
# 本次差異: 整體資料夾大小差異 (備份前快照 vs 現在, 對齊本地 Calculate_size)
_rnew=$(awk -v a="${_rtotal:-0}" -v b="${_RTOTAL_BEFORE:-0}" 'BEGIN{print a-b}')
case $_rnew in
-*) echoRgb "本次備份減少 $(size "$(awk -v n="$_rnew" 'BEGIN{print -n}')")" "3" ;;
0) echoRgb "文件大小未改變" "3" ;;
*) echoRgb "本次備份增加 $(size "$_rnew")" "3" ;;
esac
fi
# 方案A: 只清理 TMPDIR 暫存區 (絕不碰用戶既有的本地 $Backup 備份)
rm -rf "$TMPDIR/.stream_stage" 2>/dev/null
return 0
fi
if [[ $backup_has_changes = 0 ]]; then
if [[ $remote_upload_per_app = 1 ]]; then
echoRgb "逐應用上傳模式:無備份變更,只上傳依賴文件" "2"
else
echoRgb "無備份變更,只上傳依賴文件" "2"
fi
# 設置標記,跳過應用數據上傳
REMOTE_SKIP_APPDATA=1
elif [[ $remote_upload_per_app = 0 && -s "$TMPDIR/.changed_apps" ]]; then
# 非逐應用上傳模式,但有變更的應用,只上傳變更的應用
local changed_apps changed_count
changed_apps="$(sort -u "$TMPDIR/.changed_apps" | tr '\n' ' ')"
changed_count="$(sort -u "$TMPDIR/.changed_apps" | awk 'END{print NR}')"
[[ -n $remote_type ]] && echoRgb "僅上傳變更的應用 (共 $changed_count 個): $changed_apps" "2"
# 設置 REMOTE_APPLIST 為變更的應用列表
REMOTE_APPLIST="$(sort -u "$TMPDIR/.changed_apps")"
fi
case $remote_type in
webdav) upload_remote "webdav" ;;
smb) upload_remote "smb" ;;
*) return 0 ;;
esac
REMOTE_SKIP_APPDATA=0
unset REMOTE_APPLIST
}
# 秒 -> Nx天 Ny小時 Nz分鐘 Ns秒 (省略前導 0 單位)
hms() {
awk -v t="$1" 'BEGIN{
t=int(t); d=int(t/86400); h=int((t%86400)/3600); m=int((t%3600)/60); s=t%60
o=""
if(d>0) o=o d"天 "
if(o!="" || h>0) o=o h"小時 "
if(o!="" || m>0) o=o m"分鐘 "
o=o s"秒"
printf "%s", o
}'
}
# 運作時間 + 深度睡眠 (uptime vs CLOCK_MONOTONIC 差 = 深睡時長)
Show_boottime() {
local BOOT MONO
BOOT=$(awk '{print $1; exit}' /proc/uptime)
MONO=$(awk '/now at/{print $3; exit}' /proc/timer_list 2>/dev/null)
if [[ -z $MONO ]]; then
hms "$BOOT"
return
fi
awk -v b="$BOOT" -v m="$MONO" 'BEGIN{
mono=m/1e9; susp=b-mono; if(susp<0)susp=0
printf "%.0f %.0f %.1f\n", b, susp, susp/b*100
}' | while read -r rt sp pct; do
printf "%s\n -深度睡眠:%s (%s%%)" "$(hms "$rt")" "$(hms "$sp")" "$pct"
done
}
[[ -f /sys/block/sda/size ]] && ROM_TYPE="UFS" || ROM_TYPE="eMMC"
if [[ -f /proc/scsi/scsi ]]; then
UFS_MODEL="$(sed -n 3p /proc/scsi/scsi | awk '/Vendor/{print $2,$4}')"
else
UFS_MODEL="$(cat "/sys/class/block/sda/device/inquiry" 2>/dev/null)"
[[ $UFS_MODEL = "" ]] && UFS_MODEL="unknown"
fi
_model="$(getprop ro.product.model 2>/dev/null)"
Device_name="$(grep -Ew "$_model" "$tools_path/Device_List" 2>/dev/null | awk -F'"' '{print $4}' | head -1)"
[[ $Device_name = "" ]] && Device_name="$_model"
Manager_version="$(su -v 2>/dev/null)"
if [[ $Manager_version != "" ]]; then
[[ $Manager_version = *KernelSU* ]] && ksu="ksu"
[[ $ksu = "" && -d /data/adb/ksu ]] && ksu="ksu"
else
if [[ -d /data/adb/ksu ]]; then
Manager_version=KernelSU
ksu="ksu"
fi
fi
Socname="$(getprop ro.soc.model)"
if [[ $Socname != "" && -f $tools_path/soc.json ]]; then
DEVICE_NAME="$(jq -r --arg device "$Socname" '.[$device] | "處理器:\(.VENDOR) \(.NAME)"' "$tools_path/soc.json" 2>/dev/null)"
RAMINFO="$(jq -r --arg device "$Socname" '.[$device] | "RAM:\(.MEMORY) \(.CHANNELS)"' "$tools_path/soc.json" 2>/dev/null)"
[[ $DEVICE_NAME = null || $DEVICE_NAME = "" ]] && DEVICE_NAME="處理器:null"
[[ $RAMINFO = null || $RAMINFO = "" ]] && RAMINFO="RAM:null"
else
DEVICE_NAME="處理器:null"
RAMINFO="RAM:null"
fi
_brand="$(getprop ro.product.brand 2>/dev/null)"
_device="$(getprop ro.product.device 2>/dev/null)"
_busybox_path="$(which busybox)"
_busybox_ver="$(busybox | head -1 | cut -d' ' -f2)"
echoRgb "---------------------SpeedBackup---------------------"
echoRgb "腳本路徑:$MODDIR\n -已開機:$(Show_boottime)\n -執行時間:$(date +"%Y-%m-%d %H:%M:%S")\n -busybox路徑:$_busybox_path\n -busybox版本:$_busybox_ver\n -腳本版本:$backup_version\n -管理器:$Manager_version\n -品牌:$_brand\n -型號:$Device_name($_device)\n -閃存顆粒:$UFS_MODEL($ROM_TYPE)\n -$DEVICE_NAME\n -$RAMINFO\n -Android版本:$release SDK:$sdk\n -內核:$(uname -r)\n -Selinux狀態:$([[ $(getenforce) = Permissive ]] && echo "寬容" || echo "嚴格")\n -By@YAWAsau\n -Support: https://jq.qq.com/?_wv=1027&k=f5clPNC3"
case $MODDIR in
*Backup_*)
if [[ -f $MODDIR/app_details.json ]]; then
if [[ -d ${MODDIR%/*/*}/tools ]]; then
path_hierarchy="${MODDIR%/*/*}"
else
path_hierarchy="${MODDIR%/*}"
fi
else
if [[ -d ${MODDIR%/*}/tools ]]; then
path_hierarchy="${MODDIR%/*}"
else
[[ -d $MODDIR/tools ]] && path_hierarchy="$MODDIR"
fi
fi ;;
*) [[ -d $MODDIR/tools ]] && path_hierarchy="$MODDIR" ;;
esac
[[ $LANG = "" ]] && echoRgb "系統無參數語言獲取失敗\n -如果需要更改腳本語言請於$conf_path\n -Shell_LANG=填入對應數字" "0"
case $LANG in
*TW* | *tw* | *HK*)
Script_target_language="zh-TW" ;;
*CN* | *cn*)
Script_target_language="zh-CN" ;;
esac
echoRgb "$Script_target_language腳本"
# 互動式輸入選項 (音量鍵或數字鍵)
# 配合 keycheck 抓音量鍵, 沒音量鍵則退回鍵盤輸入
Enter_options() {
echoRgb "$1" "2"
unset option parameter
while true ;do
if [[ $option != "" ]]; then
case $option in
0|1)
parameter="$option"
[[ $option = 1 ]] && echoRgb "$2" "2" || echoRgb "$3" "2"
break ;;
*)
echoRgb "$option參數錯誤 只能是0或1" "0"
read option ;;
esac
else
read option
fi
done
}
# 二選一互動 helper統一 Lo=0/1/2 三種模式
# 用法 A (return 模式): ask_yn "提示" "選項1" "選項2"
# 選項1 → return 0, 選項2 → return 1
# 例: ask_yn "確認?" "確認" "取消" && do_it
#
# 用法 B (變數模式): ask_yn "提示" "選項1" "選項2" 變數名
# 結果寫入指定變數 (true/false),同時設 $branch
# 例: ask_yn "備份模式?" "完整" "僅APK" Backup_Mode
#
# 用法 C (conf預設模式): ask_yn "提示" "選項1" "選項2" 變數名 conf
# 若變數已有值則跳過詢問 (Lo=0 才跳過Lo=1 強制詢問)
# 例: ask_yn "自動更新?" "更新" "不更新" update conf
ask_yn() {
local _msg="$1" _opt1="$2" _opt2="$3" _var="$4" _conf="$5"
# conf 模式: Lo=0 且變數已有值 → 直接用 isBoolean 轉換後跳過
if [[ $_conf = conf && -n $_var && $Lo = 0 ]]; then
local _cur
eval "_cur=\"\$$_var\""
if [[ -n $_cur ]]; then
isBoolean "$_cur" "$_var" && eval "$_var=\"\$nsx\""
[[ $nsx = true ]]; return
fi
fi
case $Lo in
0|1)
echoRgb "$_msg\n -音量上=$_opt1, 音量下=$_opt2" "2"
get_version "$_opt1" "$_opt2"
;;
2)
Enter_options "$_msg\n -輸入1=$_opt1 0=$_opt2" "$_opt1" "$_opt2"
[[ $parameter = 1 ]] && branch=true || branch=false
;;
esac
# 變數模式: 把結果寫入指定變數
if [[ -n $_var ]]; then
eval "$_var=\"\$branch\""
fi
[[ $branch = true ]]
}
if [[ ! -f ${0%/*}/app_details.json ]]; then
if [[ $user = "" ]]; then
user_id="$(ls /data/user | tr ' ' '\n')"
if [[ $user_id != "" && $(ls /data/user | tr ' ' '\n' | wc -l) -gt 1 ]]; then
echo "$user_id" | while read -r; do
[[ $REPLY = 0 ]] && echoRgb "主用戶:$REPLY" "2" || echoRgb "分身用戶:$REPLY" "2"
done
echoRgb "設備存在多用戶,選擇操作目標用戶"
if [[ $(awk 'END{print NR}' <<< "$user_id") = 2 ]]; then
user1="$(echo "$user_id" | sed -n '1p')"
user2="$(echo "$user_id" | sed -n '2p')"
case $Lo in
0|1)
echoRgb "音量上選擇用戶:$user1,音量下選擇用戶:$user2" "2"
Select_user="true"
get_version "$user1" "$user2" && user="$branch"
unset Select_user ;;
2)
Enter_options "輸入1選擇用戶:$user1 0用戶:$user2" "$user1" "$user2"
case $parameter in
0) user="$user2" ;;
1) user="$user1" ;;
esac ;;
esac
else
while true ;do
if [[ $option != "" ]]; then
user="$option"
break
else
echoRgb "請輸入需要操作目標分區" "1"
read option
fi
done
fi
else
user="0"
fi
else
user_id="$(ls /data/user | tr ' ' '\n')"
if [[ $user_id != "" && $(ls /data/user | tr ' ' '\n' | wc -l) -gt 1 ]]; then
echo "$user_id" | while read -r; do
[[ $REPLY = 0 ]] && echoRgb "主用戶:$REPLY" "2" || echoRgb "分身用戶:$REPLY" "2"
done
else
echoRgb "主用戶:$user_id" "2"
fi
fi
else
case $(echo "${0%}") in
*zstd*) user="$(echo "${0%}" | sed 's/.*\/Backup_zstd_\([0-9]*\).*/\1/')" ;;
*tar*) user="$(echo "${0%}" | sed 's/.*\/Backup_tar_\([0-9]*\).*/\1/')" ;;
*) echoRgb "請勿修改備份資料夾名稱保持原本的Backup_壓縮算法名稱_使用者id" "0" && exit 2 ;;
esac
fi
[[ $user != 0 ]] && am start-user "$user"
path="/data/media/$user/Android"
path2="/data/user/$user"
path3="/data/user_de/$user"
[[ ! -d $path2 ]] && echoRgb "$user分區不存在請將上方提示的用戶id按照需求填入\n -$conf_path配置項user=,一次只能填寫一個" "0" && exit 2
echoRgb "當前操作為用戶$user"
export USER_ID="$user"
unset LD_LIBRARY_PATH
#因接收USER_ID環境變量問題故將函數放在此處
# dex 調用 wrapper: _dex_debug=1 時記錄每次調用到 .dex_call_log (用於確認批量/預掃是否生效)
# 平時 _dex_debug 為空, 零額外開銷; 要監控時在腳本開頭設 _dex_debug=1
_dex() {
[[ $_dex_debug = 1 ]] && {
local _c
for _c in "$@"; do case $_c in
grant*|revoke*|setOps*|getRuntime*|getInstalled*|getPackage*|setDisplay*|get|set) echo "$_c" >> "$TMPDIR/.dex_call_log"; break ;;
esac; done
}
command app_process "$@"
}
# 設定 dex 取應用名稱用的 locale (export 給 HiddenApiUtil 的 applyLocale 讀)
# 優先序: Shell_LANG 明確指定 > dex getLocale 取系統實際語言 > 退出提示手動設定
case $Shell_LANG in
1) export APP_LABEL_LOCALE="zh-CN" ;;
0) export APP_LABEL_LOCALE="zh-TW" ;;
*)
# 用 settings 取用戶實際設定的語言 (system_locales, 如 zh-Hant-TW / zh-Hans-CN), 命令列可靠取得
_syslocale="$(settings get system system_locales 2>/dev/null | head -1)"
[[ -z $_syslocale || $_syslocale = null ]] && _syslocale="$(getprop persist.sys.locale)"
case $_syslocale in
zh-Hant*|zh_Hant*|zh-TW*|zh_TW*|zh-HK*|zh_HK*|zh-MO*) export APP_LABEL_LOCALE="zh-TW" ;;
zh-Hans*|zh_Hans*|zh-CN*|zh_CN*|zh-SG*|zh*) export APP_LABEL_LOCALE="zh-CN" ;;
*)
echoRgb "系統語言取得失敗或非中文 (取得: ${_syslocale:-})" "0"
echoRgb " - dex 將取得英文應用名稱, 可能導致名稱比對/恢復異常" "0"
echoRgb " - 請於 backup_settings.conf 設定 Shell_LANG=0 (繁中) 或 1 (簡中)" "3"
exit 1 ;;
esac
;;
esac
alias appinfo="_dex /system/bin com.xayah.dex.HiddenApiUtil getInstalledPackagesAsUser $USER_ID $@"
alias appinfo2="_dex /system/bin com.xayah.dex.HiddenApiUtil getPackageLabel $USER_ID $@"
alias appinfo3="_dex /system/bin com.xayah.dex.HiddenApiUtil getPackageArchiveInfo $@"
alias get_ssaid="_dex /system/bin com.xayah.dex.SsaidUtil get $USER_ID $@"
alias set_ssaid="_dex /system/bin com.xayah.dex.SsaidUtil set $USER_ID $@"
alias get_uid="_dex /system/bin com.xayah.dex.HiddenApiUtil getPackageUid $USER_ID $@"
alias get_Permissions="_dex /system/bin com.xayah.dex.HiddenApiUtil getRuntimePermissions $USER_ID $@"
alias Set_true_Permissions="_dex /system/bin com.xayah.dex.HiddenApiUtil grantRuntimePermission $USER_ID $@"
alias Set_false_Permissions="_dex /system/bin com.xayah.dex.HiddenApiUtil revokeRuntimePermission $USER_ID $@"
alias Set_Ops="_dex /system/bin com.xayah.dex.HiddenApiUtil setOpsMode $USER_ID $@"
alias setDisplay="_dex /system/bin com.xayah.dex.HiddenApiUtil setDisplayPowerMode $@"
find_tools_path="$(find "$path_hierarchy"/* -maxdepth 1 -name "tools" -type d ! -path "$path_hierarchy/tools")"
# 備份 WiFi 密碼到指定目錄, 用 classes.dex 讀 system 內的 WifiConfigStore
backup_wifi() {
local wifi_dir="$1"
[[ -z $wifi_dir ]] && echoRgb "backup_wifi: 目錄參數為空" "0" && return 1
[[ ! -d $wifi_dir ]] && mkdir -p "$wifi_dir"
if [[ -d $wifi_dir ]]; then
echoRgb "備份wifi密碼"
rm -rf "${wifi_dir:?}"/*
app_process /system/bin com.xayah.dex.NetworkUtil saveNetworks>"$wifi_dir/wifi.json"
echo_log "wifi備份"
fi
}
# 從備份恢復 WiFi 密碼 (寫回 WifiConfigStore)
recover_wifi() {
if [[ -d $1 ]]; then
if [[ -f $1/wifi.json ]]; then
echoRgb "恢復wifi密碼"
app_process /system/bin com.xayah.dex.NetworkUtil restoreNetworks "$1/wifi.json"
echo_log "wifi恢復"
else
echoRgb "wifi.json遺失"
fi
else
echoRgb "$1不存在 wifi無法恢復" "0"
fi
}
Rename_script () {
HT="${HT:=0}"
find "$path_hierarchy" -maxdepth 3 -name "*.sh" -type f -not -name "tools.sh" | sort | while read -r; do
MODDIR_NAME="${REPLY%/*}"
FILE_NAME="${REPLY##*/}"
if [[ -f ${REPLY%/*}/app_details.json || -f ${REPLY%/*}/app_details ]]; then
if [[ $FILE_NAME = backup.sh ]]; then
touch_shell "1" "$REPLY"
elif [[ $FILE_NAME = recover.sh ]]; then
touch_shell "3" "$REPLY"
elif [[ $FILE_NAME = upload.sh ]]; then
touch_shell "5" "$REPLY"
fi
else
if [[ -d ${REPLY%/*}/tools ]]; then
if [[ $FILE_NAME = start.sh ]]; then
[[ -f ${REPLY%/*}/backup_settings.conf ]] && touch_shell "0" "$REPLY"
[[ -f ${REPLY%/*}/restore_settings.conf ]] && touch_shell "2" "$REPLY"
fi
fi
let HT++
fi
done
unset HT
}
# 在指定路徑生成入口腳本 (start.sh / backup.sh / recover.sh / upload.sh)
# $1=模式 (0/1/2/3/5), $2=目標檔案路徑
# 模式對應的 MODDIR 路徑推算規則不同 (依腳本放置位置)
touch_shell() {
unset conf_path MODDIR_Path
case $1 in
0)
MODDIR_Path='${0%/*}'
MODDIR_Path1="$MODDIR_Path"
conf_path='${0%/*}/backup_settings.conf' ;;
1)
MODDIR_Path='${0%/*/*/*}'
MODDIR_Path1="$MODDIR_Path"
conf_path='${0%/*/*/*}/backup_settings.conf' ;;
2)
MODDIR_Path='${0%/*}'
MODDIR_Path1="$MODDIR_Path"
conf_path='${0%/*}/restore_settings.conf' ;;
3)
MODDIR_Path='${0%/*/*}'
MODDIR_Path1='${0%/*}'
conf_path='${0%/*/*}/restore_settings.conf' ;;
5)
# upload.sh: 放在 Backup_zstd_X/<app>/, MODDIR 是 Backup_zstd_X 自己
MODDIR_Path='${0%/*/*/*}'
MODDIR_Path1='${0%/*/*}'
conf_path='${0%/*/*/*}/backup_settings.conf' ;;
esac
echo "#!/system/bin/sh
if [ -f \"$MODDIR_Path/tools/tools.sh\" ]; then
MODDIR=\"$MODDIR_Path1\"
conf_path=\"$conf_path\"
[ ! -f \"$conf_path\" ] && . \"$MODDIR_Path/tools/tools.sh\"
else
echo \"$MODDIR_Path/tools/tools.sh遺失\"
fi
mkdir -p \"\${0%/*}/log\" 2>/dev/null
logfile=\"\${0%/*}/log/log_\$(date +%Y-%m-%d_%H-%M).txt\"
. \"$MODDIR_Path/tools/tools.sh\" | tee \"\$logfile\"
sed -i \"\$(printf 's/\033\[[0-9;]*m//g')\" \"\$logfile\"" > "$2"
}
# 用 ts 翻譯檔案 (取代散落各處的 ts<X>temp && cp temp X && rm temp 模式)
# 用法: ts_inplace <檔案>
ts_inplace() {
local f="$1" tmp="$TMPDIR/.ts_$$"
if ts < "$f" > "$tmp"; then
cp "$tmp" "$f"
rm -f "$tmp"
else
rm -f "$tmp"
return 1
fi
}
# 從 zip 檔自動更新腳本 (檢測 $MODDIR 內的 .zip 並提取 tools.sh)
# 用法: update_script [zip路徑] — 有傳入時直接用,否則掃 $MODDIR
update_script() {
[[ -n $1 ]] && zipFile="$1"
[[ -z $zipFile ]] && zipFile="$(find "$MODDIR" -maxdepth 1 -name "*.zip" -type f 2>/dev/null)"
if [[ -n $zipFile ]]; then
# 多個 zip 用 case 判斷, 取代 echo|wc -l
case $zipFile in
*$'\n'*)
echoRgb "錯誤 請刪除當前目錄多餘zip\n -保留一個最新的數據備份.zip\n -下列為當前目錄zip\n$zipFile" "0"
exit 1 ;;
esac
if unzip -l "$zipFile" 2>/dev/null | grep -q "backup_settings\.conf$"; then
unzip -o "$zipFile" -j "tools/tools.sh" -d "$MODDIR" &>/dev/null
if [[ -f $MODDIR/tools.sh ]]; then
# 版本比對: 12 碼純數字時間戳 (e.g. 202605161200), awk 一次抓取
local _new_ver _cur_ver
_new_ver=$(awk -F= '/^backup_version=/ {gsub(/[a-zA-Z"]/, "", $2); print $2; exit}' "$MODDIR/tools.sh")
_cur_ver=$(echo "$backup_version" | tr -d 'a-zA-Z')
if [[ ${_new_ver:-0} -ge ${_cur_ver:-0} ]]; then
shell_language="$(awk -F= '/^shell_language=/ {gsub(/"/, "", $2); print $2}' "$MODDIR/tools.sh")"
case $MODDIR in
*Backup_*)
if [[ -f $MODDIR/app_details.json ]]; then
echoRgb "請在${MODDIR%/*}更新腳本" "0"
rm -rf "$MODDIR/tools.sh"
exit 2
fi ;;
esac
echoRgb "$zipFile更新"
if [[ -d $path_hierarchy/tools ]]; then
mv "$path_hierarchy/tools" "$TMPDIR"
[[ -d $TMPDIR/tools ]] && {
unzip -o "$zipFile" tools/* -d "$path_hierarchy" | sed 's/inflating/釋放/g ; s/creating/創建/g ; s/Archive/解壓縮/g'
echo_log "解壓縮${zipFile##*/}"
if [[ $result = 0 ]]; then
if [[ $shell_language != $Script_target_language ]]; then
echoRgb "腳本語言為$shell_language....轉換為$Script_target_language中,請稍後等待轉換...."
ts_inplace "$path_hierarchy/tools/Device_List"
echo_log "$path_hierarchy/tools/Device_List翻譯"
ts_inplace "$path_hierarchy/tools/tools.sh" && sed -i "s/shell_language=\"$shell_language\"/shell_language=\"$Script_target_language\"/g" "$path_hierarchy/tools/tools.sh"
echo_log "$path_hierarchy/tools/tools.sh翻譯"
HT=1
fi
update_backup_settings_conf>"$path_hierarchy/backup_settings.conf"
ts_inplace "$path_hierarchy/backup_settings.conf"
echo_log "$path_hierarchy/backup_settings.conf翻譯"
echo "$find_tools_path" | while read -r; do
if [[ $REPLY != $path_hierarchy/tools ]]; then
rm -rf "$REPLY"
cp -r "$path_hierarchy/tools" "${REPLY%/*}"
update_Restore_settings_conf>"${REPLY%/*}/restore_settings.conf"
ts_inplace "${REPLY%/*}/restore_settings.conf"
echo_log "${REPLY%/*}/restore_settings.conf翻譯"
fi
done
Rename_script
if [[ -n $Output_path ]]; then
[[ ${Output_path: -1} = / ]] && Output_path="${Output_path%?}"
if [[ ${Output_path:0:1} != / ]]; then
update_path="$MODDIR/$Output_path/$(get_backup_dirname)"
else
update_path="$Output_path/$(get_backup_dirname)"
fi
rm -rf "$update_path/tools"
cp -r "$path_hierarchy/tools" "$update_path"
echoRgb "$update_path/tools已經更新完成"
fi
else
mv "$TMPDIR/tools" "$MODDIR"
fi
# TMPDIR 清理加保護,避免變數為空時誤刪
[[ -n $TMPDIR ]] && rm -rf "$TMPDIR/tools" "$zipFile" "$MODDIR/tools.sh"
echoRgb "更新完成 請重新執行腳本" "2"
exit
} || echoRgb "tools移動到TMPDIR失敗" "0"
fi
else
echoRgb "${zipFile##*/}版本低於當前版本,自動刪除" "0"
rm -rf "$zipFile" "$path_hierarchy/tools.sh"
fi
else
rm -rf "$zipFile"
unset zipFile
fi
fi
fi
unset NAME
}
update_script
# 掃 Download 和 QQ 收件匣,找到有效 zip 直接傳入 update_script
# 兩個來源都嘗試 (Download 有無關 zip 時不該擋住 QQ 的更新包)
_dl_zip="$(ls -t /storage/emulated/0/Download/*.zip 2>/dev/null | head -1)"
_qq_zip="$(ls -t /storage/emulated/0/Android/data/com.tencent.mobileqq/Tencent/QQfile_recv/*.zip 2>/dev/null | head -1)"
for _try_zip in "$_dl_zip" "$_qq_zip"; do
[[ -z $_try_zip ]] && continue
# 只有「含 backup_settings.conf 的更新包」才傳入; 普通 zip 略過不處理
if unzip -l "$_try_zip" 2>/dev/null | grep -q "backup_settings\.conf$"; then
echoRgb "偵測到更新包: ${_try_zip##*/}" "2"
update_script "$_try_zip"
fi
done
unset _dl_zip _qq_zip _try_zip
if [[ $sdk -lt 30 ]]; then
alias INSTALL="pm install --user $user -r -t >/dev/null"
alias create="pm install-create --user $user -tl"
else
if [[ $sdk -gt 33 ]]; then
alias INSTALL="pm install -r --bypass-low-target-sdk-block -i com.android.vending --user $user -t >/dev/null"
alias create="pm install-create -i com.android.vending --bypass-low-target-sdk-block --user $user -t"
else
alias INSTALL="pm install -r -i com.android.vending --user $user -t >/dev/null"
alias create="pm install-create -i com.android.vending --user $user -t"
fi
fi
#settings get system system_locales
Language="https://api.github.com/repos/YAWAsau/backup_script/releases/latest"
if [[ $path_hierarchy != "" && $Script_target_language != "" ]]; then
K=1
J="$(find "$path_hierarchy" -maxdepth 3 -name "tools.sh" -type f | wc -l)"
find "$path_hierarchy" -maxdepth 3 -name "tools.sh" -type f | while read -r; do
unset shell_language
shell_language="$(awk -F= '/^shell_language=/ {gsub(/"/, "", $2); print $2}' "$REPLY")"
case $shell_language in
zh-CN|zh-TW)
if [[ $Script_target_language != $shell_language ]]; then
[[ $K = 1 ]] && echoRgb "腳本語言為$shell_language....轉換為$Script_target_language中,請稍後等待轉換...."
ts_inplace "$REPLY"
if [[ $? = 0 ]]; then
touch "$TMPDIR/0"
echo_log "$(echo "$REPLY" | sed "s|^$path_hierarchy/||")翻譯"
MODDIR="${0%/*}"
if [[ -f ${REPLY%/*/*}/backup_settings.conf ]]; then
update_backup_settings_conf>"${REPLY%/*/*}/backup_settings.conf"
ts_inplace "${REPLY%/*/*}/backup_settings.conf"
echo_log "${REPLY%/*/*}/backup_settings.conf翻譯"
fi
if [[ -f ${REPLY%/*/*}/restore_settings.conf ]]; then
update_Restore_settings_conf>"${REPLY%/*/*}/restore_settings.conf"
ts_inplace "${REPLY%/*/*}/restore_settings.conf"
echo_log "${REPLY%/*/*}/restore_settings.conf翻譯"
fi
sed "s/shell_language=\"$shell_language\"/shell_language=\"$Script_target_language\"/g" "$REPLY" > temp && cp temp "$REPLY" && rm temp
[[ $shell_language != $(awk -F= '/^shell_language=/ {gsub(/"/, "", $2); print $2}' "$REPLY") ]] && echoRgb "$(echo "$REPLY" | sed "s|^$path_hierarchy/||")變量修改成功" || echoRgb "$(echo "$REPLY" | sed "s|^$path_hierarchy/||")變量修改失敗" "0"
ts_inplace "${REPLY%/*}/Device_List"
echo_log "${REPLY%/*}/Device_List翻譯"
[[ $K = 1 ]] && Rename_script
else
echoRgb "$REPLY ts進程出現錯誤" "0"
fi
let K++
fi ;;
esac
done
[[ -e $TMPDIR/0 ]] && rm "$TMPDIR/0" && echoRgb "轉換腳本完成,退出腳本重新執行即可使用" && exit 2
fi
#校驗選填是否正確
case $Lo in
0)
[[ $update != "" ]] && isBoolean "$update" "update" && update="$nsx" || {
echoRgb "自動更新腳本?\n -音量上更新,下不更新"
get_version "更新" "不更新" && update="$branch"
} ;;
1)
[[ $update = "" ]] && {
echoRgb "自動更新腳本?\n -音量上更新,下不更新"
get_version "更新" "不更新" && update="$branch"
} || isBoolean "$update" "update" && update="$nsx" ;;
2)
[[ $update = "" ]] && {
Enter_options "輸入1自動更新腳本輸入0不自動更新腳本" "更新" "不更新" && isBoolean "$parameter" "update" && update="$nsx"
} || {
isBoolean "$update" "update" && update="$nsx"
} ;;
*) echoRgb "$conf_path Lo=$Lo填寫錯誤正確值0 1 2" "0" && exit 2 ;;
esac
if [[ $update = true ]]; then
json="$(down "$Language")"
else
echoRgb "自動更新被關閉" "0"
fi
if [[ $json != "" ]]; then
tag="$(jq -r '.tag_name'<<< "$json" 2>/dev/null)"
if [[ $tag != "" && $backup_version != $tag ]]; then
if [[ $(expr "$(echo "$backup_version" | tr -d "a-zA-Z")" \> "$(echo "$tag" | tr -d "a-zA-Z")") -eq 0 ]]; then
download="$(jq -r '.assets[].browser_download_url'<<< "$json")"
case $cdn in
0) zip_url="$download" ;;
1) zip_url="https://ghfast.top/$download" ;;
2) zip_url="https://shrill-pond-3e81.hunsh.workers.dev/$download" ;;
*) echoRgb "$conf_path cdn=設置錯誤 範圍只能是0-2" && exit 2 ;;
esac
if [[ $(expr "$(echo "$backup_version" | tr -d "a-zA-Z")" \> "$(echo "$download" | tr -d "a-zA-Z")") -eq 0 ]]; then
echoRgb "發現新版本:$tag"
if [[ $update = true ]]; then
echoRgb "$(ts "更新日誌:\n$(down "$Language" | jq -r '.body')")"
if ask_yn "是否更新腳本?" "更新" "不更新" choose; then
echoRgb "下載中.....耐心等待 如果下載失敗請掛飛機"
starttime1="$(date -u "+%s")"
down "$zip_url" >"$MODDIR/update.zip" &
wait
endtime 1
[[ ! -f $MODDIR/update.zip ]] && echoRgb "下載失敗" && exit 2
zipFile="$MODDIR/update.zip"
fi
else
echoRgb "$conf_path內update選項為0忽略更新僅提示更新" "0"
fi
fi
fi
fi
else
[[ $update = true ]] && echoRgb "更新獲取失敗" "0"
fi
update_script
# 給定路徑, 穿透 bind/FUSE 找出真實底層的「可餵給 df 的路徑」
# 用於 Android emulated storage (sdcardfs/FUSE) 上 bind 了其他分區的情況
# 例: /storage/emulated/0/虛擬分區 實際是 /mnt/YAWAsau/備份 的 bind, 應回傳 /mnt/YAWAsau
# 策略:
# 1) 先把已知的 sdcardfs/FUSE 視圖路徑顯式轉成內核真實路徑 /data/media/<N>/<X>
# (不能用 realpath, /storage/emulated 通常是 symlink 到 /mnt/installer/.../emulated,
# resolve 後反而會撞到 FUSE 掛載點, 拿不到底下的 bind)
# 2) 在 /proc/self/mountinfo 找 mp 是 target 祖先的最長匹配 → 取得 source
# 3) 找同 source 且 root="/" 的 canonical mountpoint, 回傳該路徑
# (Android toybox df 不接受 block device, 必須回傳真實「路徑」)
# 4) 找不到 canonical 時, 若 source 是現存目錄就用 source, 否則原樣回傳
_resolve_real_mount() {
local p="$1" rp target rest out
[[ -z $p ]] && { echo "$p"; return; }
# 先 readlink -f 一次, 解開常見入口 symlink (/sdcard, /storage/self/primary, /mnt/sdcard 等)
# 注意: 之前嘗試過 realpath 會把 /storage/emulated/0 解成 /mnt/installer/.../emulated/0,
# 撞到 FUSE 掛載點; 現在下方 case 已涵蓋 /mnt/installer/... 等視圖, 所以 readlink 後也能正確處理
rp="$(readlink -f "$p" 2>/dev/null)"
[[ -n $rp ]] && p="$rp"
# 把已知 sdcardfs/FUSE 視圖路徑剝皮成 /data/media/<N>/<X>
case "$p" in
/storage/emulated/*) rest="${p#/storage/emulated/}"; target="/data/media/$rest" ;;
/mnt/installer/*/emulated/*) rest="${p##*/emulated/}"; target="/data/media/$rest" ;;
/mnt/runtime/*/emulated/*) rest="${p##*/emulated/}"; target="/data/media/$rest" ;;
/mnt/pass_through/*/emulated/*) rest="${p##*/emulated/}"; target="/data/media/$rest" ;;
/mnt/user/*/emulated/*) rest="${p##*/emulated/}"; target="/data/media/$rest" ;;
*) target="$p" ;;
esac
out="$(awk -v target="$target" '
function unesc(s) { gsub(/\\040/, " ", s); return s }
{
root=unesc($4); mp=unesc($5)
i=6; while (i<=NF && $i!="-") i++
src=unesc($(i+2))
n++; mp_a[n]=mp; root_a[n]=root; src_a[n]=src
}
END {
best_len=-1
for (i=1; i<=n; i++) {
m=mp_a[i]
if (m==target || index(target, m"/")==1) {
if (length(m) > best_len) { best_len=length(m); R_src=src_a[i] }
}
}
if (best_len<0) exit
# 找同 source 且 root="/" 的 canonical mountpoint
for (i=1; i<=n; i++) {
if (src_a[i]==R_src && root_a[i]=="/") { print mp_a[i]; exit }
}
# 沒 canonical 且 source 是路徑而非 /dev/*, 就用 source
if (substr(R_src,1,1)=="/" && R_src !~ /^\/dev\//) print R_src
}' /proc/self/mountinfo 2>/dev/null)"
if [[ -n $out && ( -d $out || -b $out ) ]]; then
echo "$out"
else
echo "$p"
fi
}
# 計算本地備份目錄路徑
# 格式: $Output_path/Backup_${Compression_method}_${user}
# 並建立目錄, 設定 $Backup 全域變數供其他函數使用
# 返回帶後綴的備份目錄名 (Backup_${Compression_method}_${user}${suffix})
# 解析 Backup_suffix 中的日期時間變量: %yyyymmdd %hhmmss %yyyymmddhhmmss %yyyy %mm %dd
get_backup_dirname() {
local base="Backup_${Compression_method}_${user:-0}"
if [[ -n $Backup_suffix ]]; then
local resolved="$Backup_suffix"
local now="$(date '+%Y%m%d%H%M%S')"
resolved="${resolved//%yyyymmddhhmmss/$now}"
resolved="${resolved//%yyyymmdd/${now:0:8}}"
resolved="${resolved//%hhmmss/${now:8}}"
resolved="${resolved//%yyyy/${now:0:4}}"
resolved="${resolved//%mm/${now:4:2}}"
resolved="${resolved//%dd/${now:6:2}}"
echo "${base}${resolved}"
else
echo "$base"
fi
}
# ======================================================
# 備份路徑 / 預掃 / app_details 讀取
# ======================================================
backup_path() {
if [[ $Output_path != "" ]]; then
[[ ${Output_path: -1} = / ]] && Output_path="${Output_path%?}"
if [[ ${Output_path:0:1} != / ]]; then
Directory_type="相對路徑"
Backup="$MODDIR/$Output_path/$(get_backup_dirname)"
else
Directory_type="絕對路徑"
Backup="$Output_path/$(get_backup_dirname)"
fi
outshow="使用自定義目錄($Directory_type)"
else
Backup="$MODDIR/$(get_backup_dirname)"
if [[ ! -f ${0%/*}/app_details.json ]]; then
outshow="使用當前路徑作為備份目錄"
else
[[ -d $Backup ]] && outshow="使用上層路徑作為備份目錄" || echoRgb "$Backup目錄不存在" "0"
fi
fi
PU="$(mount | awk '$3 ~ "/mnt/media_rw/[^/]+$" {print $3, $5}' | grep -Ev "$mount_point")"
OTGPATH="$(echo "$PU" | cut -d' ' -f1)"
OTGFormat="$(echo "$PU" | cut -d' ' -f2)"
if [[ -d $OTGPATH ]]; then
if [[ $(echo "$MODDIR" | grep -Eo "^${OTGPATH}") != "" ]]; then
hx="true"
Backup="$MODDIR/$(get_backup_dirname)"
else
ask_yn "檢測到隨身碟 是否在隨身碟備份?" "選擇了隨身碟備份" "選擇了本地備份"
[[ $branch = true ]] && hx="$branch"
[[ $hx = true ]] && Backup="$OTGPATH/$(get_backup_dirname)"
fi
if [[ $hx = true ]]; then
if [[ $OTGFormat = vfat ]]; then
echoRgb "隨身碟檔案系統$OTGFormat不支持超過單檔4GB\n -請格式化為exfat" "0"
exit
fi
outshow="於隨身碟備份" && hx=usb
fi
fi
[[ ! -d $Backup ]] && mkdir -p "$Backup"
#分區詳細
_real_parent="$(_resolve_real_mount "${Backup%/*}")"
_real_suffix=""
[[ -n $_real_parent && $_real_parent != "${Backup%/*}" ]] && _real_suffix=" -└─ 掛載於: $_real_parent${Backup##${Backup%/*}}"
remote_setup
# 一致性保護: remote_stream=1 但 remote_type 無效/空 (驗證失敗被清空) → 關閉流式, 避免半啟用混亂
if [[ $remote_stream = 1 && -z $remote_type ]]; then
echoRgb "remote_stream=1 但遠端未啟用(remote_type 空/驗證失敗), 已停用流式, 改為純本機備份" "0"
remote_stream=0
fi
# 分區統計移到 remote_setup/一致性保護之後:
# 連線失敗自動轉純本機備份時, 也能正確顯示本地資訊; 流式 (數據不落地) 才不顯示
if [[ $remote_stream != 1 ]]; then
echoRgb "$hx備份資料夾所使用分區統計如下↓\n -$(df -h "$_real_parent" | sed -n 's|% /.*|%|p' | awk '{print $(NF-3),$(NF-2),$(NF-1),$(NF)}' | awk 'END{print "總共:"$1"已用:"$2"剩餘:"$3"使用率:"$4}')檔案系統:$(df -T "$_real_parent" | sed -n 's|% /.*|%|p' | awk '{print $(NF-4)}')\n -備份目錄輸出位置↓\n -$Backup\n$_real_suffix"
echoRgb "$outshow" "2"
fi
# 快照備份前遠端大小 (結尾算差異, 對齊本地備份的整體資料夾差異統計)
if [[ -n $remote_type ]]; then
_RTOTAL_BEFORE="$(remote_dir_size "$(get_backup_dirname)" 2>/dev/null)"
[[ -z $_RTOTAL_BEFORE ]] && _RTOTAL_BEFORE=0
fi
}
# 預掃 pkg → uid map (給備份主迴圈用, 避免每個 app 都 fork 一次 pm + awk)
# 寫到 $TMPDIR/.pkg_uid 格式: pkg<TAB>uid
# 用法: prepare_pkg_uid_map (backup / backup_update_apk 開頭呼叫)
prepare_pkg_uid_map() {
pm list packages -U --user "${user:-0}" 2>/dev/null \
| awk -F'[ :]' '{print $2"\t"$4}' > "$TMPDIR/.pkg_uid"
}
# 預掃 pkg → installer (安裝來源) map
# 寫到 $TMPDIR/.pkg_installer 格式: pkg<TAB>installer
# pm list packages -i 輸出: package:<pkg> installer=<installer>
prepare_pkg_installer_map() {
pm list packages -i --user "${user:-0}" 2>/dev/null \
| sed -e 's/^package://' -e 's/ installer=/\t/' \
| awk -F'\t' '$2 != "" && $2 != "null" {print $1"\t"$2}' > "$TMPDIR/.pkg_installer"
}
# 預掃各 app 的後台運行狀態 (appops RUN_ANY_IN_BACKGROUND)
# 預掃各 app 的後台運行狀態 (appops RUN_ANY_IN_BACKGROUND)
# 對應系統設定「允許在背景使用 / 無限制 / 最佳化」
# 用法: prepare_battery_whitelist [單一包名] — 不傳則掃 $txt 全部
# 寫到 $TMPDIR/.battery_wl 格式: pkg<TAB>mode (mode = allow/ignore/deny/default)
# 只記錄「有明確設定」的 (RUN_ANY_IN_BACKGROUND: xxx), 系統預設(Default mode)不記錄
prepare_battery_whitelist() {
: > "$TMPDIR/.battery_wl"
local _list
if [[ -n $1 ]]; then
_list="$1"
else
_list="$(echo "$txt" | awk '{print $2}' | grep -v '^$')"
fi
local _total _i=0
_total="$(echo "$_list" | grep -vc '^$')"
echo "$_list" | while read -r _pkg; do
[[ -z $_pkg ]] && continue
let _i++
printf '\r -預掃後台運行 %d/%d %s' "$_i" "$_total" "$(progress_bar $((_i * 100 / _total)))" >&2
# appops 導向 </dev/null 避免吃掉迴圈 stdin
_ops="$(appops get "$_pkg" RUN_ANY_IN_BACKGROUND </dev/null 2>/dev/null)"
# 兼容兩種輸出格式:
# 舊/部分裝置: "RUN_ANY_IN_BACKGROUND: allow"
# 新/部分裝置: "Default mode: allow"
_mode="$(echo "$_ops" | sed -n -e 's/^RUN_ANY_IN_BACKGROUND: \([a-z]*\).*/\1/p' -e 's/^Default mode: \([a-z]*\).*/\1/p' | head -1)"
[[ -n $_mode ]] && printf '%s\t%s\n' "$_pkg" "$_mode" >> "$TMPDIR/.battery_wl"
done
echo >&2
}
# 預掃所有待備份 app 的數據目錄大小 (data/user/user_de/obb), 並行加速 (約快 3 倍)
# 寫到 $TMPDIR/.dir_sizes, 格式: pkg<TAB>type<TAB>size; 主迴圈 _dir_size 查此表免重複遍歷
prepare_dir_size_map() {
local _map="$TMPDIR/.dir_sizes"
: > "$_map"
local _list
if [[ -n $1 ]]; then
_list="$1"
else
_list="$(echo "$txt" | awk '{print $2}' | grep -v '^$')"
fi
[[ -z $_list ]] && return
local _workdir="$TMPDIR/.dirsize_work"
rm -rf "$_workdir"; mkdir -p "$_workdir"
local _total _i=0 _running=0 _par=8
_total="$(echo "$_list" | grep -vc '^$')"
# 用 here-string 餵 while, 避免管道把迴圈丟進子 shell (子 shell 內背景任務的變數作用域問題)
local _pkg _typ _dp
while read -r _pkg; do
[[ -z $_pkg ]] && continue
let _i++
printf '\r -預掃數據大小 %d/%d %s' "$_i" "$_total" "$(progress_bar $((_i * 100 / _total)))" >&2
for _typ in user user_de data obb; do
case $_typ in
user) _dp="$path2/$_pkg" ;;
user_de) _dp="$path3/$_pkg" ;;
data) _dp="$path/data/$_pkg" ;;
obb) _dp="$path/obb/$_pkg" ;;
esac
[[ ! -d $_dp ]] && continue
# 背景並行算大小, 各寫獨立檔 (無共享寫入, 安全); 背景內再確認 workdir 存在防競態
{ [[ -d $_workdir ]] && printf '%s\t%s\t%s\n' "$_pkg" "$_typ" "$(find "$_dp" -type f -printf '%s\n' 2>/dev/null | awk '{s+=$1}END{print s+0}')" > "$_workdir/${_pkg}.${_typ}" 2>/dev/null; } &
_running=$((_running+1))
[[ $_running -ge $_par ]] && { wait; _running=0; }
done
done <<EOF
$_list
EOF
wait
echo >&2
cat "$_workdir"/* 2>/dev/null > "$_map"
rm -rf "$_workdir"
}
# 遠端模式: 並發預掃所有 app 的遠端 app_details.json 到本地快取
# 主迴圈 apk/data 增量比對直接讀快取, 免每 app 多次遠端往返
prepare_remote_json_map() {
local _cache="$TMPDIR/.remote_json"
rm -rf "$_cache"; mkdir -p "$_cache"
[[ -z $remote_type ]] && return
local _list
_list="$(echo "$txt" | awk '{sub(/[[:space:]]+[^[:space:]]+$/,""); print}' | grep -v '^$')"
[[ -z $_list ]] && { touch "$_cache/.done"; return; }
local _subdir
_subdir="$(get_backup_dirname)"
# 全部 app 直接批量抓 json (不靠遠端列表交集 — smbclient 列表對中文名會轉碼毀名導致誤配)
# 不存在的檔 get 失敗即空, 內容驗證會濾掉; 批量模式連線數不增
echo "$_list" > "$TMPDIR/.json_fetch"
local _total _i=0
_total="$(grep -vc '^$' "$TMPDIR/.json_fetch")"
if [[ $_total -eq 0 ]]; then
rm -f "$TMPDIR/.json_fetch"; touch "$_cache/.done"; echo >&2; return
fi
if [[ $remote_type = smb ]]; then
# SMB: 單連線批量 get (每批 20 檔), 連線數 120→6
local _auth SMB_OPTS _batchcmd="" _app _n=0
if [[ -n $remote_user ]]; then _auth="-U $remote_user%$remote_pass"; else _auth="-N"; fi
SMB_OPTS="-t 300 -s /dev/null${REMOTE_PORT:+ -p $REMOTE_PORT} -m SMB3"
local _base="$SMB_REM_PATH/$_subdir"; _base="${_base#/}"; _base="${_base//\//\\}"
while read -r _app; do
[[ -z $_app ]] && continue
let _i++ _n++
_batchcmd="$_batchcmd get \"${_app//\//\\}\\app_details.json\" \"$_cache/$_app.json\";"
if [[ $_n -ge 20 ]]; then
printf '\r -預掃遠端清單 %d/%d' "$_i" "$_total" >&2
command smbclient "$SMB_SHARE" $_auth $SMB_OPTS -c "cd \"$_base\"; $_batchcmd" >/dev/null 2>&1
_batchcmd=""; _n=0
fi
done < "$TMPDIR/.json_fetch"
[[ -n $_batchcmd ]] && command smbclient "$SMB_SHARE" $_auth $SMB_OPTS -c "cd \"$_base\"; $_batchcmd" >/dev/null 2>&1
printf '\r -預掃遠端清單 %d/%d' "$_total" "$_total" >&2
else
# WebDAV: curl 輕量, 8 併發逐檔
local _running=0 _app
while read -r _app; do
[[ -z $_app ]] && continue
let _i++
printf '\r -預掃遠端清單 %d/%d' "$_i" "$_total" >&2
( _stream_download "$_subdir/$_app/app_details.json" 2>/dev/null > "$_cache/$_app.json" ) &
let _running++
if [[ $_running -ge 8 ]]; then wait; _running=0; fi
done < "$TMPDIR/.json_fetch"
wait
fi
rm -f "$TMPDIR/.json_fetch"
# 內容驗證: 非 { 開頭視為不存在
local _jf
for _jf in "$_cache"/*.json; do
[[ -f $_jf ]] || continue
case "$(head -c 1 "$_jf" 2>/dev/null)" in
'{') ;;
*) rm -f "$_jf" ;;
esac
done
echo >&2
local _got
_got="$(ls "$_cache"/*.json 2>/dev/null | grep -c json)"
echoRgb "遠端清單快取: $_got/$_total 個 app 有遠端紀錄" "2"
touch "$_cache/.done"
}
# 取遠端 app_details: 預掃快取命中直接用 (含「確定不存在」), 未預掃才即時下載
_get_remote_appdetails() {
local _cache="$TMPDIR/.remote_json"
if [[ -f $_cache/.done ]]; then
if [[ -s "$_cache/$1.json" ]]; then
cp "$_cache/$1.json" "$2" 2>/dev/null
return 0
fi
return 1
fi
remote_download_single_file "$1/app_details.json" "$2"
}
# 流式模式: 並發預掃遠端各 app 是否已有入口腳本 (recover.sh)
# 結果寫 $TMPDIR/.remote_scripts (一行一個「已有腳本」的 app 名), 主迴圈查表零開銷
# 一次抓遠端檔案總列表 (供腳本檢查/核驗共用, 單連線取代逐檔往返)
prepare_remote_filelist() {
: > "$TMPDIR/.remote_files"
[[ -z $remote_type ]] && return
echoRgb "預掃遠端檔案列表 (單次連線)..." "3"
remote_list_files "$(get_backup_dirname)" > "$TMPDIR/.remote_files" 2>/dev/null
echoRgb "遠端列表取得 $(grep -vc '^$' "$TMPDIR/.remote_files" 2>/dev/null)" "2"
}
prepare_remote_scripts_map() {
local _map="$TMPDIR/.remote_scripts"
: > "$_map"
[[ $remote_stream != 1 ]] && return
# 從總列表取「已有 recover.sh」的 app (零額外連線)
awk -F'/recover.sh' '/\/recover.sh$/{print $1}' "$TMPDIR/.remote_files" 2>/dev/null > "$_map"
}
# 預掃 pkg → version code map (取代 Backup_apk 內每個 app 都 fork pm 的開銷)
# 寫到 $TMPDIR/.pkg_ver 格式: pkg<TAB>versionCode
prepare_pkg_ver_map() {
# 正確解析 pm list packages --show-versioncode 輸出
# 格式: package:<pkg> versionCode:<code> [更多欄位]
# 用 awk 找出 versionCode: 後的數字, 同 pkg 多行只取第一個
pm list packages --show-versioncode --user "${user:-0}" 2>/dev/null \
| awk '
{
pkg = ""
ver = ""
for (i = 1; i <= NF; i++) {
if ($i ~ /^package:/) {
pkg = $i
sub(/^package:/, "", pkg)
} else if ($i ~ /^versionCode:/) {
ver = $i
sub(/^versionCode:/, "", ver)
}
}
if (pkg != "" && ver != "" && !(pkg in seen)) {
print pkg "\t" ver
seen[pkg] = 1
}
}
' > "$TMPDIR/.pkg_ver"
}
# 預掃所有 app 的 runtime permissions (取代 Backup_Permissions 內每個 app 各 fork dex)
# 寫到 $TMPDIR/.pkg_perms, 格式: pkg<TAB>json (json = getRuntimePermissions 輸出轉成的 object)
# Backup_Permissions 直接 awk 查, 不再呼叫 get_Permissions
prepare_permissions_map() {
local _perms_tmp="$TMPDIR/.pkg_perms"
: > "$_perms_tmp"
# 一次取得所有 app 的包名 (空白分隔)
local _all_pkgs
_all_pkgs="$(echo "$txt" | awk '{print $2}' | grep -v '^$' | paste -sd' ' -)"
[[ -z $_all_pkgs ]] && return
echoRgb "預掃應用權限中..." "2"
# 一次 get_Permissions 讀回所有 app (dex 只啟動 1 次, 取代逐 app N 次)
# 輸出每行: 包名 權限名 true/false op mode → awk 按包名分組直接生成 json
get_Permissions $_all_pkgs 2>/dev/null | awk '
NF>=3 && $0 != "null" {
pkg=$1; perm=$2
val=$3; for(i=4;i<=NF;i++) val=val" "$i
if (seen[pkg]) entry[pkg]=entry[pkg]","
# json 跳脫: 包名/權限名/值皆為安全字元(字母數字點底線空白), 直接包引號
entry[pkg]=entry[pkg] "\"" perm "\":\"" val "\""
seen[pkg]=1
}
END { for (p in entry) print p "\t{" entry[p] "}" }
' >> "$_perms_tmp"
}
# 用法: app_details_read <檔案路徑>
# 設定全域變數: APK_VER / SSAID_OLD / PERMS_OLD / PKG_NAME / BACKUP_TIME
# SIZE_user / SIZE_data / SIZE_obb / SIZE_user_de (各類型大小)
# INSTALLER_OLD / BATTERY_OLD
app_details_read() {
local file="$1"
APK_VER=""; SSAID_OLD=""; PERMS_OLD=""; PKG_NAME=""; BACKUP_TIME=""
SIZE_user=""; SIZE_data=""; SIZE_obb=""; SIZE_user_de=""
INSTALLER_OLD=""; BATTERY_OLD=""
[[ ! -f $file ]] && return
# jq 把各值各印一行到暫存檔
# 用 try ... catch "" 確保每個 expression 不論成敗都輸出一行 (即使空字串)
# 避免某行 error 導致整個輸出位移
local tmpf="$TMPDIR/.app_details_read_$$"
jq -r '
(try ([.[] | objects | select(.apk_version != null).apk_version] | .[0]) catch "" // ""),
(try ([.[] | objects | select(.Ssaid != null).Ssaid] | .[0]) catch "" // ""),
(try ([.[] | objects | select(.permissions != null).permissions | tojson] | .[0]) catch "" // ""),
(try ([.[] | objects | select(.PackageName != null).PackageName] | .[0]) catch "" // ""),
(try (.["Backup time"].date) catch "" // ""),
(try (.user.Size) catch "" // ""),
(try (.data.Size) catch "" // ""),
(try (.obb.Size) catch "" // ""),
(try (.user_de.Size) catch "" // ""),
(try ([.[] | objects | select(.installer != null).installer] | .[0]) catch "" // ""),
(try ([.[] | objects | select(.battery_opt != null).battery_opt] | .[0]) catch "" // "")
' "$file" 2>/dev/null > "$tmpf"
# 用 FD 逐行讀 (mksh 相容)
exec 3< "$tmpf"
read -r APK_VER <&3
read -r SSAID_OLD <&3
read -r PERMS_OLD <&3
read -r PKG_NAME <&3
read -r BACKUP_TIME <&3
read -r SIZE_user <&3
read -r SIZE_data <&3
read -r SIZE_obb <&3
read -r SIZE_user_de <&3
read -r INSTALLER_OLD <&3
read -r BATTERY_OLD <&3
exec 3<&-
rm -f "$tmpf"
}
# Chrome 特例: trichromelibrary 會留多個舊版本, 只保留最新一個
# 在 Backup_apk 末尾 (name2=com.android.chrome 時) 呼叫
cleanup_chrome_legacy() {
local files
files=$(ls /data/app/*/com.google.android.trichromelibrary_*/base.apk 2>/dev/null)
[[ -z $files ]] && return
local n
n=$(awk 'END{print NR}' <<< "$files")
# 多於 1 個 → 按時間刪掉舊的, 只留最新
if [[ $n -gt 1 ]]; then
echo "$files" \
| while read -r f; do
printf '%s %s\n' "$(stat -c '%Y' "$f" 2>/dev/null)" "$f"
done \
| sort -n \
| head -n -1 \
| while read -r _ts oldfile; do
rm -rf "${oldfile%/*/*}" && echo "刪除文件:${oldfile%/*/*}"
done
fi
# 拷貝最新一個到備份目錄
local kept
kept=$(ls /data/app/*/com.google.android.trichromelibrary_*/base.apk 2>/dev/null | head -1)
[[ -f $kept ]] && cp -r "$kept" "$Backup_folder/nmsl.apk"
}
# 查 app uid (三層 fallback, 優先用 prepare_pkg_uid_map 預掃的 .pkg_uid)
# 用法: uid=$(get_app_uid "$pkg")
get_app_uid() {
local pkg="$1" uid
# 優先從預掃 map 查
if [[ -f $TMPDIR/.pkg_uid ]]; then
uid=$(awk -v p="$pkg" -F'\t' '$1 == p {print $2; exit}' "$TMPDIR/.pkg_uid")
[[ -n $uid ]] && { echo "$uid"; return; }
fi
# fallback 1: pm list
uid=$(pm list packages -U --user "${user:-0}" </dev/null | awk -v pkg="$pkg" -F'[ :]' '$2 == pkg {print $4; exit}')
# fallback 2: dumpsys
[[ -z $uid ]] && uid=$(dumpsys package "$pkg" 2>/dev/null | awk -F'uid=' '{print $2}' | grep -Eo '[0-9]+' | head -n 1)
# fallback 3: get_uid
[[ -z $uid ]] && uid=$(get_uid "$pkg" 2>/dev/null)
echo "$uid" | grep -Eo '[0-9]+' | head -n 1
}
# 恢復用: 一次抓 Release_data 需要的 Size / keystore / path
# 用法: release_details_read <app_details.json> <entry>
# 設定全域變數: REL_SIZE / REL_KEYSTORE / REL_PATH
release_details_read() {
local file="$1" entry="$2"
REL_SIZE=""; REL_KEYSTORE=""; REL_PATH=""
[[ ! -f $file ]] && return
local tmpf="$TMPDIR/.rel_jq_$$"
jq -r --arg e "$entry" '
(try .[$e].Size catch "" // ""),
(try ([.[] | objects | select(.keystore != null).keystore] | .[0]) catch "" // ""),
(try .[$e].path catch "" // "")
' "$file" 2>/dev/null > "$tmpf"
exec 3< "$tmpf"
read -r REL_SIZE <&3
read -r REL_KEYSTORE <&3
read -r REL_PATH <&3
exec 3<&-
rm -f "$tmpf"
}
# 預掃 pm list packages --user (取代 Restore 主迴圈內每 app fork)
# 寫到 $TMPDIR/.installed_pkgs (一行一個 pkg name)
prepare_installed_pkgs_map() {
pm list packages --user "${user:-0}" 2>/dev/null \
| cut -f2 -d':' > "$TMPDIR/.installed_pkgs"
}
# 計算指定目錄的總大小並輸出可讀字串 (KB/MB/GB)
Calculate_size() {
#計算出備份大小跟差異性
filesizee="$(calc_dir_size "$1")"
# awk 比較大小差異 (無 32-bit 溢位)
local _diff
_diff=$(awk -v a="$filesizee" -v b="${filesize:-0}" 'BEGIN{print a-b}')
case $_diff in
-*)
NJL="本次備份減少 $(size "$(awk -v a="${filesize:-0}" -v b="$filesizee" 'BEGIN{print a-b}')")" ;;
0)
NJL="文件大小未改變" ;;
*)
NJL="本次備份增加 $(size "$_diff")" ;;
esac
echoRgb "備份資料夾路徑↓↓↓\n -$1"
echoRgb "備份資料夾總體大小$(size "$filesizee") $filesizee"
echoRgb "$NJL"
}
# 把 bytes 轉成人類可讀格式 (B/KB/MB/GB)
# 用法: size <bytes 數值> 或 size <檔案路徑> (會 stat 取大小)
size() {
local b_size get_size
case $1 in
*[!0-9]*)
b_size="$(stat -c%s "$1" 2>/dev/null)" ;;
*)
b_size="$1" ;;
esac
# 用 awk printf 四捨五入 (跟檔案管理器一致), awk 處理大數無 32-bit 溢位問題
if [[ $b_size -eq 0 ]]; then
get_size="0 bytes"
elif [[ $(awk -v n="$b_size" 'BEGIN{print (n<1024)?1:0}') -eq 1 ]]; then
get_size="${b_size} bytes"
elif [[ $(awk -v n="$b_size" 'BEGIN{print (n<1048576)?1:0}') -eq 1 ]]; then
get_size="$(awk "BEGIN{printf \"%.2f\", $b_size/1024}") KB"
elif [[ $(awk -v n="$b_size" 'BEGIN{print (n<1073741824)?1:0}') -eq 1 ]]; then
get_size="$(awk "BEGIN{printf \"%.2f\", $b_size/1048576}") MB"
else
get_size="$(awk "BEGIN{printf \"%.2f\", $b_size/1073741824}") GB"
fi
echo "$get_size"
}
#分區佔用信息
partition_info() {
unset Skip
Occupation_status="$(df -B1 "$(_resolve_real_mount "${1%/*}")" | sed -n 's|% /.*|%|p' | awk '{print $(NF-1)}')"
Filesize2="$(size "$Filesize")"
echo " -$2大小:$Filesize2 剩餘大小:$(size "$Occupation_status")"
if [[ -n $Filesize ]]; then
if awk -v a="$Filesize" -v b="$Occupation_status" 'BEGIN{exit !(a+0 > b+0)}'; then
echoRgb "$2備份大小將超出rom可用大小" "0"
Skip=1
fi
fi
Occupation_status="$(df -h "$(_resolve_real_mount "${Backup%/*}")" | sed -n 's|% /.*|%|p' | awk '{print $(NF-1),$(NF)}')"
}
# 取得指定 app 的後台運行 PID (用於跳過正在運行的 app)
Process_Information() {
dumpsys activity processes 2>/dev/null | awk -v key="$1" -v user="$user" '
function getUserFromUid(uid){return int(uid/100000)}
# 進程塊起點: ProcessRecord{hash PID:name/uid} → 抓 pid (兼容無獨立 pid= 行的新格式)
/ProcessRecord\{/ {tmp=$0; sub(/^.*ProcessRecord\{[^ ]+ /,"",tmp); sub(/:.*/,"",tmp); pid=tmp; uid=""; pkg=""; next}
/^ *user #[0-9]+ uid=/ {if($0 ~ /ISOLATED uid=[0-9]+/){uid="";next} tmp=$0; sub(/^.*uid=/,"",tmp); sub(/ .*/,"",tmp); uid=tmp}
/packageList=\{/ {tmp=$0; sub(/^.*packageList=\{/,"",tmp); sub(/\}.*/,"",tmp); pkg=tmp; if(pkg!="" && uid!="" && pid!=""){if((key=="" || pkg==key) && (user=="" || getUserFromUid(uid)==user)) print pid}}
'
}
# 強制終止指定 app (am force-stop + pkill 雙保險)
kill_app() {
process_Information="$(Process_Information "$name2")"
if [[ $name2 != bin.mt.plus && $name2 != com.termux && $name2 != bin.mt.plus.canary ]]; then
if [[ $process_Information != "" ]]; then
am force-stop --user "$user" "$name2" &>/dev/null
echo "$process_Information" | xargs -r kill -9 2>/dev/null
pkill -9 -f "$name2$|$name2[:/_]" 2>/dev/null
#killall -9 "$name2" &>/dev/null
#am kill "$name2" &>/dev/null
echoRgb "殺死$name1進程"
fi
fi
}
# ======================================================
# 備份核心函數 (Backup_apk / Backup_data / ssaid / 權限)
# ======================================================
# 備份 app 的 apk 檔 (含 split apk, 用 tar/zstd 打包)
Backup_apk() {
#檢測apk狀態進行備份
#創建APP備份文件夾
[[ ! -d $Backup_folder ]] && mkdir -p "$Backup_folder"
[[ ! -f $app_details ]] && echo "{\n}">"$app_details"
# 從預掃 map 查當前版本 (取代 fork pm + cut + head)
eval "apk_version2=\${_pv_${name2//[!a-zA-Z0-9]/_}}"
# 如果啟用遠程備份,從遠端獲取 app_details.json 進行對比
local _remote_checked=0
if [[ -n $remote_type ]]; then
local remote_app_details="$TMPDIR/.remote_app_details_$$"
local remote_rel="${name1}/app_details.json"
if _get_remote_appdetails "$name1" "$remote_app_details" 2>/dev/null; then
[[ -s $remote_app_details ]] && {
_remote_checked=1
# 從遠端 app_details 讀取版本號
local remote_apk_ver
remote_apk_ver=$(jq -r --arg name "$name1" 'try .[$name].apk_version catch "" // ""' "$remote_app_details" 2>/dev/null)
# 如果遠端版本與當前版本一致, 且本地已有 apk 備份, 才跳過備份
# (本地缺備份時不可跳過, 否則全新備份會漏掉此 app)
local _local_apk_exists=0
{ [[ -f "$Backup_folder/apk.tar.zst" ]] || [[ -f "$Backup_folder/apk.tar" ]]; } && _local_apk_exists=1
# 流式模式: 遠端有且版本一致即可跳過 (不需本機 tar, 因流式本就不留本地)
[[ $remote_stream = 1 ]] && _local_apk_exists=1
if awk -v p="$name2" '$0==p{f=1} END{exit !f}' "$TMPDIR/.listver_changed" 2>/dev/null; then
# 啟動檢查偵測到實機版本已變: 遠端 json 版本號不可信 (可能被失敗輪汙染), 強制重備
echoRgb "清單偵測到版本已更新, 重新備份apk" "3"
elif [[ -n $remote_apk_ver && $remote_apk_ver = "$apk_version2" && $_local_apk_exists = 1 ]]; then
# 版本相符再核對遠端 apk 檔實際存在 (json 可能被舊版/失敗輪汙染而 apk 缺檔)
_rapk_ok=0
if [[ $remote_stream = 1 ]]; then
if awk -v a="$name1/apk.tar.zst" -v b="$name1/apk.tar" '$0==a||$0==b{f=1;exit} END{exit !f}' "$TMPDIR/.remote_files" 2>/dev/null; then
_rapk_ok=1
else
# 列表沒找到 (可能中文名轉碼) → 單檔下載開頭確認
case "$(_stream_download "$(get_backup_dirname)/$name1/apk.tar.zst" 2>/dev/null | head -c 60)" in
""|*NT_STATUS*) _rapk_ok=0 ;;
*) _rapk_ok=1 ;;
esac
fi
else
_rapk_ok=1
fi
if [[ $_rapk_ok = 1 ]]; then
if ! awk -v p="$name2" '$2==p{f=1} END{exit !f}' "$TMPDIR/.backup_done" 2>/dev/null; then
echo "${Backup_folder##*/} $name2" >> "$TMPDIR/.backup_done"
fi
unset xb
let osj++
result=0
echoRgb "Apk版本無更新(遠端備份無變化) 跳過備份" "2"
rm -f "$remote_app_details"
return 0
fi
echoRgb "版本相符但遠端缺apk檔, 補備份一次" "0"
fi
}
fi
rm -f "$remote_app_details"
# 遠端啟用但查無此備份: 即使本地版本未變, 仍會備份並上傳一次
[[ $_remote_checked = 0 ]] && echoRgb "遠端無此備份 將備份一次並上傳" "2"
fi
# APK_VER 已經由 app_details_read 載入 (在主迴圈呼叫過)
apk_version="$APK_VER"
# 遠端已啟用但無備份時,不應依據本地 app_details 跳過,應上傳到遠端
_local_apk_exists=0
{ [[ -f "$Backup_folder/apk.tar.zst" ]] || [[ -f "$Backup_folder/apk.tar" ]]; } && _local_apk_exists=1
# 流式模式: 不依賴本機 tar (本機可能有舊備份殘留), 強制當作無本機檔, 走重新壓縮流式
[[ $remote_stream = 1 ]] && _local_apk_exists=0
if [[ $apk_version = $apk_version2 ]] && [[ $_local_apk_exists = 1 ]]; then
# 版本一致且本地已有備份: 不重新打包
if ! awk -v p="$name2" '$2==p{f=1} END{exit !f}' "$TMPDIR/.backup_done" 2>/dev/null; then
echo "${Backup_folder##*/} $name2" >> "$TMPDIR/.backup_done"
fi
unset xb
let osj++
result=0
# 遠端啟用但查無此備份: 不重壓, 直接把現有本地檔標記為待上傳 (流式無本地檔, 不走此路)
if [[ $remote_stream != 1 && -n $remote_type && $_remote_checked = 0 ]]; then
backup_has_changes=1
awk -v n="$name1" 'BEGIN{f=0} $0==n{f=1;exit} END{if(!f)print n}' "$TMPDIR/.changed_apps" 2>/dev/null >> "$TMPDIR/.changed_apps"
echoRgb "Apk版本無更新 遠端缺檔: 直接上傳本地備份(免重壓)" "2"
else
echoRgb "Apk版本無更新 跳過備份" "2"
fi
else
if [[ $nobackup = false ]]; then
# 版本一致且本地已有 apk 備份: 不重壓 (避免重複備份)
if [[ $apk_version != "" && $apk_version = "$apk_version2" && $_local_apk_exists = 1 ]]; then
let osj++
echoRgb "版本:$apk_version 無更新 跳過備份" "2"
if ! awk -v p="$name2" '$2==p{f=1} END{exit !f}' "$TMPDIR/.backup_done" 2>/dev/null; then
echo "${Backup_folder##*/} $name2" >> "$TMPDIR/.backup_done"
fi
result=0
return 0
fi
if [[ $apk_version != "" ]]; then
if [[ $apk_version = "$apk_version2" ]]; then
let osj++
if [[ $remote_stream = 1 || -n $remote_type ]]; then
echoRgb "版本:$apk_version (遠端無此版本, 補備份一次)"
else
echoRgb "版本:$apk_version (本機無備份檔, 補備份一次)"
fi
else
let osn++
# 用暫存檔取代字串拼接
echo "$name1 \"$name2\"" >> "$TMPDIR/.update_apks"
echoRgb "版本:$apk_version>$apk_version2"
fi
else
let osk++
echo "$name1 \"$name2\"" >> "$TMPDIR/.add_apks"
echoRgb "版本:$apk_version2"
fi
unset Filesize
Filesize="$(calc_dir_size "$apk_path2")"
rm -rf "$Backup_folder/apk.tar"*
partition_info "$Backup" "$name1 apk"
if [[ $Skip != 1 ]]; then
#備份apk
echoRgb "$1"
echo "$apk_path" | sed -e '/^$/d' | while read -r; do
echoRgb "${REPLY##*/} $(size "$REPLY")"
done
tar_compress_glob "$Backup_folder/apk" "$apk_path2" "*.apk"
echo_log "備份$apk_number個Apk"
if [[ $result = 0 ]]; then
# 流式模式: apk 已流式傳遠端, 本機無 tar 可校驗, 跳過 (信任傳輸)
[[ $remote_stream != 1 ]] && Validation_file "$Backup_folder/apk.tar"*
if [[ $result = 0 ]]; then
# 加進備份完成清單 (avoid 重複)
if ! awk -v p="$name2" '$2==p{f=1} END{exit !f}' "$TMPDIR/.backup_done" 2>/dev/null; then
echo "${Backup_folder##*/} $name2" >> "$TMPDIR/.backup_done"
fi
[[ $apk_version != "" ]] && {
echoRgb "覆蓋app_details"
jq_inplace "$app_details" --arg apk_version "$apk_version2" --arg software "$name1" '.[$software].apk_version = $apk_version'
} || {
echoRgb "新增app_details"
extra_content="{
\"$name1\": {
\"PackageName\": \"$name2\",
\"apk_version\": \"$apk_version2\"
}
}"
jq_inplace "$app_details" --argjson new_content "$extra_content" '. += $new_content'
}
# 標記有備份變更
backup_has_changes=1
# 記錄有變更的應用
awk -v n="$name1" 'BEGIN{f=0} $0==n{f=1;exit} END{if(!f)print n}' "$TMPDIR/.changed_apps" 2>/dev/null >> "$TMPDIR/.changed_apps"
# Chrome 特例
[[ $name2 = com.android.chrome ]] && cleanup_chrome_legacy
else
rm -rf "$Backup_folder"
fi
else
rm -rf "$Backup_folder"
fi
fi
else
let osj++
rm -rf "$Backup_folder"
fi
fi
[[ $name2 = bin.mt.plus && ! -f $Backup/$name1.apk ]] && cp -r "$apk_path" "$Backup/$name1.apk"
}
# 備份 app 的 SSAID (應用識別碼)
# 用 classes.dex 透過 app_process 讀 /data/system/users/$user/settings_ssaid.xml
# 沒備份 SSAID 恢復後遊戲帳號會被當新裝置
Backup_ssaid() {
# Ssaid (舊值) 已由 app_details_read 載入到 SSAID_OLD
Ssaid="$SSAID_OLD"
ssaid="$(awk -v pkg="$name2" '$1 == pkg {print $2}'<<<"$ssaid_info")"
[[ $ssaid != null && $ssaid != "" ]] && echoRgb "SSAID:$ssaid"
if [[ $ssaid != null && $ssaid != $Ssaid ]]; then
echoRgb "備份ssaid"
echoRgb "$Ssaid>$ssaid"
# 用暫存檔取代字串拼接
echo "$name1 \"$name2\"" >> "$TMPDIR/.ssaid_apks"
jq_inplace "$app_details" --arg entry "$name1" --arg new_value "$ssaid" '.[$entry].Ssaid |= $new_value'
echo_log "備份ssaid"
[[ $result = 0 ]] && awk -v n="$name1" 'BEGIN{f=0} $0==n{f=1;exit} END{if(!f)print n}' "$TMPDIR/.changed_apps" 2>/dev/null >> "$TMPDIR/.changed_apps"
fi
[[ $ssaid = null ]] && ssaid=
}
# 備份 app 的 runtime permissions (運行時權限)
# 恢復時可一鍵還原所有授權, 不用再手動點
Backup_Permissions() {
# 從預掃 map 讀取當前系統權限
eval "Get_Permissions=\${_pp_${name2//[!a-zA-Z0-9]/_}}"
# 上次備份的舊值 (由 app_details_read 載入到 PERMS_OLD)
local perms_old="$PERMS_OLD"
[[ $_perm_diag = 1 ]] && echoRgb "[診斷] $name1 PERMS_OLD長度=${#perms_old} app_details=$app_details 種子存在=$([[ -s $TMPDIR/.remote_json/$name1.json ]] && echo Y || echo N)" "0" >&2
if [[ $Get_Permissions != "" && ($Get_Permissions = *true* || $Get_Permissions = *false*) ]]; then
if [[ $perms_old = "" ]]; then
echoRgb "備份權限"
jq_inplace "$app_details" --arg packageName "$name1" --argjson permissions "$Get_Permissions" '.[$packageName].permissions |= $permissions'
echo_log "備份權限"
[[ $result = 0 ]] && awk -v n="$name1" 'BEGIN{f=0} $0==n{f=1;exit} END{if(!f)print n}' "$TMPDIR/.changed_apps" 2>/dev/null >> "$TMPDIR/.changed_apps"
else
if [[ $perms_old = *true* || $perms_old = *false* ]]; then
if [[ $perms_old != $Get_Permissions ]]; then
echoRgb "權限變更"
jq -n --argjson old "$perms_old" --argjson new "$Get_Permissions" \
'$new | to_entries | map(select(.key as $k | $old[$k] == null or ($old[$k] | split(" ")[0]) != (.value | split(" ")[0])) | " \(.key): \(if ($old[.key] == null) then "新增→\(.value | split(" ")[0])" else "\($old[.key] | split(" ")[0])→\(.value | split(" ")[0])" end)") | .[]' \
-r 2>/dev/null
jq_inplace "$app_details" --arg packageName "$name1" --argjson permissions "$Get_Permissions" '.[$packageName] |= . + {permissions: $permissions}'
echo_log "備份權限"
[[ $result = 0 ]] && awk -v n="$name1" 'BEGIN{f=0} $0==n{f=1;exit} END{if(!f)print n}' "$TMPDIR/.changed_apps" 2>/dev/null >> "$TMPDIR/.changed_apps"
fi
fi
fi
else
[[ $Get_Permissions != "" ]] && echoRgb "備份權限失敗" "0"
fi
}
# 備份額外 metadata: installer (安裝來源) 與 battery_opt (電池優化白名單)
# 從預掃 map 讀取, 不額外 fork; 變更時寫入 app_details.json
Backup_extra() {
# installer name
local installer
eval "installer=\${_pi_${name2//[!a-zA-Z0-9]/_}}"
if [[ -n $installer && $installer != $INSTALLER_OLD ]]; then
jq_inplace "$app_details" --arg entry "$name1" --arg v "$installer" '.[$entry].installer |= $v'
echo_log "備份installer"
[[ $result = 0 ]] && echoRgb "安裝來源:$installer" "2"
[[ $result = 0 ]] && awk -v n="$name1" 'BEGIN{f=0} $0==n{f=1;exit} END{if(!f)print n}' "$TMPDIR/.changed_apps" 2>/dev/null >> "$TMPDIR/.changed_apps"
fi
# battery: 後台運行 appops mode (allow/ignore/deny), 原樣記錄與還原
local batt
eval "batt=\${_bw_${name2//[!a-zA-Z0-9]/_}}"
if [[ -n $batt && $batt != $BATTERY_OLD ]]; then
jq_inplace "$app_details" --arg entry "$name1" --arg v "$batt" '.[$entry].battery_opt |= $v'
echo_log "備份battery_opt"
[[ $result = 0 ]] && echoRgb "後台運行設定:$batt" "2"
[[ $result = 0 ]] && awk -v n="$name1" 'BEGIN{f=0} $0==n{f=1;exit} END{if(!f)print n}' "$TMPDIR/.changed_apps" 2>/dev/null >> "$TMPDIR/.changed_apps"
fi
}
#檢測數據位置進行備份
Backup_data() {
data_path="$path/$1/$name2"
MODDIR_NAME="${data_path%/*}"
MODDIR_NAME="${MODDIR_NAME##*/}"
# 取舊 Size:
# - 應用備份 ($1 = user/data/obb/user_de): 直接讀 app_details_read 預讀的變數
# - 媒體備份 ($1 = 動態資料夾名 Download/DCIM/...): 預讀變數沒有, fallback 用 jq 即時查
# - 其他 (thanox 等): 同 fallback
Size=""
case $1 in
user|data|obb|user_de)
eval "Size=\"\$SIZE_$1\""
;;
*)
[[ -f $app_details ]] && Size="$(jq -r --arg entry "$1" 'try .[$entry].Size catch "" // ""' "$app_details" 2>/dev/null)"
;;
esac
[[ -z $Size ]] && Size=""
case $1 in
user) data_path="$path2/$name2" ;;
user_de) data_path="$path3/$name2" ;;
data|obb) ;;
*)
data_path="$2"
if [[ $1 != thanox ]]; then
Compression_method1="$Compression_method"
Compression_method=tar
fi
zsize=1
zmediapath=1
;;
esac
# 如果啟用遠程備份,從遠端獲取 app_details.json 進行對比
local _remote_data_checked=0
if [[ -n $remote_type ]]; then
local remote_app_details="$TMPDIR/.remote_app_details_$$"
local remote_rel="${name1}/app_details.json"
if _get_remote_appdetails "$name1" "$remote_app_details" 2>/dev/null; then
[[ -s $remote_app_details ]] && {
# 從遠端 app_details 讀取 Size
local remote_size
remote_size=$(jq -r --arg entry "$1" 'try .[$entry].Size catch "" // ""' "$remote_app_details" 2>/dev/null)
# 如果遠端 Size 與當前一致,跳過備份
if [[ -n $remote_size && $remote_size != "null" ]]; then
_remote_data_checked=1
local current_size
_dir_size "$name2" "$1" "$data_path"; current_size="$_DIR_SIZE_RET"
# 本地必須已有該 tar 才可跳過, 否則全新備份會漏掉
local _local_data_exists=0
ls "$Backup_folder/$1.tar"* >/dev/null 2>&1 && _local_data_exists=1
# 流式模式: 遠端 Size 一致即可跳過 (不需本機 tar)
[[ $remote_stream = 1 ]] && _local_data_exists=1
if [[ "$remote_size" = "$current_size" && $_local_data_exists = 1 ]]; then
echoRgb "$1數據無變化(遠端備份無變化) 跳過備份" "2"
rm -f "$remote_app_details"
return 0
fi
fi
}
fi
rm -f "$remote_app_details"
fi
if [[ -d $data_path ]]; then
unset Filesize ssaid Get_Permissions result Permissions
_dir_size "$name2" "$1" "$data_path"; Filesize="$_DIR_SIZE_RET"
# ssaid/permissions 只要是 user 類型就無條件執行 (不依賴 size 變化)
case $1 in
user)
Backup_ssaid
Backup_Permissions
Backup_extra
;;
esac
[[ $Filesize != "" ]] && {
# 遠端缺檔但本地 Size 無變化且本地 tar 已存在: 不重壓, 直接標記上傳現有本地檔
local _local_data_exists2=0 _tarchk
for _tarchk in "$Backup_folder/$1".tar*; do
[[ -e $_tarchk ]] && { _local_data_exists2=1; break; }
done
# 流式模式: 忽略本機殘留 tar, 強制重新壓縮流式上傳
[[ $remote_stream = 1 ]] && _local_data_exists2=0
if [[ $remote_stream != 1 && -n $remote_type && $_remote_data_checked = 0 && $Size = $Filesize && $_local_data_exists2 = 1 ]]; then
backup_has_changes=1
case $1 in user|data|obb|user_de) awk -v n="$name1" 'BEGIN{f=0} $0==n{f=1;exit} END{if(!f)print n}' "$TMPDIR/.changed_apps" 2>/dev/null >> "$TMPDIR/.changed_apps" ;; esac
echoRgb "$1數據無變化 遠端缺檔: 直接上傳本地備份(免重壓)" "2"
return 0
fi
# 遠端已啟用但無備份時,即使本地 Size 無變化也應上傳到遠端
local _force_data_backup=0
# 遠端已啟用時,匹配的情況已在上面 return 0走到這裡代表遠端要嘛沒有 Size 要嘛不匹配,都應備份
[[ -n $remote_type ]] && _force_data_backup=1
if [[ $Size != $Filesize ]] || [[ $_force_data_backup = 1 ]]; then
case $1 in
user)
# 從預掃的 pkg→uid map 查 uid (省去 fork pm + awk)
local _uid
eval "_uid=\${_pu_${name2//[!a-zA-Z0-9]/_}}"
if [[ -n $_uid ]] && [[ $(su "$_uid" -c keystore_cli_v2 list 2>/dev/null | wc -l) -ge 2 ]]; then
echoRgb "$name1包含keystore 恢復可能閃退" "0"
jq_inplace "$app_details" --arg entry "$name1" '.[$entry].keystore |= "true"'
else
jq_inplace "$app_details" --arg entry "$name1" '.[$entry].keystore |= "false"'
fi ;;
esac
#停止應用
case $1 in
user|data|obb|user_de) kill_app ;;
esac
rm -rf "$Backup_folder/$1.tar"*
partition_info "$Backup" "$1"
if [[ $Skip != 1 ]]; then
echoRgb "備份$1數據"
# 判斷是否超過 1KB (太小的數據不值得備份, 可能是空目錄)
# 注意: Android mksh 在 32-bit 環境下 [[ $a -gt N ]] 對超過 ~2GB 的數值會溢位
# 改用字串長度判斷: bytes 數值字串長度 >= 4 就是 >= 1000 bytes (約 1KB)
if [[ ${#Filesize} -ge 4 ]]; then
Start_backup="true"
else
Start_backup="false"
fi
if [[ $Start_backup = true ]]; then
local _dp_name="${data_path##*/}"
case $1 in
user|user_de)
tar_compress_dir "$Backup_folder/$1" "${data_path%/*}" "$_dp_name" \
--exclude="$_dp_name/.ota" \
--exclude="$_dp_name/cache" \
--exclude="$_dp_name/lib" \
--exclude="$_dp_name/code_cache" \
--exclude="$_dp_name/no_backup" \
2>/dev/null
;;
*)
tar_compress_dir "$Backup_folder/$1" "${data_path%/*}" "$_dp_name" \
--exclude="Backup_*" \
--exclude="$_dp_name/cache" \
--exclude="$_dp_name/QQ" \
--exclude="$_dp_name/Telegram" \
--exclude="$_dp_name/.*"
;;
esac
echo_log "備份$1數據"
else
echoRgb "$1數據 $Filesize2太小" "0" && result=1
fi
if [[ $result = 0 ]]; then
# 流式模式: 數據已直接傳遠端, 本機無 tar 可校驗, 跳過校驗 (信任傳輸)
[[ $remote_stream != 1 ]] && Validation_file "$Backup_folder/$1.tar"*
if [[ $result = 0 ]]; then
if [[ $remote_stream = 1 ]]; then
echoRgb "$1數據已流式上傳遠端 (大小 $(size "$Filesize"))" "1"
elif [[ ! $Filesize -eq 0 ]]; then
size2="$(stat -c %s "$Backup_folder/$1.tar"*)"
rate="$(awk -v s="$size2" -v f="$Filesize" 'BEGIN{printf "%.2f", (1-(s/f))*100}')"
echoRgb "壓縮率${rate}% 大小$(size "$size2")"
fi
[[ ${Backup_folder##*/} = Media ]] && [[ $(sed -e '/^$/d' "$mediatxt" | grep -w "${REPLY##*/}.tar$" | head -1) = "" ]] && echo "$FILE_NAME" >> "$mediatxt"
if [[ $zsize != "" ]]; then
extra_content="{
\"$1\": {
\"path\": \"$2\",
\"Size\": \"$Filesize\"
},
\"Backup time\": {
\"date\": \"$(date "+%Y.%m.%d %H:%M:%S")\"
}
}"
jq_inplace "$app_details" --argjson new_content "$extra_content" '. += $new_content'
else
extra_content="{
\"$1\": {
\"Size\": \"$Filesize\"
},
\"Backup time\": {
\"date\": \"$(date "+%Y.%m.%d %H:%M:%S")\"
}
}"
jq_inplace "$app_details" --argjson new_content "$extra_content" '. += $new_content'
fi
# 標記有備份變更
backup_has_changes=1
# 記錄有變更的應用 (排除媒體/自定義路徑備份, 由 REMOTE_UPLOAD_MEDIA 處理)
case $1 in user|data|obb|user_de) awk -v n="$name1" 'BEGIN{f=0} $0==n{f=1;exit} END{if(!f)print n}' "$TMPDIR/.changed_apps" 2>/dev/null >> "$TMPDIR/.changed_apps" ;; esac
else
rm -rf "$Backup_folder/$1".tar.*
fi
fi
[[ $Compression_method1 != "" ]] && Compression_method="$Compression_method1"
unset Compression_method1
fi
else
[[ $Size != "" ]] && echoRgb "$1數據無發生變化 跳過備份" "2"
fi
}
else
[[ -f $data_path ]] && echoRgb "$1是一個文件 不支持備份" "0"
fi
}
# 恢復 app 的 data 資料 (解壓 tar.zst 到 /data/data/<pkg>/)
# 處理 selinux context、uid 綁定
Release_data() {
tar_path="$1"
X="$path2/$name2"
MODDIR_NAME="${tar_path%/*}"
MODDIR_NAME="${MODDIR_NAME##*/}"
FILE_NAME="${tar_path##*/}"
# 只去 .tar / .tar.zst 後綴 (不可用 %%.* , 否則 service.d.tar 會被砍成 service)
FILE_NAME2="${FILE_NAME%.zst}"
FILE_NAME2="${FILE_NAME2%.tar}"
case ${FILE_NAME##*.} in
zst | tar)
unset FILE_PATH Size Selinux_state
# 一次 jq 抓 Size / keystore / path (取代 3 個獨立 jq fork)
release_details_read "$app_details" "$FILE_NAME2"
Size="$REL_SIZE"
case $FILE_NAME2 in
user)
if [[ -d $X ]]; then
[[ $REL_KEYSTORE = true ]] && echoRgb "$name1存在keystore 恢復可能閃退" "0"
FILE_PATH="$path2"
# 合併 LS|awk|sed → 1 個 awk (省 2 fork)
Selinux_state="$(LS "$X" 2>/dev/null | awk 'NF>1 {gsub(/system_data_file/, "app_data_file"); print $1; exit}')"
else
echoRgb "$X不存在 無法恢復$FILE_NAME2數據" "0"
fi ;;
user_de)
X="$path3/$name2"
if [[ -d $X ]]; then
FILE_PATH="$path3"
Selinux_state="$(LS "$X" 2>/dev/null | awk 'NF>1 {gsub(/system_data_file/, "app_data_file"); print $1; exit}')"
else
echoRgb "$X不存在 無法恢復$FILE_NAME2數據" "0"
fi ;;
data)
FILE_PATH="$path/data"
Selinux_state="$(LS "$FILE_PATH" 2>/dev/null | awk 'NF>1 {gsub(/system_data_file/, "app_data_file"); print $1; exit}')"
;;
obb)
FILE_PATH="$path/obb"
Selinux_state="$(LS "$FILE_PATH" 2>/dev/null | awk 'NF>1 {gsub(/system_data_file/, "app_data_file"); print $1; exit}')"
;;
thanox) FILE_PATH="/data/system" && find "/data/system" -name "thanos"* -maxdepth 1 -type d -exec rm -rf {} \; 2>/dev/null ;;
*)
if [[ $A != "" ]]; then
if [[ ${MODDIR_NAME##*/} = Media ]]; then
FILE_PATH="$REL_PATH"
if [[ $FILE_PATH = "" ]]; then
echoRgb "路徑獲取失敗" "0"
else
echoRgb "解壓路徑↓\n -$FILE_PATH" "2"
FILE_PATH="${FILE_PATH%/*}"
[[ ! -d $FILE_PATH ]] && mkdir -p "$FILE_PATH"
fi
fi
else
echoRgb "$tar_path名稱似乎有誤" "0"
fi ;;
esac
echoRgb "恢復$FILE_NAME2數據 釋放$(size "$Size")" "3"
if [[ $FILE_PATH != "" ]]; then
[[ ${MODDIR_NAME##*/} != Media ]] && rm -rf "$FILE_PATH/$name2"
# 流式恢復: 從遠端拉 → 管道解壓 (不落地本機); _STREAM_SRC 為遠端相對路徑
if [[ $_RESTORE_STREAM = 1 && -n $_STREAM_SRC ]]; then
case ${FILE_NAME##*.} in
zst) _stream_download "$_STREAM_SRC" | zstd -d 2>/dev/null | tar --checkpoint-action="ttyout=%T\r" -xmpf - -C "$FILE_PATH" ;;
tar) [[ ${MODDIR_NAME##*/} = Media ]] && _stream_download "$_STREAM_SRC" | tar --checkpoint-action="ttyout=%T\r" -axf - -C "$FILE_PATH" || _stream_download "$_STREAM_SRC" | tar --checkpoint-action="ttyout=%T\r" -amxf - -C "$FILE_PATH" ;;
esac
result=$?
else
case ${FILE_NAME##*.} in
zst) tar --checkpoint-action="ttyout=%T\r" -I zstd -xmpf "$tar_path" -C "$FILE_PATH" ;;
tar) [[ ${MODDIR_NAME##*/} = Media ]] && tar --checkpoint-action="ttyout=%T\r" -axf "$tar_path" -C "$FILE_PATH" || tar --checkpoint-action="ttyout=%T\r" -amxf "$tar_path" -C "$FILE_PATH" ;;
esac
fi
else
Set_back_1
fi
echo_log "解壓縮$FILE_NAME"
if [[ $result = 0 ]]; then
case $FILE_NAME2 in
user|data|obb|user_de)
# 用 helper 查 uid (取代 3 層 fallback 散落)
G="$(get_app_uid "$name2")"
if [[ $G != "" ]]; then
if [[ -d $X ]]; then
case ${#G} in
5)
if [[ $user = 0 ]]; then
uid="$G:$G"
else
uid="$user$G:$user$G"
fi ;;
6|7|8|9|10)
uid="$G:$G" ;;
esac
case $FILE_NAME2 in
user|user_de)
case $FILE_NAME2 in
user) [[ $X = $path2/$name2 ]] && Validation_settings="true" || Validation_settings="false" ;;
user_de) [[ $X = $path3/$name2 ]] && Validation_settings="true" || Validation_settings="false" ;;
esac
if [[ $Validation_settings = true ]]; then
chown -hR "$uid" "$X/"
echo_log "設置用戶組$uid"
chcon -hR "$Selinux_state" "$X/" 2>/dev/null
echo_log "selinux上下文設置"
else
echoRgb "路徑:$X出現錯誤"
fi ;;
data|obb)
chown -hR "$uid" "$FILE_PATH/$name2/"
echo_log "設置用戶組$uid"
chcon -hR "$Selinux_state" "$FILE_PATH/$name2/" 2>/dev/null
echo_log "selinux上下文設置" ;;
esac
else
echoRgb "$FILE_NAME2路徑$X不存在" "0"
fi
else
echoRgb "uid獲取失敗" "0"
fi
;;
thanox)
restorecon -RF "$(find "/data/system" -name "thanos"* -maxdepth 1 -type d 2>/dev/null)/" 2>/dev/null
echo_log "selinux上下文設置" && echoRgb "警告 thanox配置恢復後務必重啟\n -否則不生效" "0"
;;
esac
fi
;;
*)
echoRgb "$FILE_NAME 壓縮包不支持解壓縮" "0"
Set_back_1
;;
esac
[[ -n $TMPDIR ]] && rm -rf "$TMPDIR"/* 2>/dev/null
}
# 安裝 apk (含 split apk 處理), 自動繞過安裝驗證
installapk() {
# 流式恢復: 從遠端拉 apk.tar.zst → 解壓到 TMPDIR (apk 安裝需檔案, pm install 不能用 stdin)
if [[ $_RESTORE_STREAM = 1 && -n $_STREAM_APK_SRC ]]; then
[[ -n $TMPDIR ]] && rm -f "$TMPDIR"/*.apk 2>/dev/null
case ${_STREAM_APK_SRC##*.} in
zst) _stream_download "$_STREAM_APK_SRC" | zstd -d 2>/dev/null | tar --checkpoint-action="ttyout=%T\r" -xmpf - -C "$TMPDIR" ;;
tar) _stream_download "$_STREAM_APK_SRC" | tar --checkpoint-action="ttyout=%T\r" -xmpf - -C "$TMPDIR" ;;
esac
result=$?
echo_log "apk流式解壓"
else
apkfile="$(find "$Backup_folder" -maxdepth 1 -name "apk.*" -type f 2>/dev/null)"
if [[ $apkfile != "" ]]; then
[[ -n $TMPDIR ]] && rm -rf "$TMPDIR"/* 2>/dev/null
case ${apkfile##*.} in
zst) tar --checkpoint-action="ttyout=%T\r" -I zstd -xmpf "$apkfile" -C "$TMPDIR" ;;
tar) tar --checkpoint-action="ttyout=%T\r" -xmpf "$apkfile" -C "$TMPDIR" ;;
*)
echoRgb "${apkfile##*/} 壓縮包不支持解壓縮" "0"
Set_back_1
;;
esac
echo_log "${apkfile##*/}解壓縮" && [[ -f $Backup_folder/nmsl.apk ]] && cp -r "$Backup_folder/nmsl.apk" "$TMPDIR"
else
echoRgb "你的Apk壓縮包離家出走了可能備份後移動過程遺失了\n -解決辦法手動安裝Apk後再執行恢復腳本" "0"
fi
fi
if [[ $result = 0 ]]; then
# 用 glob + 計數取代 find | wc (省 2 fork)
local _apks _apk_count=0
for _apks in "$TMPDIR"/*.apk; do
[[ -f $_apks ]] && let _apk_count++
done
case $_apk_count in
1)
echoRgb "恢復普通apk" "2"
INSTALL "$TMPDIR"/*.apk
echo_log "Apk安裝"
;;
0)
echoRgb "$TMPDIR中沒有apk" "0"
;;
*)
echoRgb "恢復split apk" "2"
b="$(create | grep -Eo '[0-9]+')"
if [[ -f $TMPDIR/nmsl.apk ]]; then
INSTALL "$TMPDIR/nmsl.apk"
echo_log "nmsl.apk安裝"
fi
# 用 glob 取代 find | grep -v (省 fork)
for _apks in "$TMPDIR"/*.apk; do
[[ -f $_apks && ${_apks##*/} != nmsl.apk ]] || continue
pm install-write "$b" "${_apks##*/}" "$_apks" </dev/null >/dev/null
echo_log "${_apks##*/}安裝"
done
pm install-commit "$b" >/dev/null
echo_log "split Apk安裝"
;;
esac
fi
}
# 關閉 apk 安裝驗證 (verifier_verify_adb_installs)
# 避免 Play Protect / 系統驗證攔截批次安裝
disable_verify() {
#禁用apk驗證
settings put global verifier_verify_adb_installs 0 2>/dev/null
#禁用安裝包驗證
settings put global package_verifier_enable 0 2>/dev/null
#未知來源
settings put secure install_non_market_apps 1 2>/dev/null
#關閉play安全校驗
if [[ $(settings get global package_verifier_user_consent 2>/dev/null) != -1 ]]; then
settings put global package_verifier_user_consent -1 2>/dev/null
settings put global upload_apk_enable 0 2>/dev/null
echoRgb "PLAY安全驗證為開啟狀態已被腳本關閉防止apk安裝失敗" "3"
fi
# 額外安全性攔截
settings put global harmful_app_warning_on 0 2>/dev/null
# 關閉應用的受限模式 (針對 Android 13/14 側載應用)
settings put secure enhanced_confirmation_states 0 2>/dev/null
# 設定檔案路徑
FILE="/data/data/com.android.vending/shared_prefs/finsky.xml"
if [[ -f $FILE ]]; then
# 提取當前的 auto_update_enabled 值
CURRENT_VALUE="$(sed -n '/<boolean name="auto_update_enabled" /s/.*value="\([^"]*\)".*/\1/p' "$FILE")"
if [[ $CURRENT_VALUE = true ]]; then
sed -i '/<boolean name="auto_update_enabled" /s/value="true"/value="false"/' "$FILE"
[[ $(sed -n '/<boolean name="auto_update_enabled" /s/.*value="\([^"]*\)".*/\1/p' "$FILE") = false ]] && echoRgb "play自動更新已關閉" "3"
echoRgb "殺死 Google Play 商店..."
am force-stop com.android.vending
else
if [[ $CURRENT_VALUE = "" ]]; then
sed -i '/<\/map>/i \ <boolean name="auto_update_enabled" value="false" />' "$FILE"
[[ $(sed -n '/<boolean name="auto_update_enabled" /s/.*value="\([^"]*\)".*/\1/p' "$FILE") = false ]] && echoRgb "auto_update_enabled已插入false,play自動更新已關閉" "3"
echoRgb "殺死 Google Play 商店..."
am force-stop com.android.vending
else
[[ $CURRENT_VALUE != false ]] && echoRgb "無法識別play auto_update_enabled當前$CURRENT_VALUE值" "0"
fi
fi
fi
}
# 從 app 安裝資訊取得 app 名稱 / apk 路徑 / 版本等資料
# 用 classes.dex 透過 hidden API 拿到完整 PackageInfo
get_name(){
txt="$MODDIR/appList.txt"
txt2="$MODDIR/mediaList.txt"
if [[ $1 = Apkname ]]; then
rm -rf "$txt" "$txt2"
echoRgb "列出全部資料夾內應用名與自定義目錄壓縮包名稱" "3"
fi
rgb_a=118
user="$(echo "${0%}" | sed 's/.*\/Backup_zstd_\([0-9]*\).*/\1/')"
Apk_info="$(pm list packages --user "$user" | cut -f2 -d ':' | grep -Ev 'ice.message|com.topjohnwu.magisk' | sort -u)"
if [[ $Apk_info != "" ]]; then
[[ $Apk_info = *"Failure calling service package"* ]] && Apk_info="$(appinfo "user|system" "pkgName" 2>/dev/null | grep -Ev 'ice.message|com.topjohnwu.magisk' | sort -u)"
else
Apk_info="$(appinfo "user|system" "pkgName" 2>/dev/null | grep -Ev 'ice.message|com.topjohnwu.magisk' | sort -u)"
fi
[[ $Apk_info = "" ]] && echoRgb "Apk_info變量為空" "0" && exit
starttime1="$(date -u "+%s")"
i=1
while read -r; do
Folder="${REPLY%/*}"
[[ $rgb_a -ge 229 ]] && rgb_a=118
unset PackageName NAME DUMPAPK ChineseName apk_version Ssaid dataSize userSize obbSize
if [[ -f $Folder/app_details.json ]]; then
ChineseName="$(jq -r 'to_entries[] | select(.key != null).key' "$Folder/app_details.json" | head -n 1)"
PackageName="$(jq -r '.[] | select(.PackageName != null).PackageName' "$Folder/app_details.json")"
fi
if [[ $PackageName = "" || $ChineseName = "" ]]; then
echoRgb "${Folder##*/}包名獲取失敗,解壓縮獲取包名中..." "0"
[[ -n $TMPDIR ]] && rm -rf "$TMPDIR"/* 2>/dev/null
case ${REPLY##*.} in
zst) tar -I zstd -xmpf "$REPLY" -C "$TMPDIR" --wildcards --no-anchored 'base.apk' ;;
tar) tar -xmpf "$REPLY" -C "$TMPDIR" --wildcards --no-anchored 'base.apk' ;;
*)
echoRgb "${REPLY##*/} 壓縮包不支持解壓縮" "0"
Set_back_1
;;
esac
echo_log "${REPLY##*/}解壓縮"
if [[ $result = 0 ]]; then
if [[ -f $TMPDIR/base.apk ]]; then
DUMPAPK="$(appinfo3 "$TMPDIR/base.apk")"
if [[ $DUMPAPK != "" ]]; then
app=($DUMPAPK $DUMPAPK)
PackageName="${app[1]}"
ChineseName="${app[2]}"
[[ -n $TMPDIR ]] && rm -rf "$TMPDIR"/* 2>/dev/null
else
echoRgb "appinfo輸出失敗" "0"
fi
fi
fi
fi
if [[ $PackageName != "" && $ChineseName != "" ]]; then
if [[ $(echo "$Apk_info" | awk -v pkg="$PackageName" '$1 == pkg {print $1}') = "" ]]; then
echoRgb "$ChineseName已經不存在$user使用者中"
if [[ $delete_app = "" ]]; then
delete_app="$ChineseName $PackageName"
else
delete_app="$delete_app\n$ChineseName $PackageName"
fi
fi
case $1 in
Apkname)
[[ -f $Folder/${PackageName}.sh ]] && rm -rf "$Folder/${PackageName}.sh"
[[ ! -f $Folder/recover.sh ]] && touch_shell "3" "$Folder/recover.sh"
[[ ! -f $Folder/backup.sh ]] && touch_shell "1" "$Folder/backup.sh"
echoRgb "$i:$ChineseName $PackageName"
if [[ $TMPTXT = "" ]]; then
TMPTXT="#不需要恢復還原的應用請在開頭使用#注釋 比如:#酷安 com.coolapk.market\n$ChineseName $PackageName"
else
TMPTXT="$TMPTXT\n$ChineseName $PackageName"
fi
let i++ ;;
convert)
if [[ ${Folder##*/} = $PackageName ]]; then
DIR_NAME="${Folder%/*}/$ChineseName"
echoRgb "${Folder##*/} > $ChineseName"
else
DIR_NAME="${Folder%/*}/$PackageName"
echoRgb "${Folder##*/} > $PackageName"
fi
if [[ -d $DIR_NAME ]]; then
i=1
NEW_DIR_NAME="${DIR_NAME}_${i}"
while [[ -d $NEW_DIR_NAME ]]; do
i=$((i + 1))
NEW_DIR_NAME="${DIR_NAME}_${i}"
done
DIR_NAME="$NEW_DIR_NAME"
fi
mv "$Folder" "$DIR_NAME" ;;
esac
fi
let rgb_a++
done<<<"$(find "$MODDIR" -maxdepth 2 -name "apk.*" -type f 2>/dev/null | sort)"
[[ $TMPTXT != "" ]] && echo "$TMPTXT">"$txt"
# 重新生成後一致性檢查: 比對資料夾與 appList.txt
if [[ -f $txt ]]; then
local _chk_folders="$TMPDIR/.chk_folders" _chk_listed="$TMPDIR/.chk_listed"
for d in "$MODDIR"/*/; do
d="${d%/}"; d="${d##*/}"
case "$d" in wifi|Media|tools|log) continue ;; esac
echo "$d"
done | sort > "$_chk_folders"
awk '!/^#|^/ && NF {print $1}' "$txt" | sort > "$_chk_listed"
local _only_folder _only_list
_only_folder="$(comm -23 "$_chk_folders" "$_chk_listed")"
_only_list="$(comm -13 "$_chk_folders" "$_chk_listed")"
if [[ -n $_only_folder || -n $_only_list ]]; then
echoRgb "_______________________________________" "2"
echoRgb "一致性檢查發現異常:" "0"
[[ -n $_only_folder ]] && { echoRgb "有資料夾但不在清單:" "0"; echo "$_only_folder"; }
[[ -n $_only_list ]] && { echoRgb "在清單但無資料夾:" "0"; echo "$_only_list"; }
else
echoRgb "一致性檢查通過: 資料夾與清單完全對應" "1"
fi
rm -f "$_chk_folders" "$_chk_listed" 2>/dev/null
fi
if [[ -d $MODDIR/Media ]]; then
echoRgb "存在媒體資料夾" "2"
[[ ! -f $txt2 ]] && echo "#不需要恢復的資料夾請在開頭使用#注釋 比如:#Download" > "$txt2"
find "$MODDIR/Media" -maxdepth 1 -name "*.tar*" -type f 2>/dev/null | while read -r; do
echoRgb "${REPLY##*/}" && echo "${REPLY##*/}" >> "$txt2"
done
echoRgb "$txt2重新生成" "1"
fi
if [[ $delete_app != "" ]]; then
if [[ $(echo "$delete_app" | awk 'NF != 0 { count++ } END { print count }') != "" ]]; then
echoRgb "列出需要刪除的應用中....\n -$delete_app"
if ! ask_yn "確認列表無誤後刪除?" "刪除" "退出腳本編輯列表"; then
exit 0
fi
if true; then
echoRgb "警告 即將刪除未安裝應用資料夾,請再三確認後在執行" "0"
echoRgb "以下資料夾將被刪除:" "0"
echo "$delete_app" | sed '/^$/d' | awk '{print " - "$1}'
if ! ask_yn "確認刪除?" "確認刪除" "取消"; then
echoRgb "已取消刪除" "1"
exit 0
fi
i=1
r="$(echo "$delete_app" | awk 'NF != 0 { count++ } END { print count }')"
while [[ $i -le $r ]]; do
name1="$(echo "$delete_app" | awk -v n=$i 'NF{c++} c==n{print $1; exit}')"
name2="$(echo "$delete_app" | awk -v n=$i 'NF{c++} c==n{print $2; exit}')"
if [[ -z $name1 ]]; then
echoRgb "$i個應用名稱解析失敗 跳過刪除以保護備份" "0"
let i++ && continue
fi
Backup_folder="$MODDIR/$name1"
[[ -d $Backup_folder ]] && rm -rf "$Backup_folder"
# 按應用名(第一欄)整行刪除, 避免 sed 對中文/特殊字元誤刪或留半截行
if [[ -f $txt ]]; then
awk -v t="$name1" 'NF==0 || $1 != t' "$txt" > "$txt.tmp" 2>/dev/null && mv "$txt.tmp" "$txt"
fi
let i++
done
# 刪除後一致性檢查: 確認資料夾與 appList.txt 同步
if [[ -f $txt ]]; then
_dchk_f="$TMPDIR/.dchk_folders"; _dchk_l="$TMPDIR/.dchk_listed"
for d in "$MODDIR"/*/; do
d="${d%/}"; d="${d##*/}"
case "$d" in wifi|Media|tools|log) continue ;; esac
echo "$d"
done | sort > "$_dchk_f"
awk '!/^#|^/ && NF {print $1}' "$txt" | sort > "$_dchk_l"
_d_of="$(comm -23 "$_dchk_f" "$_dchk_l")"
_d_ol="$(comm -13 "$_dchk_f" "$_dchk_l")"
if [[ -n $_d_of || -n $_d_ol ]]; then
echoRgb "_______________________________________" "2"
echoRgb "刪除後一致性檢查發現異常:" "0"
[[ -n $_d_of ]] && { echoRgb "有資料夾但不在清單:" "0"; echo "$_d_of"; }
[[ -n $_d_ol ]] && { echoRgb "在清單但無資料夾:" "0"; echo "$_d_ol"; }
else
echoRgb "刪除後一致性檢查通過: 資料夾與清單完全對應" "1"
fi
rm -f "$_dchk_f" "$_dchk_l" 2>/dev/null
fi
else
exit 0
fi
fi
fi
chown "$(stat -c '%u:%g' '/data/media/0/Download')" "$txt"
endtime 1
exit 0
}
# 腳本自我檢測 (檢查工具完整性、權限、環境)
# 啟動時呼叫, 出問題會提示並退出
self_test() {
if [[ $(dumpsys deviceidle get charging) = false && $(dumpsys battery | awk '/level/{print $2}' | grep -Eo '[0-9]+') -le 15 ]]; then
echoRgb "電量$(dumpsys battery | awk '/level/{print $2}' | grep -Eo '[0-9]+')%太低且未充電\n -為防止備份檔案或是恢復因低電量強制關機導致檔案損毀\n -請連接充電器後備份" "0" && exit 2
fi
}
# 驗證單一檔案的 sha256 校驗碼 (對照 tools 目錄內預存的雜湊)
Validation_file() {
MODDIR_NAME="${1%/*}"
MODDIR_NAME="${MODDIR_NAME##*/}"
FILE_NAME="${1##*/}"
echoRgb "校驗$FILE_NAME"
case ${FILE_NAME##*.} in
zst) zstd -t "$1" 2>/dev/null ;;
tar) tar -tf "$1" &>/dev/null ;;
esac
echo_log "${FILE_NAME##*.}校驗"
}
# 檢查壓縮檔完整性 (zstd -t / tar -t)
# 主選單「壓縮檔完整性檢查」呼叫
Check_archive() {
starttime1="$(date -u "+%s")"
error_log="$TMPDIR/error_log"
rm -rf "$error_log"
FIND_PATH="$(find "$1" -maxdepth 3 -name "*.tar*" -type f 2>/dev/null | sort)"
i=1
r="$(find "$MODDIR" -maxdepth 2 -name "app_details.json" -type f 2>/dev/null | wc -l)"
find "$MODDIR" -maxdepth 2 -name "app_details.json" -type f 2>/dev/null | sort | while read -r; do
REPLY="${REPLY%/*}"
echoRgb "校驗第$i/$r個資料夾 剩下$((r - i))" "3"
echoRgb "校驗:${REPLY##*/}"
find "$REPLY" -maxdepth 1 -name "*.tar*" -type f 2>/dev/null | sort | while read -r; do
Validation_file "$REPLY"
[[ $result != 0 ]] && echo "$REPLY">>"$error_log"
done
echoRgb "$((i * 100 / r))% $(progress_bar $((i * 100 / r)))"
let i++ nskg++
done
endtime 1
[[ -f $error_log ]] && echoRgb "以下為失敗的檔案\n $(cat "$error_log")" || echoRgb "恭喜~~全數校驗通過"
rm -rf "$error_log"
}
# 產生 ASCII 進度條, 用法: progress_bar 42 → [████████░░░░░░░░░░░░]
progress_bar() {
local pct="${1:-0}" width=20 filled i=0 bar=""
[[ $pct -lt 0 ]] && pct=0
[[ $pct -gt 100 ]] && pct=100
filled=$((pct * width / 100))
while [[ $i -lt $width ]]; do
[[ $i -lt $filled ]] && bar="$bar" || bar="$bar"
let i++
done
echo "[$bar]"
}
# 毫秒轉可讀時間 (15000 → 15秒, 60000 → 1分鐘, 1800000 → 30分鐘)
ms_to_readable() {
local ms="$1" s
[[ -z $ms || $ms = null ]] && { echo "$ms"; return; }
# INT_MAX 附近 = 系統「永不休眠」
[[ $ms -ge 2147483 ]] && [[ $ms -ge 2000000000 ]] && { echo "永不休眠"; return; }
s=$((ms / 1000))
if [[ $s -lt 60 ]]; then
echo "${s}"
elif [[ $s -lt 3600 ]]; then
local m=$((s / 60)) rs=$((s % 60))
[[ $rs = 0 ]] && echo "${m}分鐘" || echo "${m}${rs}"
else
local h=$((s / 3600)) rm=$(((s % 3600) / 60))
[[ $rm = 0 ]] && echo "${h}小時" || echo "${h}小時${rm}分鐘"
fi
}
# 把過去時間字串 (YYYY.MM.DD HH:MM:SS) 轉成「幾天幾小時幾分前」
time_ago() {
local ts="$1"
[[ -z $ts || $ts = null ]] && { echo "$ts"; return; }
local norm past now diff
norm="$(echo "$ts" | sed 's/\./-/g')"
past="$(date -d "$norm" +%s 2>/dev/null)"
now="$(date +%s)"
[[ -z $past ]] && { echo "$ts"; return; }
diff=$((now - past))
[[ $diff -lt 0 ]] && { echo "$ts"; return; }
if [[ $diff -lt 60 ]]; then
echo "${diff}秒前"
elif [[ $diff -lt 3600 ]]; then
echo "$((diff / 60))分鐘前"
elif [[ $diff -lt 86400 ]]; then
local h=$((diff / 3600)) m=$(((diff % 3600) / 60))
[[ $m = 0 ]] && echo "${h}小時前" || echo "${h}小時${m}分前"
else
local d=$((diff / 86400)) h=$(((diff % 86400) / 3600)) m=$(((diff % 3600) / 60))
echo "${d}${h}小時${m}分前"
fi
}
Set_screen_pause_seconds () {
local _scr_save="$TMPDIR/.screen_timeout_orig"
if [[ $1 = on ]]; then
#獲取系統設置的無操作息屏秒數
if [[ $Get_dark_screen_seconds = "" ]]; then
Get_dark_screen_seconds="$(settings get system screen_off_timeout)"
# 防呆: 若讀到的已是我們設定的 1800000 (代表上次沒還原成功),
# 改用上次存檔的原值, 避免把 1800000 當成原值記下來
if [[ $Get_dark_screen_seconds = 1800000 && -f $_scr_save ]]; then
Get_dark_screen_seconds="$(cat "$_scr_save" 2>/dev/null)"
fi
# 原值存檔, 即使進程異常結束下次也能還原
[[ $Get_dark_screen_seconds != 1800000 ]] && echo "$Get_dark_screen_seconds" > "$_scr_save"
#設置30分鐘後息屏
settings put system screen_off_timeout 1800000
echo_log "設置無操作息屏時間30分鐘"
fi
[[ $setDisplayPowerMode = true ]] && {
setDisplay 0
echo_log "設置螢幕狀態false"
}
elif [[ $1 = off ]]; then
# 還原: 優先用變數, 沒有則讀存檔
[[ $Get_dark_screen_seconds = "" && -f $_scr_save ]] && Get_dark_screen_seconds="$(cat "$_scr_save" 2>/dev/null)"
if [[ $Get_dark_screen_seconds != "" && $Get_dark_screen_seconds != 1800000 ]]; then
settings put system screen_off_timeout "$Get_dark_screen_seconds"
echo_log "設置無操作息屏時間為$(ms_to_readable "$Get_dark_screen_seconds")"
input keyevent 224
rm -f "$_scr_save"
Get_dark_screen_seconds=""
fi
[[ $setDisplayPowerMode = true ]] && {
setDisplay 2
echo_log "設置螢幕狀態true"
}
fi
}
restore_permissions () {
echoRgb "恢復權限"
# appops reset 雙模式: 批量時只收集包名, 迴圈後集中執行 (appops 命令一次一app, 集中避免散落)
if [[ $_batch_perm_mode = 1 ]]; then
printf '%s\n' "$name2" >> "$TMPDIR/.batch_opsreset"
else
appops reset --user "$user" "$name2" &>/dev/null
fi
# 一次 jq 抓全部需要的欄位 (true/false/ops 權限 + installer + battery + Ssaid)
# 取代原本多個 jq fork; 批量恢復時每個 app 省下數次 jq, 累積可觀
local tmpf="$TMPDIR/.perm_$$"
jq -r '
(try (to_entries[] | select(.value.permissions != null) | .value.permissions | to_entries | map(select(.value | startswith("true")) | .key) | join(" ")) catch "" // ""),
(try (to_entries[] | select(.value.permissions != null) | .value.permissions | to_entries | map(select(.value | startswith("false")) | .key) | join(" ")) catch "" // ""),
(try (.[] | select(.permissions != null).permissions | to_entries | map(.value | split(" ")) | map(select(.[1] != "-1")) | map(.[1:]) | flatten | join(" ")) catch "" // ""),
(try (.[] | select(.installer != null).installer) catch "" // ""),
(try (.[] | select(.battery_opt != null).battery_opt) catch "" // ""),
(try (.[] | select(.Ssaid != null).Ssaid) catch "" // "")
' "$app_details" 2>/dev/null > "$tmpf"
local _installer _battery
exec 3< "$tmpf"
read -r true_permissions <&3
read -r false_permissions <&3
read -r Set_Ops_permissions <&3
read -r _installer <&3
read -r _battery <&3
read -r _rp_ssaid <&3
exec 3<&-
rm -f "$tmpf"
# 雙模式: _batch_perm_mode=1 只收集到暫存檔(批量), 否則立即調 dex(單獨恢復後路)
# 目前都經批量迴圈進來故走收集; else 分支保留供日後直接單獨恢復用
[[ $true_permissions != "" ]] && {
if [[ $_batch_perm_mode = 1 ]]; then
printf '[%s %s] ' "$name2" "$true_permissions" >> "$TMPDIR/.batch_grant"
else
Set_true_Permissions "[$name2 $true_permissions]"
[[ $? != 0 ]] && echo_log "設置允許權限"
fi
}
[[ $false_permissions != "" ]] && {
if [[ $_batch_perm_mode = 1 ]]; then
printf '[%s %s] ' "$name2" "$false_permissions" >> "$TMPDIR/.batch_revoke"
else
Set_false_Permissions "[$name2 $false_permissions]"
[[ $? != 0 ]] && echo_log "設置拒絕權限"
fi
}
[[ $Set_Ops_permissions != "" ]] && {
if [[ $_batch_perm_mode = 1 ]]; then
printf '[%s %s] ' "$name2" "$Set_Ops_permissions" >> "$TMPDIR/.batch_ops"
else
Set_Ops "[$name2 $Set_Ops_permissions]"
[[ $? != 0 ]] && echo_log "設置ops權限"
fi
}
# 恢復 installer (安裝來源) 與 battery_opt (後台運行 appops mode) — 已於上方一次 jq 取得
[[ -n $_installer ]] && {
pm set-installer "$name2" "$_installer" &>/dev/null
[[ $? = 0 ]] && echoRgb "恢復安裝來源:$_installer" "2"
}
# 原樣還原備份時的 appops mode (allow/ignore/deny)
case $_battery in
allow|ignore|deny|default)
appops set "$name2" RUN_ANY_IN_BACKGROUND "$_battery" &>/dev/null
# allow(無限制)時一併加入 doze 白名單豁免
[[ $_battery = allow ]] && dumpsys deviceidle whitelist "+$name2" &>/dev/null
echoRgb "恢復後台運行設定:$_battery" "2"
;;
esac
}
# 批量沖刷: 把累積的 grant/revoke/ops 各一次 app_process 設置 (取代逐 app 各啟動 JVM)
# 批量恢復 N 個 app 的權限 JVM 從 3N 次降到最多 3 次
flush_batch_permissions() {
[[ $_batch_perm_mode != 1 ]] && return
local _g="$TMPDIR/.batch_grant" _r="$TMPDIR/.batch_revoke" _o="$TMPDIR/.batch_ops" _rs="$TMPDIR/.batch_opsreset" _rspkg
# 先批量 appops reset (必須在設權限前, 把各 app ops 清到預設再設)
if [[ -s $_rs ]]; then
echoRgb "重置應用ops中..." "3"
while read -r _rspkg; do
[[ -z $_rspkg ]] && continue
appops reset --user "$user" "$_rspkg" &>/dev/null
done < "$_rs"
rm -f "$_rs"
fi
# 有任一暫存檔有內容才提示 (避免無權限可設時也印)
[[ -s $_g || -s $_r || -s $_o ]] && echoRgb "批量設置應用權限中,請稍候..." "2"
if [[ -s $_g ]]; then
echoRgb "授予權限中..." "3"
[[ $_dex_debug = 1 ]] && echo "FLUSH-grant" >> "$TMPDIR/.dex_call_log"
xargs app_process /system/bin com.xayah.dex.HiddenApiUtil grantRuntimePermission "$USER_ID" < "$_g" >/dev/null 2>&1
[[ $? != 0 ]] && echo_log "批量設置允許權限"
fi
if [[ -s $_r ]]; then
echoRgb "撤銷權限中..." "3"
[[ $_dex_debug = 1 ]] && echo "FLUSH-revoke" >> "$TMPDIR/.dex_call_log"
xargs app_process /system/bin com.xayah.dex.HiddenApiUtil revokeRuntimePermission "$USER_ID" < "$_r" >/dev/null 2>&1
[[ $? != 0 ]] && echo_log "批量設置拒絕權限"
fi
if [[ -s $_o ]]; then
echoRgb "設置ops模式中..." "3"
[[ $_dex_debug = 1 ]] && echo "FLUSH-setOps" >> "$TMPDIR/.dex_call_log"
xargs app_process /system/bin com.xayah.dex.HiddenApiUtil setOpsMode "$USER_ID" < "$_o" >/dev/null 2>&1
[[ $? != 0 ]] && echo_log "批量設置ops權限"
fi
[[ -s $_g || -s $_r || -s $_o ]] && echoRgb "權限設置完成" "1"
# ====== 恢復後權限驗證 (只驗 grant/revoke 開關, 不驗 ops mode) ======
# flush 後一次 getRuntimePermissions 批量讀回實際權限, 跟應設狀態比對
if [[ $_perm_verify != 0 && ( -s $_g || -s $_r ) ]]; then
echoRgb "驗證權限恢復結果..." "2"
# 從 batch 檔解析出 期望狀態: 格式 pkg<TAB>perm<TAB>true/false
local _expect="$TMPDIR/.perm_expect" _actual="$TMPDIR/.perm_actual"
: > "$_expect"
# grant 檔 → 期望 true; revoke 檔 → 期望 false. 格式 [pkg perm perm] [pkg perm]
awk 'BEGIN{RS="]"} {
gsub(/\[/,""); n=split($0,a," "); if(n<2)next
for(i=2;i<=n;i++) print a[1]"\t"a[i]"\ttrue"
}' "$_g" >> "$_expect" 2>/dev/null
awk 'BEGIN{RS="]"} {
gsub(/\[/,""); n=split($0,a," "); if(n<2)next
for(i=2;i<=n;i++) print a[1]"\t"a[i]"\tfalse"
}' "$_r" >> "$_expect" 2>/dev/null
# 取所有涉及的包名, 一次批量讀回實際權限 (1 次 dex)
local _vpkgs
_vpkgs="$(awk -F'\t' '{print $1}' "$_expect" | sort -u | paste -sd' ' -)"
get_Permissions $_vpkgs 2>/dev/null | awk '{print $1"\t"$2"\t"$3}' > "$_actual"
# 比對: 期望 vs 實際, 列出不一致
local _mismatch
_mismatch="$(awk -F'\t' '
NR==FNR { act[$1"\t"$2]=$3; next }
{
key=$1"\t"$2
if (key in act) {
if (act[key] != $3) print " ✗ "$1" "$2" 應="$3" 實際="act[key]
} else {
print " ? "$1" "$2" 應="$3" 實際=未讀到"
}
}' "$_actual" "$_expect")"
if [[ -z $_mismatch ]]; then
echoRgb "✅ 權限驗證通過: 全部正確恢復" "1"
else
echoRgb "⚠️ 以下權限與備份記錄不一致:" "0"
echo "$_mismatch"
fi
rm -f "$_expect" "$_actual"
fi
rm -f "$_g" "$_r" "$_o"
}
# 取得當前正在後台運行的所有 app 列表
# 配合「後台應用忽略」設定, 跳過正在運行的 app 不備份
Background_application_list() {
[[ $activity != false ]] && {
if [[ $Background_apps_ignore = true || $1 = debug ]]; then
unset Backstage
#獲取後台
Backstage="$(dumpsys activity activities | awk -v uid="$user" '/ActivityRecord\{/{split($4,a,"/"); user=$3; pkg=a[1]; if(user~/^u[0-9]+$/ && pkg!~/\//){sub(/^u/,"",user); if(uid=="" || user==uid) if(!seen[user","pkg]++) print pkg}}')"
if [[ $Backstage = "" ]]; then
Backstage="$(am stack list | awk -v uid="$user" '/taskId/&&!/unknown/{split($2,a,"/"); pkg=a[1]; user="unknown"; for(i=1;i<=NF;i++) if($i~/^userId=/){split($i,b,"="); user=b[2]; break} if(uid==""||user==uid) if(!seen[pkg]++) print pkg}')"
[[ $Backstage = "" ]] && {
echoRgb "獲取當前後台應用失敗" "0" && unset Backstage
}
fi
fi
}
}
Background_application_list debug
pkgs="$(pm list packages --user "$user" | cut -f2 -d ':' | awk -v pkg="$(echo "$Backstage" | head -1)" '$1 == pkg {print $1}')"
if [[ $pkgs != "" ]]; then
echoRgb "後台應用獲取成功($pkgs)" "1"
[[ $(Process_Information "$pkgs") = "" ]] && echoRgb "應用pid獲取失敗" "0" || echoRgb "應用pid獲取成功$(Process_Information "$pkgs")" "1"
else
echoRgb "後台應用獲取失敗" "0" activity=false
fi
unset Backstage
# ======================================================
# backup() 主函數
# ======================================================
# 主備份函數 - 對 appList.txt 內所有 app 執行完整備份
# 流程: 讀清單 → 逐個 app → 備份 apk + data + user_de + obb → 備份 SSAID/權限
# 結尾備份 wifi、生成 start.sh、設置 REMOTE_TRIGGER=1 觸發遠端上傳
backup() {
self_test
case $MODDIR in
/storage/emulated/0/Android/* | /data/media/0/Android/* | /sdcard/Android/*) echoRgb "請勿在$MODDIR內備份" "0" && exit 2 ;;
esac
case $Compression_method in
zstd | Zstd | ZSTD | tar | Tar | TAR) ;;
*) echoRgb "$Compression_method為不支持的壓縮算法" "0" && exit 2 ;;
esac
prepare_pkg_uid_map
prepare_pkg_ver_map
load_kv_map "$TMPDIR/.pkg_uid" _pu
load_kv_map "$TMPDIR/.pkg_ver" _pv
: > "$TMPDIR/.backup_done"
: > "$TMPDIR/.update_apks"
: > "$TMPDIR/.add_apks"
: > "$TMPDIR/.ssaid_apks"
: > "$TMPDIR/.changed_apps"
# 初始化備份變更標記
backup_has_changes=0
#校驗選填是否正確
case $Lo in
0)
[[ $Backup_Mode != "" ]] && isBoolean "$Backup_Mode" "Backup_Mode" && Backup_Mode="$nsx" || {
echoRgb "選擇備份模式\n -音量上備份應用+數據,音量下僅應用不包含數據" "2"
get_version "應用+數據" "僅應用" && Backup_Mode="$branch"
}
if [[ $Backup_Mode = true ]]; then
if [[ -n $(awk '!/[#]/ && NF' <<< "$blacklist") ]]; then
if [[ $blacklist_mode != "" ]]; then
isBoolean "$blacklist_mode" "blacklist_mode" && blacklist_mode="$nsx"
else
echoRgb "選擇黑名單模式\n -音量上不備份,音量下僅備份安裝檔\n -警告! " "2"
get_version "不備份" "備份安裝檔" && blacklist_mode="$branch"
fi
fi
fi
if [[ $Backup_Mode = true ]]; then
[[ $Backup_obb_data != "" ]] && isBoolean "$Backup_obb_data" "Backup_obb_data" && Backup_obb_data="$nsx" || {
echoRgb "是否備份外部數據 即比如原神的數據包\n -音量上備份,音量下不備份" "2"
get_version "備份" "不備份" && Backup_obb_data="$branch"
}
[[ $Backup_user_data != "" ]] && isBoolean "$Backup_user_data" "Backup_user_data" && Backup_user_data="$nsx" || {
echoRgb "是否備份使用者數據\n -音量上備份,音量下不備份" "2"
get_version "備份" "不備份" && Backup_user_data="$branch"
}
else
Backup_user_data="false"
Backup_obb_data="false"
fi
[[ $backup_media != "" ]] && isBoolean "$backup_media" "backup_media" && backup_media="$nsx" || {
echoRgb "全部應用備份結束後是否備份自定義目錄\n -音量上備份,音量下不備份" "2"
get_version "備份" "不備份" && backup_media="$branch"
}
[[ $setDisplayPowerMode != "" ]] && isBoolean "$setDisplayPowerMode" "setDisplayPowerMode" && setDisplayPowerMode="$nsx" || {
echoRgb "應用備份開始後關閉螢幕\n -音量上關閉,音量下不關閉" "2"
get_version "關閉" "不關閉" && setDisplayPowerMode="$branch"
}
[[ $Background_apps_ignore != "" ]] && isBoolean "$Background_apps_ignore" "Background_apps_ignore" && Background_apps_ignore="$nsx" || {
echoRgb "存在進程忽略備份\n -音量上忽略,音量下備份" "2"
get_version "忽略" "備份" && Background_apps_ignore="$branch"
} ;;
1)
[[ $Backup_Mode = "" ]] && {
echoRgb "選擇備份模式\n -音量上備份應用+數據,音量下僅應用不包含數據" "2"
get_version "應用+數據" "僅應用" && Backup_Mode="$branch"
} || isBoolean "$Backup_Mode" "Backup_Mode" && Backup_Mode="$nsx"
if [[ $Backup_Mode = true ]]; then
if [[ -n $(awk '!/[#]/ && NF' <<< "$blacklist") ]]; then
[[ $blacklist_mode = "" ]] && {
echoRgb "選擇黑名單模式\n -音量上不備份,音量下僅備份安裝檔" "2"
get_version "不備份" "備份安裝檔" && blacklist_mode="$branch"
} || isBoolean "$blacklist_mode" "blacklist_mode" && blacklist_mode="$nsx"
fi
[[ $Backup_obb_data = "" ]] && {
echoRgb "是否備份外部數據 即比如原神的數據包\n -音量上備份,音量下不備份" "2"
get_version "備份" "不備份" && Backup_obb_data="$branch"
} || isBoolean "$Backup_obb_data" "Backup_obb_data" && Backup_obb_data="$nsx"
[[ $Backup_user_data = "" ]] && {
echoRgb "是否備份使用者數據\n -音量上備份,音量下不備份" "2"
get_version "備份" "不備份" && Backup_user_data="$branch"
} || isBoolean "$Backup_user_data" "Backup_user_data" && Backup_user_data="$nsx"
fi
[[ $backup_media = "" ]] && {
echoRgb "全部應用備份結束後是否備份自定義目錄\n -音量上備份,音量下不備份" "2"
get_version "備份" "不備份" && backup_media="$branch"
} || isBoolean "$backup_media" "backup_media" && backup_media="$nsx"
[[ $setDisplayPowerMode = "" ]] && {
echoRgb "應用備份開始後關閉螢幕\n -音量上關閉,音量下不關閉" "2"
get_version "關閉" "不關閉" && setDisplayPowerMode="$branch"
} || isBoolean "$setDisplayPowerMode" "setDisplayPowerMode" && setDisplayPowerMode="$nsx"
[[ $Background_apps_ignore = "" ]] && {
echoRgb "存在進程忽略備份\n -音量上忽略,音量下備份" "2"
get_version "忽略" "備份" && Background_apps_ignore="$branch"
} || isBoolean "$Background_apps_ignore" "Background_apps_ignore" && Background_apps_ignore="$nsx"
;;
2)
[[ $Backup_Mode = "" ]] && {
Enter_options "輸入1備份應用+數據輸入0僅應用不包含數據" "應用+數據" "僅應用" && isBoolean "$parameter" "Backup_Mode" && Backup_Mode="$nsx"
} || {
isBoolean "$Backup_Mode" "Backup_Mode" && Backup_Mode="$nsx"
}
if [[ $Backup_Mode = true ]]; then
[[ -n $(awk '!/[#]/ && NF' <<< "$blacklist") ]] && {
[[ $blacklist_mode = "" ]] && {
Enter_options "選擇黑名單模式輸入1不備份輸入0備份安裝檔" "不備份" "僅應用安裝檔" && isBoolean "$parameter" "blacklist_mode" && blacklist_mode="$nsx"
} || {
isBoolean "$blacklist_mode" "blacklist_mode" && blacklist_mode="$nsx"
}
}
[[ $Backup_obb_data = "" ]] && {
Enter_options "是否備份外部數據 即比如原神的數據包\n -輸入1備份輸入0不備份" "備份" "不備份" && isBoolean "$parameter" "Backup_obb_data" && Backup_obb_data="$nsx"
} || {
isBoolean "$Backup_obb_data" "Backup_obb_data" && Backup_obb_data="$nsx"
}
[[ $Backup_user_data = "" ]] && {
Enter_options "是否備份使用者數據輸入1備份輸入0不備份" "備份" "不備份" && isBoolean "$parameter" "Backup_user_data" && Backup_user_data="$nsx"
} || {
isBoolean "$Backup_user_data" "Backup_user_data" && Backup_user_data="$nsx"
}
fi
[[ $backup_media = "" ]] && {
Enter_options "全部應用備份結束後是否備份自定義目錄\n -輸入1備份0不備份" "備份" "不備份" && isBoolean "$parameter" "backup_media" && backup_media="$nsx"
} || {
isBoolean "$backup_media" "backup_media" && backup_media="$nsx"
}
[[ $setDisplayPowerMode = "" ]] && {
Enter_options "應用備份開始後關閉螢幕\n -輸入1關閉0不關閉" "關閉" "不關閉" && isBoolean "$parameter" "setDisplayPowerMode" && setDisplayPowerMode="$nsx"
} || {
isBoolean "$setDisplayPowerMode" "setDisplayPowerMode" && setDisplayPowerMode="$nsx"
}
[[ $Background_apps_ignore = "" ]] && {
Enter_options "存在進程忽略備份\n -輸入1不備份0備份" "忽略" "備份" && isBoolean "$parameter" "Background_apps_ignore" && Background_apps_ignore="$nsx"
} || {
isBoolean "$Background_apps_ignore" "Background_apps_ignore" && Background_apps_ignore="$nsx"
} ;;
*) echoRgb "$conf_path Lo=$Lo填寫錯誤正確值0 1 2" "0" && exit 2 ;;
esac
i=1
#數據目錄
if [[ $list_location != "" ]]; then
if [[ ${list_location:0:1} = / ]]; then
txt="$list_location"
else
txt="$MODDIR/$list_location"
fi
else
txt="$MODDIR/appList.txt"
fi
txt_path="$txt"
[[ ! -f $txt ]] && echoRgb "請執行start.sh獲取應用列表再來備份" "0" && exit 1
TXT_NAME="${txt##*/}"
case ${TXT_NAME##*.} in
txt) ;;
*) echoRgb "$txt不是腳本讀取格式" "0" && exit 2 ;;
esac
sort -u "$txt" -o "$txt" &>/dev/null
data="$MODDIR"
hx="本地"
echoRgb "腳本受到內核機制影響 息屏後IO性能嚴重影響\n -請勿關閉終端或是息屏備份 如需終止腳本\n -請執行start.sh選擇終止腳本即可停止" "3"
backup_path
show_conf backup
D="1"
Apk_info="$(pm list packages --user "$user" | cut -f2 -d ':' | grep -Ev 'ice.message|com.topjohnwu.magisk' | sort -u)"
if [[ $Apk_info != "" ]]; then
[[ $Apk_info = *"Failure calling service package"* ]] && Apk_info="$(appinfo "user|system" "pkgName" 2>/dev/null | grep -Ev 'ice.message|com.topjohnwu.magisk' | sort -u)"
else
Apk_info="$(appinfo "user|system" "pkgName" 2>/dev/null | grep -Ev 'ice.message|com.topjohnwu.magisk' | sort -u)"
fi
[[ $Apk_info = "" ]] && echoRgb "Apk_info變量為空" "0" && exit
[[ ! -f ${0%/*}/app_details.json ]] && {
echoRgb "檢查備份列表中是否存在已經卸載應用" "3"
while read -r ; do
if [[ $(echo "$REPLY" | sed -E 's/^[ \t]*//; /^[ \t]*[#!]/d') != "" ]]; then
app=($REPLY $REPLY)
if [[ ${app[1]} != "" && ${app[2]} != "" ]]; then
if [[ $(echo "$Apk_info" | awk -v pkg="${app[1]}" '$1 == pkg {print $1}') != "" ]]; then
[[ $Tmplist = "" ]] && Tmplist='#不需要備份的應用請在開頭使用#注釋 比如:#酷安 com.coolapk.market忽略安裝包和數據\n#不需要備份數據的應用請在開頭使用!注釋 比如:!酷安 com.coolapk.market僅忽略數據'
Tmplist="$Tmplist\n$REPLY"
else
echoRgb "$REPLY不存在系統,從列表中刪除" "0"
fi
fi
else
Tmplist="$Tmplist\n$REPLY"
fi
done < "$txt"
}
[[ $Update_backup = true ]] && {
echoRgb "檢查備份列表中已經更新應用" "3"
# 用暫存檔取代 here-string (mksh 不支援 <<<)
local _upd_tmp="$TMPDIR/.update_check_$$"
grep -Ev '^[#!]' "$txt" | awk '{print $1 ":" $2}' > "$_upd_tmp"
# 預掃 pkg→version map (若還沒掃過), 取代每 app fork pm
[[ ! -f $TMPDIR/.pkg_ver ]] && prepare_pkg_ver_map
while read -r apk; do
Backup_folder="$Backup/$(echo "$apk" | cut -d':' -f1)"
app_details="$Backup_folder/app_details.json"
if [[ -d $Backup_folder ]]; then
# 讀本地同步副本 (流式模式上傳成功後 cp 到本地, 記錄上次成功備份的版本)
# 與實機比對才有意義; 遠端快取是給 apk 跳過比對用的, 職責不同
apk_version="$(jq -r 'try (.[] | select(.apk_version != null).apk_version) catch ""' "$app_details" 2>/dev/null | head -n 1 | tr -d ' \t\r\n')"
# 從預掃 map 查 versionCode (取代每 app fork pm)
local _pkg
_pkg="$(echo "$apk" | cut -d':' -f2)"
apk_version2="$(awk -v pkg="$_pkg" -F'\t' '$1 == pkg {print $2; exit}' "$TMPDIR/.pkg_ver" 2>/dev/null)"
# debug: 比對版本失敗時印出來
if [[ $apk_version != $apk_version2 ]]; then
echoRgb " $(echo "$apk" | cut -d':' -f1) 版本變化: $apk_version$apk_version2" "3"
[[ $Tmplist2 = "" ]] && Tmplist2="${apk/:/ }" || Tmplist2="$Tmplist2\n${apk/:/ }"
# 記錄包名: 本輪強制重備 apk (遠端 json 可能被失敗輪汙染成新版本號而 apk 仍是舊檔)
echo "$_pkg" >> "$TMPDIR/.listver_changed"
fi
fi
done < "$_upd_tmp"
rm -f "$_upd_tmp"
}
[[ $Tmplist != "" ]] && echo "$Tmplist" | sed -e '/^$/d' | sort>"$txt"
if [[ $Tmplist2 != "" ]]; then
txt="$(echo "$Tmplist2" | sort)"
else
[[ $Update_backup != "" ]] && echoRgb "應用目前無更新" "0" && exit 0
fi
if [[ ! -f $txt ]]; then
[[ $(echo "$txt") != "" ]] && txt="$(echo "$txt" | sed -e '/^$/d')"
else
txt="$(grep -Ev '#|' "$txt" | sed -e '/^$/d')"
fi
r="$(echo "$txt" | awk 'NF != 0 { count++ } END { print count }')"
[[ -f ${0%/*}/app_details.json ]] && r=1
[[ $r = "" && ! -f ${0%/*}/app_details.json ]] && echoRgb "$MODDIR_NAME/appList.txt是空的或是包名被注釋備份個鬼\n -檢查是否注釋亦或者執行$MODDIR_NAME/start.sh" "0" && exit 1
if [[ $Backup_Mode = true ]]; then
[[ $Backup_user_data = false ]] && echoRgb "當前$MODDIR_NAME/backup_settings.conf的\n -Backup_user_data=0將不備份user數據" "0"
[[ $Backup_obb_data = false ]] && echoRgb "當前$MODDIR_NAME/backup_settings.conf的\n -Backup_obb_data=0將不備份外部數據" "0"
fi
[[ $backup_media = false ]] && echoRgb "當前$MODDIR_NAME/backup_settings.conf的\n -backup_media=0將不備份自定義資料夾" "0"
txt2="$Backup/appList.txt"
txt_path2="$txt2"
[[ ! -f $txt2 ]] && echo "#不需要恢復還原的應用請在開頭使用#注釋 比如:#酷安 com.coolapk.market">"$txt2"
txt2="$(cat "$txt2")"
[[ ! -d $Backup/tools ]] && cp -r "$tools_path" "$Backup"
[[ ! -f $Backup/start.sh ]] && touch_shell "2" "$Backup/start.sh"
[[ ! -f $Backup/restore_settings.conf ]] && update_Restore_settings_conf>"$Backup/restore_settings.conf"
if [[ -d $Backup/tools ]]; then
find "$Backup/tools" -maxdepth 1 -type f | while read -r; do
Tools_FILE_NAME="${REPLY##*/}"
if [[ -f $tools_path/$Tools_FILE_NAME ]]; then
filesha256="$(sha256sum "$tools_path/$Tools_FILE_NAME" 2>/dev/null | cut -d" " -f1)"
filesha256_1="$(sha256sum "$REPLY" 2>/dev/null | cut -d" " -f1)"
if [[ $filesha256 != $filesha256_1 ]]; then
cp -r "$tools_path/$Tools_FILE_NAME" "$REPLY"
echoRgb "更新$REPLY"
fi
fi
done
fi
filesize="$(calc_dir_size "$Backup")"
Quantity=0
#開始循環$txt內的資料進行備份
#記錄開始時間
en=118
osn=0; osj=0; osk=0
#獲取已經開啟的無障礙
var="$(settings get secure enabled_accessibility_services 2>/dev/null)"
#獲取預設鍵盤
keyboard="$(settings get secure default_input_method 2>/dev/null)"
Set_screen_pause_seconds on
[[ $txt != "" ]] && [[ $(echo "$txt" | cut -d' ' -f2 | grep -w "^${keyboard%/*}$") != ${keyboard%/*} ]] && unset keyboard
if [[ -f ${0%/*}/app_details.json ]]; then
ssaid_info="$(get_ssaid "$(jq -r '.[] | select(.PackageName != null).PackageName' "${0%/*}/app_details.json")")"
# 單獨備份模式: 只預掃這一個 app 的權限
local _single_pkg
_single_pkg="$(jq -r '.[] | select(.PackageName != null).PackageName' "${0%/*}/app_details.json" 2>/dev/null)"
local _perms_tmp="$TMPDIR/.pkg_perms"
: > "$_perms_tmp"
if [[ -n $_single_pkg ]]; then
local _raw _json
_raw="$(get_Permissions "$_single_pkg" 2>/dev/null)"
if [[ -n $_raw ]]; then
_json="$(echo "$_raw" | jq -nRc '[inputs | select(. != "null" and length>0) | split(" ") | {(.[1]): (.[2:] | join(" "))}] | if length > 0 then add else empty end' 2>/dev/null)"
[[ -n $_json ]] && printf '%s\t%s\n' "$_single_pkg" "$_json" >> "$_perms_tmp"
fi
fi
prepare_pkg_installer_map
prepare_battery_whitelist "$_single_pkg"
prepare_remote_filelist
prepare_remote_scripts_map
prepare_remote_json_map
load_kv_map "$TMPDIR/.pkg_perms" _pp
load_kv_map "$TMPDIR/.pkg_installer" _pi
load_kv_map "$TMPDIR/.battery_wl" _bw
else
ssaid_info="$(get_ssaid "$(echo "$txt" | awk '{printf "%s ", $2}')")"
prepare_permissions_map
prepare_pkg_installer_map
prepare_battery_whitelist
prepare_dir_size_map
load_dir_size_map
prepare_remote_filelist
prepare_remote_scripts_map
prepare_remote_json_map
load_kv_map "$TMPDIR/.pkg_perms" _pp
load_kv_map "$TMPDIR/.pkg_installer" _pi
load_kv_map "$TMPDIR/.battery_wl" _bw
fi
starttime1="$(date -u "+%s")"
TIME="$starttime1"
notification "101" "開始備份"
# 保存本次備份實際使用的清單,供遠端上傳用 (純變數,不寫檔)
# 子目錄 backup.sh (app_details.json 存在於 0%/*) 只備份單一 app,
# 上傳時也只該上傳這一個 app 的目錄
if [[ -n $remote_type ]]; then
if [[ -f ${0%/*}/app_details.json ]]; then
# 單獨備份: REMOTE_APPLIST 只設這個 app
# ${0%/*} 是子目錄路徑, 末段就是 app 名 (例 Chrome)
_app_dirname="${0%/*}"
REMOTE_APPLIST="${_app_dirname##*/}"
unset _app_dirname
elif [[ -n $txt ]]; then
REMOTE_APPLIST="$txt"
fi
fi
while [[ $i -le $r ]]; do
[[ $en -ge 229 ]] && en=118
unset name1 name2 apk_path apk_path2
if [[ ! -f ${0%/*}/app_details.json ]]; then
# 一次 sed 抓行, 用 parameter expansion 拆欄位 (省 3 fork)
_line="$(echo "$txt" | sed -n "${i}p")"
name1="${_line%% *}"
name2="${_line#* }"
name2="${name2%% *}"
unset _line
else
ChineseName="$(jq -r 'to_entries[] | select(.key != null).key' "${0%/*}/app_details.json" | head -n 1)"
PackageName="$(jq -r '.[] | select(.PackageName != null).PackageName' "${0%/*}/app_details.json")"
name1="$ChineseName"
name2="$PackageName"
fi
[[ $name2 = "" || $name1 = "" ]] && echoRgb "警告! appList.txt應用包名獲取失敗可能修改有問題" "0" && exit 1
apk_path="$(pm path --user "$user" "$name2" 2>/dev/null | cut -f2 -d ':')"
apk_path2="${apk_path%%$'\n'*}"
apk_path2="${apk_path2%/*}"
if [[ -d $apk_path2 ]]; then
echoRgb "備份第$i/$r個應用 剩下$((r - i))" "3"
echoRgb "備份 $name1" "2"
notification "101" "備份第$i/$r個應用 剩下$((r - i))
備份 $name1"
unset Backup_folder ChineseName PackageName nobackup No_backupdata result apk_version apk_version2 zsize zmediapath Size data_path Ssaid ssaid
nobackup="false"
Background_application_list
[[ $Backstage != "" && $(echo "$Backstage" | grep -Ew "^$name2$") != "" ]] && echoRgb "$name1存在後台 忽略備份" "0" && nobackup="true"
if [[ $Backup_Mode = true ]]; then
if [[ $name1 = !* || $name1 = * ]]; then
name1="${name1//!/}"
name1="${name1///}"
echoRgb "跳過備份所有數據" "0"
No_backupdata=1
fi
if [[ $(echo "$blacklist" | grep -w "^$name2$") = $name2 ]]; then
if [[ $blacklist_mode = true ]]; then
echoRgb "黑名單應用跳過備份" "0"
nobackup="true"
else
echoRgb "黑名單應用跳過備份所有數據" "0"
fi
No_backupdata=1
fi
fi
Backup_folder="$Backup/$name1"
app_details="$Backup_folder/app_details.json"
# 流式模式: 設遠端目標目錄 (鏡像 $name1), 遠端目錄由 _stream_upload 自動建
# 用 TMPDIR 暫存區取代本機 $Backup (不碰用戶既有本地備份, 結束無需大清理)
if [[ $remote_stream = 1 && -n $remote_type ]]; then
_STREAM_DEST="$name1"
Backup_folder="$TMPDIR/.stream_stage/$name1"
app_details="$Backup_folder/app_details.json"
# 每 app 先清空 staging (防上輪殘留), 再無條件以遠端 json 快取為種:
# 1. 權限/SSAID/installer/版本比對有舊值參照, 無變化正確跳過
# 2. 本輪只更新部分欄位時, 其餘欄位 (如版本) 不會在上傳時被覆蓋丟失
rm -rf "$Backup_folder" 2>/dev/null
mkdir -p "$Backup_folder" 2>/dev/null
[[ -s $TMPDIR/.remote_json/$name1.json ]] && \
cp "$TMPDIR/.remote_json/$name1.json" "$app_details" 2>/dev/null
fi
# 一次讀取 app_details.json 所有欄位 (APK_VER / SSAID_OLD / PERMS_OLD / PKG_NAME / BACKUP_TIME / SIZE_*)
# 取代後續每個函數內各自 fork jq
app_details_read "$app_details"
if [[ -f $app_details ]]; then
PackageName="$PKG_NAME"
[[ $PackageName != $name2 ]] && jq_inplace "$app_details" --arg name2 "$name2" 'walk(if type == "object" and .PackageName then .PackageName = $name2 else . end)'
echoRgb "上次備份時間$(time_ago "$BACKUP_TIME")"
fi
[[ $hx = USB && $PT = "" ]] && echoRgb "隨身碟意外斷開 請檢查穩定性" "0" && exit 1
starttime2="$(date -u "+%s")"
[[ $name2 = com.tencent.mobileqq ]] && echoRgb "QQ可能恢復備份失敗或是丟失聊天記錄請自行用你信賴的應用備份" "0"
[[ $name2 = com.tencent.mm ]] && echoRgb "WX可能恢復備份失敗或是丟失聊天記錄請自行用你信賴的應用備份" "0"
# 算 apk_path 有幾行 (省 echo|wc -l)
apk_number=1
case $apk_path in
*$'\n'*) apk_number=$(echo "$apk_path" | wc -l) ;;
esac
if [[ $nobackup != true ]]; then
if [[ $apk_number = 1 ]]; then
Backup_apk "非Split Apk" "3"
else
Backup_apk "Split Apk支持備份" "3"
fi
if [[ $result = 0 && $No_backupdata = "" ]]; then
if [[ $Backup_Mode = true ]]; then
if [[ $Backup_obb_data = true ]]; then
if [[ $name2 != bin.mt.plus ]]; then
#備份data數據
[[ $name1 = Nekogram ]] && rm -rf /data/media/0/Android/data/tw.nekomimi.nekogram/files/Telegram/Telegram\ {Video,Stories,Documents,Images}/{*,.*} 2>/dev/null
Backup_data "data"
#備份obb數據
Backup_data "obb"
else
echoRgb "$name1無法備份" "0"
fi
fi
#備份user數據
[[ $name2 != bin.mt.plus ]] && {
[[ $Backup_user_data = true ]] && {
Backup_data "user"
Backup_data "user_de"
}
}
[[ $name2 = github.tornaco.android.thanos ]] && Backup_data "thanox" "$(find "/data/system" -name "thanos"* -maxdepth 1 -type d 2>/dev/null)"
fi
fi
[[ -f $Backup_folder/${name2}.sh ]] && rm -rf "$Backup_folder/${name2}.sh"
# 入口腳本: 非流式寫本地; 流式查預掃表 (.remote_scripts) 缺才傳 (有就不傳, 省流量)
if [[ $remote_stream = 1 ]]; then
if ! awk -v a="$name1" '$0==a{f=1} END{exit !f}' "$TMPDIR/.remote_scripts" 2>/dev/null; then
mkdir -p "$Backup_folder" 2>/dev/null
touch_shell "3" "$Backup_folder/recover.sh"
touch_shell "1" "$Backup_folder/backup.sh"
touch_shell "5" "$Backup_folder/upload.sh"
_stream_upload "$name1/recover.sh" < "$Backup_folder/recover.sh" 2>/dev/null
_stream_upload "$name1/backup.sh" < "$Backup_folder/backup.sh" 2>/dev/null
_stream_upload "$name1/upload.sh" < "$Backup_folder/upload.sh" 2>/dev/null
fi
else
[[ ! -f $Backup_folder/recover.sh ]] && touch_shell "3" "$Backup_folder/recover.sh"
[[ ! -f $Backup_folder/backup.sh ]] && touch_shell "1" "$Backup_folder/backup.sh"
[[ ! -f $Backup_folder/upload.sh ]] && touch_shell "5" "$Backup_folder/upload.sh"
fi
fi
# 備份全部跳過時清理空的 app_details.json 殘留
[[ -f $app_details ]] && [[ "$(jq 'length' "$app_details" 2>/dev/null)" = "0" ]] && rm -f "$app_details"
endtime 2 "$name1 備份" "3"
# 流式: 數據 tar 已在壓縮時直接流到遠端, 此處補傳 app_details.json
# 只在本輪該 app 有變更 (.changed_apps) 時上傳, 全跳過則遠端 json 本就最新
# 該 app 本輪有任一流式上傳失敗 → 不傳 json (缺 json 下輪必整個重備, 避免壞數據被增量跳過殘留)
if awk -v n="$name1" '$0==n{f=1} END{exit !f}' "$TMPDIR/.stream_failed" 2>/dev/null; then
echoRgb "$name1 本輪有上傳失敗, 不更新遠端 json (下次將重新備份此應用)" "0"
elif [[ $remote_stream = 1 && -n $remote_type && -f $app_details ]] && \
awk -v n="$name1" '$0==n{f=1} END{exit !f}' "$TMPDIR/.changed_apps" 2>/dev/null; then
# 防線: staging 未以快取為種 (此 app 無 .remote_json 快取) 時,
# 先抓遠端現有 json 合併, 避免部分欄位覆蓋掉遠端完整 json (版本等丟失)
if [[ ! -s $TMPDIR/.remote_json/$name1.json ]]; then
_mergetmp="$TMPDIR/.merge_remote_$$"
if remote_download_single_file "$name1/app_details.json" "$_mergetmp" 2>/dev/null && \
[[ "$(head -c 1 "$_mergetmp" 2>/dev/null)" = "{" ]]; then
jq -s '.[0] * .[1]' "$_mergetmp" "$app_details" > "$_mergetmp.out" 2>/dev/null && \
[[ -s $_mergetmp.out ]] && mv "$_mergetmp.out" "$app_details"
fi
rm -f "$_mergetmp" "$_mergetmp.out" 2>/dev/null
fi
if _stream_upload "$name1/app_details.json" < "$app_details"; then
echoRgb "app_details.json 已上傳遠端" "1"
# 同步本地副本: 啟動「檢查已更新應用」讀本地 json,
# 不同步會永遠拿舊版本號, 每輪誤報版本變化
mkdir -p "$Backup/$name1" 2>/dev/null
cp "$app_details" "$Backup/$name1/app_details.json" 2>/dev/null
else
echoRgb "app_details.json 上傳失敗" "0"
fi
fi
# 邊備份邊上傳:每個應用備份完立即上傳遠端,然後刪除本機檔案節省空間
# 流式模式不走此路徑 (數據已流式傳走, 無本機 tar 可上傳, json 上面已傳)
if [[ $remote_stream = 1 ]]; then
:
elif [[ $remote_upload_per_app = 1 && -n $remote_type ]]; then
# 有備份變更 → 上傳
if awk -v n="$name1" 'BEGIN{f=1} $0==n{f=0} END{exit f}' "$TMPDIR/.changed_apps" 2>/dev/null; then
per_app_upload_and_cleanup "$name1"
else
# 本地無變更,但遠端可能沒有備份 → 檢查遠端 app_details.json
_remote_has_backup=0
_remote_check_file="$TMPDIR/.remote_check_$$"
if remote_download_single_file "${name1}/app_details.json" "$_remote_check_file" 2>/dev/null; then
[[ -s $_remote_check_file ]] && _remote_has_backup=1
fi
rm -f "$_remote_check_file"
if [[ $_remote_has_backup = 0 ]]; then
echoRgb "遠端無備份,上傳到遠端" "2"
per_app_upload_and_cleanup "$name1"
else
echoRgb "無備份變更,跳過上傳" "2"
fi
fi
fi
lxj="$(echo "$Occupation_status" | awk '{print $3}' | sed 's/%//g')"
echoRgb "完成$((i * 100 / r))% $(progress_bar $((i * 100 / r))) $hx$(echo "$Occupation_status" | awk 'END{print "剩餘:"$1"使用率:"$2}')" "3"
rgb_d="$rgb_a"
rgb_a=188
echoRgb "_________________$(endtime 1 "已經")___________________"
rgb_a="$rgb_d"
else
echoRgb "$name1[$name2] 不在安裝列表,備份個寂寞?" "0"
fi
if [[ $i = $r ]]; then
endtime 1 "應用備份" "3"
#設置無障礙開關
if [[ $var != "" ]]; then
if [[ $var != null ]]; then
settings put secure enabled_accessibility_services "$var" &>/dev/null
echo_log "設置無障礙"
settings put secure accessibility_enabled 1 &>/dev/null
echo_log "打開無障礙開關"
fi
fi
#設置鍵盤
if [[ $keyboard != "" ]]; then
ime enable "$keyboard" &>/dev/null
ime set "$keyboard" &>/dev/null
settings put secure default_input_method "$keyboard" &>/dev/null
echo_log "設置鍵盤$(appinfo2 "${keyboard%/*}" 2>/dev/null)"
fi
update_apk2="$(cat "$TMPDIR/.update_apks" 2>/dev/null)"
add_app2="$(cat "$TMPDIR/.add_apks" 2>/dev/null)"
SSAID_apk2="$(cat "$TMPDIR/.ssaid_apks" 2>/dev/null)"
update_apk2="${update_apk2:="暫無更新"}"
add_app2="${add_app2:="暫無更新"}"
echoRgb "\n -已更新的apk=\"$osn\"\n -已新增的備份=\"$osk\"\n -apk版本號無變化=\"$osj\"\n -下列為版本號已變更的應用\n$update_apk2\n -新增的備份....\n$add_app2\n -包含SSAID的應用\n$SSAID_apk2" "3"
notification "101" "app備份完成 $(endtime 1 "應用備份" "3")"
# 把 backup_done 暫存檔的新項目合併進 txt_path2 (保留舊內容, 用 sort -u 去重)
if [[ -s $TMPDIR/.backup_done ]]; then
if [[ -f $txt_path2 ]]; then
cat "$txt_path2" "$TMPDIR/.backup_done" | sort -u | sed '/^$/d' > "$txt_path2.new"
mv "$txt_path2.new" "$txt_path2"
else
sort -u "$TMPDIR/.backup_done" | sed '/^$/d' > "$txt_path2"
fi
fi
# 清掉備份用的暫存檔
rm -f "$TMPDIR/.backup_done" "$TMPDIR/.update_apks" "$TMPDIR/.add_apks" "$TMPDIR/.ssaid_apks" 2>/dev/null
if [[ $backup_media = true && ! -f ${0%/*}/app_details.json ]]; then
A=1
B="$(awk '!/[#]/ && NF{count++} END{print count}' <<< "$Custom_path")"
if [[ $B != "" ]]; then
echoRgb "備份結束,備份多媒體" "1"
notification "102" "Media備份開始"
starttime1="$(date -u "+%s")"
Backup_folder="$Backup/Media"
app_details="$Backup_folder/app_details.json"
if [[ $remote_stream = 1 && -n $remote_type ]]; then
_STREAM_DEST="Media"; Backup_folder="$TMPDIR/.stream_stage/Media"; app_details="$Backup_folder/app_details.json"; mkdir -p "$Backup_folder" 2>/dev/null
fi
mediatxt="$Backup/mediaList.txt"
# 延遲建立: 只有實際備份了至少一個資料夾才建立 (避免空殼)
_media_created=0
_ensure_media_dirs() {
[[ $_media_created = 1 ]] && return
[[ ! -f $Backup/start.sh ]] && touch_shell "2" "$Backup/start.sh"
[[ ! -d $Backup_folder ]] && mkdir -p "$Backup_folder"
[[ ! -f $app_details ]] && echo "{\n}">"$app_details"
[[ ! -f $mediatxt ]] && echo "#不需要恢復的資料夾請在開頭使用#注釋 比如:#Download" > "$mediatxt"
_media_created=1
}
echo "$Custom_path" | sed -e '/^#/d; /^$/d; s/\/$//' | while read -r; do
echoRgb "備份第$A/$B個資料夾 剩下$((B - A))" "3"
notification "102" "備份第$A/$B個資料夾 剩下$((B - A))"
starttime2="$(date -u "+%s")"
if [[ ${REPLY##*/} = adb ]]; then
if [[ $ksu != ksu ]]; then
echoRgb "Magisk adb"
_ensure_media_dirs
Backup_data "${REPLY##*/}" "$REPLY"
else
echoRgb "KernelSU adb不支持備份" "0"
Set_back_0
fi
else
_ensure_media_dirs
Backup_data "${REPLY##*/}" "$REPLY"
fi
endtime 2 "${REPLY##*/}備份" "1"
echoRgb "完成$((A * 100 / B))% $(progress_bar $((A * 100 / B))) $hx$(echo "$Occupation_status" | awk 'END{print "剩餘:"$1"使用率:"$2}')" "2"
rgb_d="$rgb_a"
rgb_a=188
echoRgb "_________________$(endtime 1 "已經")___________________"
rgb_a="$rgb_d" && let A++
done
# 收尾: 無實際備份檔則清空殼
if [[ -d $Backup_folder ]] && ! find "$Backup_folder" -maxdepth 1 -name "*.tar*" -type f 2>/dev/null | grep -q .; then
echoRgb "Media 無實際備份內容, 清除空目錄與 mediaList.txt" "0"
rm -rf "$Backup_folder"
[[ -f $mediatxt ]] && [[ ! -s $mediatxt || $(grep -vc "^#" "$mediatxt" 2>/dev/null) = 0 ]] && rm -f "$mediatxt"
else
echoRgb "目錄↓↓↓\n -$Backup_folder"
[[ -n $remote_type ]] && REMOTE_UPLOAD_MEDIA=1
# 流式: 補傳 Media 的 app_details.json + mediaList.txt 到遠端
if [[ $remote_stream = 1 && -n $remote_type ]]; then
[[ -f $app_details ]] && _stream_upload "Media/app_details.json" < "$app_details"
[[ -f $mediatxt ]] && _stream_upload "mediaList.txt" < "$mediatxt"
echoRgb "Media 清單已上傳遠端" "1"
fi
fi
notification "102" "Media備份完成 $(endtime 1 "自定義備份")"
endtime 1 "自定義備份"
else
echoRgb "自定義路徑為空 無法備份" "0"
fi
fi
fi
let i++ en++ nskg++
done
# 流式模式: wifi 也存 TMPDIR 暫存區 (不碰本地 $Backup)
if [[ $remote_stream = 1 && -n $remote_type ]]; then
backup_wifi "$TMPDIR/.stream_stage/wifi"
else
backup_wifi "$Backup/wifi"
fi
[[ -n $remote_type ]] && REMOTE_UPLOAD_WIFI=1
Set_screen_pause_seconds off
[[ $user != 0 ]] && am stop-user "$user" >/dev/null 2>&1
# 流式模式: 本地無備份檔 (數據在遠端), 跳過本地大小統計; 遠端統計在 remote_cleanup 結尾顯示
[[ $remote_stream != 1 ]] && Calculate_size "$Backup"
echoRgb "批量備份完成"
echoRgb "備份結束時間$(date +"%Y-%m-%d %H:%M:%S")"
starttime1="$TIME"
endtime 1 "批量備份開始到結束"
notification "105" "備份完成 $(endtime 1 "批量備份開始到結束")"
verify_backup_manifest
[[ -f $txt_path ]] && chown "$(stat -c '%u:%g' '/data/media/0/Download')" "$txt_path"
[[ -f $txt_path2 ]] && chown "$(stat -c '%u:%g' '/data/media/0/Download')" "$txt_path2"
# 備份完成後針對本次有變動的應用自動檢查 app_details.json 結構
# 流式模式: json 在遠端 (本地 staging 已刪), 跳過本地驗證 (上傳時已即時驗證)
if [[ $remote_stream != 1 && -s $TMPDIR/.changed_apps ]]; then
echoRgb "—————— 備份後 JSON 結構驗證 ——————" "3"
local _jchk_error_log="$TMPDIR/.post_json_err"
local _jchk_sorted="$TMPDIR/.post_json_apps"
rm -f "$_jchk_error_log"
sort -u "$TMPDIR/.changed_apps" > "$_jchk_sorted"
local _jchk_total _jchk_i=1
_jchk_total="$(wc -l < "$_jchk_sorted")"
while read -r _japp; do
local _jf="$Backup/$_japp/app_details.json"
echoRgb "[$_jchk_i/$_jchk_total] $_japp" "3"
if [[ ! -f $_jf ]]; then
echoRgb " ⚠️ app_details.json 不存在" "0"
elif jq empty "$_jf" >/dev/null 2>&1; then
echoRgb " ✅ JSON 結構正常" "1"
else
echoRgb " ❌ JSON 損壞" "0"
echo "$_jf" >> "$_jchk_error_log"
fi
let _jchk_i++
done < "$_jchk_sorted"
if [[ -f $_jchk_error_log ]]; then
echoRgb "JSON驗證發現損壞檔案:" "0"
while read -r _jf; do echoRgb " $_jf" "0"; done < "$_jchk_error_log"
else
echoRgb "全數 JSON 結構正常 ($_jchk_total 個)" "1"
fi
rm -f "$_jchk_error_log" "$_jchk_sorted"
fi
REMOTE_TRIGGER=1
# subshell 環境下 trap EXIT 在主 shell 不會觸發, 這裡直接呼叫
remote_cleanup
exit 0
}
# 增量備份: 只備份版本號有更新的 app
# 對照 app_details.json 內舊版本, 沒變動的跳過
backup_update_apk() {
Update_backup='true'
backup
}
# 重新生成應用列表的 app 名稱欄位 (恢復模式選單用)
dumpname() {
get_name "Apkname"
}
# 轉換 app 資料夾名稱 (舊格式 → 新格式)
convert() {
get_name "convert"
}
# 對整個備份目錄做壓縮檔完整性檢查 (Check_archive 的批次入口)
check_file() {
Check_archive "$MODDIR"
}
# 驗證所有 app_details.json 結構完整性 (jq 解析)
# 主選單「JSON結構檢查」呼叫
Check_json() {
starttime1="$(date -u "+%s")"
local error_log="$TMPDIR/json_error_log"
rm -rf "$error_log"
local r i=1
r="$(find "$MODDIR" -maxdepth 2 -name "app_details.json" -type f 2>/dev/null | wc -l)"
[[ $r -eq 0 ]] && { echoRgb "找不到任何 app_details.json" "0"; return; }
find "$MODDIR" -maxdepth 2 -name "app_details.json" -type f 2>/dev/null | sort | while read -r; do
local dir="${REPLY%/*}"
echoRgb "檢查第$i/$r個 剩下$((r - i))" "3"
echoRgb "檢查:${dir##*/}"
if jq empty "$REPLY" >/dev/null 2>&1; then
echoRgb "JSON結構正常" "1"
else
echoRgb "JSON結構損壞或格式錯誤" "0"
echo "$REPLY">>"$error_log"
fi
echoRgb "$((i * 100 / r))% $(progress_bar $((i * 100 / r)))"
let i++
done
endtime 1
if [[ -f $error_log ]]; then
echoRgb "以下 JSON 檔損壞:\n $(cat "$error_log")" "0"
else
echoRgb "恭喜~~全數 JSON 結構正常" "1"
fi
rm -rf "$error_log"
}
# ======================================================
# Restore() 主函數
# ======================================================
# 主恢復函數 - 安裝 apk + 恢復 data + 還原 SSAID/權限
# ssaid_mode=true 時只恢復含 SSAID 的 app
# 從遠端流式恢復: 讀 appList_network.txt, 逐 app 流式拉回解壓 (不佔本機)
# 復用 Restore 的全部邏輯 (uid/selinux/權限/ssaid), 只是資料來源改為遠端流式
remote_stream_restore() {
show_conf remote
[[ -z $remote_type ]] && { echoRgb "remote_type 未設定" "0"; return 1; }
case $remote_type in
smb|webdav) ;;
*) echoRgb "流式恢復僅支援 smb / webdav (目前 remote_type=$remote_type)" "0"; return 1 ;;
esac
local list="$MODDIR/appList_network.txt"
if [[ ! -f $list ]]; then
echoRgb "找不到 $list" "0"
echoRgb "請先執行 '列出遠端備份' 產生清單, 編輯後再來流式恢復" "3"
return 1
fi
# 連線預檢 + 解析 SMB 路徑 (流式 _stream_download 需要 SMB_SHARE/SMB_REM_PATH)
remote_parse_endpoint
[[ $remote_type = smb ]] && remote_parse_smb_url
if ! remote_precheck "$REMOTE_HOST" "$REMOTE_PORT"; then
echoRgb "遠端連線失敗: $REMOTE_HOST:$REMOTE_PORT" "0"
return 1
fi
# 遠端備份子目錄 (Backup_zstd_X)
_RESTORE_SUBDIR="$(get_backup_dirname)"
echoRgb "流式恢復來源: $remote_type://$REMOTE_HOST/ ($_RESTORE_SUBDIR)" "3"
echoRgb "清單: $list" "2"
# 設流式恢復旗標, 復用 Restore 全流程
_RESTORE_STREAM=1
mkdir -p "$TMPDIR/.restore_stage" 2>/dev/null
Restore
# 清理 staging (只有 json, 數據從未落地)
rm -rf "$TMPDIR/.restore_stage" 2>/dev/null
_RESTORE_STREAM=0
}
Restore() {
self_test
disable_verify
[[ ! -d $path2 ]] && echoRgb "設備不存在user目錄" "0" && exit 1
# 預掃資料 (取代主迴圈內每 app fork)
prepare_pkg_uid_map
prepare_pkg_ver_map
prepare_installed_pkgs_map
# 初始化恢復 SSAID 暫存檔 (取代 SSAID_Package2 字串拼接)
: > "$TMPDIR/.restore_ssaid"
if [[ ! -f ${0%/*}/app_details.json ]]; then
echoRgb "假設反悔了要終止腳本請儘速離開此腳本點擊$MODDIR_NAME/start.sh選擇終止腳本\n -否則腳本將繼續執行直到結束" "0"
echoRgb "如果大量提示找不到資料夾請執行$MODDIR_NAME/start.sh選擇轉換資料夾名稱"
txt="$MODDIR/appList.txt"
# 流式恢復: 改用 appList_network.txt (功能8 產生), 過濾掉註解與特殊項(wifi/Media), 只留 app 行
if [[ $_RESTORE_STREAM = 1 ]]; then
grep -Ev '^[[:space:]]*[#]|^[[:space:]]*$' "$MODDIR/appList_network.txt" 2>/dev/null \
| grep -Evx '[[:space:]]*(wifi|Media)[[:space:]]*' > "$TMPDIR/.stream_restore_list"
txt="$TMPDIR/.stream_restore_list"
fi
[[ ! -f $txt ]] && echoRgb "請執行start.sh獲取應用列表再來恢復" "0" && exit 2
sort -u "$txt" -o "$txt" 2>/dev/null
i=1
r="$(awk '!/[#]/ && NF{count++} END{print count}' "$txt" 2>/dev/null)"
[[ $r = "" ]] && echoRgb "appList.txt包名為空或是被注釋了\n -請執行start.sh獲取應用列表再來恢復" "0" && exit 1
Backup_folder2="$MODDIR/Media"
#校驗選填是否正確
case $Lo in
0)
[[ $recovery_mode != "" ]] && isBoolean "$recovery_mode" "recovery_mode" && recovery_mode="$nsx" || {
echoRgb "選擇應用恢復模式\n -音量上僅恢復未安裝,下全恢復"
get_version "恢復未安裝" "全恢復" && recovery_mode="$branch"
}
[[ $setDisplayPowerMode != "" ]] && isBoolean "$setDisplayPowerMode" "setDisplayPowerMode" && setDisplayPowerMode="$nsx" || {
echoRgb "應用恢復時關閉螢幕\n -音量上關閉,下不關閉"
get_version "關閉" "不關閉" && setDisplayPowerMode="$branch"
}
if [[ $_RESTORE_STREAM = 1 ]]; then
Get_user="$(get_backup_dirname | grep -Eo '[0-9]+$')"
else
Get_user="$(echo "$MODDIR" | rev | cut -d '/' -f1 | cut -d '_' -f1 | rev | grep -Eo '[0-9]+')"
fi
if [[ $Get_user != $user ]]; then
echoRgb "檢測當前用戶$user與恢復資料夾用戶:$Get_user不同,音量上繼續恢復,下不恢復並離開腳本"
get_version "恢復安裝" "不恢復安裝" && recovery_mode2="$branch"
fi
if [[ -d $Backup_folder2 ]]; then
[[ $media_recovery != "" ]] && isBoolean "$media_recovery" "media_recovery" && media_recovery="$nsx" || {
echoRgb "是否恢復多媒體數據\n -音量上恢復,音量下不恢復" "2"
get_version "恢復媒體數據" "跳過恢復媒體數據" && media_recovery="$branch"
}
fi
[[ $Background_apps_ignore != "" ]] && isBoolean "$Background_apps_ignore" "Background_apps_ignore" && Background_apps_ignore="$nsx" || {
echoRgb "存在進程忽略恢復\n -音量上忽略,音量下恢復" "2"
get_version "忽略" "恢復" && Background_apps_ignore="$branch"
} ;;
1)
echoRgb "選擇應用恢復模式\n -音量上僅恢復未安裝,下全恢復"
get_version "恢復未安裝" "全恢復" && recovery_mode="$branch"
echoRgb "應用恢復時關閉螢幕\n -音量上關閉,下不關閉"
get_version "關閉" "不關閉" && setDisplayPowerMode="$branch"
if [[ $_RESTORE_STREAM = 1 ]]; then
Get_user="$(get_backup_dirname | grep -Eo '[0-9]+$')"
else
Get_user="$(echo "$MODDIR" | rev | cut -d '/' -f1 | cut -d '_' -f1 | rev | grep -Eo '[0-9]+')"
fi
if [[ $Get_user != $user ]]; then
echoRgb "檢測當前用戶$user與恢復資料夾用戶:$Get_user不同,音量上繼續恢復,下不恢復並離開腳本"
get_version "恢復安裝" "不恢復安裝" && recovery_mode2="$branch"
fi
echoRgb "是否恢復多媒體數據\n -音量上恢復,音量下不恢復" "2"
get_version "恢復媒體數據" "跳過恢復媒體數據" && media_recovery="$branch"
echoRgb "存在進程忽略恢復\n -音量上忽略,音量下恢復" "2"
get_version "忽略" "恢復" && Background_apps_ignore="$branch" ;;
2)
[[ $recovery_mode = "" ]] && {
Enter_options "選擇應用恢復模式\n -輸入1僅恢復未安裝0全恢復" "僅恢復未安裝" "全恢復" && isBoolean "$parameter" "recovery_mode" && recovery_mode="$nsx"
} || {
isBoolean "$recovery_mode" "recovery_mode" && recovery_mode="$nsx"
}
[[ $setDisplayPowerMode = "" ]] && {
Enter_options "應用恢復時關閉螢幕\n -輸入1關閉0不關閉" "關閉" "不關閉" && isBoolean "$parameter" "setDisplayPowerMode" && setDisplayPowerMode="$nsx"
} || {
isBoolean "$setDisplayPowerMode" "setDisplayPowerMode" && setDisplayPowerMode="$nsx"
}
if [[ $_RESTORE_STREAM = 1 ]]; then
Get_user="$(get_backup_dirname | grep -Eo '[0-9]+$')"
else
Get_user="$(echo "$MODDIR" | rev | cut -d '/' -f1 | cut -d '_' -f1 | rev | grep -Eo '[0-9]+')"
fi
[[ $Get_user != $user ]] && {
[[ $recovery_mode2 = "" ]] && {
Enter_options "檢測當前用戶$user與恢復資料夾用戶:$Get_user不同輸入1繼續恢復0不恢復並離開腳本" "恢復安裝" "離開腳本" && isBoolean "$parameter" "recovery_mode2" && recovery_mode2="$nsx"
} || {
isBoolean "$recovery_mode2" "recovery_mode2" && recovery_mode2="$nsx"
}
}
[[ $media_recovery = "" ]] && {
Enter_options "是否恢復多媒體\n -輸入1僅恢復0不恢復" "恢復" "不恢復" && isBoolean "$parameter" "media_recovery" && media_recovery="$nsx"
} || {
isBoolean "$media_recovery" "media_recovery" && media_recovery="$nsx"
}
[[ $Background_apps_ignore = "" ]] && {
Enter_options "存在進程忽略恢復\n -輸入1不恢復0恢復" "忽略" "恢復" && isBoolean "$parameter" "Background_apps_ignore" && Background_apps_ignore="$nsx"
} || {
isBoolean "$Background_apps_ignore" "Background_apps_ignore" && Background_apps_ignore="$nsx"
} ;;
*) echoRgb "$conf_path Lo=$Lo填寫錯誤正確值0 1 2" "0" && exit 2 ;;
esac
[[ $recovery_mode2 = false ]] && exit 2
if [[ $recovery_mode = true && $ssaid_mode != true && $_RESTORE_STREAM != 1 ]]; then
echoRgb "獲取未安裝應用中"
Apk_info="$(pm list packages --user "$user" | cut -f2 -d ':' | grep -Ev 'ice.message|com.topjohnwu.magisk' | sort -u)"
if [[ $Apk_info != "" ]]; then
[[ $Apk_info = *"Failure calling service package"* ]] && Apk_info="$(appinfo "user|system" "pkgName" 2>/dev/null | grep -Ev 'ice.message|com.topjohnwu.magisk' | sort -u)"
else
Apk_info="$(appinfo "user|system" "pkgName" 2>/dev/null | grep -Ev 'ice.message|com.topjohnwu.magisk' | sort -u)"
fi
[[ $Apk_info = "" ]] && echoRgb "Apk_info變量為空" "0" && exit
while read -r ; do
if [[ $(echo "$REPLY" | sed 's/^[ \t]*//') != \#* ]]; then
app=($REPLY $REPLY)
if [[ ${app[1]} != "" && ${app[2]} != "" ]]; then
[[ $(echo "$Apk_info" | awk -v pkg="${app[1]}" '$1 == pkg {print $1}') = "" ]] && Tmplist="$Tmplist\n$REPLY"
fi
fi
done < "$txt"
if [[ $(echo "$Tmplist" | awk 'NF != 0 { count++ } END { print count }') != "" ]]; then
echoRgb "獲取完成 預計安裝$(echo "$Tmplist" | awk 'NF != 0 { count++ } END { print count }')個應用"
txt="$Tmplist"
echoRgb "未安裝應用列表\n$txt" "1"
if ! ask_yn "確認恢復?" "恢復安裝" "退出腳本"; then
exit
fi
else
echoRgb "獲取完成 但備份內應用都已安裝....正在退出腳本" "0" && exit 0
fi
fi
if [[ $ssaid_mode = true ]]; then
# 改 here-string 為暫存檔 (mksh 不支援 <<<)
# 用暫存檔取代 ssaid_name 字串拼接 (O(N²) → O(N))
local _find_tmp="$TMPDIR/.find_ssaid_$$"
local _ssaid_tmp="$TMPDIR/.ssaid_list_$$"
find "$MODDIR" -maxdepth 2 -name "app_details.json" -type f 2>/dev/null | sort > "$_find_tmp"
: > "$_ssaid_tmp"
while read -r; do
if [[ $(jq -r 'try (.[] | select(.Ssaid != null).Ssaid) catch ""' "$REPLY" 2>/dev/null) != "" ]]; then
ChineseName="$(jq -r 'try (to_entries[] | select(.key != null).key) catch ""' "$REPLY" 2>/dev/null | head -n 1)"
PackageName="$(jq -r 'try (.[] | select(.PackageName != null).PackageName) catch ""' "$REPLY" 2>/dev/null)"
echo "$ChineseName $PackageName" >> "$_ssaid_tmp"
fi
done < "$_find_tmp"
[[ -s $_ssaid_tmp ]] && ssaid_name="$(cat "$_ssaid_tmp")"
rm -f "$_find_tmp" "$_ssaid_tmp"
[[ $ssaid_name != "" ]] && txt="$ssaid_name"
fi
if [[ ! -f $txt ]]; then
[[ $(echo "$txt") != "" ]] && txt="$(echo "$txt" | sed -e '/^$/d')"
else
txt="$(grep -Ev '#|' "$txt" | sed -e '/^$/d')"
fi
r="$(echo "$txt" | awk 'NF != 0 { count++ } END { print count }')"
DX="批量恢復"
else
i=1
r=1
Backup_folder="$MODDIR"
app_details="$Backup_folder/app_details.json"
if [[ ! -f $app_details ]]; then
echoRgb "$app_details遺失,無法獲取包名" "0" && exit 1
else
ChineseName="$(jq -r 'to_entries[] | select(.key != null).key' "$app_details" | head -n 1)"
PackageName="$(jq -r '.[] | select(.PackageName != null).PackageName' "$app_details")"
apk_version="$(jq -r '.[] | select(.apk_version != null).apk_version' "$app_details")"
fi
name1="$ChineseName"
name1="${name1:="${Backup_folder##*/}"}"
[[ $name1 = "" ]] && echoRgb "應用名獲取失敗" "0" && exit 2
name2="$PackageName"
[[ $name2 = "" ]] && echoRgb "包名獲取失敗" "0" && exit 2
DX="單獨恢復"
[[ $Background_apps_ignore != "" ]] && isBoolean "$Background_apps_ignore" "Background_apps_ignore" && Background_apps_ignore="$nsx" || {
echoRgb "存在進程忽略恢復\n -音量上忽略,音量下恢復" "2"
get_version "忽略" "恢復" && Background_apps_ignore="$branch"
}
fi
#開始循環$txt內的資料進行恢復
#記錄開始時間
starttime1="$(date -u "+%s")"
TIME="$starttime1"
Set_screen_pause_seconds on
en=118
notification "105" "開始恢復app"
# 啟用權限批量模式: 迴圈內 restore_permissions 只收集到暫存檔, 迴圈結束後 flush 一次沖刷 (JVM 3N → 3)
# 此迴圈同時服務批量恢復(N個app)與單獨恢復(1個app); 單獨恢復時收集1組→flush設1組, 等價立即執行
_batch_perm_mode=1
rm -f "$TMPDIR/.batch_grant" "$TMPDIR/.batch_revoke" "$TMPDIR/.batch_ops" "$TMPDIR/.batch_opsreset"
while [[ $i -le $r ]]; do
[[ $en -ge 229 ]] && en=118
if [[ ! -f ${0%/*}/app_details.json ]]; then
echoRgb "恢復第$i/$r個應用 剩下$((r - i))" "3"
notification "105" "恢復第$i/$r個應用 剩下$((r - i))
恢復 $name1"
# 一次 sed 抓行, 用 parameter expansion 拆欄位
_line="$(echo "$txt" | sed -n "${i}p")"
name1="${_line%% *}"
name2="${_line#* }"
name2="${name2%% *}"
unset _line
unset No_backupdata apk_version
if [[ $name1 = *! || $name1 = * ]]; then
name1="${name1//!/}"
name1="${name1///}"
echoRgb "跳過恢復$name1 所有數據" "0"
No_backupdata=1
fi
Backup_folder="$MODDIR/$name1"
# 流式恢復: 本地無備份, 從遠端拉 app_details.json 到 TMPDIR staging
if [[ $_RESTORE_STREAM = 1 ]]; then
Backup_folder="$TMPDIR/.restore_stage/$name1"
mkdir -p "$Backup_folder" 2>/dev/null
_stream_download "$_RESTORE_SUBDIR/$name1/app_details.json" > "$Backup_folder/app_details.json" 2>/dev/null
fi
if [[ -f "$Backup_folder/app_details.json" ]]; then
app_details="$Backup_folder/app_details.json"
apk_version="$(jq -r '.[] | select(.apk_version != null).apk_version' "$app_details")"
# 流式: 列表(appList_network.txt)只有資料夾名, 包名 name2 從 json 的 PackageName 取
if [[ $_RESTORE_STREAM = 1 ]]; then
name2="$(jq -r '.[] | select(.PackageName != null).PackageName' "$app_details" 2>/dev/null)"
fi
else
echoRgb "$Backup_folder/app_details.json不存在" "0"
fi
[[ $name2 = "" ]] && echoRgb "應用包名獲取失敗" "0" && exit 1
fi
# 流式恢復: Backup_folder 是 staging (只有 json), 視為存在以進入恢復流程
if [[ -d $Backup_folder ]] || [[ $_RESTORE_STREAM = 1 ]]; then
echoRgb "恢復$name1" "2"
Background_application_list
restore="true"
[[ $Backstage != "" && $(echo "$Backstage" | grep -Ew "^$name2$") != "" ]] && echoRgb "$name1存在後台 忽略恢復" "0" && restore="false"
[[ $restore = true ]] && {
starttime2="$(date -u "+%s")"
# 用預掃的 .installed_pkgs 查 (取代 fork pm 3 次)
# 注意: installapk 後 app 已裝, 再裝完用 grep 重查
local _is_installed
_is_installed=$(awk -v p="$name2" '$0==p{f=1} END{exit !f}' "$TMPDIR/.installed_pkgs" 2>/dev/null && echo 1)
# 流式: 設定 apk 遠端來源 (installapk 會用)
# 流式: 設定 apk 遠端來源 (依壓縮方式決定後綴)
if [[ $_RESTORE_STREAM = 1 ]]; then
case $Compression_method in
tar|Tar|TAR) _STREAM_APK_SRC="$_RESTORE_SUBDIR/$name1/apk.tar" ;;
*) _STREAM_APK_SRC="$_RESTORE_SUBDIR/$name1/apk.tar.zst" ;;
esac
fi
local _was_installed="$_is_installed"
if [[ -z $_is_installed ]]; then
installapk
# installapk 內部會用 echo_log 設 $result
if [[ $result = 0 ]]; then
echo "$name2" >> "$TMPDIR/.installed_pkgs"
_is_installed=1
else
_is_installed=0
fi
else
# 已裝, 比版本決定要不要 reinstall
local _cur_ver
_cur_ver=$(awk -v pkg="$name2" -F'\t' '$1 == pkg {print $2; exit}' "$TMPDIR/.pkg_ver" 2>/dev/null)
if [[ $apk_version -gt ${_cur_ver:-0} ]]; then
installapk && [[ $? = 0 ]] && echoRgb "版本提升${_cur_ver}>$apk_version" "1"
fi
fi
# 流式 + 僅恢復未安裝模式: 已裝的 app 跳過數據恢復 (流式無預篩, 在此落實 recovery_mode 語義)
if [[ $_RESTORE_STREAM = 1 && $recovery_mode = true && -n $_was_installed ]]; then
echoRgb "$name1 已安裝, 僅恢復未安裝模式下跳過數據恢復" "2"
elif [[ $_is_installed = 1 ]]; then
if [[ $No_backupdata = "" ]]; then
[[ $name2 != *mt* ]] && {
kill_app
if [[ $_RESTORE_STREAM = 1 ]]; then
# 流式: 枚舉資料類型, 設 _STREAM_SRC 遠端路徑, 逐個流式解壓
local _dt
for _dt in user data obb user_de; do
# 只恢復遠端 json 有記錄的資料 (Size 存在表示有備份)
local _has
_has="$(jq -r --arg k "$_dt" 'try .[$k].Size catch "" // ""' "$app_details" 2>/dev/null)"
[[ -z $_has || $_has = null ]] && continue
case $Compression_method in
tar|Tar|TAR) _STREAM_SRC="$_RESTORE_SUBDIR/$name1/$_dt.tar" ;;
*) _STREAM_SRC="$_RESTORE_SUBDIR/$name1/$_dt.tar.zst" ;;
esac
Release_data "$Backup_folder/${_STREAM_SRC##*/}"
done
unset _STREAM_SRC
else
find "$Backup_folder" -maxdepth 1 ! -name "apk.*" -name "*.tar*" -type f 2>/dev/null | sort | while read -r; do
Release_data "$REPLY"
done
fi
unset G
restore_permissions
Ssaid="$_rp_ssaid"
if [[ $Ssaid != "" ]]; then
# 用暫存檔取代字串拼接
echo "$name1 $name2 $Ssaid" >> "$TMPDIR/.restore_ssaid"
unset Ssaid
fi
}
fi
else
[[ $No_backupdata = "" ]]&& echoRgb "$name1沒有安裝無法恢復數據" "0"
fi
endtime 2 "$name1恢復" "2" && echoRgb "完成$((i * 100 / r))% $(progress_bar $((i * 100 / r)))" "3"
rgb_d="$rgb_a"
rgb_a=188
echoRgb "_________________$(endtime 1 "已經")___________________"
rgb_a="$rgb_d"
}
else
echoRgb "$Backup_folder資料夾遺失,無法恢復" "0"
fi
if [[ $i = $r ]]; then
endtime 1 "應用恢復" "2"
# 從暫存檔讀取累積的 ssaid 清單 (取代 SSAID_Package2 字串拼接)
[[ -s $TMPDIR/.restore_ssaid ]] && SSAID_Package2="$(cat "$TMPDIR/.restore_ssaid")"
[[ $SSAID_Package2 != "" ]] && {
echoRgb "開始恢復saaid" "0"
set_ssaid "$(echo "$SSAID_Package2" | awk '{printf "%s %s ", $2, $3}')"
ssaid_info="$(get_ssaid "$(echo "$SSAID_Package2" | awk '{printf "%s ", $2}')")"
echo "$SSAID_Package2" | while read -r; do
Ssaid="$(echo "$REPLY" | cut -d' ' -f3)"
name1="$(echo "$REPLY" | cut -d' ' -f1)"
name2="$(echo "$REPLY" | cut -d' ' -f2)"
# awk 取代 <<<here-string (mksh 不支援)
if [[ $(echo "$ssaid_info" | awk -v pkg="$name2" '$1 == pkg {print $2}') = $Ssaid ]]; then
echoRgb "$name1 SSAID恢復成功" "1"
else
echoRgb "$name1 SSAID恢復失敗" "0"
fi
unset Ssaid
done
echoRgb "SSAID恢復後必須重新開機套用,否則應用閃退,如果沒有應用恢復ssaid則無須重啟" "0"
notification "107" "SSAID恢復後必須重新開機套用,否則應用閃退,如果沒有應用恢復ssaid則無須重啟"
}
notification "105" "app恢復完成 $(endtime 1 "應用恢復" "2")"
[[ ! -f ${0%/*}/app_details.json ]] && {
if [[ $media_recovery = true ]]; then
starttime1="$(date -u "+%s")"
app_details="$Backup_folder2/app_details.json"
txt="$MODDIR/mediaList.txt"
sort -u "$txt" -o "$txt" 2>/dev/null
A=1
B="$(awk '!/[#]/ && NF{count++} END{print count}' "$txt" 2>/dev/null)"
[[ $B = "" ]] && echoRgb "mediaList.txt壓縮包名為空或是被注釋了\n -請執行start.sh獲取列表再來恢復" "0" && B=0
notification "106" "Media恢復開始"
while [[ $A -le $B ]]; do
name1="$(awk -v n=$A '!/[#]/ && NF{c++} c==n{print $1; exit}' "$txt" 2>/dev/null)"
starttime2="$(date -u "+%s")"
echoRgb "恢復第$A/$B個壓縮包 剩下$((B - A))" "3"
Release_data "$Backup_folder2/$name1"
endtime 2 "$FILE_NAME2恢復" "2" && echoRgb "完成$((A * 100 / B))% $(progress_bar $((A * 100 / B)))" "3" && echoRgb "____________________________________" && let A++
done
endtime 1 "自定義恢復" "2"
notification "106" "Media恢復完成 $(endtime 1 "Media恢復" "2")"
fi
[[ $_RESTORE_STREAM != 1 ]] && recover_wifi "$MODDIR/wifi"
}
fi
let i++ en++ nskg++
done
# 迴圈結束: 一次批量設置所有 app 的權限 (grant/revoke/ops 各一次 JVM)
flush_batch_permissions
# 復位: 確保批量模式不外溢. 目前 restore_permissions 僅此迴圈調用, 但保留復位作防禦
_batch_perm_mode=0
Set_screen_pause_seconds off
[[ $user != 0 ]] && am stop-user "$user" >/dev/null 2>&1
starttime1="$TIME"
echoRgb "$DX完成" && endtime 1 "$DX開始到結束"
notification "109" "恢復完成 $(endtime 1 "$DX開始到結束")"
[[ -n $TMPDIR ]] && rm -rf "$TMPDIR"/* 2>/dev/null
}
# 恢復自定義資料夾 (Media 等)
Restore3() {
self_test
echoRgb "點錯了?這是恢復自定義資料夾腳本 如果你是要恢復應用那你就點錯了" "2"
echoRgb "假設反悔了要終止腳本請儘速離開此腳本點擊start.sh選擇終止腳本,否則腳本將繼續執行直到結束" "0"
if ! ask_yn "繼續恢復自定義資料夾?" "恢復自定義資料夾" "離開腳本"; then
exit 0
fi
mediaDir="$MODDIR/Media"
[[ -f "$mediaDir/app_details.json" ]] && app_details="$mediaDir/app_details.json"
Backup_folder2="$mediaDir"
[[ ! -d $mediaDir ]] && echoRgb "媒體資料夾不存在" "0" && exit 2
txt="$MODDIR/mediaList.txt"
[[ ! -f $txt ]] && echoRgb "請執行start.sh獲取媒體列表再來恢復" "0" && exit 2
sort -u "$txt" -o "$txt" 2>/dev/null
#記錄開始時間
starttime1="$(date -u "+%s")"
echo_log() {
if [[ $? = 0 ]]; then
echoRgb "$1成功" "1" && result=0
else
echoRgb "$1恢復失敗,過世了" "0" && result=1
fi
}
starttime1="$(date -u "+%s")"
A=1
B="$(awk '!/[#]/ && NF{count++} END{print count}' "$txt" 2>/dev/null)"
Set_screen_pause_seconds on
[[ $B = "" ]] && echoRgb "mediaList.txt壓縮包名為空或是被注釋了\n -請執行start.sh獲取列表再來恢復" "0" && exit 1
notification "108" "Media恢復開始"
while [[ $A -le $B ]]; do
name1="$(awk -v n=$A '!/[#]/ && NF{c++} c==n{print $1; exit}' "$txt" 2>/dev/null)"
starttime2="$(date -u "+%s")"
echoRgb "恢復第$A/$B個壓縮包 剩下$((B - A))" "3"
Release_data "$mediaDir/$name1"
endtime 2 "$FILE_NAME2恢復" "2" && echoRgb "完成$((A * 100 / B))% $(progress_bar $((A * 100 / B)))" "3" && echoRgb "____________________________________" && let A++
done
Set_screen_pause_seconds off
endtime 1 "恢復結束"
notification "108" "Media恢復完成 $(endtime 1 "Media恢復")"
}
# 僅恢復包含 SSAID 應用 (不含數據,只裝 apk + 還原 SSAID)
# 用於只想保留遊戲帳號識別、不要舊存檔的場景
Restore4() {
if [[ $ssaid_mode_1 = true ]]; then
while read -r; do
if [[ $(jq -r '.[] | select(.Ssaid != null).Ssaid' "$REPLY") != "" ]]; then
ChineseName="$(jq -r 'to_entries[] | select(.key != null).key' "$REPLY" | head -n 1)"
PackageName="$(jq -r '.[] | select(.PackageName != null).PackageName' "$REPLY")"
if [[ $ssaid_name = "" ]]; then
ssaid_name="$ChineseName $PackageName"
else
ssaid_name="$ssaid_name\n$ChineseName $PackageName"
fi
fi
done<<<"$(find "$MODDIR" -maxdepth 2 -name "app_details.json" -type f 2>/dev/null | sort)"
[[ $ssaid_name != "" ]] && txt="$ssaid_name"
i=1
[[ $(echo "$txt") != "" ]] && txt="$(echo "$txt" | sed -e '/^$/d')"
r="$(echo "$txt" | awk 'NF != 0 { count++ } END { print count }')"
while [[ $i -le $r ]]; do
_line="$(echo "$txt" | sed -n "${i}p")"
name1="${_line%% *}"
name2="${_line#* }"
name2="${name2%% *}"
unset _line
Backup_folder="$MODDIR/$name1"
if [[ -f "$Backup_folder/app_details.json" ]]; then
app_details="$Backup_folder/app_details.json"
apk_version="$(jq -r '.[] | select(.apk_version != null).apk_version' "$app_details")"
else
echoRgb "$Backup_folder/app_details.json不存在" "0"
fi
[[ $name2 = "" ]] && echoRgb "應用包名獲取失敗" "0" && exit 1
if [[ $(pm list packages --user "$user" | awk -v pkg="$name2" -F':' '$2 == pkg {print $2}') != "" ]]; then
[[ $name2 != *mt* ]] && {
kill_app
Ssaid="$(jq -r '.[] | select(.Ssaid != null).Ssaid' "$app_details")"
if [[ $Ssaid != "" ]]; then
SSAID_Package="$(echo "$name1 $name2 $Ssaid")"
SSAID_Package2="$(echo "$SSAID_Package\n$SSAID_Package2")"
unset Ssaid
fi
}
fi
if [[ $i = $r ]]; then
[[ $SSAID_Package2 != "" ]] && {
echoRgb "開始恢復saaid" "0"
set_ssaid "$(echo "$SSAID_Package2" | awk '{printf "%s %s ", $2, $3}')"
ssaid_info="$(get_ssaid "$(echo "$SSAID_Package2" | awk '{printf "%s ", $2}')")"
echo "$SSAID_Package2" | while read -r; do
Ssaid="$(echo "$REPLY" | cut -d' ' -f3)"
name1="$(echo "$REPLY" | cut -d' ' -f1)"
name2="$(echo "$REPLY" | cut -d' ' -f2)"
if [[ $(awk -v pkg="$name2" '$1 == pkg {print $2}'<<<"$ssaid_info") = $Ssaid ]]; then
echoRgb "$name1 SSAID恢復成功" "1"
else
echoRgb "$name1 SSAID恢復失敗" "0"
fi
unset Ssaid
done
echoRgb "SSAID恢復後必須重新開機套用,否則應用閃退,如果沒有應用恢復ssaid則無須重啟" "0"
notification "107" "SSAID恢復後必須重新開機套用,否則應用閃退,如果沒有應用恢復ssaid則無須重啟"
}
fi
let i++
done
fi
}
# ======================================================
# 生成列表 / 檢查 / backup_media / wifi
# ======================================================
# 生成應用列表 (掃描所有已安裝 user app, 輸出到 appList.txt)
# 配合 blacklist/whitelist 過濾系統 app
Getlist() {
case $MODDIR in
/storage/emulated/0/Android/* | /data/media/0/Android/* | /sdcard/Android/*) echoRgb "請勿在$MODDIR內生成列表" "0" && exit 2 ;;
esac
#校驗選填是否正確
case $Lo in
0)
[[ $blacklist_mode != "" ]] && isBoolean "$blacklist_mode" "blacklist_mode" && blacklist_mode="$nsx" || {
echoRgb "選擇黑名單模式\n -音量上不輸出,音量下輸出應用列表" "2"
get_version "不輸出" "輸出應用列表" && blacklist_mode="$branch"
} ;;
1)
if [[ -n $(awk '!/[#]/ && NF' <<< "$blacklist") ]]; then
[[ $blacklist_mode = "" ]] && {
echoRgb "選擇黑名單模式\n -音量上不輸出,音量下輸出應用列表" "2"
get_version "不輸出" "輸出應用列表" && blacklist_mode="$branch"
} || isBoolean "$blacklist_mode" "blacklist_mode" && blacklist_mode="$nsx"
fi ;;
2)
[[ $blacklist_mode = "" ]] && {
Enter_options "選擇黑名單模式輸入1不輸出輸入0輸出應用列表" "不輸出" "輸出應用列表" && isBoolean "$parameter" "blacklist_mode" && blacklist_mode="$nsx"
} || {
isBoolean "$blacklist_mode" "blacklist_mode" && blacklist_mode="$nsx"
} ;;
*) echoRgb "$conf_path Lo=$Lo填寫錯誤正確值0 1 2" "0" && exit 2 ;;
esac
txt="$TMPDIR/appList"
[[ -f "$MODDIR/appList.txt" ]] && cat "$MODDIR/appList.txt" >"$txt"
[[ ! -f $txt ]] && echo '#不需要備份的應用請在開頭使用#注釋 比如:#酷安 com.coolapk.market忽略安裝包和數據\n#不需要備份數據的應用請在開頭使用!注釋 比如:!酷安 com.coolapk.market僅忽略數據' >"$txt"
echoRgb "請勿關閉腳本,等待提示結束"
rgb_a=118
starttime1="$(date -u "+%s")"
echoRgb "提示! 腳本默認會屏蔽預裝應用 如需備份請添加預裝應用白名單" "0"
Apk_info="$(appinfo "system|user|xposed" "label|pkgName|flag" | grep -Ev 'ice.message|com.topjohnwu.magisk' | tr '/:' '_')"
xposed_name="$(echo "$Apk_info" | awk '$3 == "xposed" {print $2}')"
TARGET_PACKAGES="$(echo "$system" | paste -sd'|' - | sed 's/^|//')"
Pre_installed_apps="$(echo "$Apk_info" | awk '$3 == "system" {print $1, $2}' | grep -Ew "$TARGET_PACKAGES")"
# 在 Apk_info 被收窄前, 先存全系統包名集合 (供結尾「舊註解清理」用, 省去再跑一次 pm list packages)
echo "$Apk_info" | awk '{print $2}' | sed '/^[[:space:]]*$/d' | sort -u > "$TMPDIR/.getlist_allpkg"
Apk_info="$(printf '%s\n%s\n' "$(echo "$Apk_info" | awk '$3 != "system" {print $1, $2}')" "$Pre_installed_apps" | sed '/^[[:space:]]*$/d' | sort -u)"
[[ $Apk_info = "" ]] && {
echoRgb "appinfo輸出失敗,請截圖畫面回報作者" "0"
exit 2 ; } || Apk_info2="$(echo "$Apk_info" | cut -d' ' -f2)"
Apk_Quantity="$(awk 'END{print NR}' <<< "$Apk_info")"
echoRgb "列出第三方應用......." "2"
i=0; rc=0; rd=0; Q=0; Qc=0; rb=0
# 預先收集所有「待加進 txt」的行, 用暫存檔取代 REPLY2 字串拼接 (O(N²) → O(N))
local appended="$TMPDIR/.getlist_append"
: > "$appended"
# 一次 awk 把所有 app 預分類, 取代主迴圈內的多次 grep/awk fork
# 輸出格式: <類別>\t<原行>
# 類別: BLACK / XPOSED / WHITE / PRELOAD / NORMAL
local classified="$TMPDIR/.getlist_class"
# 分類 awk: 同時做「已存在判斷」與「同名不同包重命名」, 主迴圈不再 fork grep/add_entry
# 用 FNR==NR 先吃 $txt (現有清單): 收集已存在包名集合 exist[], 以及 app名→已被佔用 namecnt[]
# 第二檔 (Apk_info) 才做分類; 輸出格式: <類別>\t<最終label>\t<pkg>
echo "$Apk_info" | sed 's/[\/:()\[\]\-!]//g' > "$TMPDIR/.getlist_apkinfo"
awk -v whitelist="$whitelist" \
-v blacklist="$blacklist" \
-v xposed="$xposed_name" '
BEGIN {
n = split(whitelist, _w, /[ \t\n]+/)
for (k in _w) if (_w[k] != "") wl[_w[k]] = 1
n = split(blacklist, _b, /[ \t\n]+/)
for (k in _b) if (_b[k] != "" && _b[k] !~ /^[#]/) bl[_b[k]] = 1
n = split(xposed, _x, /[ \t\n]+/)
for (k in _x) if (_x[k] != "") xp[_x[k]] = 1
preload_re = "(oneplus|miui|xiaomi|oppo|flyme|meizu|coloros)"
preload_exact["com.android.soundrecorder"] = 1
preload_exact["com.mfashiongallery.emag"] = 1
preload_exact["com.mi.health"] = 1
preload_exact["com.duokan.phone.remotecontroller"] = 1
preload_exact["com.android.calendar"] = 1
preload_exact["com.android.deskclock"] = 1
preload_exact["com.google.android.safetycore"] = 1
preload_exact["com.google.android.contactkeys"] = 1
preload_exact["com.google.android.apps.messaging"] = 1
preload_exact["com.google.android.calendar"] = 1
}
# 第一檔: 現有 $txt — 收集已存在 pkg 與 app名已佔用情況
FNR==NR {
# 包名永遠是最後一欄 $NF; label 是前面所有欄位 (app 名可能含空格)
# (註解行如 "#日曆 com.google...calendar" 也要排除該 app 重複輸出)
if (NF < 2) next
_cpkg = $NF
_clabel = $1
for (_j = 2; _j < NF; _j++) _clabel = _clabel " " $_j
if ($0 ~ /^[#!]/) {
exist_cmt[_cpkg] = 1 # 被註解(#/!)的已存在包名
} else {
exist[_cpkg] = 1 # 正常已存在包名
namepkg[_clabel] = _cpkg # 同名衝突判斷只看非註解行
used[_clabel] = 1
}
next
}
# 第二檔: Apk_info — 分類
{
# 防禦: 跳過空行或缺包名的行 (避免產生空 pkg 分類, 與 Apk_Quantity 計數不一致)
if (NF < 2) next
# 包名永遠是最後一欄 $NF; label 是前面所有欄位 (app 名可能含空格)
pkg = $NF
label = $1
for (_j = 2; _j < NF; _j++) label = label " " $_j
# 已存在(正常) → EXIST; 已存在(被註解) → EXIST_CMT; 兩者主迴圈都只計數跳過
if (pkg in exist) { print "EXIST\t" label "\t" pkg; next }
if (pkg in exist_cmt) { print "EXIST_CMT\t" label "\t" pkg; next }
# 同名不同包 → 加數字後綴 (與 add_entry 等價)
final = label
if ((label in used) && namepkg[label] != pkg) {
c = 1
while ((final = label "_" c) in used) c++
}
used[final] = 1; namepkg[final] = pkg
if (pkg in bl) { print "BLACK\t" final "\t" pkg; next }
if (pkg ~ preload_re || pkg in preload_exact) {
if (pkg in xp) { print "PRELOAD_XP\t" final "\t" pkg; next }
if (pkg in wl) { print "PRELOAD_WL\t" final "\t" pkg; next }
print "PRELOAD\t" final "\t" pkg; next
}
if (pkg in xp) { print "XPOSED\t" final "\t" pkg; next }
print "NORMAL\t" final "\t" pkg
}' "$txt" "$TMPDIR/.getlist_apkinfo" > "$classified"
rm -f "$TMPDIR/.getlist_apkinfo"
[[ -n "$(echo "$blacklist" | grep -Ev '#|')" ]] && NZK=1
# 主迴圈: 從分類結果讀, 每行已預先標好類別
LR=1
local _seen=0 # 核對1: 迴圈實際處理的 app 數, 應 == Apk_Quantity
# 分類 awk 已算好最終 label 與已存在判斷, 迴圈內不再 fork (grep/cat/add_entry 全消除)
while IFS=$'\t' read -r kind app_label app_pkg; do
[[ -z $app_pkg ]] && continue
let _seen++
[[ $rgb_a -ge 229 ]] && rgb_a=118
app_name="$app_label"
REPLY="$app_label $app_pkg"
case $kind in
EXIST)
let Q++
let LR++; let rgb_a++
continue
;;
EXIST_CMT)
let Qc++
echoRgb "$app_name 已註解 略過輸出" "0"
let LR++; let rgb_a++
continue
;;
BLACK)
if [[ $NZK = 1 ]]; then
if [[ $blacklist_mode = false ]]; then
echo "$REPLY" >> "$appended"
tmp=1
echoRgb "$((i+1)):$app_name $app_pkg($rgb_a)"
let i++ rb++
else
echoRgb "$app_label黑名單應用 不輸出" "0"
let rb++
fi
fi
let LR++; let rgb_a++
continue
;;
PRELOAD_XP)
echoRgb "$((i+1)):$app_name為Xposed模塊 進行添加" "0"
echo "$REPLY" >> "$appended"
tmp=1
let i++ rd++
;;
PRELOAD_WL)
echo "$REPLY" >> "$appended"
tmp=1
echoRgb "$((i+1)):$app_name $app_pkg($rgb_a)"
let i++
;;
PRELOAD)
echoRgb "$app_name 預裝應用 忽略輸出" "0"
echo "#$REPLY" >> "$appended"
tmp=1
let rc++
;;
XPOSED)
echo "$REPLY" >> "$appended"
tmp=1
echoRgb "$((i+1)):Xposed: $app_name $app_pkg($rgb_a)"
let i++ rd++
;;
NORMAL|*)
echo "$REPLY" >> "$appended"
tmp=1
echoRgb "$((i+1)):$app_name $app_pkg($rgb_a)"
let i++
;;
esac
let LR++; let rgb_a++
done < "$classified"
# 核對1 用: 先記錄合併前 txt 既有的有效行數 (非註解) — 須在 append 前取
local _old_eff
_old_eff=$(awk '/^[[:space:]]*$/{next} /^[[:space:]]*[#!]/{next} {c++} END{print c+0}' "$txt" 2>/dev/null)
local _chk_fail=0
# ====== 數量核對1: 全員到齊 (無論有無新輸出都檢查) ======
# 迴圈處理數 _seen 應 == 分類檔行數, 且 == 系統第三方總數 Apk_Quantity
local _cls_lines
_cls_lines=$(awk 'END{print NR+0}' "$classified" 2>/dev/null)
if [[ ${_seen:-0} -ne ${_cls_lines:-0} ]]; then
echoRgb " ⚠️ 數量核對1異常: 迴圈處理=$_seen 但分類檔行數=$_cls_lines (迴圈漏讀)" "0"
_chk_fail=1
elif [[ ${_seen:-0} -ne ${Apk_Quantity:-0} ]]; then
echoRgb " ⚠️ 數量核對1異常: 已分類=$_seen 但第三方總數=$Apk_Quantity (分類前後數量不符)" "0"
_chk_fail=1
else
echoRgb " ✅ 數量核對1: 全部 $Apk_Quantity 個 app 皆已分類處理" "1"
fi
# 把累積的 append 一次寫進 txt
if [[ -s $appended ]]; then
cat "$appended" >> "$txt"
echoRgb "已經將預裝應用輸出至appList.txt並注釋# 需要備份則去掉#" "0"
[[ -n $tmp ]] && echoRgb "\n -第三方apk數量=\"$Apk_Quantity\"\n -已過濾=\"$rc\"\n -xposed=\"$rd\"\n -黑名單應用=\"$rb\"\n -存在列表中=\"$Q\"\n -已註解略過=\"$Qc\"\n -輸出=\"$i\""
# ====== 數量核對2: 輸出行 (僅在有新輸出時) ======
# 合併後有效行 應 == 合併前有效行 + 本次輸出 i
local _eff_lines _expect _new_eff
_eff_lines=$(awk '/^[[:space:]]*$/{next} /^[[:space:]]*[#!]/{next} {c++} END{print c+0}' "$txt")
# 本次 append 的非註解有效行數 (輸出 i 含註解行, 不可全加)
_new_eff=$(awk '/^[[:space:]]*$/{next} /^[[:space:]]*[#!]/{next} {c++} END{print c+0}' "$appended" 2>/dev/null)
_expect=$(( ${_old_eff:-0} + ${_new_eff:-0} ))
if [[ $_eff_lines -ne $_expect ]]; then
echoRgb " ⚠️ 數量核對2異常: 列表有效行=$_eff_lines 但預期(原有$_old_eff+本次新增有效$_new_eff)=$_expect" "0"
_chk_fail=1
else
echoRgb " ✅ 數量核對2: 列表有效行=$_eff_lines (原有$_old_eff+本次新增有效$_new_eff)" "1"
fi
else
# 無新輸出 (全部已存在/已註解): 顯示統計, 核對2 不適用
[[ -n $tmp ]] && echoRgb "\n -第三方apk數量=\"$Apk_Quantity\"\n -已過濾=\"$rc\"\n -xposed=\"$rd\"\n -黑名單應用=\"$rb\"\n -存在列表中=\"$Q\"\n -已註解略過=\"$Qc\"\n -輸出=\"$i\""
echoRgb "本次無新增應用 (全部已存在或已註解)" "2"
fi
# 任一核對失敗 → 中止, 不寫出可能有誤的列表
if [[ $_chk_fail = 1 ]]; then
echoRgb "\n -輸出異常 數量核對不通過 請聯繫作者解決" "0"
rm -rf "$txt"
rm -f "$appended" "$classified"
exit
fi
rm -f "$appended" "$classified"
# 結尾過濾: 對 txt 內的每行檢查 pkg 是否還在系統內, 不在的刪掉
# 用一個 awk 一次處理 (取代原本 per-row fork awk)
if [[ -f $txt ]]; then
local pkg_set="$TMPDIR/.getlist_pkgset"
echo "$Apk_info2" > "$pkg_set"
# 註解行用「全系統已裝包名」判斷 (Apk_info2 僅第三方+白名單預裝, 會誤刪系統 app)
# 直接複用開頭 appinfo 已存的全包名集合, 不再跑 pm list packages (省一次全系統查詢)
local all_pkg_set="$TMPDIR/.getlist_allpkg"
local _allpkg_n
_allpkg_n="$(wc -l < "$all_pkg_set" 2>/dev/null || echo 0)"
local filtered="$TMPDIR/.getlist_filtered"
# 三檔: (1)第三方清單 existing[] (2)全系統清單 allpkg[] (3)txt 逐行判斷
awk -v allpkg_n="$_allpkg_n" '
# 第一檔: 第三方清單
FNR==NR { existing[$1]=1; next }
# 第二檔: 全系統清單
FILENAME == ALLF { allpkg[$1]=1; next }
# 第三檔: txt
/^[[:space:]]*$/ { print; next }
/^[[:space:]]*[#!]/ {
cpkg = $2
if (cpkg ~ /^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z0-9_]+)+$/) {
# 合法包名的註解行: 全系統清單為空時一律保留(防 pm 失敗誤刪), 否則查全系統存在性
if (allpkg_n == 0 || cpkg in allpkg) print
else print "##__MISSING__\t" $0
} else {
print # 說明行 / 無合法包名 → 保留
}
next
}
{
pkg = $2
if (pkg == "" || pkg in existing) print
else print "##__MISSING__\t" $0
}' ALLF="$all_pkg_set" "$pkg_set" "$all_pkg_set" "$txt" > "$filtered"
# 印出被刪除的行 (給用戶看)
grep '^##__MISSING__' "$filtered" | sed 's/^##__MISSING__\t//' | while read -r missing_line; do
echoRgb "$missing_line不存在系統,從列表中刪除" "0"
done
# 寫回 txt (排序, 去空行)
grep -v '^##__MISSING__' "$filtered" \
| sed -e '/^$/d' | sort > "$txt"
rm -f "$pkg_set" "$all_pkg_set" "$filtered"
fi
wait
# ====== appList.txt 結構驗證 (類似 JSON 自動檢查) ======
# 檢查: 非註解行欄位數=2、包名格式合法、包名無重複
if [[ -f $txt ]]; then
echoRgb "—————— 應用列表結構驗證 ——————" "3"
local _lc_err="$TMPDIR/.applist_err"
: > "$_lc_err"
awk '
/^[[:space:]]*$/ { next }
/^[[:space:]]*[#!]/ { next }
{
total++
if (NF != 2) { print "欄位數異常(" NF "欄): " $0; next }
pkg = $2
if (pkg !~ /^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z0-9_]+)+$/) print "包名格式可疑: " $1 " " pkg
if (pkg in seen) print "包名重複: " pkg " (與 " seen[pkg] " 重複)"
else seen[pkg] = $1
}
END { print "##TOTAL##\t" total > "/dev/stderr" }
' "$txt" 2> "$TMPDIR/.applist_cnt" >> "$_lc_err"
local _lc_total
_lc_total="$(awk -F'\t' '/^##TOTAL##/{print $2}' "$TMPDIR/.applist_cnt" 2>/dev/null)"
rm -f "$TMPDIR/.applist_cnt"
if [[ -s $_lc_err ]]; then
echoRgb "列表驗證發現異常:" "0"
while read -r _el; do echoRgb "$_el" "0"; done < "$_lc_err"
echoRgb "請檢查 appList.txt 後再進行備份" "0"
else
echoRgb " ✅ 列表結構正常 (${_lc_total:-0} 個有效應用)" "1"
fi
rm -f "$_lc_err"
fi
endtime 1
cat "$txt">"$MODDIR/appList.txt" && rm "$txt"
chown "$(stat -c '%u:%g' '/data/media/0/Download')" "$MODDIR/appList.txt"
echoRgb "輸出包名結束 請查看$MODDIR/appList.txt"
}
# 備份自定義資料夾 (來自 Custom_path 設定)
# 例: Pictures / Download / DCIM / /data/adb 等
# 結尾設 REMOTE_UPLOAD_MEDIA=1 + REMOTE_TRIGGER=1
backup_media() {
self_test
backup_path
show_conf media
echoRgb "假設反悔了要終止腳本請儘速離開此腳本點擊start.sh選擇終止腳本,否則腳本將繼續執行直到結束" "0"
A=1
B="$(awk '!/[#]/ && NF{count++} END{print count}' <<< "$Custom_path")"
if [[ $B != "" ]]; then
starttime1="$(date -u "+%s")"
Backup_folder="$Backup/Media"
app_details="$Backup_folder/app_details.json"
if [[ $remote_stream = 1 && -n $remote_type ]]; then
_STREAM_DEST="Media"; Backup_folder="$TMPDIR/.stream_stage/Media"; app_details="$Backup_folder/app_details.json"; mkdir -p "$Backup_folder" 2>/dev/null
fi
mediatxt="$Backup/mediaList.txt"
# 延遲建立: 只有實際備份了至少一個資料夾才建立 Media/txt 等 (避免空殼)
_media_created=0
_ensure_media_dirs() {
[[ $_media_created = 1 ]] && return
[[ ! -d $Backup_folder ]] && mkdir -p "$Backup_folder"
[[ ! -f $Backup/start.sh ]] && touch_shell "2" "$Backup/start.sh"
[[ ! -d $Backup/tools ]] && cp -r "$tools_path" "$Backup"
[[ ! -f $Backup/restore_settings.conf ]] && update_Restore_settings_conf>"$Backup/restore_settings.conf"
[[ ! -f $app_details ]] && echo "{\n}">"$app_details"
[[ ! -f $mediatxt ]] && echo "#不需要恢復的資料夾請在開頭使用#注釋 比如:#Download" > "$mediatxt"
filesize="$(calc_dir_size "$Backup_folder")"
_media_created=1
}
Set_screen_pause_seconds on
notification "109" "Media備份開始"
echo "$Custom_path" | sed -e '/^#/d; /^$/d; s/\/$//' | while read -r; do
echoRgb "備份第$A/$B個資料夾 剩下$((B - A))" "3"
starttime2="$(date -u "+%s")"
if [[ ${REPLY##*/} = adb ]]; then
if [[ $ksu != ksu ]]; then
echoRgb "Magisk adb"
_ensure_media_dirs
Backup_data "${REPLY##*/}" "$REPLY"
else
echoRgb "KernelSU adb不支持備份" "0"
fi
else
_ensure_media_dirs
Backup_data "${REPLY##*/}" "$REPLY"
fi
endtime 2 "${REPLY##*/}備份" "1"
echoRgb "完成$((A * 100 / B))% $(progress_bar $((A * 100 / B))) $hx$(echo "$Occupation_status" | awk 'END{print "剩餘:"$1"使用率:"$2}')" "2" && echoRgb "____________________________________" && let A++
done
# 收尾: 若 Media 內無任何備份檔 (全部跳過/不支持), 清掉空殼避免上傳空目錄
if [[ -d $Backup_folder ]] && ! find "$Backup_folder" -maxdepth 1 -name "*.tar*" -type f 2>/dev/null | grep -q .; then
echoRgb "Media 無實際備份內容, 清除空目錄與 mediaList.txt" "0"
rm -rf "$Backup_folder"
[[ -f $mediatxt ]] && [[ ! -s $mediatxt || $(grep -vc "^#" "$mediatxt" 2>/dev/null) = 0 ]] && rm -f "$mediatxt"
else
[[ $remote_stream != 1 ]] && Calculate_size "$Backup_folder"
[[ -n $remote_type ]] && REMOTE_UPLOAD_MEDIA=1
fi
Set_screen_pause_seconds off
endtime 1 "自定義備份"
notification "109" "Media備份完成 $(endtime 1 "自定義備份")"
else
echoRgb "自定義路徑為空 無法備份" "0"
fi
REMOTE_TRIGGER=1
# subshell 環境下 trap EXIT 在主 shell 不會觸發, 這裡直接呼叫
remote_cleanup
}
# 從 tools/Device_List 對照表查詢設備識別資訊 (處理器型號、RAM 規格等)
Device_List() {
URL="https://raw.githubusercontent.com/KHwang9883/MobileModels/refs/heads/master/brands"
rm -rf "$tools_path/Device_List"
for i in $(echo "xiaomi\nxiaomi_en\nsamsung\nsamsung_global\nasus\nBlack_Shark\nBlack_Shark_en\ngoogle\nLenovo\nMEIZU\nMEIZU_en\nMotorola\nNokia\nnothing\nnubia\nOnePlus\nOnePlus_en\nSony\nrealme\nrealme_en\nvivo\nvivo_en\noppo\noppo_en"); do
echoRgb "獲取品牌$i"
case $i in
xiaomi) Brand_URL="$URL/xiaomi.md" ;;
xiaomi_en) Brand_URL="$URL/xiaomi_en.md" ;;
samsung) Brand_URL="$URL/samsung_cn.md" ;;
samsung_global) Brand_URL="$URL/samsung_global_en.md" ;;
asus) Brand_URL="$URL/asus.md" ;;
Black_Shark) Brand_URL="$URL/blackshark.md" ;;
Black_Shark_en) Brand_URL="$URL/blackshark_en.md" ;;
google) Brand_URL="$URL/google.md" ;;
Lenovo) Brand_URL="$URL/lenovo.md" ;;
MEIZU) Brand_URL="$URL/meizu.md" ;;
MEIZU_en) Brand_URL="$URL/meizu_en.md" ;;
Motorola) Brand_URL="$URL/motorola.md" ;;
Nokia) Brand_URL="$URL/nokia.md" ;;
nothing) Brand_URL="$URL/nothing.md" ;;
nubia) Brand_URL="$URL/nubia.md" ;;
OnePlus) Brand_URL="$URL/oneplus.md" ;;
OnePlus_en) Brand_URL="$URL/oneplus_en.md" ;;
Sony) Brand_URL="$URL/sony_cn.md" ;;
realme) Brand_URL="$URL/realme_cn.md" ;;
realme_en) Brand_URL="$URL/realme_global_en.md" ;;
vivo) Brand_URL="$URL/vivo_cn.md" ;;
vivo_en) Brand_URL="$URL/vivo_global_en.md" ;;
oppo) Brand_URL="$URL/oppo_cn.md" ;;
oppo_en) Brand_URL="$URL/oppo_global_en.md" ;;
esac
if [[ ! -e $tools_path/Device_List ]]; then
down "$Brand_URL" | grep -oE '`[^`]+`:[^`]*' | sed -E 's/: /:/g' | sed -E 's/`([^`]+)`:(.*)/"\1" "\2"/'>"$tools_path/Device_List"
else
down "$Brand_URL" | grep -oE '`[^`]+`:[^`]*' | sed -E 's/: /:/g' | sed -E 's/`([^`]+)`:(.*)/"\1" "\2"/' | while read -r; do
unset model
model="$(echo "$REPLY" | awk -F'"' '{print $2}')"
if [[ $(grep -Ew "$model" "$tools_path/Device_List" | awk -F'"' '{print $2}') != $model ]]; then
echo "$REPLY">>"$tools_path/Device_List"
else
echo "$(grep -Ew "$model" "$tools_path/Device_List" | awk -F'"' '{print $2}') = $model"
fi
done
fi
done
if [[ -e $tools_path/Device_List ]]; then
if [[ $(stat -c%s "$tools_path/Device_List" 2>/dev/null) -gt 1 ]]; then
[[ $shell_language = zh-TW ]] && ts_inplace "$tools_path/Device_List"
echoRgb "已下載機型列表在$tools_path/Device_List"
else
echoRgb "下載機型失敗"
fi
else
echoRgb "下載機型失敗"
fi
}
# 主選單「備份WiFi」入口
# 建立備份目錄結構 + 複製 tools/ + 生成 start.sh + 備份 wifi.json
wifi() {
backup_path
show_conf wifi
[[ ! -d $Backup/tools ]] && cp -r "$tools_path" "$Backup"
[[ ! -f $Backup/start.sh ]] && touch_shell "2" "$Backup/start.sh"
[[ ! -f $Backup/restore_settings.conf ]] && update_Restore_settings_conf>"$Backup/restore_settings.conf"
backup_wifi "$Backup/wifi"
[[ -n $remote_type ]] && REMOTE_UPLOAD_WIFI=1
}
# ======================================================
# 主選單入口
# ======================================================
if [[ $0 = *backup.sh ]]; then
start=backup
elif [[ $0 = *upload.sh ]]; then
# upload.sh 位於 Backup_zstd_X/<app>/upload.sh
# MODDIR 已被入口腳本設成 Backup_zstd_X
# 取得 app 名 = upload.sh 所在的資料夾名
_upload_app="${0%/*}"
_upload_app="${_upload_app##*/}"
start="single_upload \"$_upload_app\""
else
[[ $0 = *recover.sh ]] && start=Restore
fi
if [[ $start != "" ]]; then
case $(grep -o 'background_execution=.*' "$conf_path" | awk -F '=' '{print $2}') in
0)
eval "$start" ;;
1)
{
eval "$start"
} & ;;
esac
else
# 主選單循環: 跑完一個動作回到選單繼續
# 備份類動作 (backup/backup_update_apk/backup_media/wifi) 跑完直接退出整個腳本
# 其他動作 (Getlist/remote_test/list/download) 跑完回選單
while true; do
if [[ -f $MODDIR/backup_settings.conf ]]; then
steps=(
"生成應用列表"
"備份應用"
"備份已更新應用"
"備份自定義資料夾"
"備份WiFi"
"測試遠端連線"
"單獨上傳當前備份"
"列出遠端備份(產生 appList_network.txt)"
"從遠端下載備份"
"從遠端流式恢復(不佔本機)"
"殺死運行中腳本"
)
# 備份類 commands 結尾用 "; exit" 確保跑完退出主 shell, 而非回到選單
commands=(
"Getlist"
"backup; exit"
"backup_update_apk; exit"
"backup_media; exit"
"wifi; exit"
"remote_test"
"upload_current_backup"
"remote_list_backups"
"remote_download_backup"
"remote_stream_restore; exit"
"echoRgb '等待腳本停止中,請稍後.....' && echoRgb '腳本終止'; exit"
)
elif [[ -f $MODDIR/restore_settings.conf ]]; then
steps=(
"重新生成應用列表"
"恢復備份"
"僅恢復包含ssaid應用(含數據)"
"僅恢復包含ssaid應用(不含數據)"
"恢復自定義資料夾"
"恢復wifi"
"壓縮檔完整性檢查"
"JSON結構檢查"
"轉換文件夾名稱"
"殺死運行中腳本"
)
# 恢復類 commands 結尾用 "; exit" 確保跑完退出
commands=(
"dumpname"
"Restore; exit"
"ssaid_mode=true && Restore; exit"
"ssaid_mode_1=true && Restore4; exit"
"Restore3; exit"
"recover_wifi \"$MODDIR/wifi\"; exit"
"check_file"
"Check_json"
"convert"
"echoRgb '等待腳本停止中,請稍後.....' && echoRgb '腳本終止'; exit"
)
fi
echoRgb "請選擇要執行的操作:"
for i in "${!steps[@]}"; do
printf "%d) %s\n" "$((i+1))" "${steps[$i]}"
done
echo "x) 離開腳本"
echo -n "請輸入選項編號: "
# read 失敗 (stdin 關閉/EOF, 例如後台執行無 tty) 立刻退出,避免無限循環
if ! read choice; then
echoRgb "無互動 stdin, 退出" "0"
exit 0
fi
case $choice in
x|X)
echoRgb "已退出腳本" "0"
exit 0 ;;
[0-9]*)
if (( choice >= 1 && choice <= ${#steps[@]} )); then
index="$((choice - 1))"
echo "執行:${steps[$index]}"
background="$(grep -o 'background_execution=.*' "$conf_path" | awk -F '=' '{print $2}')"
if [[ "$background" = "1" ]]; then
# 後台執行: 用 subshell 防 exit 殺主 shell
(eval "${commands[$index]}") &
bg_pid=$!
# 不論動作類型都 wait, 確保主選單不會被子進程輸出蓋掉
wait "$bg_pid"
# 備份/恢復類動作 (commands 含 exit) 跑完整個腳本退出
case ${commands[$index]} in
*exit*) exit 0 ;;
esac
else
# 前台: 不包 subshell, command 內的 exit 會真的退出整個腳本
# (備份類 commands 結尾有 exit, 達成「跑完就退出」)
eval "${commands[$index]}"
fi
else
echoRgb "超出功能選項範圍1-${#steps[@]}" "0"
fi
;;
*)
echoRgb "輸入錯誤,請重新輸入有效的數字或輸入 x 離開。" "0" ;;
esac
done
fi