Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
build/
.swiftpm/
*.xcodeproj
!/openless-all/app/ios/OpenLess.xcodeproj/
Package.resolved
xcuserdata/
DerivedData/
Expand Down
2 changes: 2 additions & 0 deletions README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,8 @@ OpenLess 只做一件事:**把语音变成可用的书面文字(尤其是 AI 提

## 当前状态

- **iOS 原生版源码**:新增独立的 SwiftUI 应用与 UIKit 键盘扩展,覆盖听写、文字润色、历史、词典和自定义风格。工程与支持范围见 [iOS README](openless-all/app/ios/README.md)。当前交付为尚未编译验证的源工程,不是已发布的 iOS 安装包。

下面每一项,都是一层已经沉降为默认、你授权一次之后就不必再操心的能力——这就是开屏之后你所站立的基础设施:

- 共享 Rust 后端位于 `openless-core`。macOS 与 Windows 使用薄 Tauri 2 宿主和 React/TypeScript 前端;Android 暂时保留 Tauri mobile 宿主;Linux 使用独立原生宿主,不编译 Tauri 或 WebKitGTK。
Expand Down
3 changes: 3 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
| Host(Win/mac/Android) | `src-tauri/`(crate `openless`) | `src/lib.rs` 注册命令;适配窗口、热键、音频、凭据、插入、IME 和生命周期 |
| 共享 Core | `crates/openless-core/` | 业务规则、会话、服务调用和数据仓储;通过 trait 接入 Host 能力 |
| Linux Host + UI | `linux-egui/`(crate `openless-linux-egui`) | `backend.rs` 组装 `OpenLessBackend`,`main.rs` 实现 egui/eframe UI,不依赖 Tauri/WebKitGTK |
| iOS 原生应用 + 键盘 | `ios/OpenLess/`、`ios/Keyboard/`、`ios/Shared/` | SwiftUI + UIKit;Swift 独立实现录音、Apple Speech、OpenAI 兼容服务与本地存储,未链接 Rust Core |

iOS 工程入口为 `ios/OpenLess.xcodeproj`,初版支持听写主流程和用户主动发送文字到键盘后的跨应用插入。它沿用产品语义与兼容服务协议,不是 Core 的新 Host 实现,不能据此推断已覆盖桌面 provider、市场、云同步等功能。详细边界与签名配置见 [iOS README](../openless-all/app/ios/README.md)。该源工程本次未执行编译或验证。

Android 侧:`src-tauri/src/android/`(JNI/桥接)+ `android/`(aidl、kotlin、manifests、frontend);`android/frontend` 经 Vite 别名 `@android` 被 `src/` 引用;manifest 由 `scripts/merge-android-*.mjs` 合成。Linux 已有可复用 Host/UI 起点,剩余能力与产品验收见 [交接目录](linux-egui-handoff/README.md)。

Expand Down
2 changes: 2 additions & 0 deletions docs/structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
├── src-tauri/ Tauri Host,独立 Cargo manifest
├── linux-egui/ Linux Host 和 egui UI
├── android/ Kotlin / AIDL / manifest / 前端片段
├── ios/ 独立 SwiftUI 应用 / UIKit 键盘扩展 / Xcode 工程
├── windows-ime/ 原生 TSF/IME 工程
├── contract/ 机器可读 backend-2.0 合同
├── scripts/ 构建、平台检查与合同测试
Expand All @@ -45,6 +46,7 @@
| Tauri 组装与系统能力 | `src-tauri/src/coordinator.rs`、`core_adapters.rs`、`tauri_coordinator_host.rs` | 窗口、热键、权限、平台输入与生命周期 |
| Linux 原生接入 | `linux-egui/src/main.rs`、`lib.rs`、`backend.rs` | `audio/credentials/fcitx5/hotkeys/settings` 等 Host 模块;见 [交接](linux-egui-handoff/README.md) |
| Android 集成 | `android/`、`src-tauri/src/android/` | `@android` 别名与 `merge-android-*.mjs` 生成链 |
| iOS 原生应用 | `ios/OpenLess/`、`ios/Keyboard/`、`ios/Shared/` | 独立 Swift 实现;工程、签名和支持范围见 [iOS README](../openless-all/app/ios/README.md) |
| Windows 输入法 | `windows-ime/`、`src-tauri/src/windows_ime_*.rs` | 原生工程、IPC 协议、目标应用和安装检查 |

Core 其余模块按领域列于 [架构模块地图](architecture.md)。平台缺口、事件签名与验收项由专项文档维护,本文件只提供定位。
Expand Down
15 changes: 15 additions & 0 deletions openless-all/app/ios/Configuration/Project.xcconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Both targets inherit these values. Set signing identifiers for your Apple developer team here.
OPENLESS_BUNDLE_ID = com.openless.ios
OPENLESS_APP_GROUP = group.com.openless.ios
DEVELOPMENT_TEAM =
CODE_SIGN_STYLE = Automatic

IPHONEOS_DEPLOYMENT_TARGET = 17.0
SWIFT_VERSION = 5.0
TARGETED_DEVICE_FAMILY = 1,2
MARKETING_VERSION = 0.1.0
CURRENT_PROJECT_VERSION = 1
SUPPORTED_PLATFORMS = iphoneos iphonesimulator
SUPPORTS_MACCATALYST = NO
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO
GENERATE_INFOPLIST_FILE = NO
26 changes: 26 additions & 0 deletions openless-all/app/ios/Keyboard/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?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_CN</string>
<key>CFBundleDisplayName</key><string>OpenLess</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>XPC!</string>
<key>CFBundleShortVersionString</key><string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key><string>$(CURRENT_PROJECT_VERSION)</string>
<key>OpenLessAppGroup</key><string>$(OPENLESS_APP_GROUP)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key><string>com.apple.keyboard-service</string>
<key>NSExtensionPrincipalClass</key><string>$(PRODUCT_MODULE_NAME).KeyboardViewController</string>
<key>NSExtensionAttributes</key>
<dict>
<key>IsASCIICapable</key><false/>
<key>PrefersRightToLeft</key><false/>
<key>PrimaryLanguage</key><string>zh-Hans</string>
<key>RequestsOpenAccess</key><true/>
</dict>
</dict>
</dict></plist>
175 changes: 175 additions & 0 deletions openless-all/app/ios/Keyboard/KeyboardViewController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import UIKit

final class KeyboardViewController: UIInputViewController, UITableViewDataSource, UITableViewDelegate {
private let tableView = UITableView(frame: .zero, style: .plain)
private let statusLabel = UILabel()
private let emptyLabel = UILabel()
private let globeButton = UIButton(type: .system)
private var clips: [KeyboardClip] = []
private var heightConstraint: NSLayoutConstraint?
private let accent = UIColor(red: 37 / 255, green: 99 / 255, blue: 235 / 255, alpha: 1)

override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemGroupedBackground
let title = UILabel()
title.text = "OpenLess"
title.font = .preferredFont(forTextStyle: .headline)
title.adjustsFontForContentSizeCategory = true
let refresh = button(title: "刷新", symbol: "arrow.clockwise", action: #selector(refreshTapped))
let header = UIStackView(arrangedSubviews: [title, UIView(), refresh])
header.alignment = .center
header.spacing = 12
statusLabel.text = "点击一条文字,插入当前输入框"
statusLabel.font = .preferredFont(forTextStyle: .caption1)
statusLabel.textColor = .secondaryLabel
statusLabel.numberOfLines = 2
statusLabel.adjustsFontForContentSizeCategory = true

tableView.backgroundColor = .clear
tableView.dataSource = self
tableView.delegate = self
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 82
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "clip")
tableView.layer.cornerRadius = 12
tableView.clipsToBounds = true
emptyLabel.font = .preferredFont(forTextStyle: .subheadline)
emptyLabel.textColor = .secondaryLabel
emptyLabel.textAlignment = .center
emptyLabel.numberOfLines = 0
emptyLabel.adjustsFontForContentSizeCategory = true

globeButton.setImage(UIImage(systemName: "globe"), for: .normal)
globeButton.accessibilityLabel = "切换键盘,长按选择输入法"
globeButton.addTarget(self, action: #selector(handleInputModeList(from:with:)), for: .allTouchEvents)
let space = button(title: "空格", symbol: nil, action: #selector(insertSpace))
let newline = button(title: "换行", symbol: "return", action: #selector(insertNewline))
let delete = button(title: nil, symbol: "delete.left", action: #selector(deleteBackward))
delete.accessibilityLabel = "删除前一个字符"
let hide = button(title: nil, symbol: "keyboard.chevron.compact.down", action: #selector(hideKeyboard))
hide.accessibilityLabel = "收起键盘"
let controls = UIStackView(arrangedSubviews: [globeButton, space, newline, delete, hide])
controls.axis = .horizontal
controls.distribution = .fillEqually
controls.spacing = 8
for control in controls.arrangedSubviews {
control.backgroundColor = .secondarySystemGroupedBackground
control.layer.cornerRadius = 9
control.tintColor = accent
control.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).isActive = true
}

let stack = UIStackView(arrangedSubviews: [header, statusLabel, tableView, controls])
stack.axis = .vertical
stack.spacing = 10
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 12),
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -12),
stack.topAnchor.constraint(equalTo: view.topAnchor, constant: 10),
stack.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -8)
])
header.setContentHuggingPriority(.required, for: .vertical)
statusLabel.setContentHuggingPriority(.required, for: .vertical)
controls.setContentHuggingPriority(.required, for: .vertical)
reloadClips()
}

override func updateViewConstraints() {
if heightConstraint == nil {
let constraint = view.heightAnchor.constraint(equalToConstant: 340)
constraint.priority = UILayoutPriority(999)
constraint.isActive = true
heightConstraint = constraint
}
heightConstraint?.constant = traitCollection.verticalSizeClass == .compact ? 250 : 340
super.updateViewConstraints()
}

override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
reloadClips()
}

override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
globeButton.isHidden = !needsInputModeSwitchKey
}

override func textDidChange(_ textInput: UITextInput?) {
super.textDidChange(textInput)
if !hasFullAccess && !clips.isEmpty { reloadClips() }
}

private func button(title: String?, symbol: String?, action: Selector) -> UIButton {
let button = UIButton(type: .system)
var configuration = UIButton.Configuration.plain()
configuration.title = title
configuration.image = symbol.flatMap { UIImage(systemName: $0) }
configuration.imagePadding = 6
configuration.baseForegroundColor = accent
button.configuration = configuration
button.addTarget(self, action: action, for: .touchUpInside)
return button
}

private func reloadClips() {
guard isViewLoaded else { return }
guard hasFullAccess else {
clips = []
emptyLabel.text = "请在系统设置中为 OpenLess 键盘\n开启“允许完全访问”,以读取已发送的文字。"
statusLabel.text = "共享文字需要键盘的完全访问权限"
tableView.backgroundView = emptyLabel
tableView.reloadData()
return
}
do {
clips = try KeyboardStore.read()
emptyLabel.text = "先在 OpenLess 中完成听写,\n点击“发送到键盘”,再回到这里刷新。"
statusLabel.text = clips.isEmpty ? "暂时没有发送到键盘的文字" : "点击一条文字,插入当前输入框"
} catch {
clips = []
emptyLabel.text = "暂时无法读取共享文字。\n请打开 OpenLess 后重新发送。"
statusLabel.text = "共享文字读取失败"
}
tableView.backgroundView = clips.isEmpty ? emptyLabel : nil
tableView.reloadData()
}

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { clips.count }

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "clip", for: indexPath)
let clip = clips[indexPath.row]
var content = cell.defaultContentConfiguration()
content.text = clip.text
content.textProperties.numberOfLines = 3
content.textProperties.font = .preferredFont(forTextStyle: .subheadline)
content.secondaryText = "\(clip.styleName) · \(clip.text.count) 字 · \(clip.createdAt.formatted(date: .omitted, time: .shortened))"
content.secondaryTextProperties.color = .secondaryLabel
content.secondaryTextProperties.font = .preferredFont(forTextStyle: .caption2)
content.image = UIImage(systemName: "arrow.turn.down.left")
content.imageProperties.tintColor = accent
cell.contentConfiguration = content
cell.backgroundColor = .secondarySystemGroupedBackground
cell.accessibilityLabel = "插入:\(clip.text)"
cell.accessibilityTraits = .button
return cell
}

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard hasFullAccess, clips.indices.contains(indexPath.row) else { reloadClips(); return }
textDocumentProxy.insertText(clips[indexPath.row].text)
statusLabel.text = "已发送插入请求,可在输入框中继续编辑"
UIAccessibility.post(notification: .announcement, argument: "已插入所选文字")
}

@objc private func refreshTapped() { reloadClips() }
@objc private func insertSpace() { textDocumentProxy.insertText(" ") }
@objc private func insertNewline() { textDocumentProxy.insertText("\n") }
@objc private func deleteBackward() { textDocumentProxy.deleteBackward() }
@objc private func hideKeyboard() { dismissKeyboard() }
}
6 changes: 6 additions & 0 deletions openless-all/app/ios/Keyboard/OpenLessKeyboard.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?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>com.apple.security.application-groups</key>
<array><string>$(OPENLESS_APP_GROUP)</string></array>
</dict></plist>
8 changes: 8 additions & 0 deletions openless-all/app/ios/Keyboard/PrivacyInfo.xcprivacy
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?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>NSPrivacyTracking</key><false/>
<key>NSPrivacyTrackingDomains</key><array/>
<key>NSPrivacyCollectedDataTypes</key><array/>
<key>NSPrivacyAccessedAPITypes</key><array/>
</dict></plist>
Loading