Compare commits
160 Commits
9770ce3ad3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 24082dc2a4 | |||
| 746314f0ff | |||
| 68b985115e | |||
| 648f6cdfee | |||
| 0a200cfc6f | |||
| e56a309825 | |||
| 3b2b6ce741 | |||
| 22da742532 | |||
| 412624045e | |||
| af4ab583a5 | |||
| d96a19c659 | |||
| 33f261e8af | |||
| 611f4e46ac | |||
| 51f638f389 | |||
| e90dbf14e9 | |||
| 37f91c4b8c | |||
| 317fc2586a | |||
| 13c7930f88 | |||
| 083723edca | |||
| 621d94cc24 | |||
| ecc8dadd0b | |||
| 96c62ced12 | |||
| bc83fcd9d7 | |||
| f799a6df77 | |||
| 58d124919b | |||
| b9e2d86857 | |||
| fee1f77e87 | |||
| 35b9d4098d | |||
| aeea2181cc | |||
| 2aba193e96 | |||
| 48c86aae56 | |||
| 86bc5791c6 | |||
| 2251962306 | |||
| e5a9ccdcb1 | |||
| fa539aef73 | |||
| a9c9e39143 | |||
| 9ed5602b86 | |||
| 972c2d0d97 | |||
| 36a57b2015 | |||
| 01580e2fb1 | |||
| 01e18bdc03 | |||
| 267d87e43d | |||
| 242c2e99c5 | |||
| 6337c951fb | |||
| 486c84efb6 | |||
| 81761a576b | |||
| f49755cb30 | |||
| 1baf9c7fc5 | |||
| 740f45b88b | |||
| 0ccd4ee97c | |||
| 8e8d676c79 | |||
| 929061e4b5 | |||
| bdc6414f69 | |||
| 3de4a67304 | |||
| 54ee0f7490 | |||
| bde168df15 | |||
| 141d7abe3e | |||
| 964a5647fc | |||
| 9578e098b9 | |||
| 177308a69d | |||
| 06d92baf29 | |||
| 9b923ebe49 | |||
| 7de4fff7cf | |||
| 0c78a025f4 | |||
| 218e918ec0 | |||
| 6ceb583b9e | |||
| 2d61dce1ee | |||
| a233f19924 | |||
| caf6ce8deb | |||
| b22efdec01 | |||
| 4ff50ecdfc | |||
| fa6f0ce9df | |||
| 2ad53eb1db | |||
| 2254284625 | |||
| 7b732aad62 | |||
| 77d2978db1 | |||
| a808747d43 | |||
| c902e6f4d3 | |||
| 3c3bde7df9 | |||
| 27426f1f8f | |||
| 23f63e42c8 | |||
| fe3c19fb21 | |||
| fe8c07d2a6 | |||
| ba4bcff7e1 | |||
| 34b1d1ec77 | |||
| dcb3f8e5af | |||
| bfb105f324 | |||
| 26057d4afa | |||
| 4966a659aa | |||
| 6cf4d9cb03 | |||
| 2310333a60 | |||
| 855a19873e | |||
| a0fe54d504 | |||
| bb8029b498 | |||
| ac92747892 | |||
| 3da3fabe79 | |||
| d543c6f757 | |||
| d876743d3e | |||
| 67e0df8af9 | |||
| b94434692c | |||
| b2ec94c62c | |||
| 0d2782ddb8 | |||
| b0525ce817 | |||
| 4dd3eb59a4 | |||
| 81e3462f15 | |||
| 8f290cf610 | |||
| bfdf684952 | |||
| 68b329f4f9 | |||
| a4effb8058 | |||
| fa667d2520 | |||
| e94902fedf | |||
| 1391a2b37c | |||
| 08f76009b4 | |||
| 5c9b6cd4c7 | |||
| 02aa85dae2 | |||
| 31302fb43e | |||
| b9ecce6eeb | |||
| 811935ac60 | |||
| 11e72d0fae | |||
| c54a0aceb5 | |||
| 9250be8780 | |||
| 4569d28811 | |||
| d7e1d993fb | |||
| 2fe1576eaa | |||
| 2c3a71fe3c | |||
| 81bc1f5f50 | |||
| dfb03cb362 | |||
| f21b44cf19 | |||
| 5d63cc7961 | |||
| 6e2486a036 | |||
| db67024157 | |||
| deffd48bb7 | |||
| 1830c27fc5 | |||
| bb73fa6d9e | |||
| daa6345c15 | |||
| c29a663300 | |||
| 46b8576a16 | |||
| c65eb95699 | |||
| 9490da09f4 | |||
| e6768b14a5 | |||
| e0dc14ea73 | |||
| a0e1f9ef8d | |||
| b91aa99048 | |||
| b203579649 | |||
| 096b10f5f0 | |||
| 824cff5ab6 | |||
| 155f11de91 | |||
| e71bd48468 | |||
| 177757a143 | |||
| 3f5c880c0f | |||
| 06fd7ce885 | |||
| 6766ac8c20 | |||
| 6da5926b4b | |||
| aa2f291f49 | |||
| 398d980caa | |||
| f450226d2d | |||
| 5732db2cc2 | |||
| 9cf094986c | |||
| 1653da2f37 | |||
| 9eb2a82880 |
11
.gitignore
vendored
11
.gitignore
vendored
@@ -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
5
.idea/.gitignore
generated
vendored
@@ -1,5 +0,0 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
7
.idea/IOS__AI.iml
generated
Normal file
7
.idea/IOS__AI.iml
generated
Normal 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>
|
||||
15
.idea/git_toolbox_prj.xml
generated
15
.idea/git_toolbox_prj.xml
generated
@@ -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>
|
||||
9
.idea/iOSAI.iml
generated
9
.idea/iOSAI.iml
generated
@@ -1,8 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="PYTHON_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<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>
|
||||
10
.idea/inspectionProfiles/Project_Default.xml
generated
10
.idea/inspectionProfiles/Project_Default.xml
generated
@@ -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>
|
||||
|
||||
6
.idea/jsLibraryMappings.xml
generated
6
.idea/jsLibraryMappings.xml
generated
@@ -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
4
.idea/misc.xml
generated
@@ -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
8
.idea/modules.xml
generated
@@ -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
1
.idea/vcs.xml
generated
@@ -2,6 +2,5 @@
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
111
.idea/workspace.xml
generated
111
.idea/workspace.xml
generated
@@ -5,9 +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$/Module/DeviceInfo.py" beforeDir="false" afterPath="$PROJECT_DIR$/Module/DeviceInfo.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/Module/FlaskService.py" beforeDir="false" afterPath="$PROJECT_DIR$/Module/FlaskService.py" 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" />
|
||||
@@ -37,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">{
|
||||
"customColor": "",
|
||||
@@ -53,9 +64,15 @@
|
||||
<component name="PropertiesComponent">{
|
||||
"keyToString": {
|
||||
"ASKED_ADD_EXTERNAL_FILES": "true",
|
||||
"ASKED_MARK_IGNORED_FILES_AS_EXCLUDED": "true",
|
||||
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
||||
"Python.12.executor": "Run",
|
||||
"Python.123.executor": "Run",
|
||||
"Python.DeviceInfo.executor": "Run",
|
||||
"Python.IOSActivator.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",
|
||||
@@ -64,39 +81,34 @@
|
||||
"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": "F:/company code/AI item/20250820/iOSAI",
|
||||
"last_opened_file_path": "E:/python/IOSAI/Module/Main.py",
|
||||
"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",
|
||||
"settings.editor.selected.configurable": "editing.templates",
|
||||
"two.files.diff.last.used.file": "E:/share/iOSAI/Module/FlaskService.py",
|
||||
"two.files.diff.last.used.folder": "C:/Users/zhangkai/Desktop/last-item/iosai/Utils",
|
||||
"vue.rearranger.settings.migration": "true"
|
||||
}
|
||||
}</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="RunAnythingCache">
|
||||
<myKeys>
|
||||
<visibility group="Grunt" flag="true" />
|
||||
<visibility group="Gulp" flag="true" />
|
||||
<visibility group="HTTP 请求" flag="true" />
|
||||
<visibility group="Node.js" flag="true" />
|
||||
<visibility group="Run Python file" flag="true" />
|
||||
<visibility group="Run conda command" flag="true" />
|
||||
<visibility group="Run pip command" flag="true" />
|
||||
<visibility group="npm" flag="true" />
|
||||
<visibility group="yarn" flag="true" />
|
||||
<visibility group="最近的项目" flag="true" />
|
||||
<visibility group="运行配置" flag="true" />
|
||||
</myKeys>
|
||||
</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="" />
|
||||
@@ -105,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" />
|
||||
@@ -151,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" />
|
||||
@@ -165,17 +155,21 @@
|
||||
<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.12" />
|
||||
<item itemvalue="Python.123" />
|
||||
<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>
|
||||
@@ -240,6 +234,13 @@
|
||||
<workItem from="1757498954175" duration="6736000" />
|
||||
<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" />
|
||||
@@ -309,15 +310,19 @@
|
||||
</component>
|
||||
<component name="com.intellij.coverage.CoverageDataManagerImpl">
|
||||
<SUITE FILE_PATH="coverage/iOSAI$LogManager.coverage" NAME="LogManager 覆盖结果" MODIFIED="1756711414832" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$/Utils" />
|
||||
<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$FlaskService.coverage" NAME="FlaskService 覆盖结果" MODIFIED="1756730187792" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$/Module" />
|
||||
<SUITE FILE_PATH="coverage/iOSAI$test.coverage" NAME="test 覆盖结果" MODIFIED="1756467664420" 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$windows_run.coverage" NAME="windows_run Coverage Results" MODIFIED="1756473558532" 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$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$1352.coverage" NAME="1352 覆盖结果" MODIFIED="1757662777051" 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$mac_wda_agent.coverage" NAME="mac_wda_agent Coverage Results" MODIFIED="1756473148639" 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$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="1757579400023" 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="1757587713569" 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$123456.coverage" NAME="123456 覆盖结果" MODIFIED="1757672582575" 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$2111.coverage" NAME="2111 覆盖结果" MODIFIED="1757330714370" 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$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$" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -15,7 +15,8 @@ class DeviceModel(object):
|
||||
self.scale = scale
|
||||
# 1 添加 2删除
|
||||
self.type = type
|
||||
|
||||
self.ready = False
|
||||
self.deleting = False
|
||||
|
||||
# 转字典
|
||||
def toDict(self):
|
||||
|
||||
@@ -2,15 +2,15 @@ import json
|
||||
|
||||
# 返回数据模型
|
||||
class ResultData(object):
|
||||
def __init__(self, code=200, data=None, msg="获取成功"):
|
||||
def __init__(self, code=200, data=None, message="获取成功"):
|
||||
super(ResultData, self).__init__()
|
||||
self.code = code
|
||||
self.data = data
|
||||
self.msg = msg
|
||||
self.message = message
|
||||
|
||||
def toJson(self):
|
||||
return json.dumps({
|
||||
"code": self.code,
|
||||
"data": self.data,
|
||||
"msg": self.msg
|
||||
"message": self.message
|
||||
}, ensure_ascii=False) # ensure_ascii=False 确保中文不会被转义
|
||||
@@ -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 = [
|
||||
|
||||
@@ -1,317 +1,513 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
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
|
||||
|
||||
|
||||
class Deviceinfo(object):
|
||||
"""设备生命周期管理:以 deviceModelList 为唯一真理源"""
|
||||
class DeviceInfo:
|
||||
_instance = None
|
||||
_instance_lock = threading.Lock()
|
||||
|
||||
def __init__(self):
|
||||
self.deviceIndex = 0
|
||||
self.screenProxy = 9110
|
||||
self.pidList: List[Dict] = [] # 仅记录 iproxy 进程
|
||||
self.manager = FlaskSubprocessManager.get_instance()
|
||||
self.deviceModelList: List[DeviceModel] = [] # 根基,不动
|
||||
self.maxDeviceCount = 6
|
||||
# 离线宽限期(保持你原来的数值)
|
||||
REMOVE_GRACE_SEC = 5.0
|
||||
|
||||
self._lock = threading.Lock()
|
||||
self._model_index: Dict[str, DeviceModel] = {} # udid -> model
|
||||
self._miss_count: Dict[str, int] = {} # udid -> 连续未扫描到次数
|
||||
self._port_pool: List[int] = [] # 端口回收池
|
||||
self._port_in_use: set[int] = set() # 正在使用的端口
|
||||
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
|
||||
|
||||
# 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", "")
|
||||
def __init__(self) -> None:
|
||||
if getattr(self, "_initialized", False):
|
||||
return
|
||||
|
||||
self._lock = threading.RLock()
|
||||
self._models: Dict[str, DeviceModel] = {}
|
||||
self._manager = FlaskSubprocessManager.get_instance()
|
||||
self.screenPort = 9110
|
||||
|
||||
# 设备心跳时间
|
||||
self._last_seen: Dict[str, float] = {}
|
||||
|
||||
# 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
|
||||
self._popen_kwargs = dict(
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
cwd=str(self.iproxy_dir),
|
||||
shell=False,
|
||||
text=True,
|
||||
creationflags=self._creationflags,
|
||||
encoding="utf-8",
|
||||
bufsize=1,
|
||||
)
|
||||
si = subprocess.STARTUPINFO()
|
||||
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||
si.wShowWindow = 0 # SW_HIDE
|
||||
self._startupinfo = si
|
||||
|
||||
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)
|
||||
LogManager.info("DeviceInfo 初始化完成", udid="system")
|
||||
print("[Init] DeviceInfo 初始化完成")
|
||||
self._initialized = True
|
||||
|
||||
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
|
||||
# ==========================
|
||||
# 主循环
|
||||
# ==========================
|
||||
def listen(self):
|
||||
LogManager.method_info("进入主循环", "listen", udid="system")
|
||||
print("[Listen] 开始监听设备上下线...")
|
||||
|
||||
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):
|
||||
"""死循环监听设备插拔;以 deviceModelList 为准"""
|
||||
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}
|
||||
now = time.time()
|
||||
|
||||
# 0. 首次失踪登记:已在线设备若突然扫不到,计数器归零
|
||||
# 当前已知的设备(本轮循环开始时)
|
||||
with self._lock:
|
||||
for udid in list(self._model_index.keys()):
|
||||
if udid not in now_udids and udid not in self._miss_count:
|
||||
self._miss_count[udid] = 0
|
||||
LogManager.info(f"[DEBUG] 首次失踪登记:{udid}", udid)
|
||||
known = set(self._models.keys())
|
||||
current_count = len(self._models)
|
||||
|
||||
# 1. 处理已在线设备的失联计数
|
||||
with self._lock:
|
||||
for udid in list(self._miss_count.keys()):
|
||||
if udid not in now_udids:
|
||||
self._miss_count[udid] += 1
|
||||
LogManager.info(f"[DEBUG] 累加 {udid} -> {self._miss_count[udid]}", udid)
|
||||
if self._miss_count[udid] >= 3:
|
||||
print("设备下线了")
|
||||
LogManager.info(f"[DEBUG] 触发下线 {udid}", udid)
|
||||
self._remove_model(udid)
|
||||
self._miss_count.pop(udid, None)
|
||||
else:
|
||||
LogManager.info(f"[DEBUG] 设备仍在,清零 {udid}", udid)
|
||||
self._miss_count.pop(udid, None)
|
||||
# 1. 处理在线设备
|
||||
for udid in online:
|
||||
# 更新心跳时间
|
||||
self._last_seen[udid] = now
|
||||
|
||||
# 2. 处理新插入
|
||||
for d in lists:
|
||||
if d.conn_type != ConnectionType.USB:
|
||||
# 新设备但数量已达上限
|
||||
if udid not in known and current_count >= 6:
|
||||
print(f"[Add] 设备数量已达 6 台,忽略新设备: {udid}")
|
||||
LogManager.info(
|
||||
"[Add] 设备数量已达上限(6),忽略新设备",
|
||||
udid=udid,
|
||||
)
|
||||
continue
|
||||
udid = d.udid
|
||||
with self._lock:
|
||||
if udid in self._model_index:
|
||||
continue # 已存在
|
||||
if not self.is_device_trusted(udid):
|
||||
LogManager.warning("设备未信任,跳过", udid)
|
||||
continue
|
||||
if len(self.deviceModelList) >= self.maxDeviceCount:
|
||||
|
||||
# 已经在列表里的设备,跳过添加流程
|
||||
if udid in known:
|
||||
continue
|
||||
|
||||
# 只对新发现的设备做一次信任检查
|
||||
try:
|
||||
self.connectDevice(udid) # 内部会 _add_model
|
||||
if not self._is_trusted(udid):
|
||||
LogManager.info(
|
||||
"[Add] 设备未信任或未就绪,跳过本轮添加",
|
||||
udid=udid,
|
||||
)
|
||||
print(f"[Add] 设备未信任或未就绪,跳过: {udid}")
|
||||
continue
|
||||
except Exception as e:
|
||||
LogManager.error(f"连接设备失败 {udid}: {e}", udid)
|
||||
LogManager.warning(
|
||||
f"[Add] 检测设备 {udid} 信任状态异常: {e}",
|
||||
udid=udid,
|
||||
)
|
||||
print(f"[Add] 检测设备 {udid} 信任状态异常: {e}")
|
||||
continue
|
||||
|
||||
# 二次确认数量上限
|
||||
with self._lock:
|
||||
if len(self._models) >= 6:
|
||||
print(f"[Add] 二次检查: 设备数量已达 6 台,忽略新设备: {udid}")
|
||||
LogManager.info(
|
||||
"[Add] 二次检查数量上限,忽略新设备",
|
||||
udid=udid,
|
||||
)
|
||||
continue
|
||||
|
||||
# 真正添加设备
|
||||
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}")
|
||||
|
||||
# 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}")
|
||||
|
||||
# 3. iproxy 看门狗(进程 + HTTP 探活)
|
||||
try:
|
||||
self._check_iproxy_health()
|
||||
except Exception as e:
|
||||
LogManager.warning(
|
||||
f"[iproxy] 看门狗异常: {e}",
|
||||
udid="system",
|
||||
)
|
||||
print(f"[iproxy] 看门狗异常: {e}")
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
# endregion
|
||||
|
||||
# region ===================== 增删改查唯一入口(线程安全) =====================
|
||||
def _has_model(self, udid: str) -> bool:
|
||||
with self._lock:
|
||||
return udid in self._model_index
|
||||
|
||||
def _add_model(self, model: DeviceModel):
|
||||
with self._lock:
|
||||
if model.deviceId in self._model_index:
|
||||
return # 防重复
|
||||
self.deviceModelList.append(model)
|
||||
self._model_index[model.deviceId] = model
|
||||
try:
|
||||
self.manager.send(model.toDict())
|
||||
except Exception as e:
|
||||
LogManager.warning(f"发送上线事件失败:{e}", model.deviceId)
|
||||
LogManager.info(f"设备上线,当前在线数:{len(self.deviceModelList)}", model.deviceId)
|
||||
|
||||
def _remove_model(self, udid: str):
|
||||
model = self._model_index.pop(udid, None)
|
||||
if not model:
|
||||
return
|
||||
model.type = 2
|
||||
print(model.toDict())
|
||||
# ① 关键:重试 3 次,必须送达,否则崩溃
|
||||
retry = 3
|
||||
while retry:
|
||||
try:
|
||||
self.manager.send(model.toDict())
|
||||
break
|
||||
except Exception as e:
|
||||
retry -= 1
|
||||
LogManager.error(f"发送下线事件失败,剩余重试 {retry}:{e}", udid)
|
||||
time.sleep(0.2)
|
||||
else:
|
||||
LogManager.error("发送下线事件彻底失败,主动崩溃防止状态不一致", udid)
|
||||
os._exit(1)
|
||||
|
||||
# ② 安全删除
|
||||
# 判断设备是否信任
|
||||
def _is_trusted(self, udid: str) -> bool:
|
||||
try:
|
||||
idx = self.deviceModelList.index(model)
|
||||
self.deviceModelList.pop(idx)
|
||||
print(len(self.deviceModelList))
|
||||
except Exception as e:
|
||||
print("22222222")
|
||||
print(f"[FlaskSubprocessManager] 发送失败,异常类型:{type(e).__name__},内容:{e}")
|
||||
|
||||
|
||||
# ③ 回收端口
|
||||
self._free_port(model.screenPort)
|
||||
|
||||
print("继续执行了")
|
||||
|
||||
# ④ 清理 iproxy
|
||||
survivors = [item for item in self.pidList if item.get("id") != udid]
|
||||
for item in self.pidList:
|
||||
if item.get("id") == udid:
|
||||
self._terminate_proc(item.get("target"))
|
||||
self.pidList = survivors
|
||||
print("设备下线。删除设备成功")
|
||||
LogManager.info(f"设备下线,当前在线数:{len(self.deviceModelList)}", udid)
|
||||
LogManager.info(f"[Deviceinfo] 下线包已送进队列 -> type=2", udid)
|
||||
# endregion
|
||||
|
||||
# region ===================== 端口分配与回收 =====================
|
||||
def _alloc_port(self) -> int:
|
||||
with self._lock:
|
||||
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):
|
||||
with self._lock:
|
||||
if port in self._port_in_use:
|
||||
self._port_in_use.remove(port)
|
||||
self._port_pool.append(port)
|
||||
# endregion
|
||||
|
||||
# region ===================== 单台设备连接 =====================
|
||||
def connectDevice(self, udid: str):
|
||||
if not self.is_device_trusted(udid):
|
||||
LogManager.warning("设备未信任,跳过 WDA 启动", udid)
|
||||
return
|
||||
if self._has_model(udid):
|
||||
LogManager.warning("设备已存在,跳过重复连接", udid)
|
||||
return
|
||||
|
||||
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)
|
||||
self._add_model(model)
|
||||
|
||||
try:
|
||||
d.app_start(WdaAppBundleId)
|
||||
d.home()
|
||||
except Exception as e:
|
||||
LogManager.warning(f"启动/切回桌面失败:{e}", udid)
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
# 先清旧进程再启动新进程
|
||||
with self._lock:
|
||||
self.pidList = [item for item in self.pidList if item.get("id") != udid]
|
||||
target = self.relayDeviceScreenPort(udid, port)
|
||||
if target:
|
||||
with self._lock:
|
||||
self.pidList.append({"target": target, "id": udid})
|
||||
|
||||
# endregion
|
||||
|
||||
# region ===================== 工具方法 =====================
|
||||
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
|
||||
try:
|
||||
p = self._spawn_iproxy(udid, port, 9100)
|
||||
LogManager.info(f"启动 iproxy 成功,本地 {port} -> 设备 9100", udid)
|
||||
return p
|
||||
except Exception as e:
|
||||
LogManager.error(f"启动 iproxy 失败:{e}", udid)
|
||||
return None
|
||||
# ==========================
|
||||
# 添加设备
|
||||
# ==========================
|
||||
def _add_device(self, udid: str):
|
||||
with self._lock:
|
||||
if udid in self._models:
|
||||
print(f"[Add] 已存在,跳过: {udid}")
|
||||
return
|
||||
print(f"[Add] 新增设备 {udid}")
|
||||
|
||||
def _terminate_proc(self, p: Optional[subprocess.Popen]):
|
||||
if not p or p.poll() is not None:
|
||||
# 判断 iOS 版本
|
||||
try:
|
||||
t = tidevice.Device(udid)
|
||||
version_major = float(t.product_version.split(".")[0])
|
||||
except Exception as e:
|
||||
print(f"[Add] 获取系统版本失败 {udid}: {e}")
|
||||
version_major = 0
|
||||
|
||||
# 分配投屏端口 & 写入模型
|
||||
with self._lock:
|
||||
self.screenPort += 1
|
||||
screen_port = self.screenPort
|
||||
|
||||
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:
|
||||
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:
|
||||
threading.Thread(
|
||||
target=self._fetch_screen_and_notify,
|
||||
args=(udid,),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
# ==========================
|
||||
# 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:
|
||||
c = wda.USBClient(udid, wdaFunctionPort)
|
||||
size = c.window_size()
|
||||
|
||||
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]}")
|
||||
# endregion
|
||||
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
@@ -1,10 +1,11 @@
|
||||
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
|
||||
@@ -13,119 +14,257 @@ 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._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()
|
||||
|
||||
# 可以把 _find_available_port 留着备用,但 start 前先校验端口是否被占用
|
||||
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
|
||||
LogManager.info("FlaskSubprocessManager 初始化完成", udid="flask")
|
||||
|
||||
# 启动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":
|
||||
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)],
|
||||
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):
|
||||
"""启动 Flask 子进程(兼容打包后的 exe 和源码运行)"""
|
||||
with self._lock:
|
||||
if self.process is not None:
|
||||
LogManager.warning("子进程正在运行中!")
|
||||
raise RuntimeError("子进程已在运行中!")
|
||||
# 已经有一个在跑了就别重复起
|
||||
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 的稳健写法 ——
|
||||
exe_path = Path(sys.executable).resolve()
|
||||
if exe_path.name.lower() in ("python.exe", "pythonw.exe"):
|
||||
# Nuitka 某些场景里 sys.executable 可能指向 dist\python.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 跑 .py:sys.frozen 不存在
|
||||
is_frozen = bool(getattr(sys, "frozen", False))
|
||||
|
||||
if is_frozen:
|
||||
# 打包后的 exe:用当前 exe 自举
|
||||
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, "-m", "Module.Main", "--role=flask"]
|
||||
cwd = str(Path(__file__).resolve().parent) # Module 目录
|
||||
# 开发模式:用 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"[DEBUG] spawn: {cmd} (cwd={cwd}) exists(exe)={os.path.exists(cmd[0])}")
|
||||
print(f"[DEBUG] spawn: {cmd} (cwd={cwd}) exists(exe)={os.path.exists(cmd[0])}")
|
||||
self._log("info", f"[FlaskMgr] 启动 Flask: {cmd}")
|
||||
|
||||
self.process = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace", # 新增:遇到非 UTF-8 字节用 <20> 代替,避免崩溃
|
||||
bufsize=1,
|
||||
env=env,
|
||||
cwd=cwd,
|
||||
bufsize=1,
|
||||
startupinfo=self._si,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
LogManager.info(f"Flask子进程启动 (PID: {self.process.pid}, 端口: {self.comm_port})")
|
||||
print(f"Flask子进程启动 (PID: {self.process.pid}, 端口: {self.comm_port})")
|
||||
# 异步吃子进程 stdout,顺便打日志
|
||||
threading.Thread(target=self._read_stdout, daemon=True).start()
|
||||
|
||||
def print_output(stream, stream_name):
|
||||
while True:
|
||||
line = stream.readline()
|
||||
if not line:
|
||||
break
|
||||
print(f"{stream_name}: {line.strip()}")
|
||||
# 看门狗只需要起一次
|
||||
if not self._watchdog_running:
|
||||
threading.Thread(target=self._watchdog_loop, daemon=True).start()
|
||||
self._watchdog_running = True
|
||||
|
||||
threading.Thread(target=print_output, args=(self.process.stdout, "STDOUT"), daemon=True).start()
|
||||
threading.Thread(target=print_output, args=(self.process.stderr, "STDERR"), daemon=True).start()
|
||||
self._log("info", f"[FlaskMgr] Flask 子进程已启动 PID={self.process.pid}")
|
||||
|
||||
def send(self, data: Union[str, Dict, List]) -> bool:
|
||||
"""通过Socket发送数据"""
|
||||
try:
|
||||
if not isinstance(data, str):
|
||||
data = json.dumps(data)
|
||||
# 等待子进程启动并准备好
|
||||
time.sleep(1) # 延时1秒,根据实际情况调整
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.connect(('127.0.0.1', self.comm_port))
|
||||
s.sendall((data + "\n").encode('utf-8'))
|
||||
return True
|
||||
except ConnectionRefusedError:
|
||||
LogManager.error(f"连接被拒绝,确保子进程在端口 {self.comm_port} 上监听")
|
||||
print(f"连接被拒绝,确保子进程在端口 {self.comm_port} 上监听")
|
||||
return False
|
||||
except Exception as e:
|
||||
LogManager.error(f"发送失败: {e}")
|
||||
print(f"发送失败: {e}")
|
||||
return False
|
||||
def _read_stdout(self):
|
||||
if not self.process or not self.process.stdout:
|
||||
return
|
||||
for line in iter(self.process.stdout.readline, ""):
|
||||
if line:
|
||||
self._log("info", f"[Flask] {line.rstrip()}")
|
||||
|
||||
# ========================= 停止 =========================
|
||||
def stop(self):
|
||||
with self._lock:
|
||||
if self.process and self.process.poll() is None:
|
||||
print(f"[INFO] Stopping Flask child process (PID: {self.process.pid})...")
|
||||
LogManager.info(f"[INFO] Stopping Flask child process (PID: {self.process.pid})...")
|
||||
if not self.process:
|
||||
return
|
||||
|
||||
try:
|
||||
self.process.terminate()
|
||||
self.process.wait()
|
||||
print("[INFO] Flask child process stopped.")
|
||||
LogManager.info("[INFO] Flask child process stopped.")
|
||||
self._stop_event.set()
|
||||
else:
|
||||
LogManager.info("[INFO] No Flask child process to stop.")
|
||||
print("[INFO] No Flask child process to stop.")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
self.process.wait(timeout=3)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self.process.poll() is None:
|
||||
try:
|
||||
self.process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._log("warn", "[FlaskMgr] 已停止 Flask 子进程")
|
||||
self.process = None
|
||||
|
||||
# ========================= 看门狗 =========================
|
||||
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:
|
||||
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
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls) -> 'FlaskSubprocessManager':
|
||||
def get_instance(cls):
|
||||
return cls()
|
||||
355
Module/IOSActivator.py
Normal file
355
Module/IOSActivator.py
Normal 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}")
|
||||
130
Module/Main.py
130
Module/Main.py
@@ -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 → 推荐 3~4 个 worker
|
||||
|
||||
# 直接跑 Quart(ASGI 原生,不再用 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()
|
||||
|
||||
|
||||
1020
Utils/AiUtils.py
1020
Utils/AiUtils.py
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,13 @@
|
||||
import math
|
||||
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
|
||||
|
||||
@@ -55,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:
|
||||
@@ -68,14 +81,66 @@ class ControlUtils(object):
|
||||
return True
|
||||
elif session.xpath("//*[@name='nav_bar_start_back']").exists:
|
||||
back = session.xpath("//*[@name='nav_bar_start_back']")
|
||||
back.click()
|
||||
if back.exists:
|
||||
back.click()
|
||||
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]")
|
||||
back.click()
|
||||
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
|
||||
except Exception as e:
|
||||
@@ -85,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()
|
||||
@@ -121,15 +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
|
||||
@@ -176,13 +259,98 @@ class ControlUtils(object):
|
||||
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.exists:
|
||||
user_btn.click()
|
||||
time.sleep(3)
|
||||
follow_btn = session.xpath(
|
||||
"(//XCUIElementTypeTable//XCUIElementTypeButton[@name='关注' or @name='已关注'])[1]"
|
||||
).get(timeout=5)
|
||||
if follow_btn:
|
||||
x, y, w, h = follow_btn.bounds
|
||||
# 垂直方向中心 + 随机 3~8 像素偏移
|
||||
cy = int(y + h / 2 + random.randint(-8, 8))
|
||||
# 横向往左偏移 80~120 像素之间的随机值
|
||||
cx = int(x - random.randint(80, 120))
|
||||
# 点击
|
||||
session.tap(cx, cy)
|
||||
return True
|
||||
|
||||
return False
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def random_micro_swipe(
|
||||
cls,
|
||||
center_x: int,
|
||||
center_y: int,
|
||||
session,
|
||||
points: int = 6,
|
||||
duration_ms: int = 15,
|
||||
) -> None:
|
||||
"""
|
||||
在 (center_x, center_y) 附近做 20px 左右的不规则微滑动。
|
||||
使用 facebook-wda 的 session.swipe(x1, y1, x2, y2, duration) 接口。
|
||||
"""
|
||||
# 1. 随机方向
|
||||
angle = random.uniform(0, 2 * math.pi)
|
||||
length = random.uniform(18, 22) # 20px 左右
|
||||
end_x = center_x + length * math.cos(angle)
|
||||
end_y = center_y + length * math.sin(angle)
|
||||
|
||||
# 检测五分钟前和当前的状态是否相同
|
||||
# @classmethod
|
||||
# def compareCurrentWithPreviousState(cls,xml):
|
||||
# 2. 限制在 20px 圆内(防止超出)
|
||||
def clamp_to_circle(x, y, cx, cy, r):
|
||||
dx = x - cx
|
||||
dy = y - cy
|
||||
if dx * dx + dy * dy > r * r:
|
||||
scale = r / math.hypot(dx, dy)
|
||||
x = cx + dx * scale
|
||||
y = cy + dy * scale
|
||||
return int(round(x)), int(round(y))
|
||||
|
||||
end_x, end_y = clamp_to_circle(end_x, end_y, center_x, center_y, 20)
|
||||
|
||||
# 3. 加入轻微噪声,制造“不规则”曲线
|
||||
noise = 3 # 最大偏移像素
|
||||
mid_count = points - 2
|
||||
mid_points: List[Tuple[int, int]] = []
|
||||
for i in range(1, mid_count + 1):
|
||||
t = i / (mid_count + 1)
|
||||
# 线性插值 + 垂直方向噪声
|
||||
x = center_x * (1 - t) + end_x * t
|
||||
y = center_y * (1 - t) + end_y * t
|
||||
perp_angle = angle + math.pi / 2 # 垂直方向
|
||||
offset = random.uniform(-noise, noise)
|
||||
x += offset * math.cos(perp_angle)
|
||||
y += offset * math.sin(perp_angle)
|
||||
x, y = clamp_to_circle(x, y, center_x, center_y, 20)
|
||||
mid_points.append((int(round(x)), int(round(y))))
|
||||
|
||||
# 4. 构造完整轨迹
|
||||
trajectory: List[Tuple[int, int]] = (
|
||||
[(center_x, center_y)] + mid_points + [(end_x, end_y)]
|
||||
)
|
||||
|
||||
# 5. 使用 facebook-wda 的 swipe 接口(逐段 swipe)
|
||||
# 由于总时长太短,我们一次性 swipe 到终点,但用多点轨迹模拟
|
||||
# facebook-wda 支持 swipe(x1, y1, x2, y2, duration)
|
||||
# 我们直接用起点 -> 终点,duration 用总时长
|
||||
print("开始微滑动")
|
||||
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
271
Utils/CountryEnum.py
Normal 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,8 +88,8 @@ class DevDiskImageDeployer:
|
||||
exists = dst.exists()
|
||||
if exists and not self.overwrite:
|
||||
skipped += 1
|
||||
if self.verbose:
|
||||
print(f"[SKIP] {dst} 已存在(目录)")
|
||||
# if self.verbose:
|
||||
# print(f"[SKIP] {dst} 已存在(目录)")
|
||||
continue
|
||||
if exists and self.overwrite and not self.dry_run:
|
||||
shutil.rmtree(dst)
|
||||
@@ -105,8 +105,8 @@ class DevDiskImageDeployer:
|
||||
exists = dst.exists()
|
||||
if exists and not self.overwrite:
|
||||
skipped += 1
|
||||
if self.verbose:
|
||||
print(f"[SKIP] {dst} 已存在(zip)")
|
||||
# if self.verbose:
|
||||
# print(f"[SKIP] {dst} 已存在(zip)")
|
||||
continue
|
||||
if exists and self.overwrite and not self.dry_run:
|
||||
dst.unlink()
|
||||
|
||||
88
Utils/IOSAIStorage.py
Normal file
88
Utils/IOSAIStorage.py
Normal 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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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,17 +199,36 @@ class JsonUtils:
|
||||
|
||||
return updated
|
||||
|
||||
|
||||
|
||||
|
||||
@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 文件(数组)中的所有项
|
||||
:param filename: JSON 文件路径
|
||||
:return: list,可能为空
|
||||
读取 JSON 数组文件,过滤掉 sender 或 text 为空的记录
|
||||
:param filename: 文件路径
|
||||
:return: 有效记录列表,可能为空
|
||||
"""
|
||||
file_path = Path(filename)
|
||||
print(file_path)
|
||||
data = cls._read_json_list(file_path)
|
||||
return data if isinstance(data, list) else []
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
|
||||
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,
|
||||
|
||||
@@ -1,217 +1,3 @@
|
||||
#
|
||||
# import datetime
|
||||
# import io
|
||||
# import logging
|
||||
# import os
|
||||
# import re
|
||||
# import sys
|
||||
# import shutil
|
||||
# import zipfile
|
||||
# from pathlib import Path
|
||||
# import requests
|
||||
#
|
||||
#
|
||||
# class LogManager:
|
||||
# # 运行根目录:打包后取 exe 目录;源码运行取项目目录
|
||||
# if getattr(sys, "frozen", False):
|
||||
# projectRoot = os.path.dirname(sys.executable)
|
||||
# else:
|
||||
# projectRoot = os.path.dirname(os.path.dirname(__file__))
|
||||
#
|
||||
# logDir = os.path.join(projectRoot, "log")
|
||||
# _loggers = {}
|
||||
# _method_loggers = {} # 新增:缓存“设备+方法”的 logger
|
||||
#
|
||||
# # ---------- 工具函数 ----------
|
||||
# @classmethod
|
||||
# def _safe_filename(cls, name: str, max_len: int = 80) -> str:
|
||||
# """
|
||||
# 将方法名/udid等转成安全文件名:
|
||||
# - 允许字母数字、点、下划线、连字符
|
||||
# - 允许常见 CJK 字符(中日韩)
|
||||
# - 其他非法字符替换为下划线
|
||||
# - 合并多余下划线,裁剪长度
|
||||
# """
|
||||
# if not name:
|
||||
# return "unknown"
|
||||
# name = str(name).strip()
|
||||
#
|
||||
# # 替换 Windows 非法字符和控制符
|
||||
# name = re.sub(r'[\\/:*?"<>|\r\n\t]+', '_', name)
|
||||
#
|
||||
# # 只保留 ① 英数._- ② CJK 统一表意文字、日文平/片假名、韩文音节
|
||||
# name = re.sub(rf'[^a-zA-Z0-9_.\-'
|
||||
# r'\u4e00-\u9fff' # 中
|
||||
# r'\u3040-\u30ff' # 日
|
||||
# r'\uac00-\ud7a3' # 韩
|
||||
# r']+', '_', name)
|
||||
# # 合并多余下划线,去两端空白与下划线
|
||||
# name = re.sub(r'_+', '_', name).strip(' _.')
|
||||
# # 避免空
|
||||
# name = name or "unknown"
|
||||
# # Windows 预留名避免(CON/PRN/AUX/NUL/COM1…)
|
||||
# if re.fullmatch(r'(?i)(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])', name):
|
||||
# name = f"_{name}"
|
||||
# # 限长
|
||||
# return name[:max_len] or "unknown"
|
||||
#
|
||||
# # ---------- 旧的:按级别写固定文件 ----------
|
||||
# @classmethod
|
||||
# def _setupLogger(cls, udid, name, logName, level=logging.INFO):
|
||||
# """创建或获取 logger,并绑定到设备目录下的固定文件(info.log / warning.log / error.log)"""
|
||||
# deviceLogDir = os.path.join(cls.logDir, cls._safe_filename(udid))
|
||||
# os.makedirs(deviceLogDir, exist_ok=True)
|
||||
# logFile = os.path.join(deviceLogDir, logName)
|
||||
#
|
||||
# logger_name = f"{udid}_{name}"
|
||||
# logger = logging.getLogger(logger_name)
|
||||
# logger.setLevel(level)
|
||||
#
|
||||
# # 避免重复添加 handler
|
||||
# if not any(
|
||||
# isinstance(h, logging.FileHandler) and h.baseFilename == os.path.abspath(logFile)
|
||||
# for h in logger.handlers
|
||||
# ):
|
||||
# fileHandler = logging.FileHandler(logFile, mode="a", encoding="utf-8")
|
||||
# formatter = logging.Formatter(
|
||||
# "%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
# datefmt="%Y-%m-%d %H:%M:%S"
|
||||
# )
|
||||
# fileHandler.setFormatter(formatter)
|
||||
# logger.addHandler(fileHandler)
|
||||
#
|
||||
# return logger
|
||||
#
|
||||
# @classmethod
|
||||
# def info(cls, text, udid="system"):
|
||||
# cls._setupLogger(udid, "infoLogger", "info.log", level=logging.INFO).info(f"[{udid}] {text}")
|
||||
#
|
||||
# @classmethod
|
||||
# def warning(cls, text, udid="system"):
|
||||
# cls._setupLogger(udid, "warningLogger", "warning.log", level=logging.WARNING).warning(f"[{udid}] {text}")
|
||||
#
|
||||
# @classmethod
|
||||
# def error(cls, text, udid="system"):
|
||||
# cls._setupLogger(udid, "errorLogger", "error.log", level=logging.ERROR).error(f"[{udid}] {text}")
|
||||
#
|
||||
# # ---------- 新增:按“设备+方法”分别写独立日志文件 ----------
|
||||
# @classmethod
|
||||
# def _setupMethodLogger(cls, udid: str, method: str, level=logging.INFO):
|
||||
# """
|
||||
# 为某设备的某个方法单独创建 logger:
|
||||
# log/<udid>/<method>.log
|
||||
# """
|
||||
# udid_key = cls._safe_filename(udid or "system")
|
||||
# method_key = cls._safe_filename(method or "general")
|
||||
# cache_key = (udid_key, method_key)
|
||||
#
|
||||
# # 命中缓存
|
||||
# if cache_key in cls._method_loggers:
|
||||
# return cls._method_loggers[cache_key]
|
||||
#
|
||||
# deviceLogDir = os.path.join(cls.logDir, udid_key)
|
||||
# os.makedirs(deviceLogDir, exist_ok=True)
|
||||
# logFile = os.path.join(deviceLogDir, f"{method_key}.log")
|
||||
#
|
||||
# logger_name = f"{udid_key}.{method_key}"
|
||||
# logger = logging.getLogger(logger_name)
|
||||
# logger.setLevel(level)
|
||||
# logger.propagate = False # 避免向根 logger 传播导致控制台重复打印
|
||||
#
|
||||
# # 避免重复添加 handler
|
||||
# if not any(
|
||||
# isinstance(h, logging.FileHandler) and h.baseFilename == os.path.abspath(logFile)
|
||||
# for h in logger.handlers
|
||||
# ):
|
||||
# fileHandler = logging.FileHandler(logFile, mode="a", encoding="utf-8")
|
||||
# formatter = logging.Formatter(
|
||||
# "%(asctime)s - %(levelname)s - %(name)s - %(message)s",
|
||||
# datefmt="%Y-%m-%d %H:%M:%S"
|
||||
# )
|
||||
# fileHandler.setFormatter(formatter)
|
||||
# logger.addHandler(fileHandler)
|
||||
#
|
||||
# cls._method_loggers[cache_key] = logger
|
||||
# return logger
|
||||
#
|
||||
# @classmethod
|
||||
# def method_info(cls, text, method, udid="system"):
|
||||
# """按设备+方法写 INFO 到 log/<udid>/<method>.log"""
|
||||
# cls._setupMethodLogger(udid, method, level=logging.INFO).info(f"[{udid}][{method}] {text}")
|
||||
#
|
||||
# @classmethod
|
||||
# def method_warning(cls, text, method, udid="system"):
|
||||
# cls._setupMethodLogger(udid, method, level=logging.WARNING).warning(f"[{udid}][{method}] {text}")
|
||||
#
|
||||
# @classmethod
|
||||
# def method_error(cls, text, method, udid="system"):
|
||||
# cls._setupMethodLogger(udid, method, level=logging.ERROR).error(f"[{udid}][{method}] {text}")
|
||||
#
|
||||
# # 清空日志
|
||||
# @classmethod
|
||||
# def clearLogs(cls):
|
||||
# """启动时清空 log 目录下所有文件"""
|
||||
#
|
||||
# # 关闭所有 handler
|
||||
# for name, logger in logging.Logger.manager.loggerDict.items():
|
||||
# if isinstance(logger, logging.Logger):
|
||||
# for handler in logger.handlers[:]:
|
||||
# try:
|
||||
# handler.close()
|
||||
# except Exception:
|
||||
# pass
|
||||
# logger.removeHandler(handler)
|
||||
#
|
||||
# # 删除 log 目录
|
||||
# log_path = Path(cls.logDir)
|
||||
# if log_path.exists():
|
||||
# for item in log_path.iterdir():
|
||||
# if item.is_file():
|
||||
# item.unlink()
|
||||
# elif item.is_dir():
|
||||
# shutil.rmtree(item)
|
||||
#
|
||||
# # 清缓存
|
||||
# cls._method_loggers.clear()
|
||||
#
|
||||
# @classmethod
|
||||
# def upload_all_logs(cls, server_url, token, userId, tenantId):
|
||||
# log_path = Path(cls.logDir)
|
||||
# if not log_path.exists():
|
||||
# return False
|
||||
#
|
||||
# timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
# filename = f"{timestamp}_logs.zip"
|
||||
# print(filename)
|
||||
# zip_buf = io.BytesIO()
|
||||
# with zipfile.ZipFile(zip_buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
# for p in log_path.rglob("*"):
|
||||
# if p.is_file():
|
||||
# arcname = str(p.relative_to(log_path))
|
||||
# zf.write(p, arcname=arcname)
|
||||
#
|
||||
# zip_bytes = zip_buf.getvalue()
|
||||
#
|
||||
# headers = {"vvtoken": token}
|
||||
# data = {"tenantId": tenantId, "userId": userId}
|
||||
#
|
||||
#
|
||||
# files = {
|
||||
# "file": (filename, io.BytesIO(zip_bytes), "application/zip")
|
||||
# }
|
||||
#
|
||||
# # 3) 上传
|
||||
# resp = requests.post(server_url, headers=headers, data=data, files=files)
|
||||
# if resp.json()['data']:
|
||||
# return True
|
||||
# return False
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
import datetime
|
||||
import io
|
||||
@@ -240,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:
|
||||
"""
|
||||
@@ -445,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
243
Utils/OCRUtils.py
Normal 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
|
||||
@@ -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/"
|
||||
@@ -17,7 +17,7 @@ class Requester():
|
||||
"vvtoken": token,
|
||||
}
|
||||
url = BaseUrl + cls.prologue
|
||||
result = requests.get(headers=headers, url=url)
|
||||
result = requests.get(headers=headers, url=url, verify=False)
|
||||
json = result.json()
|
||||
data = json.get("data")
|
||||
for i in data:
|
||||
@@ -29,42 +29,119 @@ class Requester():
|
||||
@classmethod
|
||||
def translation(cls, msg, country="英国"):
|
||||
try:
|
||||
parame = {
|
||||
if country == "":
|
||||
country = "英国"
|
||||
|
||||
param = {
|
||||
"msg": msg,
|
||||
"country": country,
|
||||
}
|
||||
url = "https://ai.yolozs.com/translation"
|
||||
result = requests.request(url=url, json=parame, method="POST")
|
||||
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")
|
||||
print(data)
|
||||
return data
|
||||
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 = {
|
||||
"agentName":agentName,
|
||||
"guildName":guildName,
|
||||
"contactTool":contactTool,
|
||||
"contact":contact
|
||||
"name": agentName,
|
||||
"Trade_union": guildName,
|
||||
"contcat_method": contactTool,
|
||||
"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.request(url=url, json=param, method="POST")
|
||||
|
||||
# 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("data", {})
|
||||
return data
|
||||
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:
|
||||
LogManager.method_error(f"ai聊天失败,ai聊天出现异常,报错的原因:{e}", "ai聊天接口异常")
|
||||
|
||||
24
Utils/SubprocessKit.py
Normal file
24
Utils/SubprocessKit.py
Normal file
@@ -0,0 +1,24 @@
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
__all__ = ['check_output', 'popen', 'PIPE']
|
||||
|
||||
# 模块级单例,导入时只创建一次
|
||||
if os.name == "nt":
|
||||
_si = subprocess.STARTUPINFO()
|
||||
_si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||
_si.wShowWindow = subprocess.SW_HIDE
|
||||
else:
|
||||
_si = None
|
||||
|
||||
PIPE = subprocess.PIPE
|
||||
|
||||
def check_output(cmd, **kw):
|
||||
if os.name == "nt":
|
||||
kw.setdefault('startupinfo', _si)
|
||||
return subprocess.check_output(cmd, **kw)
|
||||
|
||||
def popen(*args, **kw):
|
||||
if os.name == "nt":
|
||||
kw.setdefault('startupinfo', _si)
|
||||
return subprocess.Popen(*args, **kw)
|
||||
327
Utils/TencentOCRUtils.py
Normal file
327
Utils/TencentOCRUtils.py
Normal 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))
|
||||
|
||||
@@ -1,33 +1,226 @@
|
||||
from threading import Thread, Event
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import threading
|
||||
import ctypes
|
||||
import inspect
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import Dict, Optional, List, Tuple, Any
|
||||
|
||||
from Utils.LogManager import LogManager
|
||||
from script.ScriptManager import ScriptManager
|
||||
|
||||
|
||||
class ThreadManager():
|
||||
threads = {}
|
||||
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_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
|
||||
"""
|
||||
_threads: Dict[str, threading.Thread] = {}
|
||||
_lock = threading.RLock()
|
||||
|
||||
# ========== 基础 ==========
|
||||
@classmethod
|
||||
def add(cls, udid, t: Thread, stopEvent: Event):
|
||||
if udid in cls.threads:
|
||||
print("▲ 线程已存在")
|
||||
return
|
||||
cls.threads[udid] = {"thread": t, "stopEvent": stopEvent}
|
||||
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, "任务已存在"
|
||||
|
||||
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
|
||||
|
||||
@classmethod
|
||||
def stop(cls, udid):
|
||||
try:
|
||||
info = cls.threads[udid]
|
||||
if info:
|
||||
info["stopEvent"].set() # 停止线程
|
||||
info["thread"].join(timeout=3) # 等待线程退出
|
||||
del cls.threads[udid]
|
||||
LogManager.info("停止线程成功", udid)
|
||||
return 200, "停止线程成功 " + udid
|
||||
else:
|
||||
LogManager.info("无此线程,无需关闭", udid)
|
||||
return 1001, "无此线程,无需关闭 " + udid
|
||||
except KeyError as e:
|
||||
LogManager.info("无此线程,无需关闭", udid)
|
||||
return 1001, "停止脚本失败 " + udid
|
||||
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:
|
||||
t.start()
|
||||
except Exception:
|
||||
return 1001, "创建任务失败"
|
||||
|
||||
cls._threads[udid] = t
|
||||
# 保留你原有的创建成功日志
|
||||
try:
|
||||
LogManager.method_info(f"创建任务成功 [{udid}],线程ID={t.ident}", "task")
|
||||
except Exception:
|
||||
pass
|
||||
return 200, "创建任务成功"
|
||||
|
||||
@classmethod
|
||||
def get_thread(cls, udid: str) -> Optional[threading.Thread]:
|
||||
with cls._lock:
|
||||
return cls._threads.get(udid)
|
||||
|
||||
@classmethod
|
||||
def get_tid(cls, udid: str) -> Optional[int]:
|
||||
t = cls.get_thread(udid)
|
||||
return t.ident if t else None
|
||||
|
||||
@classmethod
|
||||
def is_running(cls, udid: str) -> bool:
|
||||
t = cls.get_thread(udid)
|
||||
return bool(t and t.is_alive())
|
||||
|
||||
@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:
|
||||
"""
|
||||
对指定 udid 执行一次强杀流程;返回 True=已停止/不存在,False=仍存活或被拒。
|
||||
"""
|
||||
with cls._lock:
|
||||
t = cls._threads.get(udid)
|
||||
|
||||
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:
|
||||
return 1001, "failed"
|
||||
|
||||
@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, "停止任务成功", []
|
||||
|
||||
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
|
||||
@@ -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
|
||||
24
build.bat
24
build.bat
@@ -1,24 +0,0 @@
|
||||
python -m nuitka "Module/Main.py" ^
|
||||
--standalone ^
|
||||
--msvc=latest ^
|
||||
--windows-console-mode=disable ^
|
||||
--remove-output ^
|
||||
--output-dir="F:/company code/AI item/20250820/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="F:/company code/AI item/20250820/iOSAI/SupportFiles=SupportFiles" ^
|
||||
--include-data-dir="F:/company code/AI item/20250820/iOSAI/resources=resources" ^
|
||||
--include-data-files="F:/company code/AI item/20250820/iOSAI/resources/iproxy/*=resources/iproxy/" ^
|
||||
--windows-icon-from-ico="F:/company code/AI item/20250820/iOSAI/resources/icon.ico"
|
||||
@@ -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
|
||||
Binary file not shown.
BIN
resources/comment2.png
Normal file
BIN
resources/comment2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
BIN
resources/insert_comment.png
Normal file
BIN
resources/insert_comment.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
BIN
resources/insert_comment2.png
Normal file
BIN
resources/insert_comment2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
resources/insert_comment2x.png
Normal file
BIN
resources/insert_comment2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
BIN
resources/ios.exe
Normal file
BIN
resources/ios.exe
Normal file
Binary file not shown.
Binary file not shown.
BIN
resources/like1.png
Normal file
BIN
resources/like1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
21
resources/server.crt
Normal file
21
resources/server.crt
Normal file
@@ -0,0 +1,21 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDaTCCAlGgAwIBAgIUAQMZUx/qRQIv49P6EIWt+mjYlKYwDQYJKoZIhvcNAQEL
|
||||
BQAwXTELMAkGA1UEBhMCQ04xDjAMBgNVBAgMBUxvY2FsMQ4wDAYDVQQHDAVMb2Nh
|
||||
bDEMMAoGA1UECgwDRGV2MQwwCgYDVQQLDANEZXYxEjAQBgNVBAMMCWxvY2FsaG9z
|
||||
dDAeFw0yNTExMTgwNjMyMjlaFw0zNTExMTYwNjMyMjlaMF0xCzAJBgNVBAYTAkNO
|
||||
MQ4wDAYDVQQIDAVMb2NhbDEOMAwGA1UEBwwFTG9jYWwxDDAKBgNVBAoMA0RldjEM
|
||||
MAoGA1UECwwDRGV2MRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB
|
||||
AQUAA4IBDwAwggEKAoIBAQDxY0znIdwlVW+EVcQww7Xg/16iN3JpX0svpEOIbo1u
|
||||
40S3bRn057cS//1AG+c+55iisfOaoUX59ZUQhihcAje7IfTKO1/dCzDy4d/PT2hl
|
||||
UOO9Zo8GQzflFM0U6fIi2Ifly09JTGJEyxr+SrJHcJPENualiR6zwNLlqupE9bDP
|
||||
40ydznYWZRvw3N0QmrkOg+eY6FwaYtWspvf/KiJWucscc31zGyA0MhF552k6sIVg
|
||||
9Vskr9Bd3g52Umv/1yPZmESkuM905ImCwSCK0VPAY+rooUeTYw3ktE7q/iy5+l71
|
||||
s6hN9YHVo3m4pIJz4G0YT039TnjtZxxHt8IIVSv6Ymr3AgMBAAGjITAfMB0GA1Ud
|
||||
DgQWBBQ6Q3V0wyXXyNm3jvmSKc2KoAMvAzANBgkqhkiG9w0BAQsFAAOCAQEAacsO
|
||||
ja4qpX/vWUTelhdvzg5alD5WDrP8iSIXmGF+HSHgJbbjxbDm4vlMZjzwh8iqODQR
|
||||
yJ9iuRiFFXGCktEqFx2NTCIUBmyoBg/LFeLtOn0Ncqs11ypoSoRxqE0IaeDjirBH
|
||||
hNUIXzJ+3pOqgyHU+3WqgEzEjW63pNmjX1esVZqA0SQJejsv4hJOvBzGoFFgSRcC
|
||||
Zp7NrusZ8IDkdLbUgD9pgZHPI8YNH/MVocV3wd45o9Y3nkMPhIqkp/1GOWIdN6qj
|
||||
co66o0hYsJduQC9fEBceWpRNWUirEKd231SeaW9vZMPMrfmOZanDY6pXdvhsZR3L
|
||||
9ZGsJk6ktoTagz2AYw==
|
||||
-----END CERTIFICATE-----
|
||||
28
resources/server.key
Normal file
28
resources/server.key
Normal file
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQDxY0znIdwlVW+E
|
||||
VcQww7Xg/16iN3JpX0svpEOIbo1u40S3bRn057cS//1AG+c+55iisfOaoUX59ZUQ
|
||||
hihcAje7IfTKO1/dCzDy4d/PT2hlUOO9Zo8GQzflFM0U6fIi2Ifly09JTGJEyxr+
|
||||
SrJHcJPENualiR6zwNLlqupE9bDP40ydznYWZRvw3N0QmrkOg+eY6FwaYtWspvf/
|
||||
KiJWucscc31zGyA0MhF552k6sIVg9Vskr9Bd3g52Umv/1yPZmESkuM905ImCwSCK
|
||||
0VPAY+rooUeTYw3ktE7q/iy5+l71s6hN9YHVo3m4pIJz4G0YT039TnjtZxxHt8II
|
||||
VSv6Ymr3AgMBAAECgf9Wf6myg+iV5TRN77TAGbAP6udHacgYn+7Ad5eQC+ZlobuU
|
||||
Y3Tnh325tXqvPRFSGFwiAlMQVDECt1u/PDm6vJ077t/UHQ5zjr3sYPU1oONuptJ7
|
||||
Jo0WsoFKLkH/oMeBi7h643+Oo9/GH04/nTdnMS9kqL1lYy/aUOVGW5JXIZUWhwMc
|
||||
gTEXuyuaajqdRbQex6vSQANpauYMewj9jlU9RVfjB6LGUHLDJwWbi61dTOYakxkn
|
||||
ssfzEDPMFuGQ8qjG5Li6ceUgNHlmY73WZBXNHKt6FcwNqldy5FB4PiIxoWx0ZHmq
|
||||
z/i89BQlurp20RyFD8f+iMTIbU/z0gHM86JkziECgYEA+qO5KVSg0XQlQmsyXT7Z
|
||||
nKI+DakGxn4ipJWuUDLdi0QoymNIF3diUi1o9P4Q8QzfNFCTVdUsQft24ekXAkJX
|
||||
QtGL61BXBJX5vZvihbBuCEGatE+31LRQZOxWLaeWE6y8XbFfez22SRpa+IrTma+h
|
||||
BxBRyKSqO03xyojKTPPMbDkCgYEA9ozsY02MUJKDXxtE2BRgwGQumoHi7HPRUISk
|
||||
ij0MWw/AYdYoCsrALP4IKB65tK0M7lJsK+g5M2Kyh8XD/Z4hfT1oNnJW8SOtPBwd
|
||||
heuPUzjnhIhnnxoLFeKMo6bGNqVkIYAOKbcX9s2J7U9NwUlYxMxmjNrV2/Nb2MFE
|
||||
Raun8K8CgYBbX1ydaLDIKyN6N0JBJCyJIcylhj1mF43hmn/V1PVXVB3ayp75jxhV
|
||||
BSECT601c4/brpRH8lMUKuyIJ0WwGSdewK3Vt5BBp7tIGJBYVJ2IfQI9QeKutJ2q
|
||||
bU5tjm7z9UEmlwdMEo9lzyni+hlyKcj2nkhycTVuMVg4ke3OaALaYQKBgDD3XBt8
|
||||
01lNP/ormEiyA2UygG7/TOpZNkEflu49oa6UOkk0F0/NZM2KxmPxdkCD/gV3KTSv
|
||||
Mm0aNQryJDLCrTQKdiAaJVpPE6DUlKh8WELXEmQoEyxuJ7V5ASWfgc5omrJslGOE
|
||||
kaXavIH9NhwlTRQI5HUlIURF2P/7omuT5A7RAoGBAJhtThrzxExrzmf4bTIXe+EQ
|
||||
M5lAPqIn46ThlujcNCLroxWz32Ekzs1ywn2NKwK6F1gMJEsFXqQDXtumJYTJ94tY
|
||||
L1fv0knHgRnURsq6xZ4IQgq3YzV0OXQwN4PBzqcRR6sMgFAXfCzXMy/eE7iz8eZT
|
||||
FsrkWh/9tgVvITaUrqr5
|
||||
-----END PRIVATE KEY-----
|
||||
BIN
resources/wintun.dll
Normal file
BIN
resources/wintun.dll
Normal file
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -1,38 +0,0 @@
|
||||
# windows_run.py(替换你起 iproxy 的那几行)
|
||||
import os, subprocess, time, requests, wda
|
||||
from pathlib import Path
|
||||
|
||||
UDID = "00008110-00067D0014D3B01E"
|
||||
MAC = "http://192.168.1.219:8765"
|
||||
|
||||
# 让 Mac 起 WDA(不转发)
|
||||
requests.post(f"{MAC}/startWDA", json={"udid": UDID}, timeout=600).raise_for_status()
|
||||
|
||||
# 计算 iproxy 绝对路径(项目根/resources/iproxy/iproxy.exe)
|
||||
BASE = Path(__file__).resolve().parents[1] # iOSAI/
|
||||
IPROXY = BASE / "resources" / "iproxy" / "iproxy.exe"
|
||||
if not IPROXY.exists():
|
||||
raise FileNotFoundError(f"iproxy 不在这里: {IPROXY}")
|
||||
|
||||
# 可选:把 iproxy 目录加入 PATH,避免 DLL 依赖找不到
|
||||
env = os.environ.copy()
|
||||
env["PATH"] = str(IPROXY.parent) + os.pathsep + env.get("PATH", "")
|
||||
try:
|
||||
os.add_dll_directory(str(IPROXY.parent)) # 仅 Windows 有效
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 起 iproxy:本地 9111 -> 设备 8100
|
||||
p = subprocess.Popen([str(IPROXY), "-u", UDID, "9111", "8100"],
|
||||
cwd=str(IPROXY.parent),
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, creationflags=0x08000000)
|
||||
|
||||
# 探活 WDA
|
||||
c = wda.Client("http://127.0.0.1:9111")
|
||||
|
||||
for _ in range(120):
|
||||
try:
|
||||
print(c.status()); break
|
||||
except:
|
||||
time.sleep(1)
|
||||
@@ -1,35 +0,0 @@
|
||||
from pathlib import Path
|
||||
import os, subprocess, time, requests, wda
|
||||
|
||||
UDID = "00008110-00067D0014D3B01E"
|
||||
MAC = "http://192.168.1.90:8765"
|
||||
|
||||
# 让 Mac 起 WDA
|
||||
requests.post(f"{MAC}/startWDA", json={"udid": UDID}, timeout=600).raise_for_status()
|
||||
|
||||
# 计算 iproxy 绝对路径:项目根/resources/iproxy/iproxy.exe
|
||||
BASE = Path(__file__).resolve().parents[1] # iOSAI/
|
||||
IPROXY = BASE / "resources" / "iproxy" / "iproxy.exe"
|
||||
if not IPROXY.exists():
|
||||
raise FileNotFoundError(f"iproxy 不在这里:{IPROXY}")
|
||||
|
||||
# 避免 DLL 找不到:把目录加入 PATH(以及 Windows 的 DLL 搜索路径)
|
||||
env = os.environ.copy()
|
||||
env["PATH"] = str(IPROXY.parent) + os.pathsep + env.get("PATH", "")
|
||||
try:
|
||||
os.add_dll_directory(str(IPROXY.parent))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 起 iproxy:本地 9111 -> 设备 8100
|
||||
p = subprocess.Popen([str(IPROXY), "-u", UDID, "9111", "8100"],
|
||||
cwd=str(IPROXY.parent),
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
|
||||
# 探活 WDA
|
||||
c = wda.Client("http://127.0.0.1:9111")
|
||||
for _ in range(120):
|
||||
try:
|
||||
print(c.status()); break
|
||||
except Exception:
|
||||
time.sleep(1)
|
||||
@@ -1,3 +1,37 @@
|
||||
# import sys, traceback, os
|
||||
# from tidevice.__main__ import main
|
||||
#
|
||||
# import sys, os
|
||||
# with open(os.path.join(os.path.dirname(sys.executable), '_entry_log.txt'), 'w') as f:
|
||||
# f.write('entry reached\nargs=%r\n' % sys.argv)
|
||||
#
|
||||
# if hasattr(sys, 'frozen') and sys.executable.endswith('.exe'):
|
||||
# # 打包后且无控制台时,把标准流扔掉
|
||||
# sys.stdout = sys.stderr = open(os.devnull, 'w', encoding='utf-8')
|
||||
#
|
||||
# if __name__ == "__main__":
|
||||
# try:
|
||||
# main()
|
||||
# except Exception:
|
||||
# # 把 traceback 写到日志文件,但**不输出到控制台**
|
||||
# with open(os.path.expanduser("~/tidevice_crash.log"), "a", encoding="utf-8") as f:
|
||||
# traceback.print_exc(file=f)
|
||||
# # 静默退出,返回码 1
|
||||
# sys.exit(1)
|
||||
|
||||
|
||||
import sys, traceback, os
|
||||
from tidevice.__main__ import main
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except SystemExit as se: # 允许正常 exit(code) 继续生效
|
||||
raise
|
||||
except Exception: # 真正异常时才写日志
|
||||
crash_log = os.path.expanduser("~/tidevice_crash.log")
|
||||
with open(crash_log, "a", encoding="utf-8") as f:
|
||||
f.write("----- tidevice exe crash -----\n")
|
||||
traceback.print_exc(file=f)
|
||||
# 如果想让用户知道崩溃了,可以返回非 0
|
||||
sys.exit(1)
|
||||
Reference in New Issue
Block a user