Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,32 @@ jobs:
- uses: actions/checkout@v4
- name: Build and test SwiftUI app
run: xcodebuild -project WriteBench.xcodeproj -scheme WriteBench -configuration Debug -derivedDataPath build -destination 'platform=macOS' CODE_SIGN_IDENTITY=- test
ipad:
runs-on: macos-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Select an available iPad simulator
run: |
xcrun simctl list devices available -j > /tmp/simulators.json
python3 - <<'PY' >> "$GITHUB_ENV"
import json
devices = json.load(open('/tmp/simulators.json'))['devices']
ipad = next(d for group in devices.values() for d in group if d['name'].startswith('iPad'))
print('IPAD_SIMULATOR=' + ipad['udid'])
PY
- name: Boot iPad simulator
run: |
xcrun simctl boot "$IPAD_SIMULATOR"
xcrun simctl bootstatus "$IPAD_SIMULATOR" -b
- name: Build and test native iPad app
run: xcodebuild -project WriteBench.xcodeproj -scheme WriteBenchPad -destination "platform=iOS Simulator,id=$IPAD_SIMULATOR" -parallel-testing-enabled NO -derivedDataPath build-ipad CODE_SIGN_IDENTITY=- test
- name: Upload iPad test evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: ipad-verification
path: build-ipad/Logs/Test/*.xcresult
windows:
runs-on: windows-latest
steps:
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,7 @@ Documentation/codex-live-check.json
*.iml

.codex-build/

.codex-ipad/

.codex-ipad-device/
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,8 @@ macOS 自动化测试覆盖 87 个案例,Android 有 5 个领域测试与 2
<p align="center">WriteBench · 一次练习,一份原稿,一次认真重写。</p>

考研翻译报告支持“意群精讲”:词汇、结构与翻译要点、分组译文及完整译文组合;英一只讲解作答划线句,英二按全文句子展开。点击交卷即暂停计时,提交受阻时可点击继续作答恢复。


### iPadOS 开发版

已新增 iPad 专用原生 target `WriteBenchPad`(iPadOS 18+),支持横竖屏答题、PencilKit 手写与 OCR 校对、内置六级题库、DeepSeek 评阅及与 macOS 兼容的备份。详见 [iPad 开发和安装说明](platforms/ipad/README.md)。实体 iPad 安装需要自己的 Apple 开发签名,当前尚无公开可安装的 IPA。
418 changes: 416 additions & 2 deletions WriteBench.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

113 changes: 113 additions & 0 deletions WriteBench.xcodeproj/xcshareddata/xcschemes/WriteBenchPad.xcscheme
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "2CE8CC0563717B5A4D293DA4"
BuildableName = "WriteBenchPad.app"
BlueprintName = "WriteBenchPad"
ReferencedContainer = "container:WriteBench.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "2CE8CC0563717B5A4D293DA4"
BuildableName = "WriteBenchPad.app"
BlueprintName = "WriteBenchPad"
ReferencedContainer = "container:WriteBench.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "4D319C0D85B3B2D52BB9F38B"
BuildableName = "WriteBenchPadTests.xctest"
BlueprintName = "WriteBenchPadTests"
ReferencedContainer = "container:WriteBench.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9A12594761FB8665EB47620A"
BuildableName = "WriteBenchPadUITests.xctest"
BlueprintName = "WriteBenchPadUITests"
ReferencedContainer = "container:WriteBench.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
<CommandLineArguments>
</CommandLineArguments>
</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 = "2CE8CC0563717B5A4D293DA4"
BuildableName = "WriteBenchPad.app"
BlueprintName = "WriteBenchPad"
ReferencedContainer = "container:WriteBench.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "2CE8CC0563717B5A4D293DA4"
BuildableName = "WriteBenchPad.app"
BlueprintName = "WriteBenchPad"
ReferencedContainer = "container:WriteBench.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
2 changes: 2 additions & 0 deletions WriteBench/Persistence/EssaySession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ import SwiftData
}

@Model final class WritingDraft {
@Attribute(.externalStorage) var richText: Data? = nil
@Attribute(.externalStorage) var pencilDrawing: Data? = nil
@Attribute(.unique) var subtype: String
var question: String
var questionLabel: String
Expand Down
6 changes: 4 additions & 2 deletions WriteBench/Services/Backup/BackupService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ struct BackupArchive: Codable {
var parentSessionID: UUID?, questionImage: Data?, sourceImages: Data?
}
struct DraftRecord: Codable {
var richText: Data? = nil
var pencilDrawing: Data? = nil
var subtype: String, question: String, questionLabel: String, essay: String, elapsed: Double, inputMode: String, updatedAt: Date
var rewriteSessionID: UUID?, questionImage: Data?, sourceImages: Data?
}
Expand Down Expand Up @@ -59,7 +61,7 @@ struct BackupSummary: Equatable {
questionImage: $0.questionImage, sourceImages: $0.sourceImages)
},
drafts: try context.fetch(FetchDescriptor<WritingDraft>()).map {
.init(subtype: $0.subtype, question: $0.question, questionLabel: $0.questionLabel, essay: $0.essay, elapsed: $0.elapsed,
.init(richText: $0.richText, pencilDrawing: $0.pencilDrawing, subtype: $0.subtype, question: $0.question, questionLabel: $0.questionLabel, essay: $0.essay, elapsed: $0.elapsed,
inputMode: $0.inputMode, updatedAt: $0.updatedAt, rewriteSessionID: $0.rewriteSessionID, questionImage: $0.questionImage, sourceImages: $0.sourceImages)
},
questions: try context.fetch(FetchDescriptor<SavedQuestion>()).map {
Expand Down Expand Up @@ -111,7 +113,7 @@ struct BackupSummary: Equatable {
if drafts[record.subtype] == nil { context.insert(draft) }
draft.question = record.question; draft.questionLabel = record.questionLabel; draft.essay = record.essay; draft.elapsed = record.elapsed
draft.inputMode = record.inputMode; draft.updatedAt = record.updatedAt; draft.rewriteSessionID = record.rewriteSessionID
draft.questionImage = record.questionImage; draft.sourceImages = record.sourceImages
draft.questionImage = record.questionImage; draft.sourceImages = record.sourceImages; draft.richText = record.richText; draft.pencilDrawing = record.pencilDrawing
summary.added += 1
}
let questionIDs = Set(try context.fetch(FetchDescriptor<SavedQuestion>()).map(\.id))
Expand Down
5 changes: 5 additions & 0 deletions WriteBench/Services/OCR/OCRService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import Foundation
import Vision
import ImageIO
import UniformTypeIdentifiers
#if os(macOS)
import AppKit
#endif

struct OCRPage: Identifiable, Sendable {
var id = UUID()
Expand Down Expand Up @@ -66,6 +68,7 @@ struct VisionOCRService: Sendable {
}
}

#if os(macOS)
@MainActor enum ImageImporter {
static func selectImages() async -> [URL] {
let panel = NSOpenPanel()
Expand All @@ -78,3 +81,5 @@ struct VisionOCRService: Sendable {
return await panel.begin() == .OK ? panel.urls : []
}
}

#endif
40 changes: 40 additions & 0 deletions platforms/ipad/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?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>$(DEVELOPMENT_LANGUAGE)</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>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict/>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
38 changes: 38 additions & 0 deletions platforms/ipad/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# WriteBench for iPad

原生 SwiftUI / UIKit / PencilKit 应用,要求 **iPadOS 18 或更新版本**。`TARGETED_DEVICE_FAMILY = 2`,只面向 iPad,不包含 iPhone 版,也不启用 Mac Catalyst。

## 功能

- 考研英语一/二、六级、IELTS 的写作与翻译练习;66 道近五年六级完整正文随应用打包。
- 宽窗口题目与答题卡并排;窄窗口上下排列。横竖屏、窗口大小变化均可使用;窄屏切到手写时自动滚到答题卡。
- 原生键盘编辑:保留连续空格,Tab 插入四个空格,当前/选中段落左对齐、居中、右对齐。草稿和备份保留段落格式。
- PencilKit 手写纸、手指/Apple Pencil 输入、橡皮擦和清空确认;笔迹随草稿和备份保存。手写识别需要人工校对,再转换成评阅所需文字。
- 文件 App 图片导入、本机 Vision 中英文 OCR、原图预览、强制校对确认;UTF-8 文本、带文字层 PDF 导入。
- 交卷立即暂停计时;页面切换和退到后台暂停计时并保存草稿。离开前台会取消未完成的评阅,保留提交内容,不生成部分总分。
- DeepSeek 快速/三评审评阅,共用桌面的 rubric、JSON 校验、聚合规则、修改建议与翻译教学。
- 评阅历史、分享复习文本、错题/表达的间隔复习、按题型统计。
- Keychain 保存 API Key;无额外账号或云同步。可导入/导出与 macOS 兼容的 JSON 备份,备份不含凭据。自定义六级题库 JSON 需单独导入到题库。

桌面版的 Codex CLI 无法在 iPadOS 执行,因此 iPad 版目前只提供 DeepSeek。付费真实评阅与实体 Apple Pencil 尚需实机验证。

## Xcode 开发与测试

在仓库根目录:

```sh
xcodegen generate
xcodebuild -project WriteBench.xcodeproj -scheme WriteBenchPad \
-destination 'platform=iOS Simulator,name=iPad Pro 11-inch (M5)' \
-derivedDataPath build-ipad CODE_SIGN_IDENTITY=- test
```

使用本机实际可用的 iPad 模拟器名称(`xcrun simctl list devices available`)。模拟器构建使用临时签名;不要设置 `CODE_SIGNING_ALLOWED=NO` 来运行应用,否则 Keychain 会返回缺少 entitlement。

CI 会自动选取可用 iPad 模拟器,执行8 项单元测试与 6 项 UI 测试并上传 xcresult。测试使用内存数据库;OCR 测试用样本图片,不调用付费评阅。

## 安装到实体 iPad / TestFlight

打开 `WriteBench.xcodeproj`,选择 **WriteBenchPad** target,在 Signing & Capabilities 选择自己的 Apple 开发 Team,连接 iPad 并运行。按设备要求开启 Developer Mode。

TestFlight / App Store 需要 Apple Developer Program、有效的分发签名和 App Store Connect 配置。模拟器 `.app` 与未签名设备构建都不能直接作为可安装 IPA 分发。当前没有发布 iPad 安装包,不会把模拟器包伪装成 IPA。
Binary file added platforms/ipad/Screenshots/settings-portrait.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added platforms/ipad/Screenshots/writing-landscape.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
25 changes: 25 additions & 0 deletions platforms/ipad/VALIDATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# iPad 验证记录

验证日期:2026-10-03(本机时区)。Xcode 27.0,iPadOS 26.4 模拟器。

| 检查 | 结果 |
| --- | --- |
| iPad Pro 11-inch (M5) | 8 项单元测试、6 项 UI 测试全部通过 |
| iPad mini (A17 Pro) | 同一套 14 项测试全部通过 |
| iOS arm64 设备构建 | 通过,未签名,不作为可安装 IPA |
| macOS 回归 | 87 项原有测试全部通过 |
| 设备族配置 | UIDeviceFamily = [2],只面向 iPad |

单元测试实际读取内置六级题库(33 写作 + 33 翻译)与全部 rubric,执行真实 Vision OCR,并核对草稿的连续空格、排版和备份数据。题型切换保持独立草稿,间隔复习推进卡片。

UI 测试实际点击题库正文与选题、键盘输入 Tab/空格、交卷后暂停计时、横竖屏导航、PencilKit 笔迹绘制与清空确认、OCR 的必选校对开关,以及切到后台后的计时和草稿状态。OCR 校对 UI 使用调试样本;真实图像 OCR 另由单元测试执行。

未验证实体 iPad、实体 Apple Pencil、多任务窗口拖拽、付费 DeepSeek 评阅或 TestFlight 分发。模拟器运行包采用临时签名;本机没有有效 Apple 开发签名证书。

## 界面预览

![横屏答题](Screenshots/writing-landscape.png)

![竖屏设置](Screenshots/settings-portrait.png)

CI 的 iPad job 会执行测试并上传 xcresult。真机安装需要配置 Apple 开发 Team,详见 [安装说明](README.md)。
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"images": [{"filename": "icon.png", "idiom": "universal", "platform": "ios", "size": "1024x1024"}], "info": {"author": "xcode", "version": 1}}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading