Compare commits

...

123 Commits

Author SHA1 Message Date
24082dc2a4 加固视频流处理。当前版本为稳定版 2025-12-04 14:20:53 +08:00
746314f0ff 添加最大设备数量限制。优化确认设备离线时间为3秒。 2025-11-27 15:40:33 +08:00
68b985115e 添加未信任状态,优化goios启动逻辑。 此版本为稳定版。 2025-11-27 14:35:58 +08:00
648f6cdfee 优化quart接口。优化添加设备逻辑。 2025-11-26 16:18:02 +08:00
0a200cfc6f 优化启动iproxy时出现的黑框 2025-11-25 20:33:11 +08:00
e56a309825 删除多余文件 2025-11-25 18:13:02 +08:00
3b2b6ce741 合并代码 2025-11-24 20:39:05 +08:00
22da742532 优化逻辑 2025-11-24 20:38:50 +08:00
412624045e 增加翻译接口的重试次数,防止读取信息失败 2025-11-24 20:22:34 +08:00
af4ab583a5 优化底层逻辑 2025-11-21 22:03:35 +08:00
d96a19c659 优化iproxy看门狗 2025-11-20 16:49:37 +08:00
33f261e8af stop tracking build scripts 2025-11-19 20:56:45 +08:00
611f4e46ac 优化iproxy看门狗 2025-11-19 20:47:28 +08:00
51f638f389 项目优化。瘦身 2025-11-19 17:23:41 +08:00
e90dbf14e9 舍弃flask。请求增加H2协议。 2025-11-18 22:09:19 +08:00
37f91c4b8c 调整证书路径 2025-11-17 19:42:27 +08:00
317fc2586a 增加flask启动端口检测,增加H2协议的支持。 2025-11-17 17:13:22 +08:00
13c7930f88 修改偶尔闪退问题 2025-11-14 21:26:16 +08:00
083723edca 修改偶尔异常闪退 2025-11-14 20:07:13 +08:00
621d94cc24 Merge branch 'main' of https://git.hanxiaokj.cn/zw/iOSAI 2025-11-14 17:03:28 +08:00
ecc8dadd0b 修复发送已看过问题 2025-11-14 17:03:25 +08:00
96c62ced12 优化flask获取设备列表的逻辑 2025-11-14 16:58:55 +08:00
bc83fcd9d7 Merge branch 'main' of https://git.hanxiaokj.cn/zw/iOSAI 2025-11-13 19:31:14 +08:00
f799a6df77 新增支持逻辑分辨率为2.0的机型 2025-11-13 19:31:09 +08:00
58d124919b iproxy的小优化 2025-11-13 19:28:57 +08:00
b9e2d86857 Merge branch 'main' of https://git.hanxiaokj.cn/zw/iOSAI 2025-11-12 13:25:07 +08:00
fee1f77e87 新增日志,修改个别国家打招呼发中文问题 2025-11-12 13:25:03 +08:00
35b9d4098d 优化处监听iproxy逻辑,增加安全性 2025-11-12 13:21:36 +08:00
aeea2181cc 调整批量停止任务为并行任务 2025-11-07 21:58:02 +08:00
2aba193e96 调整停止脚本逻辑为直接杀线程 2025-11-07 21:37:25 +08:00
48c86aae56 Merge branch 'main' of https://git.hanxiaokj.cn/zw/iOSAI 2025-11-07 18:32:52 +08:00
86bc5791c6 修改停止线程 2025-11-07 18:32:42 +08:00
2251962306 临时提交 2025-11-07 18:32:17 +08:00
e5a9ccdcb1 修改停止线程的bug 2025-11-07 16:31:18 +08:00
fa539aef73 关闭flask日志 2025-11-07 14:31:07 +08:00
a9c9e39143 同步代码。修复若干bug 2025-11-06 22:26:27 +08:00
9ed5602b86 加固flask看门狗 2025-11-06 21:50:28 +08:00
972c2d0d97 flask增加并发支持 2025-11-06 21:08:39 +08:00
36a57b2015 加固添加设备逻辑。加固iproxy逻辑,加固flask 2025-11-05 21:04:04 +08:00
01580e2fb1 合并代码 2025-11-05 17:08:08 +08:00
01e18bdc03 修复bug 2025-11-05 17:07:51 +08:00
267d87e43d 解决问题:评论可能卡住问题,AI检测到消息不回复 2025-11-05 14:48:05 +08:00
242c2e99c5 解决锁的问题 2025-11-04 20:53:18 +08:00
6337c951fb 新增搜索主播id锁,防止多台手机重复获取同一个id 2025-11-04 20:44:23 +08:00
486c84efb6 添加设备数量限制。修复误杀iproxy逻辑。 2025-11-04 20:33:09 +08:00
81761a576b 修改全部停止的逻辑,增加停止的节点 2025-11-04 14:39:51 +08:00
f49755cb30 修改全部停止,增加关注打招呼特殊翻译功能 2025-11-04 14:07:46 +08:00
1baf9c7fc5 修改返回主页 2025-11-03 20:05:38 +08:00
740f45b88b 修复不能滑动的bug 2025-11-03 19:08:25 +08:00
0ccd4ee97c 临时提交 2025-11-03 14:27:31 +08:00
8e8d676c79 同步代码 2025-10-31 19:45:58 +08:00
929061e4b5 修正检测投屏端口逻辑 2025-10-31 19:41:44 +08:00
bdc6414f69 修改,解决视频无法播放问题 2025-10-31 19:38:19 +08:00
3de4a67304 修改wda。增加网络状态查询接口。 2025-10-31 15:51:09 +08:00
54ee0f7490 增加监听投屏端口 2025-10-31 13:19:55 +08:00
bde168df15 增加定时重加设备逻辑 2025-10-30 20:11:14 +08:00
141d7abe3e Merge branch 'main' of https://git.hanxiaokj.cn/zw/iOSAI 2025-10-29 21:08:51 +08:00
964a5647fc 修复极端情况下的回复问题 2025-10-29 21:08:42 +08:00
9578e098b9 临时提交 2025-10-29 20:11:51 +08:00
177308a69d 修改acList文件存储位置 2025-10-29 19:10:36 +08:00
06d92baf29 合并代码 2025-10-29 16:58:02 +08:00
9b923ebe49 临时提交 2025-10-29 16:56:34 +08:00
7de4fff7cf 修改acList数据的目录 2025-10-29 16:54:18 +08:00
0c78a025f4 临时提交 2025-10-28 21:30:23 +08:00
218e918ec0 合并代码 2025-10-28 18:30:05 +08:00
6ceb583b9e 临时提交 2025-10-28 18:26:22 +08:00
2d61dce1ee 修改翻译逻辑 2025-10-28 18:25:55 +08:00
a233f19924 修改翻译逻辑 2025-10-28 18:19:31 +08:00
caf6ce8deb Resolved conflicts and committed changes 2025-10-28 16:51:31 +08:00
b22efdec01 Update compiled Python files 2025-10-28 15:48:25 +08:00
4ff50ecdfc 修改翻译逻辑 2025-10-28 15:46:46 +08:00
fa6f0ce9df 修复脚本错误 2025-10-28 15:37:20 +08:00
2ad53eb1db 修复bug 2025-10-28 15:09:36 +08:00
2254284625 修复掉视频的bug 2025-10-27 21:44:16 +08:00
7b732aad62 修复flask问题 2025-10-27 17:04:45 +08:00
77d2978db1 添加评论开关 2025-10-27 16:57:25 +08:00
a808747d43 添加评论开关 2025-10-27 16:54:45 +08:00
c902e6f4d3 修复掉设备问题 2025-10-27 16:25:47 +08:00
3c3bde7df9 优化批量停止任务 2025-10-25 01:18:41 +08:00
27426f1f8f 优化 2025-10-25 00:22:16 +08:00
23f63e42c8 修复掉画面的bug 2025-10-24 22:04:28 +08:00
fe3c19fb21 合并代码 2025-10-24 19:15:45 +08:00
fe8c07d2a6 2025-10-24 更新评论功能 2025-10-24 19:04:07 +08:00
ba4bcff7e1 优化设备链接逻辑 2025-10-24 16:24:09 +08:00
34b1d1ec77 修复一点点小bug 2025-10-24 14:36:00 +08:00
dcb3f8e5af 修复启动app接口错误 2025-10-23 22:06:31 +08:00
bfb105f324 修复bug 2025-10-23 21:38:18 +08:00
26057d4afa 合并代码 2025-10-23 19:55:58 +08:00
4966a659aa 调整目录结构 2025-10-23 18:57:37 +08:00
6cf4d9cb03 修复打包问题 2025-10-23 18:53:22 +08:00
2310333a60 临时提交 2025-10-22 19:17:52 +08:00
855a19873e 合并代码。临时上传 2025-10-22 18:24:43 +08:00
a0fe54d504 修复控制功能 2025-10-21 18:19:19 +08:00
bb8029b498 修复bug 2025-10-21 16:55:40 +08:00
ac92747892 临时提交 2025-10-21 15:49:44 +08:00
3da3fabe79 适配iOS高版本 2025-10-21 15:43:02 +08:00
d543c6f757 增加切换账号功能 2025-09-28 20:42:01 +08:00
d876743d3e 优化僵尸iproxy进程 2025-09-28 14:35:09 +08:00
67e0df8af9 临时提交 2025-09-24 19:46:37 +08:00
b94434692c 增加启动校验 2025-09-24 16:32:05 +08:00
b2ec94c62c 修复环境变量路径 2025-09-23 20:33:36 +08:00
0d2782ddb8 完善tidevice逻辑 2025-09-23 20:17:33 +08:00
b0525ce817 合并代码 2025-09-22 19:52:45 +08:00
4dd3eb59a4 优化批量停止脚本 2025-09-22 19:10:58 +08:00
81e3462f15 调整deviceinfo内部逻辑 2025-09-22 14:36:05 +08:00
8f290cf610 临时提交 2025-09-20 21:57:37 +08:00
bfdf684952 修改真实环境到虚拟环境 2025-09-20 20:21:53 +08:00
68b329f4f9 继续优化批量停止脚本 2025-09-20 20:07:16 +08:00
a4effb8058 优化停止脚本,调整停止脚本方案 2025-09-20 14:50:58 +08:00
fa667d2520 临时提交 2025-09-20 13:57:31 +08:00
e94902fedf 修复一些问题 2025-09-19 19:39:32 +08:00
1391a2b37c 修复tidevice闪烁的问题 2025-09-19 15:11:23 +08:00
08f76009b4 增加健壮度。修复wda无法启动的bug。 2025-09-19 14:30:39 +08:00
5c9b6cd4c7 20250904-初步功能已完成 2025-09-18 21:45:31 +08:00
02aa85dae2 保存本地修改 2025-09-18 21:37:50 +08:00
31302fb43e 20250918-新增主播库功能 2025-09-18 21:36:51 +08:00
b9ecce6eeb 20250918-新增主播库功能 2025-09-18 21:31:56 +08:00
811935ac60 优化杀死iproxy逻辑 2025-09-18 21:31:23 +08:00
11e72d0fae 20250918-新增主播库功能 2025-09-18 20:15:07 +08:00
c54a0aceb5 优化停止任务逻辑 2025-09-18 20:09:52 +08:00
9250be8780 增加开发包文件 2025-09-18 13:36:01 +08:00
4569d28811 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	.idea/workspace.xml
#	Module/DeviceInfo.py
#	Utils/ControlUtils.py
#	script/ScriptManager.py
2025-09-18 13:11:50 +08:00
d7e1d993fb 20250904-初步功能已完成 2025-09-18 13:07:11 +08:00
118 changed files with 5616 additions and 1638 deletions

11
.gitignore vendored
View File

@@ -1,11 +1,15 @@
# Byte-compiled / optimized / DLL files
__pycache__/
# Python bytecode & caches
*.pyc
*.pyo
*.pyd
*.py[cod]
*$py.class
build.bat
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
@@ -20,6 +24,8 @@ var/
.installed.cfg
*.egg
out/
Main.build/
Main.dist/
# PyInstaller
# Usually these files are written by a python script from a template
@@ -123,5 +129,4 @@ dmypy.json
# Cython debug symbols
cython_debug/
*.bat
build-tidevice.bat

5
.idea/.gitignore generated vendored
View File

@@ -1,5 +0,0 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/

7
.idea/IOS__AI.iml generated Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<module version="4">
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />
</component>
</module>

View File

@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GitToolBoxProjectSettings">
<option name="commitMessageIssueKeyValidationOverride">
<BoolValueOverride>
<option name="enabled" value="true" />
</BoolValueOverride>
</option>
<option name="commitMessageValidationEnabledOverride">
<BoolValueOverride>
<option name="enabled" value="true" />
</BoolValueOverride>
</option>
</component>
</project>

11
.idea/iOSAI.iml generated
View File

@@ -1,10 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.12 (IOS-AI)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
<module version="4">
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />
</component>
</module>

View File

@@ -4,11 +4,9 @@
<inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoredPackages">
<value>
<list size="4">
<item index="0" class="java.lang.String" itemvalue="PySide6" />
<item index="1" class="java.lang.String" itemvalue="pyusb" />
<item index="2" class="java.lang.String" itemvalue="PyGObject-stubs" />
<item index="3" class="java.lang.String" itemvalue="PyGObject" />
<list size="2">
<item index="0" class="java.lang.String" itemvalue="numpy" />
<item index="1" class="java.lang.String" itemvalue="facebook_wda" />
</list>
</value>
</option>
@@ -16,7 +14,7 @@
<inspection_tool class="PyPep8NamingInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="ignoredErrors">
<list>
<option value="N806" />
<option value="N803" />
</list>
</option>
</inspection_tool>

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JavaScriptLibraryMappings">
<includedPredefinedLibrary name="Node.js Core" />
</component>
</project>

4
.idea/misc.xml generated
View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.12" />
<option name="sdkName" value="Python 3.12 (AI-IOS)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (IOS-AI)" project-jdk-type="Python SDK" />
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12" project-jdk-type="Python SDK" />
</project>

8
.idea/modules.xml generated
View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/iOSAI.iml" filepath="$PROJECT_DIR$/.idea/iOSAI.iml" />
</modules>
</component>
</project>

1
.idea/vcs.xml generated
View File

@@ -2,6 +2,5 @@
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

179
.idea/workspace.xml generated
View File

@@ -5,16 +5,19 @@
</component>
<component name="ChangeListManager">
<list default="true" id="eceeff5e-51c1-459c-a911-d21ec090a423" name="Changes" comment="20250904-初步功能已完成">
<change afterPath="$PROJECT_DIR$/.idea/git_toolbox_blame.xml" afterDir="false" />
<change afterPath="$PROJECT_DIR$/resources/insert_comment2.png" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/iOSAI.iml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/iOSAI.iml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/inspectionProfiles/Project_Default.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/inspectionProfiles/Project_Default.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/misc.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/misc.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/build.bat" beforeDir="false" afterPath="$PROJECT_DIR$/build.bat" afterDir="false" />
<change beforePath="$PROJECT_DIR$/resources/add.png" beforeDir="false" afterPath="$PROJECT_DIR$/resources/add.png" afterDir="false" />
<change beforePath="$PROJECT_DIR$/resources/advertisement.png" beforeDir="false" afterPath="$PROJECT_DIR$/resources/advertisement.png" afterDir="false" />
<change beforePath="$PROJECT_DIR$/resources/back.png" beforeDir="false" afterPath="$PROJECT_DIR$/resources/back.png" afterDir="false" />
<change beforePath="$PROJECT_DIR$/resources/comment.png" beforeDir="false" afterPath="$PROJECT_DIR$/resources/comment.png" afterDir="false" />
<change beforePath="$PROJECT_DIR$/resources/fc18bc21951daf7be012a8a687b00a4de8b24c18/bgv.png" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/resources/icon.ico" beforeDir="false" afterPath="$PROJECT_DIR$/resources/icon.ico" afterDir="false" />
<change beforePath="$PROJECT_DIR$/resources/like.png" beforeDir="false" afterPath="$PROJECT_DIR$/resources/like.png" afterDir="false" />
<change beforePath="$PROJECT_DIR$/resources/search.png" beforeDir="false" afterPath="$PROJECT_DIR$/resources/search.png" afterDir="false" />
<<<<<<< HEAD
<change beforePath="$PROJECT_DIR$/Utils/LogManager.py" beforeDir="false" afterPath="$PROJECT_DIR$/Utils/LogManager.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/resources/00008110-000120603C13801E/bgv.png" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/resources/00008110-000120603C13801E/bgv_comment.png" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/script/ScriptManager.py" beforeDir="false" afterPath="$PROJECT_DIR$/script/ScriptManager.py" afterDir="false" />
=======
>>>>>>> e024b5d (111)
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -44,6 +47,7 @@
</component>
<component name="HighlightingSettingsPerFile">
<setting file="file://$PROJECT_DIR$/build.bat" root0="SKIP_INSPECTION" />
<setting file="file://$PROJECT_DIR$/script/ScriptManager.py" root0="FORCE_HIGHLIGHTING" />
</component>
<component name="ProjectColorInfo">{
&quot;customColor&quot;: &quot;&quot;,
@@ -57,44 +61,54 @@
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"ASKED_ADD_EXTERNAL_FILES": "true",
"ASKED_MARK_IGNORED_FILES_AS_EXCLUDED": "true",
"Python.12.executor": "Run",
"Python.123.executor": "Run",
"Python.Main.executor": "Run",
"Python.Test.executor": "Run",
"Python.test.executor": "Run",
"Python.tidevice_entry.executor": "Run",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
"RunOnceActivity.git.unshallow": "true",
"SHARE_PROJECT_CONFIGURATION_FILES": "true",
"git-widget-placeholder": "main",
"javascript.nodejs.core.library.configured.version": "20.17.0",
"javascript.nodejs.core.library.typings.version": "20.17.58",
"last_opened_file_path": "C:/Users/zhangkai/Desktop/20250916ios/iOSAI/resources",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
"settings.editor.selected.configurable": "preferences.editor.code.editing",
"vue.rearranger.settings.migration": "true"
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;ASKED_ADD_EXTERNAL_FILES&quot;: &quot;true&quot;,
&quot;ASKED_MARK_IGNORED_FILES_AS_EXCLUDED&quot;: &quot;true&quot;,
&quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;,
&quot;Python.12.executor&quot;: &quot;Run&quot;,
&quot;Python.123.executor&quot;: &quot;Run&quot;,
&quot;Python.DeviceInfo.executor&quot;: &quot;Run&quot;,
&quot;Python.IOSActivator.executor&quot;: &quot;Run&quot;,
&quot;Python.Main.executor&quot;: &quot;Run&quot;,
&quot;Python.Test.executor&quot;: &quot;Run&quot;,
&quot;Python.test.executor&quot;: &quot;Run&quot;,
&quot;Python.tidevice_entry.executor&quot;: &quot;Run&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;SHARE_PROJECT_CONFIGURATION_FILES&quot;: &quot;true&quot;,
&quot;git-widget-placeholder&quot;: &quot;main&quot;,
&quot;javascript.nodejs.core.library.configured.version&quot;: &quot;20.17.0&quot;,
&quot;javascript.nodejs.core.library.typings.version&quot;: &quot;20.17.58&quot;,
&quot;last_opened_file_path&quot;: &quot;E:/python/IOSAI/Module/Main.py&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;editing.templates&quot;,
&quot;two.files.diff.last.used.file&quot;: &quot;E:/share/iOSAI/Module/FlaskService.py&quot;,
&quot;two.files.diff.last.used.folder&quot;: &quot;C:/Users/zhangkai/Desktop/last-item/iosai/Utils&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
}
}]]></component>
}</component>
<component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS">
<recent name="E:\python\IOSAI\resources" />
<recent name="E:\code\Python\iOSAi\resources\iproxy" />
<recent name="C:\Users\zhangkai\Desktop\last-item\iosai\Utils" />
<recent name="C:\Users\zhangkai\Desktop\20250916ios\iOSAI\resources" />
</key>
<key name="MoveFile.RECENT_KEYS">
<recent name="E:\code\Python\iOSAi\Module" />
<recent name="E:\code\Python\iOSAi" />
<recent name="E:\Code\python\iOSAI\resources" />
<recent name="E:\Code\python\iOSAI" />
</key>
</component>
<component name="RunManager" selected="Python.Main">
<configuration name="12" type="PythonConfigurationType" factoryName="Python" temporary="true" nameIsGenerated="true">
<configuration name="IOSActivator" type="PythonConfigurationType" factoryName="Python" temporary="true" nameIsGenerated="true">
<module name="iOSAI" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
@@ -103,35 +117,12 @@
<env name="PYTHONUNBUFFERED" value="1" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/Module" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/12.py" />
<option name="PARAMETERS" value="" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="false" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
<configuration name="123" type="PythonConfigurationType" factoryName="Python" temporary="true" nameIsGenerated="true">
<module name="iOSAI" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/123.py" />
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/Module/IOSActivator.py" />
<option name="PARAMETERS" value="" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
@@ -149,6 +140,7 @@
<env name="PYTHONUNBUFFERED" value="1" />
</envs>
<option name="SDK_HOME" value="" />
<option name="SDK_NAME" value="IOSAI" />
<option name="WORKING_DIRECTORY" value="" />
<option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="true" />
@@ -163,65 +155,21 @@
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
<configuration name="Test" type="PythonConfigurationType" factoryName="Python" temporary="true" nameIsGenerated="true">
<module name="iOSAI" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/Utils" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/Utils/Test.py" />
<option name="PARAMETERS" value="" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="false" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
<configuration name="test" type="PythonConfigurationType" factoryName="Python" temporary="true" nameIsGenerated="true">
<module name="iOSAI" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/Utils" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/Utils/test.py" />
<option name="PARAMETERS" value="" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="false" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
<list>
<item itemvalue="Python.Main" />
<item itemvalue="Python.IOSActivator" />
</list>
<recent_temporary>
<list>
<item itemvalue="Python.test" />
<item itemvalue="Python.123" />
<item itemvalue="Python.Test" />
<item itemvalue="Python.12" />
<item itemvalue="Python.IOSActivator" />
</list>
</recent_temporary>
</component>
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-python-sdk-ce6832f46686-7b97d883f26b-com.jetbrains.pycharm.pro.sharedIndexes.bundled-PY-252.25557.178" />
<option value="bundled-js-predefined-1d06a55b98c1-0b3e54e931b4-JavaScript-PY-241.18034.82" />
<option value="bundled-python-sdk-975db3bf15a3-2767605e8bc2-com.jetbrains.pycharm.pro.sharedIndexes.bundled-PY-241.18034.82" />
</set>
</attachedChunks>
</component>
@@ -287,6 +235,12 @@
<workItem from="1757506636968" duration="5910000" />
<workItem from="1757567423145" duration="16668000" />
<workItem from="1757998910052" duration="3676000" />
<workItem from="1758354349526" duration="2512000" />
<workItem from="1758358259572" duration="8994000" />
<workItem from="1761294846575" duration="176000" />
<workItem from="1761295123036" duration="2130000" />
<workItem from="1761302874416" duration="154000" />
<workItem from="1761303188517" duration="637000" />
</task>
<task id="LOCAL-00001" summary="ai 开始测试">
<option name="closed" value="true" />
@@ -366,6 +320,7 @@
<SUITE FILE_PATH="coverage/iOSAI$456.coverage" NAME="456 覆盖结果" MODIFIED="1757654671631" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$" />
<SUITE FILE_PATH="coverage/iOSAI$123__1_.coverage" NAME="123 (1) 覆盖结果" MODIFIED="1756897091135" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$" />
<SUITE FILE_PATH="coverage/iOSAI$tidevice_entry.coverage" NAME="tidevice_entry 覆盖结果" MODIFIED="1757061969626" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$" />
<SUITE FILE_PATH="coverage/iosai$Main.coverage" NAME="Main 覆盖结果" MODIFIED="1758369933536" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="" />
<SUITE FILE_PATH="coverage/iOSAI$ScriptManager.coverage" NAME="ScriptManager 覆盖结果" MODIFIED="1756896057801" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$/script" />
<SUITE FILE_PATH="coverage/iOSAI$Main.coverage" NAME="Main 覆盖结果" MODIFIED="1758120400301" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="" />
<SUITE FILE_PATH="coverage/iOSAI$123.coverage" NAME="123 覆盖结果" MODIFIED="1758115088356" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$" />

View File

@@ -1,5 +1,3 @@
# from getpass import fallback_getpass
# 设备模型
class DeviceModel(object):

View File

@@ -2,15 +2,15 @@ import json
# 返回数据模型
class ResultData(object):
def __init__(self, code=200, data=None, massage="获取成功"):
def __init__(self, code=200, data=None, message="获取成功"):
super(ResultData, self).__init__()
self.code = code
self.data = data
self.massage = massage
self.message = message
def toJson(self):
return json.dumps({
"code": self.code,
"data": self.data,
"massage": self.massage
"message": self.message
}, ensure_ascii=False) # ensure_ascii=False 确保中文不会被转义

View File

@@ -3,14 +3,23 @@ from typing import Dict, Any
from Entity.AnchorModel import AnchorModel
# wda apple bundle id
WdaAppBundleId = "com.yolozsAgent.wda.xctrunner"
WdaAppBundleId = "com.yolojtAgent.wda.xctrunner"
# WdaAppBundleId = "com.yolozsAgent.wda.xctrunner"
# wda投屏端口
wdaScreenPort = 9567
# wda功能端口
wdaFunctionPort = 8567
# 全局主播列表
anchorList: list[AnchorModel] = []
# 线程锁
anchorListLock = threading.Lock()
# 打招呼数据
prologueList: list[str] = []
prologueList = {}
# 评论数据
commentList = []
API_KEY = "app-sdRfZy2by9Kq7uJg7JdOSVr8"
# 本地储存的打招呼数据
localPrologueList = [

View File

@@ -1,408 +1,513 @@
# -*- coding: utf-8 -*-
import json
import os
import signal
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
import wda
import socket
import threading
import time
import subprocess
from pathlib import Path
from typing import List, Dict, Optional
from typing import Dict
import tidevice
import wda
from tidevice import Usbmux, ConnectionType
from tidevice._device import BaseDevice
from Entity.DeviceModel import DeviceModel
from Entity.Variables import WdaAppBundleId
from Entity.Variables import WdaAppBundleId, wdaFunctionPort
from Module.FlaskSubprocessManager import FlaskSubprocessManager
from Module.IOSActivator import IOSActivator
from Utils.LogManager import LogManager
from Utils.SubprocessKit import check_output as sp_check_output, popen as sp_popen
class Deviceinfo(object):
"""设备生命周期管理:以 deviceModelList 为唯一真理源"""
class DeviceInfo:
_instance = None
_instance_lock = threading.Lock()
def __init__(self):
...
# ✅ 新增:连接线程池(最大 6 并发)
self._connect_pool = ThreadPoolExecutor(max_workers=6)
...
# 离线宽限期(保持你原来的数值)
REMOVE_GRACE_SEC = 5.0
if os.name == "nt":
self._si = subprocess.STARTUPINFO()
self._si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
self._si.wShowWindow = subprocess.SW_HIDE # 0
else:
self._si = None
def __new__(cls, *args, **kwargs):
if not cls._instance:
with cls._instance_lock:
if not cls._instance:
cls._instance = super().__new__(cls)
return cls._instance
self.deviceIndex = 0
self.screenProxy = 9110
self.pidList: List[Dict] = [] # 仅记录 iproxy 进程
self.manager = FlaskSubprocessManager.get_instance()
self.deviceModelList: List[DeviceModel] = [] # 根基,不动
self.maxDeviceCount = 6
def __init__(self) -> None:
if getattr(self, "_initialized", False):
return
self._lock = threading.Lock()
self._model_index: Dict[str, DeviceModel] = {} # udid -> model
# ✅ 1. 失踪时间戳记录(替代原来的 miss_count
self._lock = threading.RLock()
self._models: Dict[str, DeviceModel] = {}
self._manager = FlaskSubprocessManager.get_instance()
self.screenPort = 9110
# 设备心跳时间
self._last_seen: Dict[str, float] = {}
self._port_pool: List[int] = []
self._port_in_use: set[int] = set()
# region iproxy 初始化(原逻辑不变)
try:
self.iproxy_path = self._iproxy_path()
self.iproxy_dir = self.iproxy_path.parent
os.environ["PATH"] = str(self.iproxy_dir) + os.pathsep + os.environ.get("PATH", "")
# iproxy 子进程udid -> Popen
self._iproxy_process: Dict[str, subprocess.Popen] = {}
# iproxy HTTP 健康检查失败次数udid -> 连续失败次数
self._iproxy_fail_count: Dict[str, int] = {}
# Windows 下隐藏子进程窗口(给 iproxy 用)
self._creationflags = 0
self._startupinfo = None
if os.name == "nt":
try:
os.add_dll_directory(str(self.iproxy_dir))
# type: ignore[attr-defined]
self._creationflags = subprocess.CREATE_NO_WINDOW
except Exception:
pass
self._creationflags = 0
self._creationflags = 0x08000000 if os.name == "nt" else 0
si = subprocess.STARTUPINFO()
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
si.wShowWindow = 0 # SW_HIDE
self._startupinfo = si
self._popen_kwargs = dict(
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
cwd=str(self.iproxy_dir),
shell=False,
text=True,
creationflags=0x08000000 if os.name == "nt" else 0, # CREATE_NO_WINDOW
encoding="utf-8",
bufsize=1,
)
LogManager.info("DeviceInfo 初始化完成", udid="system")
print("[Init] DeviceInfo 初始化完成")
self._initialized = True
def _spawn_iproxy(udid: str, local_port: int, remote_port: int = 9100) -> subprocess.Popen:
args = [str(self.iproxy_path), "-u", udid, str(local_port), str(remote_port)]
p = subprocess.Popen(args, **self._popen_kwargs)
# ==========================
# 主循环
# ==========================
def listen(self):
LogManager.method_info("进入主循环", "listen", udid="system")
print("[Listen] 开始监听设备上下线...")
def _pipe_to_log(name: str, stream):
try:
for line in iter(stream.readline, ''):
s = line.strip()
if s:
LogManager.info(f"[iproxy {name}] {s}", udid)
except Exception:
pass
threading.Thread(target=_pipe_to_log, args=("STDOUT", p.stdout), daemon=True).start()
threading.Thread(target=_pipe_to_log, args=("STDERR", p.stderr), daemon=True).start()
return p
self._spawn_iproxy = _spawn_iproxy
LogManager.info(f"iproxy 启动器已就绪,目录: {self.iproxy_dir}")
except Exception as e:
self.iproxy_path = None
self.iproxy_dir = None
self._spawn_iproxy = None
LogManager.error(f"初始化 iproxy 失败:{e}")
# endregion
# ------------------------------------------------------------------
# 主监听循环 → 只负责“发现”和“提交任务”
# ------------------------------------------------------------------
def startDeviceListener(self):
MISS_WINDOW = 5.0
while True:
try:
lists = Usbmux().device_list()
usb = Usbmux().device_list()
# 只看 USB 连接的设备
online = {d.udid for d in usb if d.conn_type == ConnectionType.USB}
except Exception as e:
LogManager.warning(f"usbmuxd 连接失败: {e}2 秒后重试")
time.sleep(2)
LogManager.warning(f"[device_list] 异常:{e}", udid="system")
time.sleep(1)
continue
now_udids = {d.udid for d in lists if d.conn_type == ConnectionType.USB}
usb_sn_set = self._usb_enumerate_sn()
now = time.time()
# 1. 失踪判定(同旧逻辑
need_remove = []
# 当前已知的设备(本轮循环开始时
with self._lock:
for udid in list(self._model_index.keys()):
if udid not in now_udids:
last = self._last_seen.get(udid, time.time())
if time.time() - last > MISS_WINDOW and udid not in usb_sn_set:
need_remove.append(udid)
else:
self._last_seen[udid] = time.time()
for udid in need_remove:
self._remove_model(udid)
known = set(self._models.keys())
current_count = len(self._models)
# 2. 发现新设备 → 并发连接
with self._lock:
new_udids = [d.udid for d in lists
if d.conn_type == ConnectionType.USB and
d.udid not in self._model_index and
len(self.deviceModelList) < self.maxDeviceCount]
if new_udids:
futures = {self._connect_pool.submit(self._connect_device_task, udid): udid
for udid in new_udids}
for f in as_completed(futures, timeout=10):
udid = futures[f]
try:
f.result(timeout=8) # 单台 8 s 硬截止
except Exception as e:
LogManager.error(f"连接任务超时/失败: {e}", udid)
# 1. 处理在线设备
for udid in online:
# 更新心跳时间
self._last_seen[udid] = now
time.sleep(1)
# 新设备但数量已达上限
if udid not in known and current_count >= 6:
print(f"[Add] 设备数量已达 6 台,忽略新设备: {udid}")
LogManager.info(
"[Add] 设备数量已达上限(6),忽略新设备",
udid=udid,
)
continue
# ------------------------------------------------------------------
# ✅ 3. USB 层枚举 SN跨平台
# ------------------------------------------------------------------
def _usb_enumerate_sn(self) -> set[str]:
try:
out = sp_check_output(["idevice_id", "-l"], text=True, timeout=3)
return {line.strip() for line in out.splitlines() if line.strip()}
except Exception:
return set()
# 已经在列表里的设备,跳过添加流程
if udid in known:
continue
# ===================== 以下代码与原文件完全一致 =====================
def _wda_health_checker(self):
while True:
time.sleep(1)
with self._lock:
online = [m for m in self.deviceModelList if m.ready]
for model in online:
udid = model.deviceId
if not self._wda_ok(udid):
LogManager.warning(f"WDA 异常,重启通道:{udid}", udid)
with self._lock:
self._remove_model(udid)
self.connectDevice(udid)
# 只对新发现的设备做一次信任检查
try:
if not self._is_trusted(udid):
LogManager.info(
"[Add] 设备未信任或未就绪,跳过本轮添加",
udid=udid,
)
print(f"[Add] 设备未信任或未就绪,跳过: {udid}")
continue
except Exception as e:
LogManager.warning(
f"[Add] 检测设备 {udid} 信任状态异常: {e}",
udid=udid,
)
print(f"[Add] 检测设备 {udid} 信任状态异常: {e}")
continue
def _wda_ok(self, udid: str) -> bool:
try:
c = wda.USBClient(udid, 8100)
st = c.status()
if st.get("state") != "success":
return False
return True
except Exception as e:
LogManager.error(f"WDA health-check 异常:{e}", udid)
return False
# 二次确认数量上限
with self._lock:
if len(self._models) >= 6:
print(f"[Add] 二次检查: 设备数量已达 6 台,忽略新设备: {udid}")
LogManager.info(
"[Add] 二次检查数量上限,忽略新设备",
udid=udid,
)
continue
# -------------------- 增删改查唯一入口(未改动) --------------------
def _has_model(self, udid: str) -> bool:
return udid in self._model_index
# 真正添加设备
try:
self._add_device(udid)
current_count += 1
except Exception as e:
LogManager.warning(
f"[Add] 处理设备 {udid} 异常: {e}",
udid=udid,
)
print(f"[Add] 处理设备 {udid} 异常: {e}")
def _add_model(self, model: DeviceModel):
if model.deviceId in self._model_index:
return
model.ready = True
self.deviceModelList.append(model)
self._model_index[model.deviceId] = model
try:
self.manager.send(model.toDict())
except Exception as e:
LogManager.warning(f"{model.deviceId} 发送上线事件失败:{e}")
LogManager.method_info(f"{model.deviceId} 加入设备成功,当前在线数:{len(self.deviceModelList)}", method="device_count")
# 2. 处理可能离线的设备(只看本轮开始时 known 里的)
for udid in list(known):
if udid not in online:
last = self._last_seen.get(udid, 0)
if now - last > self.REMOVE_GRACE_SEC:
try:
self._remove_device(udid)
except Exception as e:
LogManager.method_error(
f"移除失败:{e}",
"listen",
udid=udid,
)
print(f"[Remove] 移除失败 {udid}: {e}")
def _remove_model(self, udid: str):
print(f"【删】进入删除方法 udid={udid}")
LogManager.method_info(f"【删】进入删除方法 udid={udid}", method="device_count")
with self._lock:
print(f"【删】拿到锁 udid={udid}")
LogManager.method_info(f"【删】拿到锁 udid={udid}", method="device_count")
model = self._model_index.pop(udid, None)
if not model:
print(f"【删】模型已空,直接返回 udid={udid}")
LogManager.method_info(f"【删】模型已空,直接返回 udid={udid}", method="device_count")
return
if model.deleting:
print(f"【删】正在删除中,幂等返回 udid={udid}")
LogManager.method_info(method="device_count", text=f"【删】正在删除中,幂等返回 udid={udid}")
return
model.deleting = True
model.type = 2
print(f"【删】标记 deleting=True udid={udid}")
LogManager.method_info("【删】标记 deleting=True udid={udid}", "device_count")
before = len(self.deviceModelList)
self.deviceModelList = [m for m in self.deviceModelList if m.deviceId != udid]
after = len(self.deviceModelList)
print(f"【删】列表过滤 before={before} → after={after} udid={udid}")
LogManager.method_info(f"【删】列表过滤 before={before} → after={after} udid={udid}", "device_count")
self._port_in_use.discard(model.screenPort)
self._port_pool.append(model.screenPort)
print(f"【删】回收端口 port={model.screenPort} udid={udid}")
LogManager.method_info(f"【删】回收端口 port={model.screenPort} udid={udid}", method="device_count")
to_kill = [item for item in self.pidList if item.get("id") == udid]
self.pidList = [item for item in self.pidList if item.get("id") != udid]
print(f"【删】待杀进程数 count={len(to_kill)} udid={udid}")
LogManager.method_info(f"【删】待杀进程数 count={len(to_kill)} udid={udid}", method="device_count")
for idx, item in enumerate(to_kill, 1):
print(f"【删】杀进程 {idx}/{len(to_kill)} pid={item.get('target').pid} udid={udid}")
LogManager.method_info(f"【删】杀进程 {idx}/{len(to_kill)} pid={item.get('target').pid} udid={udid}", method="device_count")
self._terminate_proc(item.get("target"))
print(f"【删】进程清理完成 udid={udid}")
LogManager.method_info(f"【删】进程清理完成 udid={udid}", method="device_count")
retry = 3
while retry:
# 3. iproxy 看门狗(进程 + HTTP 探活)
try:
self.manager.send(model.toDict())
print(f"【删】下线事件已发送 udid={udid}")
LogManager.method_info(f"【删】下线事件已发送 udid={udid}", method="device_count")
break
self._check_iproxy_health()
except Exception as e:
retry -= 1
print(f"【删】发送事件失败 retry={retry} err={e} udid={udid}")
LogManager.method_error(f"【删】发送事件失败 retry={retry} err={e} udid={udid}", method="device_count")
time.sleep(0.2)
else:
print(f"【删】发送事件彻底失败,主动退出 udid={udid}")
LogManager.method_error(f"【删】发送事件彻底失败,主动退出 udid={udid}", method="device_count")
LogManager.warning(
f"[iproxy] 看门狗异常: {e}",
udid="system",
)
print(f"[iproxy] 看门狗异常: {e}")
print(f"【删】===== 设备 {udid} 删除全流程结束 =====")
LogManager.method_info(f"【删】===== 设备 {udid} 删除全流程结束 =====", method="device_count")
print(len(self.deviceModelList))
LogManager.method_info(f"当前剩余设备数量:{len(self.deviceModelList)}", method="device_count")
time.sleep(1)
# -------------------- 端口分配与回收(未改动) --------------------
def _alloc_port(self) -> int:
if self._port_pool:
port = self._port_pool.pop()
else:
self.screenProxy += 1
port = self.screenProxy
self._port_in_use.add(port)
return port
def _free_port(self, port: int):
if port in self._port_in_use:
self._port_in_use.remove(port)
self._port_pool.append(port)
# ------------------------------------------------------------------
# 线程池里真正干活的地方(原 connectDevice 逻辑搬过来)
# ------------------------------------------------------------------
def _connect_device_task(self, udid: str):
if not self.is_device_trusted(udid):
LogManager.warning("设备未信任,跳过 WDA 启动", udid)
return
# 判断设备是否信任
def _is_trusted(self, udid: str) -> bool:
try:
d = wda.USBClient(udid, 8100)
except Exception as e:
LogManager.error(f"启动 WDA 失败: {e}", udid)
return
width, height, scale = 0, 0, 1.0
try:
size = d.window_size()
width, height = size.width, size.height
scale = d.scale
except Exception as e:
LogManager.warning(f"读取屏幕信息失败:{e}", udid)
port = self._alloc_port()
model = DeviceModel(udid, port, width, height, scale, type=1)
# 先做完所有 IO再抢锁写内存
try:
d.app_start(WdaAppBundleId)
d.home()
except Exception as e:
LogManager.warning(f"启动/切回桌面失败:{e}", udid)
time.sleep(2) # 原逻辑保留
target = self.relayDeviceScreenPort(udid, port)
# 毫秒级临界区
with self._lock:
if udid in self._model_index: # 并发防重
return
self._add_model(model)
if target:
self.pidList.append({"target": target, "id": udid})
# ------------------------------------------------------------------
# 原函数保留(改名即可)
# ------------------------------------------------------------------
def connectDevice(self, udid: str):
"""对外保留接口,实际走线程池"""
self._connect_pool.submit(self._connect_device_task, udid)
# -------------------- 工具方法(未改动) --------------------
def is_device_trusted(self, udid: str) -> bool:
try:
d = BaseDevice(udid)
d.get_value("DeviceName")
d = tidevice.Device(udid)
_ = d.product_version
return True
except Exception:
except Exception as e:
msg = str(e)
if "NotTrusted" in msg or "Please trust" in msg or "InvalidHostID" in msg:
print(f"[Trust] 设备未信任udid={udid}, err={msg}")
return False
print(f"[Trust] 检测信任状态出错,当作未信任处理 udid={udid}, err={msg}")
return False
def relayDeviceScreenPort(self, udid: str, port: int) -> Optional[subprocess.Popen]:
if not self._spawn_iproxy:
LogManager.error("iproxy 启动器未就绪", udid)
return None
while self._port_in_use and self._is_port_open(port):
pid = self._get_pid_by_port(port)
if pid and pid != os.getpid():
LogManager.warning(f"端口 {port} 仍被 PID {pid} 占用,尝试释放", udid)
self._kill_pid_gracefully(pid)
else:
break
# ==========================
# 添加设备
# ==========================
def _add_device(self, udid: str):
with self._lock:
if udid in self._models:
print(f"[Add] 已存在,跳过: {udid}")
return
print(f"[Add] 新增设备 {udid}")
# 判断 iOS 版本
try:
p = self._spawn_iproxy(udid, port, 9100)
self._port_in_use.add(port)
LogManager.info(f"启动 iproxy 成功,本地 {port} -> 设备 9100", udid)
return p
t = tidevice.Device(udid)
version_major = float(t.product_version.split(".")[0])
except Exception as e:
LogManager.error(f"启动 iproxy 失败{e}", udid)
return None
print(f"[Add] 获取系统版本失败 {udid}: {e}")
version_major = 0
def _is_port_open(self, port: int) -> bool:
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
return s.connect_ex(("127.0.0.1", port)) == 0
# 分配投屏端口 & 写入模型
with self._lock:
self.screenPort += 1
screen_port = self.screenPort
def _get_pid_by_port(self, port: int) -> Optional[int]:
model = DeviceModel(
deviceId=udid,
screenPort=screen_port,
width=0,
height=0,
scale=0,
type=1,
)
self._models[udid] = model
print(f"[Add] 新设备完成 {udid}, screenPort={screen_port}")
self._manager_send()
# 启动 iproxy投屏转发
try:
if os.name == "nt":
out = sp_check_output(["netstat", "-ano", "-p", "tcp"], text=True)
for line in out.splitlines():
if f"127.0.0.1:{port}" in line and "LISTENING" in line:
return int(line.strip().split()[-1])
self._start_iproxy(udid, screen_port)
except Exception as e:
print(f"[iproxy] 启动失败 {udid}: {e}")
LogManager.warning(f"[iproxy] 启动失败: {e}", udid=udid)
# 启动 WDA
if version_major >= 17.0:
threading.Thread(
target=IOSActivator().activate_ios17,
args=(udid, self._on_wda_ready),
daemon=True,
).start()
else:
try:
tidevice.Device(udid).app_start(WdaAppBundleId)
except Exception as e:
print(f"[Add] 使用 tidevice 启动 WDA 失败 {udid}: {e}")
LogManager.warning(
f"[Add] 使用 tidevice 启动 WDA 失败: {e}",
udid=udid,
)
else:
out = sp_check_output(["lsof", "-t", f"-iTCP:{port}", "-sTCP:LISTEN"], text=True)
return int(out.strip().split()[0])
except Exception:
return None
threading.Thread(
target=self._fetch_screen_and_notify,
args=(udid,),
daemon=True,
).start()
def _kill_pid_gracefully(self, pid: int):
# ==========================
# WDA 启动回调iOS17+
# ==========================
def _on_wda_ready(self, udid: str):
print(f"[WDA] 回调触发,准备获取屏幕信息 udid={udid}")
time.sleep(1)
threading.Thread(
target=self._fetch_screen_and_notify,
args=(udid,),
daemon=True,
).start()
# ==========================
# 通过 WDA 获取屏幕信息
# ==========================
def _screen_info(self, udid: str):
try:
os.kill(pid, signal.SIGTERM)
time.sleep(1)
os.kill(pid, signal.SIGKILL)
except Exception:
pass
c = wda.USBClient(udid, wdaFunctionPort)
size = c.window_size()
def _terminate_proc(self, p: Optional[subprocess.Popen]):
if not p or p.poll() is not None:
w = int(size.width)
h = int(size.height)
s = float(c.scale)
print(f"[Screen] 成功获取屏幕 {w}x{h} scale={s} {udid}")
return w, h, s
except Exception as e:
print(f"[Screen] 获取屏幕失败: {e} udid={udid}")
return 0, 0, 0.0
# ==========================
# 异步获取屏幕尺寸并通知 Flask
# ==========================
def _fetch_screen_and_notify(self, udid: str):
"""
后台线程里多次尝试通过 WDA 获取屏幕尺寸,
成功后更新 model 并发一次 snapshot。
"""
max_retry = 15
interval = 1.0
time.sleep(2.0)
for _ in range(max_retry):
with self._lock:
if udid not in self._models:
print(f"[Screen] 设备已移除,停止获取屏幕信息 udid={udid}")
return
w, h, s = self._screen_info(udid)
if w > 0 and h > 0:
with self._lock:
m = self._models.get(udid)
if not m:
print(f"[Screen] 模型已不存在,无法更新 udid={udid}")
return
m.width = w
m.height = h
m.scale = s
print(f"[Screen] 屏幕信息更新完成,准备推送到 Flask udid={udid}")
try:
self._manager_send()
except Exception as e:
print(f"[Screen] 发送屏幕更新到 Flask 失败 udid={udid}, err={e}")
return
time.sleep(interval)
print(f"[Screen] 多次尝试仍未获取到屏幕信息 udid={udid}")
# ==========================
# iproxy 管理
# ==========================
def _start_iproxy(self, udid: str, local_port: int):
iproxy_path = self._find_iproxy()
p = self._iproxy_process.get(udid)
if p is not None and p.poll() is None:
print(f"[iproxy] 已存在运行中的进程,跳过 {udid}")
return
args = [
iproxy_path,
"-u",
udid,
str(local_port), # 本地端口(投屏)
"9567", # 手机端口go-ios screencast
]
print(f"[iproxy] 启动进程: {args}")
proc = subprocess.Popen(
args,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=self._creationflags,
startupinfo=self._startupinfo,
)
self._iproxy_process[udid] = proc
def _stop_iproxy(self, udid: str):
p = self._iproxy_process.get(udid)
if not p:
return
try:
p.terminate()
p.wait(timeout=3)
try:
p.wait(timeout=2)
except Exception:
p.kill()
except Exception:
pass
self._iproxy_process.pop(udid, None)
print(f"[iproxy] 已停止 {udid}")
def _is_iproxy_http_healthy(self, local_port: int, timeout: float = 1.0) -> bool:
"""
通过向本地 iproxy 转发端口发一个最小的 HTTP 请求,
来判断隧道是否“活着”:
- 正常:能在超时时间内读到一些 HTTP 头 / 任意字节;
- 异常:连接失败、超时、完全收不到字节,都认为不健康。
"""
try:
with socket.create_connection(("127.0.0.1", local_port), timeout=timeout) as s:
s.settimeout(timeout)
req = b"GET / HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n"
s.sendall(req)
data = s.recv(128)
if not data:
return False
if data.startswith(b"HTTP/") or b"\r\n" in data:
return True
# 即使不是标准 HTTP 头,只要有返回字节,也说明隧道有响应
return True
except (socket.timeout, OSError):
return False
except Exception:
return False
def _check_iproxy_health(self):
"""
iproxy 看门狗:
- 先看进程是否存在 / 已退出;
- 再做一次 HTTP 层探活;
- 连续多次失败才重启,避免抖动时频繁重启。
"""
with self._lock:
items = list(self._models.items())
for udid, model in items:
proc = self._iproxy_process.get(udid)
# 1) 进程不存在或已退出:直接重启
if proc is None or proc.poll() is not None:
msg = f"[iproxy] 进程已退出,准备重启 | udid={udid}"
print(msg)
LogManager.warning(msg, "iproxy")
self._iproxy_fail_count[udid] = 0
try:
self._start_iproxy(udid, model.screenPort)
except Exception as e:
msg = f"[iproxy] 重启失败 | udid={udid} | err={e}"
print(msg)
LogManager.warning(msg, "iproxy")
continue
# 2) 进程还在,做一次 HTTP 探活
is_ok = self._is_iproxy_http_healthy(model.screenPort)
if is_ok:
if self._iproxy_fail_count.get(udid):
msg = f"[iproxy] HTTP 探活恢复正常 | udid={udid}"
print(msg)
LogManager.info(msg, "iproxy")
self._iproxy_fail_count[udid] = 0
continue
# 3) HTTP 探活失败:记录一次失败
fail = self._iproxy_fail_count.get(udid, 0) + 1
self._iproxy_fail_count[udid] = fail
msg = f"[iproxy] HTTP 探活失败 {fail} 次 | udid={udid}"
print(msg)
LogManager.warning(msg, "iproxy")
FAIL_THRESHOLD = 3
if fail >= FAIL_THRESHOLD:
msg = f"[iproxy] 连续 {fail} 次 HTTP 探活失败,准备重启 | udid={udid}"
print(msg)
LogManager.warning(msg, "iproxy")
self._iproxy_fail_count[udid] = 0
try:
self._stop_iproxy(udid)
self._start_iproxy(udid, model.screenPort)
except Exception as e:
msg = f"[iproxy] HTTP 探活重启失败 | udid={udid} | err={e}"
print(msg)
LogManager.warning(msg, "iproxy")
# ==========================
# 移除设备
# ==========================
def _remove_device(self, udid: str):
print(f"[Remove] 移除设备 {udid}")
self._stop_iproxy(udid)
with self._lock:
self._models.pop(udid, None)
self._last_seen.pop(udid, None)
self._iproxy_fail_count.pop(udid, None)
self._manager_send()
# ==========================
# 工具方法
# ==========================
def _find_iproxy(self) -> str:
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
name = "iproxy.exe" if os.name == "nt" else "iproxy"
return os.path.join(base, "resources", "iproxy", name)
# ==========================
# 同步数据到 Flask
# ==========================
def _manager_send(self):
try:
self._send_snapshot_to_flask()
except Exception:
try:
if os.name == "posix":
os.killpg(os.getpgid(p.pid), signal.SIGKILL)
else:
p.kill()
p.wait(timeout=2)
self._manager.start()
except Exception:
pass
try:
self._send_snapshot_to_flask()
except Exception:
pass
def _base_dir(self) -> Path:
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent
return Path(__file__).resolve().parents[1]
def _send_snapshot_to_flask(self):
with self._lock:
devices = [m.toDict() for m in self._models.values()]
def _iproxy_path(self) -> Path:
exe = "iproxy.exe" if os.name == "nt" else "iproxy"
base = self._base_dir()
candidates = [base / "resources" / "iproxy" / exe]
for p in candidates:
if p.exists():
return p
raise FileNotFoundError(f"iproxy not found, tried: {[str(c) for c in candidates]}")
payload = json.dumps({"devices": devices}, ensure_ascii=False)
port = int(os.getenv("FLASK_COMM_PORT", "34566"))
with socket.create_connection(("127.0.0.1", port), timeout=1.5) as s:
s.sendall(payload.encode() + b"\n")
print(f"[SNAPSHOT] 已发送 {len(devices)} 台设备")

File diff suppressed because it is too large Load Diff

View File

@@ -1,206 +1,270 @@
import subprocess
import sys
import threading
# -*- coding: utf-8 -*-
import atexit
import json
import os
import socket
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import Optional, Union, Dict, List
import psutil
from Utils.LogManager import LogManager
class FlaskSubprocessManager:
_instance: Optional['FlaskSubprocessManager'] = None
_lock: threading.Lock = threading.Lock()
"""
超稳定版 Flask 子进程守护
- 单线程 watchdog唯一监控点
- 强制端口检测
- 端口不通 / 子进程退出 → 100% 重启
- 完整支持 exe + Python 模式
- 自动恢复设备列表快照
"""
_instance = None
_lock = threading.RLock()
def __new__(cls):
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._init_manager()
return cls._instance
cls._instance._initialize()
return cls._instance
def _init_manager(self):
# ========================= 初始化 =========================
def _initialize(self):
self.process: Optional[subprocess.Popen] = None
self.comm_port = 34566
self._watchdog_running = False
self._stop_event = threading.Event()
self._monitor_thread: Optional[threading.Thread] = None
# 新增:启动前先把可能残留的 Flask 干掉
self._kill_orphan_flask()
atexit.register(self.stop)
LogManager.info("FlaskSubprocessManager 单例已初始化", udid="system")
def _kill_orphan_flask(self):
"""根据端口 34566 把遗留进程全部杀掉"""
self._restart_cooldown = 5 # 每次重启最少间隔
self._restart_fail_threshold = 3 # 端口检查连续失败几次才重启
self._restart_fail_count = 0
self._restart_window = 600 # 10 分钟
self._restart_limit = 5 # 最多次数
self._restart_record: List[float] = []
if os.name == "nt":
si = subprocess.STARTUPINFO()
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
si.wShowWindow = 0
self._si = si
else:
self._si = None
atexit.register(self.stop)
self._kill_orphans()
LogManager.info("FlaskSubprocessManager 初始化完成", udid="flask")
# ========================= 工具 =========================
def _log(self, level, msg):
print(msg)
if level == "info":
LogManager.info(msg, udid="flask")
elif level == "warn":
LogManager.warning(msg, udid="flask")
else:
LogManager.error(msg, udid="flask")
# 杀死残留 python.exe 占用端口
def _kill_orphans(self):
try:
if os.name == "nt":
# Windows
out = subprocess.check_output(
["netstat", "-ano"],
text=True, startupinfo=self._si
)
out = subprocess.check_output(["netstat", "-ano"], text=True)
for line in out.splitlines():
if f"127.0.0.1:{self.comm_port}" in line and "LISTENING" in line:
pid = int(line.strip().split()[-1])
if pid != os.getpid():
subprocess.run(["taskkill", "/F", "/PID", str(pid)],
startupinfo=self._si,
capture_output=True)
else:
# macOS / Linux
out = subprocess.check_output(
["lsof", "-t", f"-iTCP:{self.comm_port}", "-sTCP:LISTEN"],
text=True
)
for pid in map(int, out.split()):
if pid != os.getpid():
os.kill(pid, 9)
subprocess.run(
["taskkill", "/F", "/PID", str(pid)],
capture_output=True
)
self._log("warn", f"[FlaskMgr] 杀死残留 Flask 实例 PID={pid}")
except Exception:
pass
# ---------- 启动 ----------
def _port_alive(self):
"""检测 Flask 与 Quart 的两个端口是否活着"""
def _check(p):
try:
with socket.create_connection(("127.0.0.1", p), timeout=0.4):
return True
except Exception:
return False
return _check(self.comm_port) or _check(self.comm_port + 1)
# ========================= 启动 =========================
# ========================= 启动 =========================
def start(self):
with self._lock:
if self._is_alive():
LogManager.warning("子进程已在运行,无需重复启动", udid="system")
# 已经有一个在跑了就别重复起
if self.process and self.process.poll() is None:
self._log("warn", "[FlaskMgr] Flask 已在运行,跳过")
return
# 设定环境变量,给子进程用
env = os.environ.copy()
env["FLASK_COMM_PORT"] = str(self.comm_port)
exe_path = Path(sys.executable).resolve()
if exe_path.name.lower() in ("python.exe", "pythonw.exe"):
exe_path = Path(sys.argv[0]).resolve()
is_frozen = exe_path.suffix.lower() == ".exe" and exe_path.exists()
# ✅ 正确判断是否是 Nuitka/打包后的 exe
# - 被 Nuitka 打包sys.frozen 会存在/为 True
# - 直接用 python 跑 .pysys.frozen 不存在
is_frozen = bool(getattr(sys, "frozen", False))
if is_frozen:
cmd = [str(exe_path), "--role=flask"]
cwd = str(exe_path.parent)
# 打包后的 exe 模式:直接调用自己
exe = Path(sys.executable).resolve()
cmd = [str(exe), "--role=flask"]
cwd = str(exe.parent)
else:
cmd = [sys.executable, "-u", "-m", "Module.Main", "--role=flask"]
cwd = str(Path(__file__).resolve().parent)
# 开发模式:用 python 去跑 Module/Main.py --role=flask
project_root = Path(__file__).resolve().parents[1]
main_py = project_root / "Module" / "Main.py"
cmd = [sys.executable, "-u", str(main_py), "--role=flask"]
cwd = str(project_root)
LogManager.info(f"准备启动 Flask 子进程: {cmd} cwd={cwd}", udid="system")
self._log("info", f"[FlaskMgr] 启动 Flask: {cmd}")
# 关键:不再自己 open 文件,直接走 LogManager
# 用 PIPE 捕获,再转存到 system 级日志
self.process = subprocess.Popen(
cmd,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
bufsize=1,
env=env,
cwd=cwd,
start_new_session=True
bufsize=1,
startupinfo=self._si,
start_new_session=True,
)
# 守护线程:把子进程 stdout → LogManager.info/system
threading.Thread(target=self._flush_stdout, daemon=True).start()
# 异步吃子进程 stdout,顺便打日志
threading.Thread(target=self._read_stdout, daemon=True).start()
LogManager.info(f"Flask 子进程已启动PID={self.process.pid},端口={self.comm_port}", udid="system")
# 看门狗只需要起一次
if not self._watchdog_running:
threading.Thread(target=self._watchdog_loop, daemon=True).start()
self._watchdog_running = True
if not self._wait_port_open(timeout=10):
LogManager.error("等待端口监听超时,启动失败", udid="system")
self.stop()
raise RuntimeError("Flask 启动后 10 s 内未监听端口")
self._log("info", f"[FlaskMgr] Flask 子进程已启动 PID={self.process.pid}")
self._monitor_thread = threading.Thread(target=self._monitor, daemon=True)
self._monitor_thread.start()
LogManager.info("端口守护线程已启动", udid="system")
# ---------- 实时把子进程 stdout 刷到 system 日志 ----------
def _flush_stdout(self):
def _read_stdout(self):
if not self.process or not self.process.stdout:
return
for line in iter(self.process.stdout.readline, ""):
if line:
LogManager.info(line.rstrip(), udid="system")
self.process.stdout.close()
self._log("info", f"[Flask] {line.rstrip()}")
# ---------- 发送 ----------
def send(self, data: Union[str, Dict, List]) -> bool:
if isinstance(data, (dict, list)):
data = json.dumps(data, ensure_ascii=False)
try:
with socket.create_connection(("127.0.0.1", self.comm_port), timeout=3.0) as s:
s.sendall((data + "\n").encode("utf-8"))
LogManager.info(f"数据已成功发送到 Flask 端口:{self.comm_port}", udid="system")
return True
except Exception as e:
LogManager.error(f"发送失败:{e}", udid="system")
return False
# ---------- 停止 ----------
# ========================= 停止 =========================
def stop(self):
with self._lock:
if not self.process:
return
pid = self.process.pid
LogManager.info(f"正在停止 Flask 子进程 PID={pid}", udid="system")
try:
# 1. 杀整棵树Windows 也适用)
parent = psutil.Process(pid)
for child in parent.children(recursive=True):
child.kill()
parent.kill()
gone, alive = psutil.wait_procs([parent] + parent.children(), timeout=3)
for p in alive:
p.kill() # 保险再补一刀
self.process.wait()
except psutil.NoSuchProcess:
self.process.terminate()
except Exception:
pass
except Exception as e:
LogManager.error(f"停止子进程异常:{e}", udid="system")
finally:
self.process = None
self._stop_event.set()
# ---------- 端口守护 ----------
def _monitor(self):
LogManager.info("守护线程开始运行,周期性检查端口存活", udid="system")
while not self._stop_event.wait(1.0):
if not self._port_alive():
LogManager.error("检测到端口不通,准备重启 Flask", udid="system")
with self._lock:
if self.process and self.process.poll() is None:
self.stop()
try:
self.process.wait(timeout=3)
except Exception:
pass
if self.process.poll() is None:
try:
self.start()
except Exception as e:
LogManager.error(f"自动重启失败:{e}", udid="system")
time.sleep(2)
self.process.kill()
except Exception:
pass
# ---------- 辅助 ----------
def _is_port_busy(self, port: int) -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(0.2)
return s.connect_ex(("127.0.0.1", port)) == 0
self._log("warn", "[FlaskMgr] 已停止 Flask 子进程")
self.process = None
def _port_alive(self) -> bool:
# ========================= 看门狗 =========================
def _watchdog_loop(self):
self._log("info", "[FlaskWD] 看门狗已启动")
while not self._stop_event.is_set():
time.sleep(1.2)
# 1) 子进程退出
if not self.process or self.process.poll() is not None:
self._log("error", "[FlaskWD] Flask 子进程退出,准备重启")
self._restart()
continue
# 2) 端口不通
if not self._port_alive():
self._restart_fail_count += 1
self._log("warn", f"[FlaskWD] 端口检测失败 {self._restart_fail_count}/"
f"{self._restart_fail_threshold}")
if self._restart_fail_count >= self._restart_fail_threshold:
self._restart()
continue
# 3) 端口正常
self._restart_fail_count = 0
# ========================= 重启核心逻辑 =========================
def _restart(self):
now = time.time()
# 10 分钟限频
self._restart_record = [t for t in self._restart_record if now - t < self._restart_window]
if len(self._restart_record) >= self._restart_limit:
self._log("error", "[FlaskWD] 10 分钟内重启次数太多,暂停监控")
return
# 冷却
if self._restart_record and now - self._restart_record[-1] < self._restart_cooldown:
self._log("warn", "[FlaskWD] 冷却中,暂不重启")
return
self._log("warn", "[FlaskWD] >>> 重启 Flask 子进程 <<<")
# 执行重启
try:
with socket.create_connection(("127.0.0.1", self.comm_port), timeout=0.5):
return True
self.stop()
time.sleep(1)
self.start()
self._restart_record.append(now)
self._restart_fail_count = 0
except Exception as e:
self._log("error", f"[FlaskWD] 重启失败: {e}")
# 重启后推送设备快照
self._push_snapshot()
# ========================= 推送设备快照 =========================
def _push_snapshot(self):
"""Flask 重启后重新同步 DeviceInfo 内容"""
try:
from Module.DeviceInfo import DeviceInfo
info = DeviceInfo()
with info._lock:
for m in info._models.values():
self.send(m.toDict())
except Exception:
pass
# ========================= 发送数据 =========================
def send(self, data: Union[str, Dict]):
if isinstance(data, dict):
data = json.dumps(data, ensure_ascii=False)
try:
with socket.create_connection(("127.0.0.1", self.comm_port), timeout=2) as s:
s.sendall((data + "\n").encode())
return True
except Exception:
return False
def _wait_port_open(self, timeout: float) -> bool:
t0 = time.time()
while time.time() - t0 < timeout:
if self._port_alive():
return True
time.sleep(0.2)
return False
def _is_alive(self) -> bool:
return self.process is not None and self.process.poll() is None and self._port_alive()
@classmethod
def get_instance(cls) -> 'FlaskSubprocessManager':
def get_instance(cls):
return cls()

355
Module/IOSActivator.py Normal file
View File

@@ -0,0 +1,355 @@
import os
import sys
import time
import threading
import subprocess
from typing import Optional, Callable
from Entity.Variables import WdaAppBundleId
class IOSActivator:
"""
给 iOS17+ 用的 go-ios 激活器(单例):
- 维护一条全局 tunnel 进程
- 流程tunnel start -> pair(可多次重试) -> image auto(非致命) -> runwda(多次重试+日志判定成功)
- WDA 启动成功后触发回调 on_wda_ready(udid)
"""
# ===== 单例 & 全局 tunnel =====
_instance = None
_instance_lock = threading.Lock()
_tunnel_proc: Optional[subprocess.Popen] = None
_tunnel_lock = threading.Lock()
def __new__(cls, *args, **kwargs):
with cls._instance_lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(
self,
ios_path: Optional[str] = None,
pair_timeout: int = 60, # 配对最多等多久
pair_retry_interval: int = 3, # 配对重试间隔
runwda_max_retry: int = 10, # runwda 最大重试次数
runwda_retry_interval: int = 3, # runwda 重试间隔
runwda_wait_timeout: int = 25 # 单次 runwda 等待“成功日志”的超时时间
):
if getattr(self, "_inited", False):
return
# 运行路径处理(源码 / Nuitka EXE
if "__compiled__" in globals():
base_dir = os.path.dirname(sys.executable)
else:
cur_file = os.path.abspath(__file__)
base_dir = os.path.dirname(os.path.dirname(cur_file))
resource_dir = os.path.join(base_dir, "resources")
if not ios_path:
ios_path = os.path.join(resource_dir, "ios.exe")
self.ios_path = ios_path
self.pair_timeout = pair_timeout
self.pair_retry_interval = pair_retry_interval
self.runwda_max_retry = runwda_max_retry
self.runwda_retry_interval = runwda_retry_interval
self.runwda_wait_timeout = runwda_wait_timeout
self._lock = threading.Lock()
# ========= 关键:这里改成“真正隐藏窗口”的安全版 =========
self._creationflags = 0
self._startupinfo = None
if os.name == "nt":
try:
# 只用 CREATE_NO_WINDOW不搞 DETACHED_PROCESS / NEW_PROCESS_GROUP 之类的骚操作
self._creationflags = subprocess.CREATE_NO_WINDOW # type: ignore[attr-defined]
except Exception:
self._creationflags = 0
si = subprocess.STARTUPINFO()
try:
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW # type: ignore[attr-defined]
except Exception:
# 某些极端环境下可能没有 STARTF_USESHOWWINDOW忽略即可
pass
si.wShowWindow = 0 # SW_HIDE
self._startupinfo = si
# ========= 关键部分结束 =========
self._inited = True
# ===== 通用同步命令执行 =====
def _run(
self,
args,
desc: str = "",
timeout: Optional[int] = None,
check: bool = True,
):
cmd = [self.ios_path] + list(args)
cmd_str = " ".join(cmd)
if desc:
print(f"[ios] 执行命令({desc}): {cmd_str}")
else:
print(f"[ios] 执行命令: {cmd_str}")
try:
proc = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=timeout,
creationflags=self._creationflags,
startupinfo=self._startupinfo,
)
except subprocess.TimeoutExpired:
if check:
raise
return -1, "", "timeout"
out = proc.stdout or ""
err = proc.stderr or ""
if check and proc.returncode != 0:
print(f"[ios] 命令失败({desc}), rc={proc.returncode}")
raise RuntimeError(f"[ios] 命令失败({desc}), returncode={proc.returncode}")
return proc.returncode, out, err
# ===== tunnel 相关 =====
def _drain_process_output(self, proc: subprocess.Popen, name: str):
"""吃掉后台进程输出,防止缓冲区阻塞"""
try:
if proc.stdout:
for line in proc.stdout:
line = line.rstrip()
if line:
print(f"[ios][{name}] {line}")
except Exception as e:
print(f"[ios][{name}] 读取 stdout 异常: {e}")
try:
if proc.stderr:
for line in proc.stderr:
line = line.rstrip()
if line:
print(f"[ios][{name}][stderr] {line}")
except Exception as e:
print(f"[ios][{name}] 读取 stderr 异常: {e}")
def _spawn_tunnel(self):
"""启动 / 复用全局 tunnel不隐藏窗口"""
with IOSActivator._tunnel_lock:
# 已有并且还在跑就复用
if IOSActivator._tunnel_proc is not None and IOSActivator._tunnel_proc.poll() is None:
print("[ios] tunnel 已经在运行,跳过重新启动")
return
cmd = [self.ios_path, "tunnel", "start"]
print("[ios] 启动 go-ios tunnel:", " ".join(cmd))
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
creationflags=self._creationflags, # 0不隐藏
startupinfo=self._startupinfo, # None不隐藏
)
except Exception as e:
print("[ios] 启动 tunnel 失败(忽略):", e)
return
IOSActivator._tunnel_proc = proc
print("[ios] tunnel 启动成功, PID=", proc.pid)
# 后台吃日志
threading.Thread(
target=self._drain_process_output,
args=(proc, "tunnel"),
daemon=True,
).start()
# ===== pair & image =====
def _pair_until_success(self, udid: str):
deadline = time.time() + self.pair_timeout
attempt = 0
while True:
attempt += 1
print(f"[ios] 开始配对设备({udid}),第 {attempt} 次尝试")
rc, out, err = self._run(
["--udid", udid, "pair"],
desc=f"pair({udid})",
timeout=20,
check=False,
)
text = (out or "") + "\n" + (err or "")
# 打印一份完整输出,方便调试
if text.strip():
print("[ios][pair] output:\n", text.strip())
if "Successfully paired" in text:
print(f"[ios] 设备 {udid} 配对成功")
return
if time.time() >= deadline:
raise RuntimeError(f"[ios] 设备 {udid} 在超时时间内配对失败(rc={rc})")
time.sleep(self.pair_retry_interval)
def _mount_dev_image(self, udid: str):
print(f"[ios] 开始为设备 {udid} 挂载开发者镜像 (image auto)")
rc, out, err = self._run(
["--udid", udid, "image", "auto"],
desc=f"image auto({udid})",
timeout=300,
check=False,
)
text = (out or "") + "\n" + (err or "")
text_lower = text.lower()
success_keywords = [
"success mounting image",
"there is already a developer image mounted",
]
if any(k in text_lower for k in success_keywords):
print(f"[ios] 设备 {udid} 开发者镜像挂载完成")
if text.strip():
print("[ios][image auto] output:\n", text.strip())
return
print(f"[ios] 设备 {udid} 挂载开发者镜像可能失败(rc={rc}),输出:\n{text.strip()}")
# ===== runwda关键逻辑 =====
def _run_wda_once_async(self, udid: str, on_wda_ready: Optional[Callable[[str], None]]) -> bool:
"""
单次 runwda
- 异步启动 ios.exe
- 实时读 stdout/stderr
- 捕获关键日志got capabilities / authorized true视为成功
- 超时/进程退出且未成功 -> 失败
"""
cmd = [
self.ios_path,
f"--udid={udid}",
"runwda",
f"--bundleid={WdaAppBundleId}",
f"--testrunnerbundleid={WdaAppBundleId}",
"--xctestconfig=yolo.xctest",
]
print("[ios] 异步启动 runwda:", " ".join(cmd))
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
creationflags=self._creationflags, # 0不隐藏
startupinfo=self._startupinfo,
)
except Exception as e:
print(f"[ios] 启动 runwda 进程失败: {e}")
return False
success_evt = threading.Event()
def _reader(pipe, tag: str):
try:
for raw in pipe:
line = (raw or "").rstrip()
if not line:
continue
print(f"[WDA-LOG] {line}")
lower = line.lower()
# 你实测的“成功特征”
if "got capabilities" in lower or '"authorized":true' in lower:
success_evt.set()
print(f"[ios] 捕获到 WDA 启动成功日志({tag})udid={udid}")
break
except Exception as e:
print(f"[ios] 读取 {tag} 日志异常: {e}")
# 日志线程
if proc.stdout:
threading.Thread(target=_reader, args=(proc.stdout, "stdout"), daemon=True).start()
if proc.stderr:
threading.Thread(target=_reader, args=(proc.stderr, "stderr"), daemon=True).start()
# 等待成功 / 退出 / 超时
start = time.time()
while True:
if success_evt.is_set():
print(f"[ios] WDA 日志确认已启动udid={udid}")
if on_wda_ready:
try:
on_wda_ready(udid)
except Exception as e:
print(f"[WDA] 回调执行异常: {e}")
# 不主动杀进程,让 WDA 挂在那儿
return True
rc = proc.poll()
if rc is not None:
print(f"[ios] runwda 进程退出 rc={rc}未检测到成功日志udid={udid}")
return False
if time.time() - start > self.runwda_wait_timeout:
print(f"[ios] runwda 等待超时({self.runwda_wait_timeout}s)未确认成功udid={udid}")
try:
proc.terminate()
except Exception:
pass
return False
time.sleep(0.2)
def _run_wda_with_retry(self, udid: str, on_wda_ready: Optional[Callable[[str], None]]) -> bool:
for attempt in range(1, self.runwda_max_retry + 1):
print(f"[ios] runwda 尝试 {attempt}/{self.runwda_max_retry}udid={udid}")
ok = self._run_wda_once_async(udid, on_wda_ready)
if ok:
print(f"[ios] runwda 第 {attempt} 次尝试成功udid={udid}")
return True
print(f"[ios] runwda 第 {attempt} 次尝试失败udid={udid}")
if attempt < self.runwda_max_retry:
time.sleep(self.runwda_retry_interval)
print(f"[ios] runwda 多次失败放弃udid={udid}")
return False
# ===== 对外主流程 =====
def activate_ios17(self, udid: str, on_wda_ready: Optional[Callable[[str], None]] = None) -> None:
print(f"[WDA] iOS17+ 激活开始udid={udid}, 回调={on_wda_ready}")
# 1. 先确保 tunnel 在跑
self._spawn_tunnel()
# 2. 配对
try:
self._pair_until_success(udid)
except Exception as e:
print(f"[WDA] pair 失败,终止激活流程 udid={udid}, err={e}")
return
# 3. 挂镜像(非致命)
try:
self._mount_dev_image(udid)
except Exception as e:
print(f"[WDA] 挂载开发者镜像异常(忽略) udid={udid}, err={e}")
# 4. runwda + 回调
ok = self._run_wda_with_retry(udid, on_wda_ready)
if not ok:
print(f"[WDA] runwda 多次失败可能需要手动检查设备udid={udid}")
print(f"[WDA] iOS17+ 激活流程结束不代表一定成功udid={udid}")

View File

@@ -1,13 +1,18 @@
import asyncio
import ctypes
# ===== Main.py 顶部放置(所有 import 之前)=====
import os
import sys
from pathlib import Path
from hypercorn.asyncio import serve
from hypercorn.config import Config
from Module.DeviceInfo import Deviceinfo
from Module.DeviceInfo import DeviceInfo
from Module.FlaskSubprocessManager import FlaskSubprocessManager
from Utils.AiUtils import AiUtils
from Utils.DevDiskImageDeployer import DevDiskImageDeployer
from Utils.LogManager import LogManager
# 确定 exe 或 py 文件所在目录
BASE = Path(getattr(sys, 'frozen', False) and sys.executable or __file__).resolve().parent
LOG_DIR = BASE / "log"
@@ -16,43 +21,145 @@ LOG_DIR.mkdir(exist_ok=True) # 确保 log 目录存在
print(f"日志目录: {LOG_DIR}")
def _run_flask_role():
from Module import FlaskService
port = int(os.getenv("FLASK_COMM_PORT", "34567")) # 固定端口的兜底仍是 34567
app_factory = getattr(FlaskService, "create_app", None)
app = app_factory() if callable(app_factory) else FlaskService.app
app.run(host="0.0.0.0", port=port + 1, debug=False, use_reloader=False)
from Module.FlaskService import get_app, bootstrap_server_side_effects
print("Flask Pid:", os.getpid())
port = int(os.getenv("FLASK_COMM_PORT", "34566")) # 固定端口的兜底仍是 34567
app = get_app()
flaskPort = port + 1
AiUtils.flask_port_free(flaskPort)
bootstrap_server_side_effects()
# ==== 关键:统一获取 resources 目录 ====
if "__compiled__" in globals():
# 被 Nuitka 编译后的 exe 运行时
base_dir = os.path.dirname(sys.executable) # exe 所在目录
else:
# 开发环境,直接跑 .py
cur_file = os.path.abspath(__file__) # Module/Main.py 所在目录
base_dir = os.path.dirname(os.path.dirname(cur_file)) # 回到项目根 iOSAi
resource_dir = os.path.join(base_dir, "resources")
# Hypercorn 配置
config = Config()
config.bind = [f"0.0.0.0:{flaskPort}"]
config.certfile = os.path.join(resource_dir, "server.crt")
config.keyfile = os.path.join(resource_dir, "server.key")
config.alpn_protocols = ["h2", "http/1.1"]
config.workers = 6 # 你机器 4GB → 推荐 34 个 worker
# 直接跑 QuartASGI 原生,不再用 WsgiToAsgi
asyncio.run(serve(app, config))
if "--role=flask" in sys.argv:
_run_flask_role()
sys.exit(0)
def _ensure_wintun_installed():
"""
确保 wintun.dll 已经在系统目录里:
- 优先从当前目录的 resources 中找 wintun.dll
- 如果 System32 中没有,就复制过去(需要管理员权限)
"""
try:
# ==== 关键:统一获取 resources 目录 ====
if "__compiled__" in globals():
# Nuitka 编译后的 exe
base_dir = os.path.dirname(sys.executable) # exe 所在目录
else:
# 开发环境运行 .py
cur_file = os.path.abspath(__file__) # Module/Main.py 所在目录
base_dir = os.path.dirname(os.path.dirname(cur_file)) # 回到 iOSAi 根目录
resource_dir = os.path.join(base_dir, "resources")
src = os.path.join(resource_dir, "wintun.dll")
# 1. 检查源文件是否存在
if not os.path.exists(src):
print(f"[wintun] 未找到资源文件: {src}")
return
# 2. 系统 System32 目录
windir = os.environ.get("WINDIR", r"C:\Windows")
system32 = Path(windir) / "System32"
dst = system32 / "wintun.dll"
# 3. System32 中已经存在则无需复制
if dst.exists():
print(f"[wintun] System32 中已存在: {dst}")
return
# 4. 执行复制
import shutil
print(f"[wintun] 复制 {src} -> {dst}")
shutil.copy2(src, dst)
print("[wintun] 复制完成")
except PermissionError as e:
print(f"[wintun] 权限不足,无法写入 System32{e}")
except Exception as e:
print(f"[wintun] 安装 wintun.dll 时异常: {e}")
# 启动锁
def main(arg):
if len(arg) != 2 or arg[1] != "iosai":
sys.exit(0)
# 判断是否为管理员身份原型
def isAdministrator():
"""
检测当前进程是否具有管理员权限。
- Windows 下调用 Shell32.IsUserAnAdmin()
- 如果不是管理员,直接退出程序
"""
try:
is_admin = ctypes.windll.shell32.IsUserAnAdmin()
except Exception:
# 非 Windows 或无法判断的情况,一律按“非管理员”处理
is_admin = False
if not is_admin:
print("[ERROR] 需要以管理员身份运行本程序!")
sys.exit(0)
return True
# 项目入口
if __name__ == "__main__":
# 检测是否有管理员身份权限
isAdministrator()
# 检测程序合法性
main(sys.argv)
# 清空日志
LogManager.clearLogs()
# 添加iOS开发包到电脑上
deployer = DevDiskImageDeployer(verbose=True)
deployer.deploy_all()
# 复制wintun.dll到system32目录下
_ensure_wintun_installed()
# 启动 Flask 子进程
manager = FlaskSubprocessManager.get_instance()
manager.start()
# 设备监听(即使失败/很快返回,也不会导致主进程退出)
try:
info = Deviceinfo()
info.startDeviceListener()
info = DeviceInfo()
info.listen()
except Exception as e:
print("[WARN] Device listener not running:", e)
# === 保活:阻塞主线程,直到收到 Ctrl+C/关闭 ===
import threading, time, signal
stop = threading.Event()
def _handle(_sig, _frm):
stop.set()
# Windows 上 SIGINT/SIGTERM 都可以拦到
try:
signal.signal(signal.SIGINT, _handle)
@@ -66,3 +173,4 @@ if __name__ == "__main__":
finally:
# 进程退出前记得把子进程关掉
manager.stop()

BIN
SupportFiles/14.0.zip Normal file

Binary file not shown.

BIN
SupportFiles/14.1.zip Normal file

Binary file not shown.

BIN
SupportFiles/14.2.zip Normal file

Binary file not shown.

BIN
SupportFiles/14.3.zip Normal file

Binary file not shown.

BIN
SupportFiles/14.4.zip Normal file

Binary file not shown.

BIN
SupportFiles/14.5.zip Normal file

Binary file not shown.

BIN
SupportFiles/14.6.zip Normal file

Binary file not shown.

BIN
SupportFiles/14.7.zip Normal file

Binary file not shown.

BIN
SupportFiles/14.8.zip Normal file

Binary file not shown.

BIN
SupportFiles/15.0.zip Normal file

Binary file not shown.

BIN
SupportFiles/15.1.zip Normal file

Binary file not shown.

BIN
SupportFiles/15.2.zip Normal file

Binary file not shown.

BIN
SupportFiles/15.3.zip Normal file

Binary file not shown.

BIN
SupportFiles/15.4.zip Normal file

Binary file not shown.

BIN
SupportFiles/15.5.zip Normal file

Binary file not shown.

BIN
SupportFiles/15.6.zip Normal file

Binary file not shown.

BIN
SupportFiles/15.7.zip Normal file

Binary file not shown.

BIN
SupportFiles/15.8.zip Normal file

Binary file not shown.

BIN
SupportFiles/16.0.zip Normal file

Binary file not shown.

BIN
SupportFiles/16.1.zip Normal file

Binary file not shown.

BIN
SupportFiles/16.2.zip Normal file

Binary file not shown.

BIN
SupportFiles/16.3.zip Normal file

Binary file not shown.

BIN
SupportFiles/16.4.zip Normal file

Binary file not shown.

BIN
SupportFiles/16.5.zip Normal file

Binary file not shown.

BIN
SupportFiles/16.6.zip Normal file

Binary file not shown.

BIN
SupportFiles/16.7.zip Normal file

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -3,10 +3,11 @@ import random
import re
import time
from typing import Tuple, List
import tidevice
import wda
from wda import Client
from Entity.Variables import wdaFunctionPort
from Utils.AiUtils import AiUtils
from Utils.LogManager import LogManager
@@ -59,12 +60,20 @@ class ControlUtils(object):
@classmethod
def clickBack(cls, session: Client):
try:
back = session.xpath(
"//*[@label='返回']"
# ① 常见中文文案
"//*[@label='返回' or @label='返回上一屏幕']"
" | "
"//*[@label='返回上一屏幕']"
" | "
"//XCUIElementTypeButton[@visible='true' and @name='TTKProfileNavBarBaseItemComponent' and @label='IconChevronLeftOffsetLTR']"
# ② 英文 / 内部 name / 图标 label 的按钮(仅限 Button且可见
"//XCUIElementTypeButton[@visible='true' and ("
"@name='Back' or @label='Back' or " # 英文
"@name='返回' or @label='返回' or " # 中文
"@label='返回上一屏幕' or " # 中文另一种
"@name='returnButton' or"
"@name='nav_bar_start_back' or " # 内部常见 name
"(@name='TTKProfileNavBarBaseItemComponent' and @label='IconChevronLeftOffsetLTR')" # 你给的特例
")]"
)
if back.exists:
@@ -79,9 +88,58 @@ class ControlUtils(object):
"//Window[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]").exists:
back = session.xpath(
"//Window[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]")
if back.exists:
back.click()
return True
elif session.xpath(
"(//XCUIElementTypeOther[@y='20' and @height='44']//XCUIElementTypeButton[@visible='true'])[1]").exists:
back = session.xpath(
"(//XCUIElementTypeOther[@y='20' and @height='44']//XCUIElementTypeButton[@visible='true'])[1]")
if back.exists:
back.click()
return True
else:
return False
except Exception as e:
print(e)
return False
@classmethod
def isClickBackEnabled(cls, session: Client):
try:
back = session.xpath(
# ① 常见中文文案
"//*[@label='返回' or @label='返回上一屏幕']"
" | "
# ② 英文 / 内部 name / 图标 label 的按钮(仅限 Button且可见
"//XCUIElementTypeButton[@visible='true' and ("
"@name='Back' or @label='Back' or " # 英文
"@name='返回' or @label='返回' or " # 中文
"@label='返回上一屏幕' or " # 中文另一种
"@name='returnButton' or"
"@name='nav_bar_start_back' or " # 内部常见 name
"(@name='TTKProfileNavBarBaseItemComponent' and @label='IconChevronLeftOffsetLTR')" # 你给的特例
")]"
)
if back.exists:
return True
elif session.xpath("//*[@name='nav_bar_start_back']").exists:
back = session.xpath("//*[@name='nav_bar_start_back']")
if back.exists:
return True
elif session.xpath(
"//Window[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]").exists:
back = session.xpath(
"//Window[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]")
if back.exists:
return True
elif session.xpath(
"(//XCUIElementTypeOther[@y='20' and @height='44']//XCUIElementTypeButton[@visible='true'])[1]").exists:
back = session.xpath(
"(//XCUIElementTypeOther[@y='20' and @height='44']//XCUIElementTypeButton[@visible='true'])[1]")
if back.exists:
return True
else:
return False
@@ -92,21 +150,41 @@ class ControlUtils(object):
# 点赞
@classmethod
def clickLike(cls, session: Client, udid):
scale = session.scale
x, y = AiUtils.findImageInScreen("add", udid)
print(x, y)
if x > -1:
LogManager.info("点赞了", udid)
session.click(x // scale, y // scale + 50)
return True
else:
LogManager.info("没有找到目标", udid)
return False
try:
from script.ScriptManager import ScriptManager
width, height, scale = ScriptManager.get_screen_info(udid)
if scale == 3.0:
x, y = AiUtils.findImageInScreen("add", udid)
if x > -1:
LogManager.method_info(f"点赞了,点赞的坐标是:{x // scale, y // scale + 50}", "关注打招呼", udid)
session.click(int(x // scale), int(y // scale + 50))
return True
else:
LogManager.method_info("没有找到目标", "关注打招呼", udid)
return False
else:
x, y = AiUtils.findImageInScreen("like1", udid)
if x > -1:
LogManager.method_info(f"点赞了,点赞的坐标是:{x // scale, y // scale}", "关注打招呼", udid)
session.click(int(x // scale), int(y // scale))
return True
else:
LogManager.method_info("没有找到目标", "关注打招呼", udid)
return False
except Exception as e:
LogManager.method_info(f"点赞出现异常,异常的原因:{e}", "关注打招呼", udid)
raise False
# 点击搜索
@classmethod
def clickSearch(cls, session: Client):
obj = session.xpath("//*[@name='搜索']")
# obj = session.xpath("//*[@name='搜索']")
obj = session(xpath='//*[@name="搜索" or @label="搜索" or @name="Search" or @label="Search"]')
try:
if obj.exists:
obj.click()
@@ -128,16 +206,13 @@ class ControlUtils(object):
# 获取主播详情页的第一个视频
@classmethod
def clickFirstVideoFromDetailPage(cls, session: Client):
# videoCell = session.xpath(
# '//Window/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[2]/Other[1]/ScrollView[1]/Other[1]/CollectionView[1]/Cell[2]')
videoCell = session.xpath(
'(//XCUIElementTypeCollectionView//XCUIElementTypeCell[.//XCUIElementTypeImage[@name="profile_video"]])[1]')
tab = session.xpath(
'//XCUIElementTypeButton[@name="TTKProfileTabVideoButton_0" or contains(@label,"作品") or contains(@name,"作品")]'
).get(timeout=5) # 某些版本 tab.value 可能就是数量;或者 tab.label 类似 “作品 7”
).get(timeout=5) # 某些版本 tab.value 可能就是数量;或者 tab.label 类似 “作品 7”
m = re.search(r"\d+", tab.label)
num = 0
@@ -156,8 +231,6 @@ class ControlUtils(object):
print("没有找到主页的第一个视频")
return False, num
@classmethod
def clickFollow(cls, session, aid):
# 1) 含“关注/已关注/Follow/Following”的首个 cell
@@ -185,11 +258,12 @@ class ControlUtils(object):
left_x = max(1, rect.x - 20)
center_y = rect.y + rect.height // 2
session.tap(left_x, center_y)
@classmethod
def userClickProfile(cls, session, aid):
try:
user_btn = session.xpath("(//XCUIElementTypeButton[@name='用户' and @visible='true'])[1]")
if user_btn:
if user_btn.exists:
user_btn.click()
time.sleep(3)
follow_btn = session.xpath(
@@ -270,6 +344,13 @@ class ControlUtils(object):
session.swipe(center_x, center_y, end_x, end_y, duration_ms / 1000)
print("随机微滑动:", trajectory)
# 向上滑动 脚本内部使用
@classmethod
def swipe_up(cls, client):
client.swipe(200, 350, 200, 250, 0.05)
# 向下滑动,脚本内使用
@classmethod
def swipe_down(cls, udid):
dev = wda.USBClient(udid, wdaFunctionPort)
dev.swipe(200, 250, 200, 350, 0.05)

271
Utils/CountryEnum.py Normal file
View File

@@ -0,0 +1,271 @@
class CountryLanguageMapper:
# 初始化一个字典,映射国家到语言代码
country_to_language = {
"中国大陆": "zh-CN",
"台湾": "zh-TW",
"香港": "zh-TW",
"澳门": "zh-TW",
"美国": "en",
"英国": "en",
"澳大利亚": "en",
"日本": "ja",
"韩国": "ko",
"俄罗斯": "ru",
"法国": "fr",
"德国": "de",
"意大利": "it",
"西班牙": "es",
"墨西哥": "es",
"巴西": "pt",
"葡萄牙": "pt",
"印度": "hi",
"泰国": "th",
"越南": "vi",
"马来西亚": "ms",
"印度尼西亚": "id",
"阿联酋": "ar",
"沙特阿拉伯": "ar",
"埃及": "ar",
"以色列": "he",
"缅甸": "my",
"斯里兰卡": "ta",
"巴基斯坦": "ur",
"孟加拉国": "bn",
"波兰": "pl",
"荷兰": "nl",
"罗马尼亚": "ro",
"土耳其": "tr",
"老挝": "lo",
"乌克兰": "uk",
"芬兰": "fi",
"南非": "af",
"阿尔巴尼亚": "sq",
"安道尔": "ca",
"安提瓜和巴布达": "en",
"阿根廷": "es",
"亚美尼亚": "hy",
"奥地利": "de",
"阿塞拜疆": "az",
"巴哈马": "en",
"巴林": "ar",
"巴巴多斯": "en",
"白俄罗斯": "be",
"比利时": "fr",
"伯利兹": "en",
"贝宁": "fr",
"不丹": "dz",
"玻利维亚": "es",
"波斯尼亚和黑塞哥维那": "bs",
"博茨瓦纳": "en",
"文莱": "ms",
"保加利亚": "bg",
"布基纳法索": "fr",
"布隆迪": "fr",
"柬埔寨": "km",
"喀麦隆": "fr",
"加拿大": "en",
"佛得角": "pt",
"开曼群岛": "en",
"中非共和国": "fr",
"乍得": "fr",
"智利": "es",
"中国": "zh-CN",
"圣诞岛": "en",
"科科斯群岛": "en",
"哥伦比亚": "es",
"科摩罗": "ar",
"刚果": "fr",
"库克群岛": "en",
"哥斯达黎加": "es",
"科特迪瓦": "fr",
"克罗地亚": "hr",
"古巴": "es",
"库拉索": "nl",
"塞浦路斯": "el",
"捷克": "cs",
"丹麦": "da",
"吉布提": "fr",
"多米尼克": "en",
"多米尼加共和国": "es",
"厄瓜多尔": "es",
"萨尔瓦多": "es",
"赤道几内亚": "es",
"厄立特里亚": "ti",
"爱沙尼亚": "et",
"埃斯瓦蒂尼": "en",
"埃塞俄比亚": "am",
"福克兰群岛": "en",
"法罗群岛": "fo",
"斐济": "en",
"法属圭亚那": "fr",
"法属波利尼西亚": "fr",
"法属南部领地": "fr",
"加蓬": "fr",
"冈比亚": "en",
"格鲁吉亚": "ka",
"加纳": "en",
"直布罗陀": "en",
"希腊": "el",
"格陵兰": "kl",
"格林纳达": "en",
"瓜德罗普": "fr",
"关岛": "en",
"危地马拉": "es",
"根西岛": "en",
"几内亚": "fr",
"几内亚比绍": "pt",
"圭亚那": "en",
"海地": "fr",
"赫德岛和麦克唐纳群岛": "en",
"梵蒂冈": "it",
"洪都拉斯": "es",
"中国香港特别行政区": "zh-TW",
"匈牙利": "hu",
"冰岛": "is",
"伊朗": "fa",
"伊拉克": "ar",
"爱尔兰": "en",
"曼岛": "en",
"牙买加": "en",
"泽西岛": "en",
"约旦": "ar",
"哈萨克斯坦": "kk",
"肯尼亚": "en",
"基里巴斯": "en",
"朝鲜": "ko",
"科威特": "ar",
"吉尔吉斯斯坦": "ky",
"拉脱维亚": "lv",
"黎巴嫩": "ar",
"莱索托": "en",
"利比里亚": "en",
"利比亚": "ar",
"列支敦士登": "de",
"立陶宛": "lt",
"卢森堡": "fr",
"中国澳门特别行政区": "zh-TW",
"马达加斯加": "fr",
"马拉维": "en",
"马尔代夫": "dv",
"马里": "fr",
"马耳他": "mt",
"马绍尔群岛": "en",
"马提尼克": "fr",
"毛里塔尼亚": "ar",
"毛里求斯": "en",
"马约特": "fr",
"密克罗尼西亚": "en",
"摩尔多瓦": "ro",
"摩纳哥": "fr",
"蒙古": "mn",
"黑山": "sr",
"蒙特塞拉特": "en",
"摩洛哥": "ar",
"莫桑比克": "pt",
"纳米比亚": "en",
"瑙鲁": "en",
"尼泊尔": "ne",
"新喀里多尼亚": "fr",
"新西兰": "en",
"尼加拉瓜": "es",
"尼日尔": "fr",
"尼日利亚": "en",
"纽埃": "en",
"诺福克岛": "en",
"北马其顿": "mk",
"北马里亚纳群岛": "en",
"挪威": "no",
"阿曼": "ar",
"帕劳": "en",
"巴勒斯坦": "ar",
"巴拿马": "es",
"巴布亚新几内亚": "en",
"巴拉圭": "es",
"秘鲁": "es",
"菲律宾": "tl",
"皮特凯恩群岛": "en",
"波多黎各": "es",
"卡塔尔": "ar",
"留尼汪": "fr",
"卢旺达": "rw",
"圣巴泰勒米": "fr",
"圣赫勒拿": "en",
"圣基茨和尼维斯": "en",
"圣卢西亚": "en",
"法属圣马丁": "fr",
"圣皮埃尔和密克隆": "fr",
"圣文森特和格林纳丁斯": "en",
"萨摩亚": "sm",
"圣马力诺": "it",
"圣多美和普林西比": "pt",
"塞内加尔": "fr",
"塞尔维亚": "sr",
"塞舌尔": "fr",
"塞拉利昂": "en",
"新加坡": "en",
"荷属圣马丁": "nl",
"斯洛伐克": "sk",
"斯洛文尼亚": "sl",
"所罗门群岛": "en",
"索马里": "so",
"南乔治亚岛和南桑威奇群岛": "en",
"南苏丹": "en",
"苏丹": "ar",
"苏里南": "nl",
"斯瓦尔巴群岛和扬马延岛": "no",
"瑞典": "sv",
"瑞士": "de",
"叙利亚": "ar",
"台湾省": "zh-TW",
"塔吉克斯坦": "tg",
"坦桑尼亚": "sw",
"东帝汶": "tet",
"多哥": "fr",
"托克劳": "en",
"汤加": "to",
"特立尼达和多巴哥": "en",
"突尼斯": "ar",
"土库曼斯坦": "tk",
"特克斯和凯科斯群岛": "en",
"图瓦卢": "en",
"乌干达": "en",
"美国本土外小岛屿": "en",
"乌拉圭": "es",
"乌兹别克斯坦": "uz",
"瓦努阿图": "bi",
"委内瑞拉": "es",
"英属维尔京群岛": "en",
"美属维尔京群岛": "en",
"瓦利斯和富图纳": "fr",
"西撒哈拉": "ar",
"也门": "ar",
"赞比亚": "en",
"津巴布韦": "en",
"阿富汗": "fa",
"阿尔及利亚": "ar",
"美属萨摩亚": "en",
"安哥拉": "pt",
"安圭拉": "en",
"南极洲": "en",
"百慕大": "en",
"荷属加勒比区": "nl",
"布韦岛": "no",
"英属印度洋领地": "en",
}
@classmethod
def get_language_code(cls, country):
return cls.country_to_language.get(country)
# 使用示例
if __name__ == "__main__":
mapper = CountryLanguageMapper()
countries = ['英国', '美国', '日本', '未知国家']
for country in countries:
code = mapper.get_language_code(country)
if code:
print(f"{country} 对应的语言代码是 {code}")
else:
print(f"没有找到 {country} 对应的语言代码")

88
Utils/IOSAIStorage.py Normal file
View File

@@ -0,0 +1,88 @@
import json
import os
from pathlib import Path
class IOSAIStorage:
@staticmethod
def _get_iosai_dir() -> Path:
"""获取 C:/Users/<用户名>/IOSAI/ 目录"""
user_dir = Path.home()
iosai_dir = user_dir / "IOSAI"
iosai_dir.mkdir(parents=True, exist_ok=True)
return iosai_dir
@classmethod
def save(cls, data: dict | list, filename: str = "data.json", mode: str = "overwrite") -> Path:
file_path = cls._get_iosai_dir() / filename
file_path.parent.mkdir(parents=True, exist_ok=True)
def _load_json():
try:
return json.loads(file_path.read_text("utf-8"))
except Exception:
return {} if isinstance(data, dict) else []
if mode == "merge" and isinstance(data, dict):
old = _load_json()
if not isinstance(old, dict):
old = {}
old.update(data)
to_write = old
elif mode == "append" and isinstance(data, list):
old = _load_json()
if not isinstance(old, list):
old = []
old.extend(data)
to_write = old
else:
to_write = data # 覆盖
# 原子写入
tmp = file_path.with_suffix(file_path.suffix + ".tmp")
with open(tmp, "w", encoding="utf-8") as f:
json.dump(to_write, f, ensure_ascii=False, indent=2)
os.replace(tmp, file_path)
print(f"[IOSAIStorage] 已写入: {file_path}")
return file_path
@classmethod
def load(cls, filename: str = "data.json") -> dict | list | None:
"""
从 C:/Users/<用户名>/IOSAI/filename 读取数据
"""
file_path = cls._get_iosai_dir() / filename
if not file_path.exists():
print(f"[IOSAIStorage] 文件不存在: {file_path}")
return {}
try:
with open(file_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
print(f"[IOSAIStorage] 读取失败: {e}")
return {}
@classmethod
def overwrite(cls, data: dict | list, filename: str = "data.json") -> Path:
"""
强制覆盖写入数据到 C:/Users/<用户名>/IOSAI/filename
(无论是否存在,都会写入)
"""
file_path = cls._get_iosai_dir() / filename
try:
# "w" 模式本身就是覆盖,但这里单独做一个方法
with open(file_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f"[IOSAIStorage] 已覆盖写入: {file_path}")
except Exception as e:
print(f"[IOSAIStorage] 覆盖失败: {e}")
return file_path

View File

@@ -1,10 +1,9 @@
import os
import json
import os
from pathlib import Path
from Utils.LogManager import LogManager
from pathlib import Path
import portalocker as locker # ① 引入跨平台锁
import portalocker as locker # ① 引入跨平台锁
class JsonUtils:
@staticmethod
@@ -120,11 +119,33 @@ class JsonUtils:
json.dump(data, f, ensure_ascii=False, indent=4)
# --- 新增:通用追加(不做字段校验) ---
# @classmethod
# def append_json_items(cls, items, filename="log/last_message.json"):
# """
# 将 dict 或 [dict, ...] 追加到 JSON 文件(数组)中;不校验字段。
# """
# file_path = Path(filename)
# data = cls._read_json_list(file_path)
#
# # 统一成 list
# if isinstance(items, dict):
# items = [items]
# elif not isinstance(items, list):
# # 既不是 dict 也不是 list直接忽略
# return
#
# # 只接受字典项
# items = [it for it in items if isinstance(it, dict)]
# if not items:
# return
#
# data.extend(items)
#
# # LogManager.method_info(filename,"路径")
# cls._write_json_list(file_path, data)
@classmethod
def append_json_items(cls, items, filename="log/last_message.json"):
"""
将 dict 或 [dict, ...] 追加到 JSON 文件(数组)中;不校验字段。
"""
file_path = Path(filename)
data = cls._read_json_list(file_path)
@@ -132,20 +153,19 @@ class JsonUtils:
if isinstance(items, dict):
items = [items]
elif not isinstance(items, list):
# 既不是 dict 也不是 list直接忽略
return
# 只接受字典
items = [it for it in items if isinstance(it, dict)]
# 只保留 sender 非空的字典
items = [
it for it in items
if isinstance(it, dict) and it.get("sender") != ""
]
if not items:
return
data.extend(items)
LogManager.method_info(filename,"路径")
cls._write_json_list(file_path, data)
@classmethod
def update_json_items(cls, match: dict, patch: dict, filename="log/last_message.json", multi: bool = True) -> int:
"""
@@ -179,31 +199,36 @@ class JsonUtils:
return updated
# @classmethod
# def query_all_json_items(cls, filename="log/last_message.json") -> list:
# """
# 查询 JSON 文件(数组)中的所有项
# :param filename: JSON 文件路径
# :return: list可能为空
# """
# file_path = Path(filename)
# print(file_path)
# data = cls._read_json_list(file_path)
# return data if isinstance(data, list) else []
@classmethod
def query_all_json_items(cls, filename="log/last_message.json") -> list:
def query_all_json_items(cls, filename="log/last_message.json"):
"""
查询 JSON 文件(数组)中的所有项,并剔除 sender 为空的记录
:param filename: JSON 文件路径
:return: list,可能为空
读取 JSON 数组文件,过滤掉 sender 或 text 为空的记录
:param filename: 文件路径
:return: 有效记录列表,可能为空
"""
file_path = Path(filename)
data = cls._read_json_list(file_path)
if not isinstance(data, list):
return []
# 过滤 sender 为空字符串的项
return [item for item in data if isinstance(item, dict) and item.get("sender", "").strip()]
def _is_valid(d):
if not isinstance(d, dict):
return False
sender = d.get("sender") or ""
text = d.get("text") or ""
return (
isinstance(sender, str)
and isinstance(text, str)
and sender.strip() != ""
and text.strip() != ""
)
return [item for item in data if _is_valid(item)]
@classmethod
def delete_json_items(cls,

View File

@@ -26,44 +26,7 @@ def _force_utf8_everywhere():
except Exception:
pass
# _force_utf8_everywhere()
# ========= 全局:强制 UTF-8 + 关闭缓冲(运行期立刻生效) =========
def _force_utf8_everywhere():
os.environ.setdefault("PYTHONUTF8", "1")
# 等价于 -u让 stdout/stderr 无缓冲
os.environ.setdefault("PYTHONUNBUFFERED", "1")
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
# 若是 3.7+,优先用 reconfigure 实时改流
try:
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace",
line_buffering=True, write_through=True)
elif getattr(sys.stdout, "buffer", None):
# 退路:重新包一层,启用行缓冲 + 直写
sys.stdout = io.TextIOWrapper(
sys.stdout.buffer, encoding="utf-8",
errors="replace", line_buffering=True
)
except Exception:
pass
try:
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8", errors="replace",
line_buffering=True, write_through=True)
elif getattr(sys.stderr, "buffer", None):
sys.stderr = io.TextIOWrapper(
sys.stderr.buffer, encoding="utf-8",
errors="replace", line_buffering=True
)
except Exception:
pass
# ===========================================================
_force_utf8_everywhere()
class LogManager:
"""
@@ -231,6 +194,7 @@ class LogManager:
@classmethod
def clearLogs(cls):
print("清空日志")
"""启动时清空 log 目录下所有文件"""
# 先关闭所有 logger 的文件句柄
for _, logger in logging.Logger.manager.loggerDict.items():

243
Utils/OCRUtils.py Normal file
View File

@@ -0,0 +1,243 @@
import os
import cv2
import numpy as np
from typing import List, Tuple, Union, Optional
from PIL import Image
ArrayLikeImage = Union[np.ndarray, str, Image.Image]
class OCRUtils:
@classmethod
def _to_gray(cls, img: ArrayLikeImage) -> np.ndarray:
"""
接受路径/np.ndarray/PIL.Image统一转为灰度 np.ndarray。
"""
# 路径
if isinstance(img, str):
arr = cv2.imread(img, cv2.IMREAD_GRAYSCALE)
if arr is None:
raise FileNotFoundError(f"图像加载失败,请检查路径: {img}")
return arr
# PIL.Image
if isinstance(img, Image.Image):
return cv2.cvtColor(np.array(img.convert("RGB")), cv2.COLOR_RGB2GRAY)
# numpy 数组
if isinstance(img, np.ndarray):
if img.ndim == 2:
return img # 已是灰度
if img.ndim == 3:
return cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
raise ValueError("不支持的图像维度(期望 2D 灰度或 3D BGR/RGB")
raise TypeError("large_image 类型必须是 str / np.ndarray / PIL.Image.Image")
@classmethod
def non_max_suppression(
cls,
boxes: List[List[float]],
scores: Optional[np.ndarray] = None,
overlapThresh: float = 0.5
) -> np.ndarray:
"""
boxes: [ [x1,y1,x2,y2], ... ]
scores: 每个框的置信度(用于“按分数做 NMS”。若为 None则退化为按 y2 排序的经典近似。
返回: 经过 NMS 保留的 boxes(int) ndarray形状 (N,4)
"""
if len(boxes) == 0:
return np.empty((0, 4), dtype=int)
boxes = np.asarray(boxes, dtype=np.float32)
x1, y1, x2, y2 = boxes.T
areas = (x2 - x1 + 1) * (y2 - y1 + 1)
if scores is None:
order = np.argsort(y2) # 经典写法
else:
scores = np.asarray(scores, dtype=np.float32)
order = np.argsort(scores)[::-1] # 分数从高到低
keep = []
while order.size > 0:
i = order[0] if scores is not None else order[-1]
keep.append(i)
rest = order[1:] if scores is not None else order[:-1]
xx1 = np.maximum(x1[i], x1[rest])
yy1 = np.maximum(y1[i], y1[rest])
xx2 = np.minimum(x2[i], x2[rest])
yy2 = np.minimum(y2[i], y2[rest])
w = np.maximum(0, xx2 - xx1 + 1)
h = np.maximum(0, yy2 - yy1 + 1)
inter = w * h
ovr = inter / areas[rest]
inds = np.where(ovr <= overlapThresh)[0]
order = rest[inds]
return boxes[keep].astype(int)
# @classmethod
# def find_template(
# cls,
# template_path: str,
# large_image: ArrayLikeImage,
# threshold: float = 0.8,
# overlapThresh: float = 0.5,
# return_boxes: bool = False
# ) -> Union[List[Tuple[int, int]], Tuple[List[Tuple[int, int]], np.ndarray]]:
# """
# 在 large_image 中查找 template_path 模板的位置。
# - large_image 可为文件路径、np.ndarray 或 PIL.Image
# - threshold: 模板匹配阈值TM_CCOEFF_NORMED
# - overlapThresh: NMS 重叠阈值
# - return_boxes: True 时同时返回保留的框数组 (N,4)
#
# 返回:
# centers 或 (centers, boxes)
# centers: [(cx, cy), ...]
# boxes: [[x1,y1,x2,y2], ...] (np.ndarray, int)
# """
# # 模板(灰度)
# template = cv2.imread(template_path, cv2.IMREAD_GRAYSCALE)
# if template is None:
# raise FileNotFoundError(f"模板图像加载失败,请检查路径: {template_path}")
#
# # 大图(灰度)
# gray = cls._to_gray(large_image)
#
# # 模板尺寸
# tw, th = template.shape[::-1]
#
# # 模板匹配(相关系数归一化)
# result = cv2.matchTemplate(gray, template, cv2.TM_CCOEFF_NORMED)
#
# # 阈值筛选
# ys, xs = np.where(result >= threshold)
# if len(xs) == 0:
# return ([], np.empty((0, 4), dtype=int)) if return_boxes else []
#
# # 收集候选框与分数
# boxes = []
# scores = []
# for (x, y) in zip(xs, ys):
# boxes.append([x, y, x + tw, y + th])
# scores.append(result[y, x])
#
# # 按分数做 NMS
# boxes_nms = cls.non_max_suppression(boxes, scores=np.array(scores), overlapThresh=overlapThresh)
#
# # 计算中心点
# centers = [((x1 + x2) // 2, (y1 + y2) // 2) for (x1, y1, x2, y2) in boxes_nms]
#
#
#
# if return_boxes:
# return centers, boxes_nms
#
#
# return centers
@classmethod
def find_template(
cls,
template_path: str,
large_image: ArrayLikeImage,
threshold: float = 0.8,
overlapThresh: float = 0.5,
return_boxes: bool = False
) -> Union[List[Tuple[int, int]], Tuple[List[Tuple[int, int]], np.ndarray]]:
"""
在 large_image 中查找 template_path 模板的位置。
- large_image 可为文件路径、np.ndarray 或 PIL.Image
- threshold: 模板匹配阈值TM_CCOEFF_NORMED
- overlapThresh: NMS 重叠阈值
- return_boxes: True 时同时返回保留的框数组 (N,4)
若检测结果为空,则在相同阈值下最多重试三次(共 3 次尝试)。
返回:
centers 或 (centers, boxes)
centers: [(cx, cy), ...]
boxes: [[x1,y1,x2,y2], ...] (np.ndarray, int)
"""
if not os.path.isfile(template_path):
print(f"模板文件不存在 → {template_path}")
raise FileNotFoundError(f"模板文件不存在 → {template_path}")
size = os.path.getsize(template_path)
if size == 0:
print(f"模板文件大小为 0 → {template_path} ")
raise ValueError(f"模板文件大小为 0 → {template_path}")
# 模板(灰度)
template = cv2.imread(template_path, cv2.IMREAD_GRAYSCALE)
if template is None:
raise FileNotFoundError(f"模板图像加载失败,请检查路径: {template_path}")
# 大图(灰度)
gray = cls._to_gray(large_image)
# 模板尺寸
tw, th = template.shape[::-1]
# 内部:执行一次匹配并返回 (centers, boxes_nms)
def _match_once(cur_threshold: float):
# 模板匹配(相关系数归一化)
result = cv2.matchTemplate(gray, template, cv2.TM_CCOEFF_NORMED)
# 阈值筛选
ys, xs = np.where(result >= cur_threshold)
if len(xs) == 0:
return [], np.empty((0, 4), dtype=int)
# 收集候选框与分数
boxes = []
scores = []
for (x, y) in zip(xs, ys):
boxes.append([int(x), int(y), int(x + tw), int(y + th)])
scores.append(float(result[y, x]))
# 按分数做 NMS
boxes_nms = cls.non_max_suppression(
boxes,
scores=np.asarray(scores, dtype=np.float32),
overlapThresh=overlapThresh
)
# 计算中心点(转为 Python int
centers = [(int((x1 + x2) // 2), int((y1 + y2) // 2))
for (x1, y1, x2, y2) in boxes_nms]
# 统一为 np.ndarray[int]
boxes_nms = np.asarray(boxes_nms, dtype=int)
return centers, boxes_nms
# ===== 重试控制(最多 3 次)=====
MAX_RETRIES = 3
THRESHOLD_DECAY = 0.0 # 如需越试越宽松,可改为 0.02~0.05;不需要则保持 0
MIN_THRESHOLD = 0.6
cur_threshold = float(threshold)
last_centers, last_boxes = [], np.empty((0, 4), dtype=int)
for attempt in range(MAX_RETRIES):
centers, boxes_nms = _match_once(cur_threshold)
if centers:
if return_boxes:
return centers, boxes_nms
return centers
# 记录最后一次(若最终失败按规范返回空)
last_centers, last_boxes = centers, boxes_nms
# 为下一次尝试准备(这里默认不衰减阈值;如需可打开 THRESHOLD_DECAY
if attempt < MAX_RETRIES - 1 and THRESHOLD_DECAY > 0.0:
cur_threshold = max(MIN_THRESHOLD, cur_threshold - THRESHOLD_DECAY)
# 全部尝试失败
if return_boxes:
return last_centers, last_boxes
return last_centers

View File

@@ -1,6 +1,6 @@
import requests
from Entity.Variables import prologueList
from Utils.JsonUtils import JsonUtils
from Entity.Variables import prologueList, API_KEY
from Utils.IOSAIStorage import IOSAIStorage
from Utils.LogManager import LogManager
BaseUrl = "https://crawlclient.api.yolozs.com/api/common/"
@@ -52,31 +52,95 @@ class Requester():
except Exception as e:
LogManager.method_error(f"翻译失败,报错的原因:{e}", "翻译失败异常")
# 翻译
@classmethod
def translationToChinese(cls, msg):
try:
param = {
"msg": msg,
}
url = "https://ai.yolozs.com/translationToChinese"
result = requests.post(url=url, json=param, verify=False)
LogManager.info(f"翻译 请求的参数:{param}", "翻译")
LogManager.info(f"翻译,状态码:{result.status_code},服务器返回的内容:{result.text}", "翻译")
if result.status_code != 200:
LogManager.error(f"翻译失败,状态码:{result.status_code},服务器返回的内容:{result.text}")
return None
json = result.json()
data = json.get("data")
return data
except Exception as e:
LogManager.method_error(f"翻译失败,报错的原因:{e}", "翻译失败异常")
# ai聊天
@classmethod
def chatToAi(cls, param):
aiConfig = JsonUtils.read_json("aiConfig")
# aiConfig = JsonUtils.read_json("aiConfig")
aiConfig = IOSAIStorage.load("aiConfig.json")
agentName = aiConfig.get("agentName")
guildName = aiConfig.get("guildName")
contactTool = aiConfig.get("contactTool", "")
contact = aiConfig.get("contact", "")
age = aiConfig.get("age", 20)
sex = aiConfig.get("sex", "")
height = aiConfig.get("height", 160)
weight = aiConfig.get("weight", 55)
body_features = aiConfig.get("body_features", "")
nationality = aiConfig.get("nationality", "中国")
personality = aiConfig.get("personality", "")
strengths = aiConfig.get("strengths", "")
inputs = {
"name": agentName,
"Trade_union": guildName,
"contcat_method": contactTool,
"contcat_info": contact
"contcat_info": contact,
"age": age,
"sex": sex,
"height": height,
"weight": weight,
"body_features": body_features,
"nationality": nationality,
"personality": personality,
"strengths": strengths,
}
param["inputs"] = inputs
try:
url = "https://ai.yolozs.com/chat"
result = requests.post(url=url, json=param, verify=False)
# url = "https://ai.yolozs.com/chat"
url = "https://ai.yolozs.com/customchat"
result = requests.post(url=url, json=param, verify=False)
LogManager.method_info(f"ai聊天的参数{param}", "ai聊天")
print(f"ai聊天的参数{param}")
json = result.json()
data = json.get("answer", {})
session_id = json.get("conversation_id", {})
LogManager.method_info(f"ai聊天的参数:{param},ai聊天返回的内容:{result.json()}", "ai聊天")
data = json.get("answer", "")
session_id = json.get("conversation_id", "")
LogManager.method_info(f"ai聊天返回的内容{result.json()}", "ai聊天")
return data, session_id
except Exception as e:

327
Utils/TencentOCRUtils.py Normal file
View File

@@ -0,0 +1,327 @@
# -*- coding: utf-8 -*-
import base64
import hashlib
import hmac
import json
import os
import re
import socket
import time
from datetime import datetime, timezone
from http.client import HTTPSConnection
from typing import Any, Dict, List, Optional
Point = Dict[str, int]
ItemPolygon = Dict[str, int]
class TencentOCR:
"""腾讯云 OCR 封装,自动从环境变量或配置文件加载密钥"""
@staticmethod
def _load_secret() -> Dict[str, str]:
# 优先从环境变量读取
sid = "AKIDXw86q6D8pJYZOEvOm25wZy96oIZcQ1OX"
skey = "ye7MNAj4ub5PVO2TmriLkwtc8QTItGPO"
# 如果没有,就尝试从 ~/.tencent_ocr.json 加载
if not sid or not skey:
cfg_path = os.path.expanduser("~/.tencent_ocr.json")
if os.path.exists(cfg_path):
with open(cfg_path, "r", encoding="utf-8") as f:
cfg = json.load(f)
sid = sid or cfg.get("secret_id")
skey = skey or cfg.get("secret_key")
if not sid or not skey:
raise RuntimeError(
"❌ 未找到腾讯云 OCR 密钥,请设置环境变量 TENCENT_SECRET_ID / TENCENT_SECRET_KEY"
"或在用户目录下创建 ~/.tencent_ocr.json格式{\"secret_id\":\"...\",\"secret_key\":\"...\"}"
)
return {"secret_id": sid, "secret_key": skey}
@staticmethod
def _hmac_sha256(key: bytes, msg: str) -> bytes:
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
@staticmethod
def _strip_data_uri_prefix(b64: str) -> str:
if "," in b64 and b64.strip().lower().startswith("data:"):
return b64.split(",", 1)[1]
return b64
@staticmethod
def _now_ts_and_date():
ts = int(time.time())
date = datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d")
return ts, date
@staticmethod
def recognize(
*,
image_path: Optional[str] = None,
image_bytes: Optional[bytes] = None,
image_url: Optional[str] = None,
region: Optional[str] = None,
token: Optional[str] = None,
action: str = "GeneralBasicOCR",
version: str = "2018-11-19",
service: str = "ocr",
host: str = "ocr.tencentcloudapi.com",
timeout: int = 15,
) -> Dict[str, Any]:
"""
调用腾讯云 OCR三选一image_path / image_bytes / image_url
自动加载密钥(优先环境变量 -> ~/.tencent_ocr.json
"""
# 读取密钥
sec = TencentOCR._load_secret()
secret_id = sec["secret_id"]
secret_key = sec["secret_key"]
assert sum(v is not None for v in (image_path, image_bytes, image_url)) == 1, \
"必须且只能提供 image_path / image_bytes / image_url 之一"
# 1. payload
payload: Dict[str, Any] = {}
if image_url:
payload["ImageUrl"] = image_url
else:
if image_bytes is None:
with open(image_path, "rb") as f:
image_bytes = f.read()
img_b64 = base64.b64encode(image_bytes).decode("utf-8")
img_b64 = TencentOCR._strip_data_uri_prefix(img_b64)
payload["ImageBase64"] = img_b64
payload_str = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
# 2. 参数准备
algorithm = "TC3-HMAC-SHA256"
http_method = "POST"
canonical_uri = "/"
canonical_querystring = ""
content_type = "application/json; charset=utf-8"
signed_headers = "content-type;host;x-tc-action"
timestamp, date = TencentOCR._now_ts_and_date()
credential_scope = f"{date}/{service}/tc3_request"
# 3. 规范请求串
canonical_headers = (
f"content-type:{content_type}\n"
f"host:{host}\n"
f"x-tc-action:{action.lower()}\n"
)
hashed_request_payload = hashlib.sha256(payload_str.encode("utf-8")).hexdigest()
canonical_request = (
f"{http_method}\n{canonical_uri}\n{canonical_querystring}\n"
f"{canonical_headers}\n{signed_headers}\n{hashed_request_payload}"
)
# 4. 签名
hashed_canonical_request = hashlib.sha256(canonical_request.encode("utf-8")).hexdigest()
string_to_sign = (
f"{algorithm}\n{timestamp}\n{credential_scope}\n{hashed_canonical_request}"
)
secret_date = TencentOCR._hmac_sha256(("TC3" + secret_key).encode("utf-8"), date)
secret_service = hmac.new(secret_date, service.encode("utf-8"), hashlib.sha256).digest()
secret_signing = hmac.new(secret_service, b"tc3_request", hashlib.sha256).digest()
signature = hmac.new(secret_signing, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
authorization = (
f"{algorithm} "
f"Credential={secret_id}/{credential_scope}, "
f"SignedHeaders={signed_headers}, "
f"Signature={signature}"
)
# 5. headers
headers = {
"Authorization": authorization,
"Content-Type": content_type,
"Host": host,
"X-TC-Action": action,
"X-TC-Timestamp": str(timestamp),
"X-TC-Version": version,
}
if region:
headers["X-TC-Region"] = region
if token:
headers["X-TC-Token"] = token
# 6. 发请求
try:
conn = HTTPSConnection(host, timeout=timeout)
conn.request("POST", "/", body=payload_str.encode("utf-8"), headers=headers)
resp = conn.getresponse()
raw = resp.read().decode("utf-8", errors="replace")
try:
data = json.loads(raw)
except Exception:
data = {"NonJSONBody": raw}
return {
"http_status": resp.status,
"http_reason": resp.reason,
"headers": dict(resp.getheaders()),
"body": data,
}
except socket.gaierror as e:
return {"error": "DNS_RESOLUTION_FAILED", "detail": str(e)}
except socket.timeout:
return {"error": "NETWORK_TIMEOUT", "detail": f"Timeout after {timeout}s"}
except Exception as e:
return {"error": "REQUEST_FAILED", "detail": str(e)}
finally:
try:
conn.close()
except Exception:
pass
@staticmethod
def _norm(s: str) -> str:
return (s or "").strip().lstrip("@").lower()
@staticmethod
def _rect_from_polygon(poly: List[Point]) -> Optional[ItemPolygon]:
if not poly:
return None
xs = [p["X"] for p in poly]
ys = [p["Y"] for p in poly]
return {"X": min(xs), "Y": min(ys), "Width": max(xs) - min(xs), "Height": max(ys) - min(ys)}
@classmethod
def find_last_name_bbox(cls, ocr: Dict[str, Any], name: str) -> Optional[Dict[str, Any]]:
"""
从 OCR JSON 中找到指定名字的“最后一次”出现并返回坐标信息。
:param ocr: 完整 OCR JSON含 Response.TextDetections
:param name: 前端传入的名字,比如 'lee39160'
:return: dict 或 None例如
{
"index": 21,
"text": "lee39160",
"item": {"X": 248, "Y": 1701, "Width": 214, "Height": 49},
"polygon": [...],
"center": {"x": 355.0, "y": 1725.5}
}
"""
dets = (ocr.get("body") or ocr).get("Response", {}).get("TextDetections", [])
if not dets or not name:
return None
target = cls._norm(name)
found = -1
# 从后往前找最后一个严格匹配
for i in range(len(dets) - 1, -1, -1):
txt = cls._norm(dets[i].get("DetectedText", ""))
if txt == target:
found = i
break
# 兜底:再匹配原始文本(可能带 @
if found == -1:
for i in range(len(dets) - 1, -1, -1):
raw = (dets[i].get("DetectedText") or "").strip().lower()
if raw.lstrip("@") == target:
found = i
break
if found == -1:
return None
det = dets[found]
item: Optional[ItemPolygon] = det.get("ItemPolygon")
poly: List[Point] = det.get("Polygon") or []
# 没有 ItemPolygon 就从 Polygon 算
if not item:
item = cls._rect_from_polygon(poly)
if not item:
return None
center = {"x": item["X"] + item["Width"] / 2.0, "y": item["Y"] + item["Height"] / 2.0}
return {
"index": found,
"text": det.get("DetectedText", ""),
"item": item,
"polygon": poly,
"center": center,
}
@staticmethod
def _get_detections(ocr: Dict[str, Any]) -> List[Dict[str, Any]]:
"""兼容含 body 层的 OCR 结构,提取 TextDetections 列表"""
return (ocr.get("body") or ocr).get("Response", {}).get("TextDetections", []) or []
@staticmethod
def _norm_txt(s: str) -> str:
"""清洗文本:去空格"""
return (s or "").strip()
@classmethod
def slice_texts_between(
cls,
ocr: Dict[str, Any],
start_keyword: str = "切换账号",
end_keyword: str = "添加账号",
*,
username_like: bool = False, # True 时只保留像用户名的文本
min_conf: int = 0 # 置信度下限
) -> List[Dict[str, Any]]:
"""
返回位于 start_keyword 与 end_keyword 之间的所有文本项(不含两端),
每项保留原始 DetectedText、Confidence、ItemPolygon 等信息。
"""
dets = cls._get_detections(ocr)
if not dets:
return []
# 找“切换账号”最后一次出现的下标
start_idx = -1
for i, d in enumerate(dets):
txt = cls._norm_txt(d.get("DetectedText", ""))
if txt == start_keyword:
start_idx = i
# 找“添加账号”第一次出现的下标
end_idx = -1
for i, d in enumerate(dets):
txt = cls._norm_txt(d.get("DetectedText", ""))
if txt == end_keyword:
end_idx = i
break
if start_idx == -1 or end_idx == -1 or end_idx <= start_idx:
return []
# 提取两者之间的内容
mid = []
for d in dets[start_idx + 1:end_idx]:
if int(d.get("Confidence", 0)) < min_conf:
continue
txt = cls._norm_txt(d.get("DetectedText", ""))
if not txt:
continue
mid.append(d)
if not username_like:
return mid
# 只保留像用户名的文本
pat = re.compile(r"^[A-Za-z0-9_.-]{3,}$")
filtered = [d for d in mid if pat.match(cls._norm_txt(d.get("DetectedText", "")))]
return filtered
if __name__ == "__main__":
result = TencentOCR.recognize(
image_path=r"C:\Users\zhangkai\Desktop\last-item\iosai\test.png",
action="GeneralAccurateOCR",
)
print(json.dumps(result, ensure_ascii=False, indent=2))

View File

@@ -1,126 +1,226 @@
import os
import signal
import sys
# -*- coding: utf-8 -*-
import threading
import ctypes
import inspect
import time
import psutil
import subprocess
from pathlib import Path
from threading import Event, Thread
from typing import Dict, Optional
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Dict, Optional, List, Tuple, Any
from Utils.LogManager import LogManager
def _raise_async_exception(tid: int, exc_type) -> int:
if not inspect.isclass(exc_type):
raise TypeError("exc_type must be a class")
return ctypes.pythonapi.PyThreadState_SetAsyncExc(
ctypes.c_long(tid), ctypes.py_object(exc_type)
)
def _kill_thread_by_tid(tid: Optional[int]) -> bool:
if tid is None:
return False
res = _raise_async_exception(tid, SystemExit)
if res == 0:
return False
if res > 1:
_raise_async_exception(tid, None)
raise SystemError("PyThreadState_SetAsyncExc affected multiple threads; reverted.")
return True
class ThreadManager:
"""
对调用方完全透明:
add(udid, thread_obj, stop_event) 保持原签名
stop(udid) 保持原签名
但内部把 thread_obj 当成“壳”,真正拉起的是子进程。
- add(udid, thread_or_target, *args, **kwargs) -> (code, msg)
- stop(udid, join_timeout=2.0, retries=5, wait_step=0.2) -> (code, msg) # 强杀
- batch_stop(udids, join_timeout_each=2.0, retries_each=5, wait_step_each=0.2) -> (code, msg)
- get_thread / get_tid / is_running / list_udids
"""
_pool: Dict[str, psutil.Process] = {}
_lock = threading.Lock()
_threads: Dict[str, threading.Thread] = {}
_lock = threading.RLock()
# ========== 基础 ==========
@classmethod
def add(cls, udid: str, dummy_thread, dummy_event: Event) -> None:
LogManager.method_info(f"【1】入口 udid={udid} 长度={len(udid)}", method="task")
if udid in cls._pool:
LogManager.method_warning(f"{udid} 仍在运行,先强制清理旧任务", method="task")
cls.stop(udid)
LogManager.method_info(f"【2】判断旧任务后 udid={udid} 长度={len(udid)}", method="task")
port = cls._find_free_port()
LogManager.method_info(f"【3】找端口后 udid={udid} 长度={len(udid)}", method="task")
proc = cls._start_worker_process(udid, port)
LogManager.method_info(f"【4】子进程启动后 udid={udid} 长度={len(udid)}", method="task")
cls._pool[udid] = proc
LogManager.method_info(f"【5】已写入字典udid={udid} 长度={len(udid)}", method="task")
def add(cls, udid: str, thread_or_target: Any, *args, **kwargs) -> Tuple[int, str]:
"""
兼容两种用法:
1) add(udid, t) # t 是 threading.Thread 实例
2) add(udid, target, *args, **kwargs) # target 是可调用
返回:(200, "创建任务成功") / (1001, "任务已存在") / (1001, "创建任务失败")
"""
with cls._lock:
exist = cls._threads.get(udid)
if exist and exist.is_alive():
return 1001, "任务已存在"
@classmethod
def stop(cls, udid: str) -> tuple[int, str]:
with cls._lock: # 类级锁
proc = cls._pool.get(udid) # 1. 只读,不删
if proc is None:
return 1001, f"无此任务 {udid}"
if isinstance(thread_or_target, threading.Thread):
t = thread_or_target
try:
t.daemon = True
except Exception:
pass
if not t.name:
t.name = f"task-{udid}"
# 包装 run退出时从表移除
orig_run = t.run
def run_wrapper():
try:
orig_run()
finally:
with cls._lock:
if cls._threads.get(udid) is t:
cls._threads.pop(udid, None)
t.run = run_wrapper # type: ignore
else:
target = thread_or_target
def _wrapper():
try:
target(*args, **kwargs)
finally:
with cls._lock:
cur = cls._threads.get(udid)
if cur is threading.current_thread():
cls._threads.pop(udid, None)
t = threading.Thread(target=_wrapper, daemon=True, name=f"task-{udid}")
try:
proc.terminate()
gone, alive = psutil.wait_procs([proc], timeout=3)
if alive:
for p in alive:
for child in p.children(recursive=True):
child.kill()
p.kill()
psutil.wait_procs(alive, timeout=2)
t.start()
except Exception:
return 1001, "创建任务失败"
# 正常退出
cls._pool.pop(udid)
LogManager.method_info("任务停止成功", method="task")
return 200, f"停止线程成功 {udid}"
cls._threads[udid] = t
# 保留你原有的创建成功日志
try:
LogManager.method_info(f"创建任务成功 [{udid}]线程ID={t.ident}", "task")
except Exception:
pass
return 200, "创建任务成功"
except psutil.NoSuchProcess: # 精准捕获
cls._pool.pop(udid)
LogManager.method_info("进程已自然退出", method="task")
return 200, f"进程已退出 {udid}"
@classmethod
def get_thread(cls, udid: str) -> Optional[threading.Thread]:
with cls._lock:
return cls._threads.get(udid)
except Exception as e: # 真正的异常
LogManager.method_error(f"停止异常: {e}", method="task")
return 1002, f"停止异常 {udid}"
@classmethod
def get_tid(cls, udid: str) -> Optional[int]:
t = cls.get_thread(udid)
return t.ident if t else None
# ------------------------------------------------------
# 以下全是内部工具,外部无需调用
# ------------------------------------------------------
@staticmethod
def _find_free_port(start: int = 50000) -> int:
"""找个随机空闲端口,给子进程当通信口(可选)"""
import socket
for p in range(start, start + 1000):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
if s.connect_ex(("127.0.0.1", p)) != 0:
return p
raise RuntimeError("无可用端口")
@classmethod
def is_running(cls, udid: str) -> bool:
t = cls.get_thread(udid)
return bool(t and t.is_alive())
@staticmethod
def _start_worker_process(udid: str, port: int) -> psutil.Process:
@classmethod
def list_udids(cls) -> List[str]:
with cls._lock:
return list(cls._threads.keys())
# ========== 内部:强杀一次 ==========
@classmethod
def _stop_once(cls, udid: str, join_timeout: float, retries: int, wait_step: float) -> bool:
"""
真正拉起子进程:
打包环境exe --udid=xxx
源码环境python -m Module.Worker --udid=xxx
对指定 udid 执行一次强杀流程;返回 True=已停止/不存在False=仍存活或被拒。
"""
exe_path = Path(sys.executable).resolve()
is_frozen = exe_path.suffix.lower() == ".exe" and exe_path.exists()
with cls._lock:
t = cls._threads.get(udid)
if is_frozen:
# 打包后
cmd = [str(exe_path), "--role=worker", f"--udid={udid}", f"--port={port}"]
cwd = str(exe_path.parent)
if not t:
return True # 视为已停止
main_tid = threading.main_thread().ident
cur_tid = threading.get_ident()
if t.ident in (main_tid, cur_tid):
return False
try:
_kill_thread_by_tid(t.ident)
except Exception:
pass
if join_timeout < 0:
join_timeout = 0.0
t.join(join_timeout)
while t.is_alive() and retries > 0:
evt = threading.Event()
evt.wait(wait_step)
retries -= 1
dead = not t.is_alive()
if dead:
with cls._lock:
if cls._threads.get(udid) is t:
cls._threads.pop(udid, None)
return dead
# ========== 对外stop / batch_stop均返回二元组 ==========
@classmethod
def stop(cls, udid: str, join_timeout: float = 2.0,
retries: int = 5, wait_step: float = 0.2) -> Tuple[int, str]:
"""
强杀单个:返回 (200, "stopped") 或 (1001, "failed")
"""
ok = cls._stop_once(udid, join_timeout, retries, wait_step)
if ok:
return 200, "stopped"
else:
# 源码运行
cmd = [sys.executable, "-u", "-m", "Module.Worker", f"--udid={udid}", f"--port={port}"]
cwd = str(Path(__file__).resolve().parent.parent)
return 1001, "failed"
# 核心CREATE_NO_WINDOW + 独立会话,父进程死也不影响
creation_flags = 0x08000000 if os.name == "nt" else 0
proc = subprocess.Popen(
cmd,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
bufsize=1,
cwd=cwd,
start_new_session=True, # 独立进程组
creationflags=creation_flags
)
# 守护线程:把子进程 stdout 实时打到日志
Thread(target=lambda: ThreadManager._log_stdout(proc, udid), daemon=True).start()
return psutil.Process(proc.pid)
@classmethod
def batch_stop(cls, udids: List[str]) -> Tuple[int, str, List[str]]:
"""
并行批量停止(简化版):
- 只接收 udids 参数
- 其他参数写死join_timeout=2.0, retries=5, wait_step=0.2
- 所有设备同时执行,失败的重试 3 轮,每轮间隔 1 秒
- 返回:
(200, "停止任务成功", [])
(1001, "停止任务失败", [失败udid...])
"""
if not udids:
return 200, "停止任务成功", []
@staticmethod
def _log_stdout(proc: subprocess.Popen, udid: str):
for line in iter(proc.stdout.readline, ""):
if line:
LogManager.info(line.rstrip(), udid)
proc.stdout.close()
join_timeout = 2.0
retries = 5
wait_step = 0.2
retry_rounds = 3
round_interval = 1.0
def _stop_one(u: str) -> Tuple[str, bool]:
ok = cls._stop_once(u, join_timeout, retries, wait_step)
return u, ok
# === 第一轮:并行执行所有设备 ===
fail: List[str] = []
with ThreadPoolExecutor(max_workers=len(udids)) as pool:
futures = [pool.submit(_stop_one, u) for u in udids]
for f in as_completed(futures):
u, ok = f.result()
if not ok:
fail.append(u)
# === 对失败的设备重试 3 轮(每轮间隔 1 秒) ===
for _ in range(retry_rounds):
if not fail:
break
time.sleep(round_interval)
remain: List[str] = []
with ThreadPoolExecutor(max_workers=len(fail)) as pool:
futures = [pool.submit(_stop_one, u) for u in fail]
for f in as_completed(futures):
u, ok = f.result()
if not ok:
remain.append(u)
fail = remain
# === 返回结果 ===
if not fail:
return 200, "停止任务成功", []
else:
return 1001, "停止任务失败", fail

View File

@@ -1,9 +0,0 @@
pyinstaller -F -n tidevice ^
--hidden-import=tidevice._proto ^
--hidden-import=tidevice._instruments ^
--hidden-import=tidevice._usbmux ^
--hidden-import=tidevice._wdaproxy ^
--collect-all tidevice ^
--noconsole ^
--add-data="C:\Users\milk\AppData\Local\Programs\Python\Python312\Lib\site-packages\tidevice;tidevice" ^
tidevice_entry.py

View File

@@ -1,24 +0,0 @@
python -m nuitka "C:\Users\zhangkai\Desktop\20250916ios\iOSAI\Module\Main.py" ^
--standalone ^
--msvc=latest ^
--windows-console-mode=disable ^
--remove-output ^
--output-dir="C:\Users\zhangkai\Desktop\20250916ios\iOSAI\out" ^
--output-filename=IOSAI ^
--include-package=Module,Utils,Entity,script ^
--include-module=flask ^
--include-module=flask_cors ^
--include-module=jinja2 ^
--include-module=werkzeug ^
--include-module=cv2 ^
--include-module=numpy ^
--include-module=lxml ^
--include-module=lxml.etree ^
--include-module=requests ^
--include-module=urllib3 ^
--include-module=certifi ^
--include-module=idna ^
--include-data-dir="C:\Users\zhangkai\Desktop\20250916ios\iOSAI\SupportFiles=SupportFiles" ^
--include-data-dir="C:\Users\zhangkai\Desktop\20250916ios\iOSAI\resources=resources" ^
--include-data-files="C:\Users\zhangkai\Desktop\20250916ios\iOSAI\resources\iproxy\*=resources/iproxy/" ^
--windows-icon-from-ico="C:\Users\zhangkai\Desktop\20250916ios\iOSAI\resources\icon.ico"

View File

@@ -1,5 +0,0 @@
facebook_wda==1.5.1
Flask==3.1.2
flask_cors==6.0.1
Requests==2.32.5
tidevice==0.12.10

BIN
resources/comment2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

BIN
resources/ios.exe Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More