release: v1.0.2

加固:release 构建 + R8 混淆,清理无用资源,修启动链路
This commit is contained in:
maipulse authored and shouko committed 2026-10-30 21:35:19 +08:00
1 parent 5b85408f17
commit 967e93af98
29 files changed
+1640 -1490

No files matched your search

+33 -5
View File
@@ -11,8 +11,9 @@ android {
applicationId "com.maipulse.app"
minSdk 24
targetSdk 34
versionCode 1
versionName "1.0"
// 和网页里的 APP_VERSION 对齐,升级时一起改
versionCode 102
versionName "1.0.2"
ndk {
abiFilters "arm64-v8a"
@@ -20,7 +21,7 @@ android {
// Chaquopy 16 的 DSL 挂在 android.defaultConfig 下面
python {
buildPython "C:/Users/20558/AppData/Local/Programs/Python/Python38/python.exe"
buildPython "python"
pip {
install "flask"
install "requests"
@@ -33,6 +34,12 @@ android {
// 而 zipalign 之后我们要用 apksigner 重签。两边不是同一个 key 的话,
// 出来的包签名不一样,用户必须卸载才能覆盖安装。
signingConfigs {
release {
storeFile file("../maipulse-debug.keystore")
storePassword "android"
keyAlias "androiddebugkey"
keyPassword "android"
}
debug {
storeFile file("../maipulse-debug.keystore")
storePassword "android"
@@ -46,7 +53,13 @@ android {
signingConfig signingConfigs.debug
}
release {
minifyEnabled false
// 出正式包:关调试标志,开 R8 去死代码 + 混淆 Java 侧
signingConfig signingConfigs.release
debuggable false
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'),
'proguard-rules.pro'
}
}
@@ -56,10 +69,25 @@ android {
}
androidResources {
// 这几类是已经压过的/要被 mmap 的,再压反而慢
noCompress 'json', 'png'
}
lint {
// release 构建默认跑 lintVital,它会联网拉 lint-gradle 依赖。
// 这条链断了整包就出不来,而 lint 结果对发版没有决定意义,直接关掉。
checkReleaseBuilds false
abortOnError false
}
packagingOptions {
jniLibs {
// 原生库直接从 APK 里 mmap,不再解压一份到 /data
useLegacyPackaging = false
}
}
}
dependencies {
implementation 'androidx.core:core:1.12.0'
}
}
+7 -3
View File
@@ -5,19 +5,23 @@
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:allowBackup="true"
android:allowBackup="false"
android:fullBackupContent="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:label="maipulse"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:usesCleartextTraffic="true"
android:hardwareAccelerated="true"
android:largeHeap="true"
android:theme="@style/AppTheme">
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|keyboardHidden"
android:windowSoftInputMode="adjustResize">
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
android:windowSoftInputMode="adjustResize"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@@ -7,24 +7,32 @@ import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.content.res.AssetManager;
import android.graphics.Color;
import android.graphics.Typeface;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.provider.Settings;
import android.util.Log;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.KeyEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.Window;
import android.view.WindowManager;
import android.webkit.JavascriptInterface;
import android.webkit.ValueCallback;
import android.webkit.WebChromeClient;
import android.webkit.WebResourceRequest;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebChromeClient;
import android.webkit.WebViewClient;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
@@ -45,54 +53,144 @@ import java.net.Socket;
/**
* maipulse 的 Android 外壳。
*
* 流程:复制 assets/app -> filesDir/app(assets 只读,Flask 要写 logs 与缓存),
* 起 Chaquopy 跑 Flask 绑 127.0.0.1:8787,端口通了再把 WebView 指过去。
* 启动顺序:
* 1. 先画原生启动页(图标 + 名字 + 状态文字),保证点开立刻有东西
* 2. 后台线程把 assets/app 复制到 filesDir/app(assets 只读,Flask 要写 logs)
* 3. 回到主线程装载 Chaquopy(Python.start 有主线程要求)
* 4. 后台线程起 Flask,另一个线程轮询 127.0.0.1:8787
* 5. 端口通了再加载 WebView,等 onPageFinished 把启动页淡出、网页淡入
*
* 额外给了网页一个 Maipulse.installApk(url):走系统 DownloadManager 下 APK,
* 下完弹安装器,这样更新不用让用户自己去找文件。
* 用户导入的机台配置(astral/config.py 和 astral/api/MaimaiNet.py)在资源重建时会被保留。
*/
public class MainActivity extends Activity {
private static final String TAG = "maipulse";
private static final int PORT = 8787;
private static final String HOME = "http://127.0.0.1:" + PORT + "/";
private static final String BG = "#EEF2F9";
/* 启动页 */
private View splash;
private TextView splashTip;
private TextView splashRetry;
private volatile WebView web;
private volatile boolean firstPageDone = false;
private volatile boolean loadRequested = false;
private WebView web;
private TextView tip;
private static final int REQ_PICK_FILE = 1001;
private ValueCallback<Uri[]> filePathCallback;
private long pendingDownloadId = -1;
private BroadcastReceiver downloadReceiver;
private long lastBackPress = 0;
@SuppressLint("SetJavaScriptEnabled")
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setupSystemBars();
buildUi();
// 关掉 WebView 远程调试(debug 包里默认是可调试的,能被 chrome://inspect 接管)
try {
WebView.setWebContentsDebuggingEnabled(false);
} catch (Throwable ignored) {
}
registerDownloadReceiver();
// web.post 保证首帧先画出来,再进到会阻塞的主线程初始化
web.post(this::prepareAndBoot);
}
/* ---------------- 系统栏 ---------------- */
private void setupSystemBars() {
Window w = getWindow();
w.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
w.setStatusBarColor(Color.parseColor(BG));
w.setNavigationBarColor(Color.WHITE);
int flags = View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
flags |= View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR;
}
w.getDecorView().setSystemUiVisibility(flags);
}
/* ---------------- 界面 ---------------- */
private int dp(float v) {
return Math.round(TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, v, getResources().getDisplayMetrics()));
}
private void buildUi() {
FrameLayout root = new FrameLayout(this);
root.setBackgroundColor(Color.parseColor("#EEF2F9"));
root.setBackgroundColor(Color.parseColor(BG));
web = new WebView(this);
web.setBackgroundColor(Color.parseColor("#EEF2F9"));
web.setBackgroundColor(Color.parseColor(BG));
web.setAlpha(0f);
web.setOverScrollMode(View.OVER_SCROLL_NEVER);
web.setVerticalScrollBarEnabled(false);
web.setHorizontalScrollBarEnabled(false);
web.setLongClickable(false);
web.setHapticFeedbackEnabled(false);
WebSettings s = web.getSettings();
s.setJavaScriptEnabled(true);
s.setDomStorageEnabled(true);
s.setAllowFileAccess(true);
s.setDatabaseEnabled(true);
s.setMediaPlaybackRequiresUserGesture(false);
s.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW);
s.setCacheMode(WebSettings.LOAD_DEFAULT);
// 禁掉双指缩放与双击放大(网页里能放大就是这里没设)
// 只通过 http://127.0.0.1 访问本地页面,不需要 file:// 与 content://
s.setAllowFileAccess(false);
s.setAllowContentAccess(false);
s.setJavaScriptCanOpenWindowsAutomatically(false);
s.setGeolocationEnabled(false);
s.setSavePassword(false);
s.setSupportZoom(false);
s.setBuiltInZoomControls(false);
s.setDisplayZoomControls(false);
s.setTextZoom(100);
// 长按不再弹出「复制/分享」这类浏览器菜单
web.setLongClickable(false);
web.setHapticFeedbackEnabled(false);
web.setWebViewClient(new WebViewClient());
s.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
s.setSafeBrowsingEnabled(false);
}
web.setWebViewClient(new WebViewClient() {
@Override
public boolean shouldOverrideUrlLoading(WebView v, WebResourceRequest req) {
Uri u = req.getUrl();
String host = u.getHost();
if (host == null) return true;
// 本地服务内部跳转放行,其余一律甩给系统浏览器——不然点外链就变成浏览器了
if ("127.0.0.1".equals(host) || "localhost".equals(host)) return false;
try {
startActivity(new Intent(Intent.ACTION_VIEW, u));
} catch (Exception e) {
Toast.makeText(MainActivity.this, "没有能打开该链接的应用", Toast.LENGTH_SHORT).show();
}
return true;
}
@Override
public void onPageFinished(WebView v, String url) {
if (firstPageDone) return;
firstPageDone = true;
showApp();
}
@Override
public void onReceivedError(WebView v, WebResourceRequest req, android.webkit.WebResourceError err) {
if (req.isForMainFrame()) {
Log.e(TAG, "page error: " + err.getDescription());
}
}
});
// 网页里的 <input type=file> 走 WebChromeClient.onShowFileChooser,挂在
// WebViewClient 上编译不过,必须单独设一个 chrome client。
web.setWebChromeClient(new WebChromeClient() {
@Override
public boolean onShowFileChooser(WebView view, ValueCallback<Uri[]> callback,
@@ -112,19 +210,85 @@ public class MainActivity extends Activity {
});
web.addJavascriptInterface(new Bridge(), "Maipulse");
tip = new TextView(this);
tip.setText("正在启动服务…");
tip.setTextColor(Color.parseColor("#5A6A82"));
tip.setTextSize(15);
tip.setPadding(48, 48, 48, 48);
root.addView(web, new FrameLayout.LayoutParams(-1, -1));
root.addView(tip, new FrameLayout.LayoutParams(-1, -1));
splash = buildSplash();
root.addView(splash, new FrameLayout.LayoutParams(-1, -1));
setContentView(root);
}
registerDownloadReceiver();
new Thread(this::bootPython, "maipulse-boot").start();
new Thread(this::waitAndLoad, "maipulse-wait").start();
/** 原生启动页:图标 + 名字 + 一句状态 + 重试按钮(启动失败才出现) */
private View buildSplash() {
LinearLayout box = new LinearLayout(this);
box.setOrientation(LinearLayout.VERTICAL);
box.setGravity(Gravity.CENTER);
box.setBackgroundColor(Color.parseColor(BG));
box.setClickable(true); // 吃掉点击,防止启动过程中点穿到下面的 WebView
ImageView icon = new ImageView(this);
try {
icon.setImageResource(R.mipmap.ic_launcher);
} catch (Throwable ignored) {
}
LinearLayout.LayoutParams ip = new LinearLayout.LayoutParams(dp(72), dp(72));
ip.bottomMargin = dp(18);
box.addView(icon, ip);
TextView title = new TextView(this);
title.setText("maipulse");
title.setTextSize(22);
title.setTypeface(Typeface.DEFAULT_BOLD);
title.setTextColor(Color.parseColor("#1B1C21"));
title.setLetterSpacing(0.06f);
box.addView(title, new LinearLayout.LayoutParams(-2, -2));
splashTip = new TextView(this);
splashTip.setText("正在启动…");
splashTip.setTextSize(13);
splashTip.setTextColor(Color.parseColor("#6B7280"));
LinearLayout.LayoutParams tp = new LinearLayout.LayoutParams(-2, -2);
tp.topMargin = dp(10);
box.addView(splashTip, tp);
splashRetry = new TextView(this);
splashRetry.setText("点这里重试");
splashRetry.setTextSize(14);
splashRetry.setTextColor(Color.parseColor("#D6336C"));
splashRetry.setPadding(dp(18), dp(10), dp(18), dp(10));
splashRetry.setVisibility(View.GONE);
splashRetry.setOnClickListener(v -> {
splashRetry.setVisibility(View.GONE);
splashTip.setText("正在重试…");
web.post(this::prepareAndBoot);
});
LinearLayout.LayoutParams rp = new LinearLayout.LayoutParams(-2, -2);
rp.topMargin = dp(16);
box.addView(splashRetry, rp);
return box;
}
private void tip(final String text) {
runOnUiThread(() -> {
if (splashTip != null) splashTip.setText(text);
});
}
private void showApp() {
if (web != null) web.animate().alpha(1f).setDuration(240).start();
if (splash != null) {
splash.animate().alpha(0f).setDuration(240).withEndAction(() -> {
if (splash != null) splash.setVisibility(View.GONE);
}).start();
}
}
private void fail(final String msg) {
Log.e(TAG, msg);
runOnUiThread(() -> {
if (splash != null) splash.setAlpha(1f);
if (splashTip != null) splashTip.setText(msg);
if (splashRetry != null) splashRetry.setVisibility(View.VISIBLE);
});
}
/* ---------------- 网页可调的接口 ---------------- */
@@ -155,8 +319,11 @@ public class MainActivity extends Activity {
try {
File dir = getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS);
if (dir != null) {
for (File f : dir.listFiles()) {
if (f.getName().endsWith(".apk")) f.delete();
File[] old = dir.listFiles();
if (old != null) {
for (File f : old) {
if (f.getName().endsWith(".apk")) f.delete();
}
}
}
DownloadManager.Request req = new DownloadManager.Request(Uri.parse(url));
@@ -199,7 +366,6 @@ public class MainActivity extends Activity {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
&& !getPackageManager().canRequestPackageInstalls()) {
// 没有「安装未知应用」权限,跳设置页让用户开
Intent s = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:" + getPackageName()));
startActivity(s);
@@ -217,76 +383,157 @@ public class MainActivity extends Activity {
}
}
/* ---------------- Python 启动 ---------------- */
/* ---------------- 启动流程 ---------------- */
private void bootPython() {
try {
File files = getFilesDir();
File appDir = new File(files, "app");
File stamp = new File(files, "asset.rev");
// 内置资源版本号:打包时由 build_apk.ps1 写进 assets/app/asset.rev,
// 每次打包都会变,所以数据更新后一定能触发重建(手写常量太容易忘)。
String want = "";
try (InputStream in = getAssets().open("app/asset.rev")) {
byte[] b = new byte[128];
int n = in.read(b);
if (n > 0) want = new String(b, 0, n, "UTF-8").trim();
} catch (Exception ignored) {
}
String have = "";
/**
* 主线程入口:先把资源复制这一坨挪到后台,再回主线程做 Python.start()。
* Chaquopy 要求 Python.start() 在主线程调用,所以这里不能整个丢进子线程。
*/
private void prepareAndBoot() {
tip("正在准备资源…");
new Thread(() -> {
try {
if (stamp.exists()) {
byte[] b = new byte[(int) stamp.length()];
try (InputStream in = new FileInputStream(stamp)) {
int n = in.read(b);
if (n > 0) have = new String(b, 0, n, "UTF-8").trim();
}
}
} catch (Exception ignored) {
prepareAssets();
} catch (Throwable t) {
fail("资源解包失败:" + t);
return;
}
boolean broken = !appDir.exists()
|| !new File(appDir, "app.py").exists()
|| !new File(appDir, "data/master.json").exists();
if (broken || want.isEmpty() || !want.equals(have)) {
tip("正在释放内置资源");
// 用户导入的机台配置要保住,其余整目录重建(否则旧资源永远不更新)
byte[] conf = null;
File confFile = new File(new File(appDir, "astral"), "config.py");
if (confFile.exists() && confFile.length() > 0) {
conf = new byte[(int) confFile.length()];
try (InputStream in = new FileInputStream(confFile)) {
int n = in.read(conf);
if (n <= 0) conf = null;
}
}
deleteTree(appDir);
copyAssetDir("app", appDir);
if (conf != null) {
File cf = new File(new File(appDir, "astral"), "config.py");
File parent = cf.getParentFile();
if (parent != null && !parent.exists()) parent.mkdirs();
try (OutputStream out = new FileOutputStream(cf)) {
out.write(conf);
}
}
try (OutputStream out = new FileOutputStream(stamp)) {
out.write(want.getBytes("UTF-8"));
}
}
tip("正在加载 Python 运行时…");
runOnUiThread(() -> {
tip("正在加载运行时…");
// 让这句提示先渲染出来,再进阻塞的 Python.start()
web.postDelayed(this::startPython, 60);
});
}, "mp-assets").start();
}
private void startPython() {
try {
if (!Python.isStarted()) {
Python.start(new AndroidPlatform(this));
}
tip("正在启动本地服务…");
Python.getInstance().getModule("android_boot").callAttr("boot", files.getAbsolutePath());
} catch (Throwable t) {
Log.e(TAG, "boot failed", t);
tip("启动失败:" + t);
fail("运行时装载失败:" + t);
return;
}
tip("正在启动本地服务…");
new Thread(this::bootFlask, "mp-flask").start();
new Thread(this::waitAndLoad, "mp-wait").start();
}
/** assets/app -> filesDir/app;内置资源版本号变了就整目录重建,但保住用户导入的配置 */
private void prepareAssets() throws IOException {
File files = getFilesDir();
File appDir = new File(files, "app");
File stamp = new File(files, "asset.rev");
String want = "";
try (InputStream in = getAssets().open("app/asset.rev")) {
byte[] b = new byte[128];
int n = in.read(b);
if (n > 0) want = new String(b, 0, n, "UTF-8").trim();
} catch (Exception ignored) {
}
String have = "";
try {
if (stamp.exists()) {
byte[] b = new byte[(int) stamp.length()];
try (InputStream in = new FileInputStream(stamp)) {
int n = in.read(b);
if (n > 0) have = new String(b, 0, n, "UTF-8").trim();
}
}
} catch (Exception ignored) {
}
boolean broken = !appDir.exists()
|| !new File(appDir, "app.py").exists()
|| !new File(appDir, "data/master.json").exists();
if (!broken && !want.isEmpty() && want.equals(have)) return;
tip("正在释放内置资源");
// 用户填的机台身份写在这两个文件里,重建前先捞出来,重建后写回去
byte[] confAstral = readFile(new File(new File(appDir, "astral"), "config.py"));
byte[] confNet = readFile(new File(new File(appDir, "astral/api"), "MaimaiNet.py"));
deleteTree(appDir);
copyAssetDir("app", appDir);
writeFile(new File(new File(appDir, "astral"), "config.py"), confAstral);
writeFile(new File(new File(appDir, "astral/api"), "MaimaiNet.py"), confNet);
try (OutputStream out = new FileOutputStream(stamp)) {
out.write(want.getBytes("UTF-8"));
}
}
private void tip(final String text) {
runOnUiThread(() -> tip.setText(text));
private byte[] readFile(File f) {
if (f == null || !f.exists() || f.length() <= 0) return null;
try (InputStream in = new FileInputStream(f)) {
byte[] b = new byte[(int) f.length()];
int n = in.read(b);
if (n <= 0) return null;
if (n < b.length) {
byte[] c = new byte[n];
System.arraycopy(b, 0, c, 0, n);
return c;
}
return b;
} catch (Exception e) {
return null;
}
}
private void writeFile(File f, byte[] data) throws IOException {
if (data == null || f == null) return;
File parent = f.getParentFile();
if (parent != null && !parent.exists() && !parent.mkdirs()) {
throw new IOException("mkdirs failed: " + parent);
}
try (OutputStream out = new FileOutputStream(f)) {
out.write(data);
}
}
private void bootFlask() {
try {
Python.getInstance().getModule("android_boot")
.callAttr("boot", getFilesDir().getAbsolutePath());
} catch (Throwable t) {
Log.e(TAG, "flask exited", t);
fail("本地服务异常退出:" + t);
}
}
private void waitAndLoad() {
if (loadRequested) return;
// 最多等 90 秒。Chaquopy 首次装载 + 资源解包在低端机上确实会慢。
for (int i = 0; i < 450; i++) {
if (web == null) return;
try (Socket sock = new Socket()) {
sock.connect(new InetSocketAddress("127.0.0.1", PORT), 300);
loadRequested = true;
runOnUiThread(() -> {
if (web != null) {
web.loadUrl(HOME);
// 兜底:页面迟迟不触发 onPageFinished 也不能一直卡在启动页
web.postDelayed(() -> {
if (!firstPageDone) showApp();
}, 15000);
}
});
return;
} catch (IOException ignored) {
}
try {
Thread.sleep(200);
} catch (InterruptedException e) {
return;
}
if (i == 25) tip("还在启动,稍等…");
}
fail("本地服务没能在 90 秒内启动");
}
private void deleteTree(File f) {
@@ -316,7 +563,6 @@ public class MainActivity extends Activity {
}
private void copyAssetFile(String src, File dst) throws IOException {
// 不跳过已存在文件:整目录重建后旧的残留必须被覆盖
File parent = dst.getParentFile();
if (parent != null && !parent.exists() && !parent.mkdirs()) {
throw new IOException("mkdirs failed: " + parent);
@@ -329,25 +575,7 @@ public class MainActivity extends Activity {
}
}
private void waitAndLoad() {
for (int i = 0; i < 200; i++) {
try (Socket sock = new Socket()) {
sock.connect(new InetSocketAddress("127.0.0.1", PORT), 300);
runOnUiThread(() -> {
tip.setVisibility(View.GONE);
web.loadUrl("http://127.0.0.1:" + PORT + "/");
});
return;
} catch (IOException ignored) {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
return;
}
}
}
tip("服务没有起来,看看日志");
}
/* ---------------- 生命周期 ---------------- */
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
@@ -375,8 +603,23 @@ public class MainActivity extends Activity {
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
if (keyCode == KeyEvent.KEYCODE_BACK && web != null && web.canGoBack()) {
web.goBack();
if (keyCode == KeyEvent.KEYCODE_BACK) {
// 启动页还在 = 还没进主界面,直接交给系统
if (splash != null && splash.getVisibility() == View.VISIBLE
&& splash.getAlpha() > 0.5f) {
return super.onKeyDown(keyCode, event);
}
if (web != null && web.canGoBack()) {
web.goBack();
return true;
}
long now = System.currentTimeMillis();
if (now - lastBackPress < 2000) {
finish();
} else {
lastBackPress = now;
Toast.makeText(this, "再按一次退出", Toast.LENGTH_SHORT).show();
}
return true;
}
return super.onKeyDown(keyCode, event);
@@ -389,6 +632,8 @@ public class MainActivity extends Activity {
} catch (Exception ignored) {
}
if (web != null) {
ViewGroup p = (ViewGroup) web.getParent();
if (p != null) p.removeView(web);
web.destroy();
web = null;
}
@@ -3,13 +3,33 @@
为什么不在 assets 里直接 import:app.py 顶层会 os.makedirs(logs),
assets 是只读的,必须用 Java 侧复制出来的可写副本。
为什么要先探一次端口:Activity 退出后进程可能还在(Flask 线程是常驻的),
用户再点图标进来时 boot() 会被再调用一次。这时 8787 已经有服务在跑,
直接 return 让外壳去连既有服务,比抛 "Address already in use" 干净。
"""
import os
import socket
import sys
PORT = 8787
def _port_in_use():
s = socket.socket()
s.settimeout(0.3)
try:
s.connect(("127.0.0.1", PORT))
return True
except OSError:
return False
finally:
try:
s.close()
except Exception:
pass
def boot(files_dir):
base = os.path.join(files_dir, "app")
if base not in sys.path:
@@ -21,6 +41,10 @@ def boot(files_dir):
os.environ.setdefault("MAIPULSE_PORT", str(PORT))
os.chdir(base)
# 上一次的 Flask 还活着就直接复用,别再起一个
if _port_in_use():
return "already running"
import app as maipulse_app # noqa: E402
# 绑定 127.0.0.1:只有本机 WebView 能访问
maipulse_app.app.run(host="127.0.0.1", port=PORT, debug=False,
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- allowBackup 已经是 false,这里再堵一层:任何域名都不参与云备份和换机迁移。
配置里有机台身份(Keychip / 店铺 / 密钥),不该跟着账号漂到别的设备上。 -->
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
</device-transfer>
</data-extraction-rules>