Add word-book practice, iOS app shell, and fix embedded WebView blank screen.
Ship dual-track learning (daily accumulation vs textbook),沪教/商务词书 APIs and UI, native iOS wrapper with bundled H5, and production book import on deploy. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
# WordLoop iOS App
|
||||
|
||||
原生 iOS 壳应用,内嵌最新 Vue 前端构建产物,通过 `WKWebView` 展示,API 指向所选服务器。
|
||||
|
||||
## 与 H5 同步
|
||||
|
||||
Xcode 编译前会自动执行 `ios/scripts/build-web-assets.sh`,将 `frontend/` 最新代码构建并复制到 App 包内的 `www/`。因此首页(日常积累 / 词书练习双模式)、设置页(微信风格账号安全)等与本地 H5 保持一致,不依赖生产站是否已部署新版。
|
||||
|
||||
手动构建前端资源:
|
||||
|
||||
```bash
|
||||
bash ios/scripts/build-web-assets.sh
|
||||
```
|
||||
|
||||
## 服务器配置
|
||||
|
||||
| 环境 | API 地址 | 说明 |
|
||||
|------|----------|------|
|
||||
| **生产** | `https://w.tkmind.cn/api` | 默认环境 |
|
||||
| **本地** | `http://127.0.0.1:18004/api` | 对应 `./start.sh` 后端;真机请改为电脑局域网 IP |
|
||||
|
||||
底部可展开切换服务器。界面与登录在同一 WebView,勾选「记住登录状态」后下次打开自动进入首页。
|
||||
|
||||
## 打开工程
|
||||
|
||||
```bash
|
||||
open ios/WordLoop/WordLoop.xcodeproj
|
||||
```
|
||||
|
||||
在 Xcode 中选择你的 **Team**(Signing & Capabilities),然后运行到模拟器或真机。
|
||||
|
||||
## 本地调试
|
||||
|
||||
1. 项目根目录启动服务:
|
||||
|
||||
```bash
|
||||
./start.sh
|
||||
```
|
||||
|
||||
2. iOS 模拟器:底部展开服务器栏,选「本地服务器」,默认 `http://127.0.0.1:18003`(API 自动映射到 18004)。
|
||||
|
||||
3. 真机:将本地地址改为 Mac 局域网 IP,例如 `http://192.168.1.8:18003`。
|
||||
|
||||
## 架构说明
|
||||
|
||||
- **原生层**:SwiftUI + WKWebView + 底部服务器栏
|
||||
- **界面层**:内嵌 `frontend` 构建产物,经 `wordloop://` 本地协议加载(避免 `file://` 白屏)
|
||||
- **数据层**:请求发往所选服务器的 `/api`
|
||||
|
||||
内嵌页通过 `loadHTMLString` 加载,`app.js` 构建时会内联到 `</body>` 前(避免在 `<head>` 执行时 `#app` 尚未存在导致白屏)。
|
||||
|
||||
若出现白屏,请先执行 `bash ios/scripts/build-web-assets.sh`,再在 Xcode **Product → Clean Build Folder** 后重新 Run。
|
||||
@@ -0,0 +1,414 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 56;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
A10000010000000000000001 /* WordLoopApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000001 /* WordLoopApp.swift */; };
|
||||
A10000010000000000000002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000002 /* ContentView.swift */; };
|
||||
A10000010000000000000003 /* ServerEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000003 /* ServerEnvironment.swift */; };
|
||||
A10000010000000000000004 /* ServerGatewayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000004 /* ServerGatewayView.swift */; };
|
||||
A10000010000000000000005 /* WordLoopWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000005 /* WordLoopWebView.swift */; };
|
||||
A10000010000000000000009 /* BundleWebSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000009 /* BundleWebSchemeHandler.swift */; };
|
||||
A10000010000000000000006 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000006 /* Assets.xcassets */; };
|
||||
A10000010000000000000008 /* www in Resources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000008 /* www */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
A10000020000000000000001 /* WordLoopApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordLoopApp.swift; sourceTree = "<group>"; };
|
||||
A10000020000000000000002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
A10000020000000000000003 /* ServerEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerEnvironment.swift; sourceTree = "<group>"; };
|
||||
A10000020000000000000004 /* ServerGatewayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerGatewayView.swift; sourceTree = "<group>"; };
|
||||
A10000020000000000000005 /* WordLoopWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordLoopWebView.swift; sourceTree = "<group>"; };
|
||||
A10000020000000000000006 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
A10000020000000000000007 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
A10000020000000000000008 /* www */ = {isa = PBXFileReference; lastKnownFileType = folder; path = www; sourceTree = "<group>"; };
|
||||
A10000020000000000000009 /* BundleWebSchemeHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleWebSchemeHandler.swift; sourceTree = "<group>"; };
|
||||
A10000030000000000000001 /* WordLoop.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = WordLoop.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
A10000040000000000000001 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
A10000050000000000000001 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A10000050000000000000002 /* WordLoop */,
|
||||
A10000050000000000000003 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A10000050000000000000006 /* Services */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A10000020000000000000009 /* BundleWebSchemeHandler.swift */,
|
||||
);
|
||||
path = Services;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A10000050000000000000002 /* WordLoop */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A10000020000000000000001 /* WordLoopApp.swift */,
|
||||
A10000020000000000000002 /* ContentView.swift */,
|
||||
A10000050000000000000004 /* Models */,
|
||||
A10000050000000000000005 /* Views */,
|
||||
A10000050000000000000006 /* Services */,
|
||||
A10000020000000000000006 /* Assets.xcassets */,
|
||||
A10000020000000000000008 /* www */,
|
||||
A10000020000000000000007 /* Info.plist */,
|
||||
);
|
||||
path = WordLoop;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A10000050000000000000003 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A10000030000000000000001 /* WordLoop.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A10000050000000000000004 /* Models */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A10000020000000000000003 /* ServerEnvironment.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A10000050000000000000005 /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A10000020000000000000004 /* ServerGatewayView.swift */,
|
||||
A10000020000000000000005 /* WordLoopWebView.swift */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
A10000060000000000000001 /* WordLoop */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = A10000090000000000000003 /* Build configuration list for PBXNativeTarget "WordLoop" */;
|
||||
buildPhases = (
|
||||
A100000C0000000000000001 /* Build Web Assets */,
|
||||
A10000070000000000000001 /* Sources */,
|
||||
A10000040000000000000001 /* Frameworks */,
|
||||
A10000080000000000000001 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = WordLoop;
|
||||
productName = WordLoop;
|
||||
productReference = A10000030000000000000001 /* WordLoop.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
A100000A0000000000000001 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 1600;
|
||||
LastUpgradeCheck = 2650;
|
||||
TargetAttributes = {
|
||||
A10000060000000000000001 = {
|
||||
CreatedOnToolsVersion = 16.0;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = A10000090000000000000001 /* Build configuration list for PBXProject "WordLoop" */;
|
||||
compatibilityVersion = "Xcode 14.0";
|
||||
developmentRegion = "zh-Hans";
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
"zh-Hans",
|
||||
);
|
||||
mainGroup = A10000050000000000000001;
|
||||
productRefGroup = A10000050000000000000003 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
A10000060000000000000001 /* WordLoop */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
A10000080000000000000001 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A10000010000000000000006 /* Assets.xcassets in Resources */,
|
||||
A10000010000000000000008 /* www in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
A100000C0000000000000001 /* Build Web Assets */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"$(SRCROOT)/../scripts/build-web-assets.sh",
|
||||
"$(SRCROOT)/../../frontend/package.json",
|
||||
);
|
||||
name = "Build Web Assets";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(SRCROOT)/WordLoop/www/index.html",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/bash;
|
||||
shellScript = "set -euo pipefail\nexport PATH=\"/usr/local/bin:/opt/homebrew/bin:${PATH}\"\nbash \"${SRCROOT}/../scripts/build-web-assets.sh\"\n";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
A10000070000000000000001 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A10000010000000000000001 /* WordLoopApp.swift in Sources */,
|
||||
A10000010000000000000002 /* ContentView.swift in Sources */,
|
||||
A10000010000000000000003 /* ServerEnvironment.swift in Sources */,
|
||||
A10000010000000000000004 /* ServerGatewayView.swift in Sources */,
|
||||
A10000010000000000000005 /* WordLoopWebView.swift in Sources */,
|
||||
A10000010000000000000009 /* BundleWebSchemeHandler.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
A100000B0000000000000001 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = 426W6QBL47;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
A100000B0000000000000002 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = 426W6QBL47;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
A100000B0000000000000003 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 426W6QBL47;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = WordLoop/Info.plist;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = cn.tkmind.wordloop;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
A100000B0000000000000004 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 426W6QBL47;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = WordLoop/Info.plist;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = cn.tkmind.wordloop;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
A10000090000000000000001 /* Build configuration list for PBXProject "WordLoop" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A100000B0000000000000001 /* Debug */,
|
||||
A100000B0000000000000002 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
A10000090000000000000003 /* Build configuration list for PBXNativeTarget "WordLoop" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A100000B0000000000000003 /* Debug */,
|
||||
A100000B0000000000000004 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = A100000A0000000000000001 /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2650"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "A10000060000000000000001"
|
||||
BuildableName = "WordLoop.app"
|
||||
BlueprintName = "WordLoop"
|
||||
ReferencedContainer = "container:WordLoop.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "A10000060000000000000001"
|
||||
BuildableName = "WordLoop.app"
|
||||
BlueprintName = "WordLoop"
|
||||
ReferencedContainer = "container:WordLoop.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "A10000060000000000000001"
|
||||
BuildableName = "WordLoop.app"
|
||||
BlueprintName = "WordLoop"
|
||||
ReferencedContainer = "container:WordLoop.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.969",
|
||||
"green" : "0.431",
|
||||
"red" : "0.310"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.984",
|
||||
"green" : "0.965",
|
||||
"red" : "0.956"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@State private var environment = ServerSettings.environment
|
||||
@State private var localBaseURL = ServerSettings.localBaseURL
|
||||
@State private var activeBaseURL = ServerSettings.activeBaseURL
|
||||
|
||||
var body: some View {
|
||||
WordLoopWebContainer(
|
||||
environment: $environment,
|
||||
localBaseURL: $localBaseURL,
|
||||
baseURL: activeBaseURL,
|
||||
onBaseURLChange: { baseURL in
|
||||
activeBaseURL = baseURL
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>zh-Hans</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>WordLoop</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
<true/>
|
||||
<key>NSExceptionDomains</key>
|
||||
<dict>
|
||||
<key>127.0.0.1</key>
|
||||
<dict>
|
||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||
<true/>
|
||||
<key>NSIncludesSubdomains</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>localhost</key>
|
||||
<dict>
|
||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||
<true/>
|
||||
<key>NSIncludesSubdomains</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>连接本地开发服务器(前后端)时需要访问局域网。</string>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
<key>UIColorName</key>
|
||||
<string>LaunchBackground</string>
|
||||
</dict>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,125 @@
|
||||
import Foundation
|
||||
|
||||
enum ServerEnvironment: String, CaseIterable, Identifiable {
|
||||
case production
|
||||
case local
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .production: return "生产服务器"
|
||||
case .local: return "本地服务器"
|
||||
}
|
||||
}
|
||||
|
||||
var subtitle: String {
|
||||
switch self {
|
||||
case .production: return "w.tkmind.cn"
|
||||
case .local: return "开发机前后端 (18003/18004)"
|
||||
}
|
||||
}
|
||||
|
||||
var defaultBaseURL: String {
|
||||
switch self {
|
||||
case .production: return "https://w.tkmind.cn"
|
||||
case .local: return "http://127.0.0.1:18003"
|
||||
}
|
||||
}
|
||||
|
||||
var iconName: String {
|
||||
switch self {
|
||||
case .production: return "cloud.fill"
|
||||
case .local: return "laptopcomputer"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ServerSettings {
|
||||
private static let environmentKey = "wordloop.server.environment"
|
||||
private static let localBaseURLKey = "wordloop.server.localBaseURL"
|
||||
|
||||
static var environment: ServerEnvironment {
|
||||
get {
|
||||
guard let raw = UserDefaults.standard.string(forKey: environmentKey),
|
||||
let env = ServerEnvironment(rawValue: raw) else {
|
||||
return .production
|
||||
}
|
||||
return env
|
||||
}
|
||||
set {
|
||||
UserDefaults.standard.set(newValue.rawValue, forKey: environmentKey)
|
||||
}
|
||||
}
|
||||
|
||||
static var localBaseURL: String {
|
||||
get {
|
||||
UserDefaults.standard.string(forKey: localBaseURLKey)
|
||||
?? ServerEnvironment.local.defaultBaseURL
|
||||
}
|
||||
set {
|
||||
UserDefaults.standard.set(newValue.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
forKey: localBaseURLKey)
|
||||
}
|
||||
}
|
||||
|
||||
static var activeBaseURL: String {
|
||||
switch environment {
|
||||
case .production:
|
||||
return ServerEnvironment.production.defaultBaseURL
|
||||
case .local:
|
||||
return normalizedBaseURL(localBaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
static var activeAPIBaseURL: String {
|
||||
apiBaseURL(for: activeBaseURL)
|
||||
}
|
||||
|
||||
static func apiBaseURL(for webBaseURL: String) -> String {
|
||||
let normalized = normalizedBaseURL(webBaseURL)
|
||||
if normalized.contains(":18003") {
|
||||
return normalized.replacingOccurrences(of: ":18003", with: ":18004") + "/api"
|
||||
}
|
||||
return normalized + "/api"
|
||||
}
|
||||
|
||||
static var bundledWWWDirectory: URL? {
|
||||
if let url = Bundle.main.url(forResource: "www", withExtension: nil) {
|
||||
return url
|
||||
}
|
||||
let fallback = Bundle.main.bundleURL.appendingPathComponent("www", isDirectory: true)
|
||||
return FileManager.default.fileExists(atPath: fallback.path) ? fallback : nil
|
||||
}
|
||||
|
||||
static var bundledHomeURL: URL? {
|
||||
bundledWWWDirectory?.appendingPathComponent("index.html")
|
||||
}
|
||||
|
||||
static func bundledReadAccessURL() -> URL? {
|
||||
bundledWWWDirectory ?? Bundle.main.bundleURL
|
||||
}
|
||||
|
||||
static func homeURL(for baseURL: String) -> URL? {
|
||||
bundledHomeURL ?? {
|
||||
let trimmed = normalizedBaseURL(baseURL)
|
||||
return URL(string: "\(trimmed)/")
|
||||
}()
|
||||
}
|
||||
|
||||
static func loginURL(for baseURL: String) -> URL? {
|
||||
if bundledHomeURL != nil {
|
||||
return bundledHomeURL
|
||||
}
|
||||
let trimmed = normalizedBaseURL(baseURL)
|
||||
return URL(string: "\(trimmed)/login")
|
||||
}
|
||||
|
||||
static func normalizedBaseURL(_ value: String) -> String {
|
||||
var url = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
while url.hasSuffix("/") {
|
||||
url.removeLast()
|
||||
}
|
||||
return url
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
/// 通过 wordloop:// 协议从 App Bundle 的 www 目录提供前端资源,避免 file:// 脚本无法执行。
|
||||
final class BundleWebSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
private let wwwRoot: URL
|
||||
private let queue = DispatchQueue(label: "wordloop.bundle-web-scheme")
|
||||
|
||||
init(wwwRoot: URL) {
|
||||
self.wwwRoot = wwwRoot
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
|
||||
queue.async { [wwwRoot] in
|
||||
self.serve(urlSchemeTask: urlSchemeTask, wwwRoot: wwwRoot)
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
|
||||
// cancelled navigations; no response needed
|
||||
}
|
||||
|
||||
private func serve(urlSchemeTask: WKURLSchemeTask, wwwRoot: URL) {
|
||||
guard let requestURL = urlSchemeTask.request.url else {
|
||||
fail(urlSchemeTask, code: 400, message: "Bad request")
|
||||
return
|
||||
}
|
||||
|
||||
var path = requestURL.path
|
||||
if path.isEmpty || path == "/" {
|
||||
path = "/index.html"
|
||||
}
|
||||
if path.hasPrefix("/") {
|
||||
path.removeFirst()
|
||||
}
|
||||
|
||||
let fileURL = wwwRoot.appendingPathComponent(path)
|
||||
guard fileURL.path.hasPrefix(wwwRoot.path),
|
||||
FileManager.default.fileExists(atPath: fileURL.path) else {
|
||||
fail(urlSchemeTask, code: 404, message: "Not found: \(path)")
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
var data = try Data(contentsOf: fileURL)
|
||||
let mime = Self.mimeType(for: fileURL.pathExtension)
|
||||
guard let response = HTTPURLResponse(
|
||||
url: requestURL,
|
||||
statusCode: 200,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: [
|
||||
"Content-Type": mime,
|
||||
"Content-Length": String(data.count),
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
]
|
||||
) else {
|
||||
fail(urlSchemeTask, code: 500, message: "Bad response")
|
||||
return
|
||||
}
|
||||
urlSchemeTask.didReceive(response)
|
||||
urlSchemeTask.didReceive(data)
|
||||
urlSchemeTask.didFinish()
|
||||
} catch {
|
||||
fail(urlSchemeTask, code: 500, message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func fail(_ task: WKURLSchemeTask, code: Int, message: String) {
|
||||
let error = NSError(
|
||||
domain: "WordLoopWeb",
|
||||
code: code,
|
||||
userInfo: [NSLocalizedDescriptionKey: message]
|
||||
)
|
||||
task.didFailWithError(error)
|
||||
}
|
||||
|
||||
private static func mimeType(for ext: String) -> String {
|
||||
switch ext.lowercased() {
|
||||
case "html", "htm": return "text/html"
|
||||
case "js": return "text/javascript; charset=utf-8"
|
||||
case "css": return "text/css"
|
||||
case "json": return "application/json"
|
||||
case "png": return "image/png"
|
||||
case "jpg", "jpeg": return "image/jpeg"
|
||||
case "svg": return "image/svg+xml"
|
||||
case "woff2": return "font/woff2"
|
||||
case "woff": return "font/woff"
|
||||
default: return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum BundledWebEntry {
|
||||
static let schemeURL = URL(string: "wordloop://app/index.html#/")!
|
||||
|
||||
static func register(in configuration: WKWebViewConfiguration, wwwRoot: URL) {
|
||||
configuration.setURLSchemeHandler(BundleWebSchemeHandler(wwwRoot: wwwRoot), forURLScheme: "wordloop")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ServerBottomBar: View {
|
||||
@Binding var environment: ServerEnvironment
|
||||
@Binding var localBaseURL: String
|
||||
let onServerChange: (String) -> Void
|
||||
|
||||
@State private var expanded = false
|
||||
@State private var validationMessage: String?
|
||||
|
||||
private var resolvedBaseURL: String {
|
||||
environment == .production
|
||||
? ServerEnvironment.production.defaultBaseURL
|
||||
: ServerSettings.normalizedBaseURL(localBaseURL)
|
||||
}
|
||||
|
||||
private var shortLabel: String {
|
||||
switch environment {
|
||||
case .production:
|
||||
return "w.tkmind.cn"
|
||||
case .local:
|
||||
let url = resolvedBaseURL
|
||||
if let host = URL(string: url)?.host {
|
||||
return host
|
||||
}
|
||||
return "本地"
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Divider()
|
||||
if expanded {
|
||||
expandedPanel
|
||||
}
|
||||
collapsedRow
|
||||
}
|
||||
.background(Color(.systemBackground))
|
||||
}
|
||||
|
||||
private var collapsedRow: some View {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
expanded.toggle()
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "server.rack")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color(red: 0.31, green: 0.43, blue: 0.97))
|
||||
Text("服务器")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(environment.title)
|
||||
.font(.caption.weight(.semibold))
|
||||
Text(shortLabel)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 0)
|
||||
Image(systemName: expanded ? "chevron.down" : "chevron.up")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var expandedPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Picker("服务器", selection: $environment) {
|
||||
ForEach(ServerEnvironment.allCases) { item in
|
||||
Text(item.title).tag(item)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: environment) { _, _ in
|
||||
validationMessage = nil
|
||||
applyServerChange()
|
||||
}
|
||||
|
||||
if environment == .local {
|
||||
TextField("http://192.168.x.x:18003", text: $localBaseURL)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.keyboardType(.URL)
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.onSubmit {
|
||||
applyServerChange()
|
||||
}
|
||||
|
||||
Text("模拟器 127.0.0.1:18003;真机填电脑局域网 IP")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let validationMessage {
|
||||
Text(validationMessage)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
private func applyServerChange() {
|
||||
validationMessage = nil
|
||||
let baseURL = resolvedBaseURL
|
||||
|
||||
guard baseURL.hasPrefix("http://") || baseURL.hasPrefix("https://") else {
|
||||
validationMessage = "地址需以 http:// 或 https:// 开头"
|
||||
return
|
||||
}
|
||||
guard ServerSettings.apiBaseURL(for: baseURL).hasPrefix("http") else {
|
||||
validationMessage = "地址格式无效"
|
||||
return
|
||||
}
|
||||
|
||||
ServerSettings.environment = environment
|
||||
if environment == .local {
|
||||
ServerSettings.localBaseURL = baseURL
|
||||
}
|
||||
onServerChange(baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ServerBottomBar(
|
||||
environment: .constant(.production),
|
||||
localBaseURL: .constant("http://127.0.0.1:18003"),
|
||||
onServerChange: { _ in }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
@MainActor
|
||||
final class WebViewStore: ObservableObject {
|
||||
@Published var isLoading = true
|
||||
@Published var canGoBack = false
|
||||
@Published var pageTitle = "WordLoop"
|
||||
weak var webView: WKWebView?
|
||||
var schemeHandler: BundleWebSchemeHandler?
|
||||
}
|
||||
|
||||
struct WordLoopWebView: UIViewRepresentable {
|
||||
let entryURL: URL
|
||||
let apiBaseURL: String
|
||||
let usesBundledFrontend: Bool
|
||||
@ObservedObject var store: WebViewStore
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(store: store, usesBundledFrontend: usesBundledFrontend)
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
let configuration = WKWebViewConfiguration()
|
||||
configuration.defaultWebpagePreferences.allowsContentJavaScript = true
|
||||
configuration.websiteDataStore = .default()
|
||||
configuration.userContentController.addUserScript(
|
||||
Self.makeInjectionScript(apiBaseURL: apiBaseURL)
|
||||
)
|
||||
|
||||
let webView = WKWebView(frame: .zero, configuration: configuration)
|
||||
if #available(iOS 16.4, *) {
|
||||
webView.isInspectable = true
|
||||
}
|
||||
webView.navigationDelegate = context.coordinator
|
||||
webView.allowsBackForwardNavigationGestures = true
|
||||
webView.scrollView.bounces = true
|
||||
webView.isOpaque = false
|
||||
webView.backgroundColor = UIColor(red: 0.956, green: 0.965, blue: 0.984, alpha: 1)
|
||||
|
||||
context.coordinator.attach(webView)
|
||||
context.coordinator.load(entryURL: entryURL, apiBaseURL: apiBaseURL, in: webView)
|
||||
return webView
|
||||
}
|
||||
|
||||
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||
context.coordinator.attach(webView)
|
||||
context.coordinator.load(entryURL: entryURL, apiBaseURL: apiBaseURL, in: webView)
|
||||
context.coordinator.updateAPIBase(apiBaseURL, in: webView)
|
||||
}
|
||||
|
||||
private static func makeInjectionScript(apiBaseURL: String) -> WKUserScript {
|
||||
let escapedAPI = apiBaseURL
|
||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "'", with: "\\'")
|
||||
let source = """
|
||||
window.__WORDLOOP_RUNTIME__ = {
|
||||
embedded: true,
|
||||
iosApp: true,
|
||||
apiBase: '\(escapedAPI)'
|
||||
};
|
||||
"""
|
||||
return WKUserScript(source: source, injectionTime: .atDocumentStart, forMainFrameOnly: true)
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, WKNavigationDelegate {
|
||||
private let store: WebViewStore
|
||||
private let usesBundledFrontend: Bool
|
||||
private var loadedEntryURL: URL?
|
||||
private var loadedAPIBaseURL: String?
|
||||
private var pendingAPIBaseURL: String?
|
||||
|
||||
init(store: WebViewStore, usesBundledFrontend: Bool) {
|
||||
self.store = store
|
||||
self.usesBundledFrontend = usesBundledFrontend
|
||||
}
|
||||
|
||||
func attach(_ webView: WKWebView) {
|
||||
store.webView = webView
|
||||
}
|
||||
|
||||
func load(entryURL: URL, apiBaseURL: String, in webView: WKWebView) {
|
||||
let entryChanged = loadedEntryURL != entryURL
|
||||
let apiChanged = loadedAPIBaseURL != apiBaseURL
|
||||
guard entryChanged || (apiChanged && loadedEntryURL == nil) else {
|
||||
if apiChanged {
|
||||
updateAPIBase(apiBaseURL, in: webView)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
loadedEntryURL = entryURL
|
||||
loadedAPIBaseURL = apiBaseURL
|
||||
pendingAPIBaseURL = apiBaseURL
|
||||
store.isLoading = true
|
||||
|
||||
if usesBundledFrontend, let bundleEntry = Self.bundledHTMLPayload() {
|
||||
// loadHTMLString 比 wordloop:// 自定义协议更可靠(WKWebView 对内联/外链脚本执行更稳定)
|
||||
webView.loadHTMLString(bundleEntry.html, baseURL: bundleEntry.baseURL)
|
||||
} else {
|
||||
webView.load(URLRequest(url: entryURL, cachePolicy: .reloadIgnoringLocalCacheData))
|
||||
}
|
||||
}
|
||||
|
||||
private static func bundledHTMLPayload() -> (html: String, baseURL: URL)? {
|
||||
guard let wwwRoot = ServerSettings.bundledWWWDirectory,
|
||||
let indexURL = ServerSettings.bundledHomeURL,
|
||||
let html = try? String(contentsOf: indexURL, encoding: .utf8),
|
||||
!html.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
return (html, wwwRoot)
|
||||
}
|
||||
|
||||
func updateAPIBase(_ apiBaseURL: String, in webView: WKWebView) {
|
||||
guard loadedAPIBaseURL != apiBaseURL else { return }
|
||||
loadedAPIBaseURL = apiBaseURL
|
||||
let escapedAPI = apiBaseURL
|
||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "'", with: "\\'")
|
||||
webView.evaluateJavaScript("""
|
||||
window.__WORDLOOP_RUNTIME__ = window.__WORDLOOP_RUNTIME__ || {};
|
||||
window.__WORDLOOP_RUNTIME__.apiBase = '\(escapedAPI)';
|
||||
if (window.__wordloopSetApiBase) window.__wordloopSetApiBase('\(escapedAPI)');
|
||||
""")
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||
store.isLoading = true
|
||||
store.canGoBack = webView.canGoBack
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
store.isLoading = false
|
||||
store.canGoBack = webView.canGoBack
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||
store.isLoading = false
|
||||
store.canGoBack = webView.canGoBack
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
store.isLoading = false
|
||||
store.canGoBack = webView.canGoBack
|
||||
store.pageTitle = webView.title ?? "WordLoop"
|
||||
if let apiBaseURL = pendingAPIBaseURL {
|
||||
updateAPIBase(apiBaseURL, in: webView)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WordLoopWebContainer: View {
|
||||
@Binding var environment: ServerEnvironment
|
||||
@Binding var localBaseURL: String
|
||||
let baseURL: String
|
||||
let onBaseURLChange: (String) -> Void
|
||||
|
||||
@StateObject private var store = WebViewStore()
|
||||
@State private var entryURL: URL
|
||||
@State private var apiBaseURL: String
|
||||
@State private var usesBundledFrontend: Bool
|
||||
|
||||
init(
|
||||
environment: Binding<ServerEnvironment>,
|
||||
localBaseURL: Binding<String>,
|
||||
baseURL: String,
|
||||
onBaseURLChange: @escaping (String) -> Void
|
||||
) {
|
||||
_environment = environment
|
||||
_localBaseURL = localBaseURL
|
||||
self.baseURL = baseURL
|
||||
self.onBaseURLChange = onBaseURLChange
|
||||
|
||||
let hasBundle = ServerSettings.bundledWWWDirectory != nil
|
||||
let resolvedEntry = hasBundle
|
||||
? (ServerSettings.bundledHomeURL ?? BundledWebEntry.schemeURL)
|
||||
: (ServerSettings.homeURL(for: baseURL)
|
||||
?? URL(string: ServerEnvironment.production.defaultBaseURL + "/")!)
|
||||
let resolvedAPI = ServerSettings.apiBaseURL(for: baseURL)
|
||||
|
||||
_entryURL = State(initialValue: resolvedEntry)
|
||||
_apiBaseURL = State(initialValue: resolvedAPI)
|
||||
_usesBundledFrontend = State(initialValue: hasBundle)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
WordLoopWebView(
|
||||
entryURL: entryURL,
|
||||
apiBaseURL: apiBaseURL,
|
||||
usesBundledFrontend: usesBundledFrontend,
|
||||
store: store
|
||||
)
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
|
||||
if store.isLoading {
|
||||
ProgressView("加载中…")
|
||||
.padding(20)
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
.navigationTitle(store.pageTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
Button {
|
||||
store.webView?.reload()
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
.disabled(store.webView == nil)
|
||||
|
||||
Button {
|
||||
if store.webView?.canGoBack == true {
|
||||
store.webView?.goBack()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "chevron.backward")
|
||||
}
|
||||
.disabled(!store.canGoBack)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ServerBottomBar(
|
||||
environment: $environment,
|
||||
localBaseURL: $localBaseURL,
|
||||
onServerChange: handleServerChange
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleServerChange(_ newBaseURL: String) {
|
||||
let newAPI = ServerSettings.apiBaseURL(for: newBaseURL)
|
||||
onBaseURLChange(newBaseURL)
|
||||
apiBaseURL = newAPI
|
||||
|
||||
if !usesBundledFrontend, let remoteURL = ServerSettings.homeURL(for: newBaseURL) {
|
||||
entryURL = remoteURL
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct WordLoopApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
FRONTEND="$ROOT/frontend"
|
||||
OUT="$ROOT/ios/WordLoop/WordLoop/www"
|
||||
|
||||
echo ">>> 构建 iOS 内嵌前端"
|
||||
cd "$FRONTEND"
|
||||
if [ ! -d node_modules ]; then
|
||||
npm install --silent
|
||||
fi
|
||||
npm run build:ios
|
||||
|
||||
rm -rf "$OUT"
|
||||
mkdir -p "$OUT"
|
||||
cp -R dist/* "$OUT/"
|
||||
|
||||
python3 - <<PY
|
||||
from pathlib import Path
|
||||
|
||||
out = Path("$OUT")
|
||||
html_path = out / "index.html"
|
||||
js_path = out / "assets" / "app.js"
|
||||
css_path = out / "assets" / "app.css"
|
||||
html = html_path.read_text(encoding="utf-8")
|
||||
js = js_path.read_text(encoding="utf-8")
|
||||
css = css_path.read_text(encoding="utf-8")
|
||||
|
||||
# 单文件内联,避免 wordloop:// 二次请求脚本失败
|
||||
html = html.replace('<link rel="stylesheet" href="wordloop://app/assets/app.css">', f"<style>{css}</style>")
|
||||
html = html.replace(
|
||||
'<script src="wordloop://app/assets/app.js"></script>',
|
||||
"",
|
||||
)
|
||||
# 脚本放在 </body> 前,确保 #app 已存在(head 内联会导致 Vue mount 失败、白屏)
|
||||
html = html.replace(
|
||||
"</body>",
|
||||
f" <script>{js}</script>\\n </body>",
|
||||
)
|
||||
|
||||
html_path.write_text(html, encoding="utf-8")
|
||||
print(">>> 已内联 app.js / app.css 到 index.html")
|
||||
PY
|
||||
|
||||
echo ">>> 已复制到 $OUT"
|
||||
Reference in New Issue
Block a user