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:
john
2026-06-06 21:09:27 +08:00
parent 2fae90b597
commit e76fa586f1
76 changed files with 47488 additions and 445 deletions
@@ -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
}
}
+22
View File
@@ -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()
}
+68
View File
@@ -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
}
}
}
+10
View File
@@ -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