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,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
Reference in New Issue
Block a user