mirror of
https://github.com/carey314/mio-plugin-clipper.git
synced 2026-08-10 07:44:32 +00:00
v0.1.0: initial 划词侠 — AI Text Actions plugin for MioIsland
Quick AI actions on clipboard or selected text. Four actions: - 翻译 (中/英/日) - 总结 (3-bullet Chinese) - 改语气 (正式/口语/友好/严谨) - 邮件回复 Powered by DeepSeek with Server-Sent Events streaming for typewriter feedback (~600ms first byte). Two source modes: clipboard (no permission) and selected-text via NSAccessibility (graceful fallback to clipboard + permission banner if denied). Five-state UI machine: idle → input → loading (cancellable) → result → missing-config. Last 20 actions persisted to UserDefaults. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
4e22618364
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
build/
|
||||||
|
.DS_Store
|
||||||
|
*.swiftmodule
|
||||||
|
*.dSYM
|
||||||
|
.build/
|
||||||
35
Info.plist
Normal file
35
Info.plist
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<?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>CFBundleExecutable</key>
|
||||||
|
<string>ClipperPlugin</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>com.mioisland.plugin.clipper</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>划词侠</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>BNDL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>0.1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>NSPrincipalClass</key>
|
||||||
|
<string>ClipperPlugin.ClipperPlugin</string>
|
||||||
|
<!--
|
||||||
|
Panel size: 380×520.
|
||||||
|
- 40pt notch reservation strip at the top (matches Music Player /
|
||||||
|
看盘侠 pattern so the host's floating back-chevron has room).
|
||||||
|
- 480pt content area below: header + source preview + 2x2 action
|
||||||
|
grid + footer toggle.
|
||||||
|
-->
|
||||||
|
<key>MioPluginPreferredWidth</key>
|
||||||
|
<integer>380</integer>
|
||||||
|
<key>MioPluginPreferredHeight</key>
|
||||||
|
<integer>520</integer>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 MioMioOS
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
83
README.md
Normal file
83
README.md
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
# 划词侠 — AI Text Actions for MioIsland
|
||||||
|
|
||||||
|
Quick AI actions on any clipboard or selected text. Translate,
|
||||||
|
summarize, rewrite tone, or draft an email reply — all powered by
|
||||||
|
DeepSeek with streaming output for instant feedback.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **One-shot actions** — 翻译(中/英/日) / 总结 / 改语气(正式/口语/友好/严谨) / 邮件回复.
|
||||||
|
- **Streaming output** — typewriter effect via Server-Sent Events.
|
||||||
|
Long replies start showing within ~600ms instead of after the
|
||||||
|
full completion lands.
|
||||||
|
- **Two text sources** — clipboard (works without permissions) or
|
||||||
|
selected text via NSAccessibility (requires the user to grant
|
||||||
|
Accessibility permission). Falls back gracefully with a banner
|
||||||
|
explaining the System Settings → Privacy step.
|
||||||
|
- **History** — last 20 actions persisted in UserDefaults; tap to
|
||||||
|
re-copy the result without re-running the model.
|
||||||
|
- **Five-state UI** — idle, input, loading (cancellable), result,
|
||||||
|
missing-config. Each transition is animated.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Drop your DeepSeek API key into
|
||||||
|
`~/.config/codeisland/clipper-config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"deepseek_api_key": "sk-...",
|
||||||
|
"default_translate_target": "zh"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Get a key at https://platform.deepseek.com/ — DeepSeek's pricing is
|
||||||
|
roughly **¥0.001/1k tokens for input, ¥0.002/1k for output** (~1/100
|
||||||
|
of GPT-4), so a typical 划词侠 session costs less than a fraction of
|
||||||
|
a cent.
|
||||||
|
|
||||||
|
Without the file, the panel shows a friendly missing-config state
|
||||||
|
with the file path one click away on the clipboard.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- macOS 15.0+
|
||||||
|
- MioIsland v2.2.0+
|
||||||
|
- DeepSeek API key (for actual AI calls; UI works without it)
|
||||||
|
- Optional: Accessibility permission (for selected-text source)
|
||||||
|
|
||||||
|
## Building from source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build.sh # produce build/clipper.bundle + build/clipper.zip
|
||||||
|
./build.sh install # build + copy to ~/.config/codeisland/plugins/
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart MioIsland (Cmd+Q + reopen) to load the new build.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
Sources/
|
||||||
|
├── MioPlugin.swift # protocol (verbatim from host)
|
||||||
|
├── ClipperPlugin.swift # principal class
|
||||||
|
├── ui/
|
||||||
|
│ ├── ExpandedView.swift # 380×520 panel, state machine
|
||||||
|
│ ├── ActionGrid.swift # 2×2 action buttons + sub-options
|
||||||
|
│ ├── SourcePreview.swift # truncated input preview
|
||||||
|
│ ├── ResultView.swift # scrollable streaming output
|
||||||
|
│ ├── PermissionBanner.swift # AX permission hint
|
||||||
|
│ └── Theme.swift # design tokens
|
||||||
|
├── data/
|
||||||
|
│ ├── DeepSeekClient.swift # actor + URLSession + SSE streaming
|
||||||
|
│ ├── TextSource.swift # clipboard / selection providers
|
||||||
|
│ └── ClipperConfig.swift # config loader
|
||||||
|
└── engine/
|
||||||
|
├── ClipperStore.swift # @MainActor state machine + history
|
||||||
|
├── HistoryStore.swift # UserDefaults, last 20 actions
|
||||||
|
└── ClipperDebugLog.swift # /tmp/clipper-plugin.log
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT — see [LICENSE](LICENSE).
|
||||||
37
Sources/ClipperPlugin.swift
Normal file
37
Sources/ClipperPlugin.swift
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
//
|
||||||
|
// ClipperPlugin.swift
|
||||||
|
// Mio Island plugin: 划词侠
|
||||||
|
//
|
||||||
|
// Principal class. Module = ClipperPlugin, Class = ClipperPlugin →
|
||||||
|
// NSPrincipalClass = "ClipperPlugin.ClipperPlugin".
|
||||||
|
//
|
||||||
|
|
||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
final class ClipperPlugin: NSObject, MioPlugin {
|
||||||
|
var id: String { "clipper" }
|
||||||
|
var name: String { "划词侠" }
|
||||||
|
var icon: String { "wand.and.stars" }
|
||||||
|
var version: String { "0.1.0" }
|
||||||
|
|
||||||
|
func activate() {
|
||||||
|
ClipperDebugLog.write("plugin activate")
|
||||||
|
Task { @MainActor in
|
||||||
|
ClipperStore.shared.activate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deactivate() {
|
||||||
|
ClipperDebugLog.write("plugin deactivate")
|
||||||
|
Task { @MainActor in
|
||||||
|
ClipperStore.shared.deactivate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeView() -> NSView {
|
||||||
|
let view = NSHostingView(rootView: ExpandedView())
|
||||||
|
view.autoresizingMask = [.width, .height]
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
}
|
||||||
18
Sources/MioPlugin.swift
Normal file
18
Sources/MioPlugin.swift
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
//
|
||||||
|
// MioPlugin.swift
|
||||||
|
// Mio Island Plugin SDK (verbatim copy from host).
|
||||||
|
// Runtime conformance is by ObjC selector, not module identity.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
@objc protocol MioPlugin: AnyObject {
|
||||||
|
var id: String { get }
|
||||||
|
var name: String { get }
|
||||||
|
var icon: String { get }
|
||||||
|
var version: String { get }
|
||||||
|
func activate()
|
||||||
|
func deactivate()
|
||||||
|
func makeView() -> NSView
|
||||||
|
@objc optional func viewForSlot(_ slot: String, context: [String: Any]) -> NSView?
|
||||||
|
}
|
||||||
75
Sources/data/ClipperConfig.swift
Normal file
75
Sources/data/ClipperConfig.swift
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
//
|
||||||
|
// ClipperConfig.swift
|
||||||
|
// 划词侠 plugin
|
||||||
|
//
|
||||||
|
// Loads ~/.config/codeisland/clipper-config.json on demand.
|
||||||
|
// Schema:
|
||||||
|
// { "deepseek_api_key": "sk-...",
|
||||||
|
// "default_translate_target": "zh" }
|
||||||
|
//
|
||||||
|
// Never bundles a key. If the file is missing/empty we surface a
|
||||||
|
// state-E "请先填" screen with a helper that copies the path to the
|
||||||
|
// clipboard so the user can `open -e` it.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct ClipperConfig {
|
||||||
|
var deepseekApiKey: String
|
||||||
|
var defaultTranslateTarget: String // "zh" | "en" | "ja"
|
||||||
|
|
||||||
|
static let configPath: String = {
|
||||||
|
let home = NSHomeDirectory()
|
||||||
|
return "\(home)/.config/codeisland/clipper-config.json"
|
||||||
|
}()
|
||||||
|
|
||||||
|
static var configDir: String {
|
||||||
|
let home = NSHomeDirectory()
|
||||||
|
return "\(home)/.config/codeisland"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort load. If anything is missing we return nil so the UI
|
||||||
|
/// can render the onboarding banner — never throws to the caller.
|
||||||
|
static func load() -> ClipperConfig? {
|
||||||
|
let url = URL(fileURLWithPath: configPath)
|
||||||
|
guard let data = try? Data(contentsOf: url) else {
|
||||||
|
ClipperDebugLog.write("config: file missing at \(configPath)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||||
|
ClipperDebugLog.write("config: malformed JSON")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let key = (obj["deepseek_api_key"] as? String) ?? ""
|
||||||
|
let target = (obj["default_translate_target"] as? String) ?? "zh"
|
||||||
|
if key.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
|| key.trimmingCharacters(in: .whitespaces) == "TODO" {
|
||||||
|
ClipperDebugLog.write("config: deepseek_api_key empty/TODO")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ClipperConfig(deepseekApiKey: key, defaultTranslateTarget: target)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a stub file the user can edit. Idempotent — won't clobber
|
||||||
|
/// an existing real config.
|
||||||
|
@discardableResult
|
||||||
|
static func ensureStubExists() -> Bool {
|
||||||
|
let fm = FileManager.default
|
||||||
|
if fm.fileExists(atPath: configPath) { return false }
|
||||||
|
try? fm.createDirectory(atPath: configDir, withIntermediateDirectories: true)
|
||||||
|
let stub = """
|
||||||
|
{
|
||||||
|
"deepseek_api_key": "TODO",
|
||||||
|
"default_translate_target": "zh"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
do {
|
||||||
|
try stub.write(toFile: configPath, atomically: true, encoding: .utf8)
|
||||||
|
ClipperDebugLog.write("config: wrote stub at \(configPath)")
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
ClipperDebugLog.write("config: stub write failed: \(error)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
220
Sources/data/DeepSeekClient.swift
Normal file
220
Sources/data/DeepSeekClient.swift
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
//
|
||||||
|
// DeepSeekClient.swift
|
||||||
|
// 划词侠 plugin
|
||||||
|
//
|
||||||
|
// Tiny actor wrapping URLSession for the DeepSeek chat/completions
|
||||||
|
// endpoint. v0.1 uses non-streaming for simplicity; the call site is
|
||||||
|
// cancellable via Task cancellation.
|
||||||
|
//
|
||||||
|
// POST https://api.deepseek.com/chat/completions
|
||||||
|
// Body: {
|
||||||
|
// model: "deepseek-chat",
|
||||||
|
// messages: [
|
||||||
|
// {role:"system",content:<actionPrompt>},
|
||||||
|
// {role:"user",content:<inputText>}
|
||||||
|
// ],
|
||||||
|
// stream: false,
|
||||||
|
// temperature: 0.7
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum DeepSeekError: Error, LocalizedError {
|
||||||
|
case missingKey
|
||||||
|
case badStatus(Int, String)
|
||||||
|
case malformed(String)
|
||||||
|
case network(Error)
|
||||||
|
case cancelled
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .missingKey: return "未配置 DeepSeek API Key"
|
||||||
|
case .badStatus(let c, let m): return "HTTP \(c) — \(m)"
|
||||||
|
case .malformed(let m): return "返回格式异常: \(m)"
|
||||||
|
case .network(let e): return "网络错误: \(e.localizedDescription)"
|
||||||
|
case .cancelled: return "已取消"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actor DeepSeekClient {
|
||||||
|
static let shared = DeepSeekClient()
|
||||||
|
|
||||||
|
private let session: URLSession
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let cfg = URLSessionConfiguration.default
|
||||||
|
// DeepSeek can take 5-15s for a 200-token completion. Budget
|
||||||
|
// generously so the user doesn't see a timeout on long inputs.
|
||||||
|
cfg.timeoutIntervalForRequest = 60
|
||||||
|
cfg.timeoutIntervalForResource = 90
|
||||||
|
cfg.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||||
|
self.session = URLSession(configuration: cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a chat-completion request. Returns the assistant's text.
|
||||||
|
/// Throws DeepSeekError; respects Task.isCancelled at boundaries.
|
||||||
|
func chat(systemPrompt: String, userText: String, apiKey: String) async throws -> String {
|
||||||
|
guard !apiKey.isEmpty else { throw DeepSeekError.missingKey }
|
||||||
|
|
||||||
|
let url = URL(string: "https://api.deepseek.com/chat/completions")!
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.httpMethod = "POST"
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
req.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
||||||
|
|
||||||
|
let body: [String: Any] = [
|
||||||
|
"model": "deepseek-chat",
|
||||||
|
"stream": false,
|
||||||
|
"temperature": 0.7,
|
||||||
|
"messages": [
|
||||||
|
["role": "system", "content": systemPrompt],
|
||||||
|
["role": "user", "content": userText],
|
||||||
|
],
|
||||||
|
]
|
||||||
|
do {
|
||||||
|
req.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||||
|
} catch {
|
||||||
|
throw DeepSeekError.malformed("encode body: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if Task.isCancelled { throw DeepSeekError.cancelled }
|
||||||
|
|
||||||
|
let data: Data
|
||||||
|
let resp: URLResponse
|
||||||
|
do {
|
||||||
|
(data, resp) = try await session.data(for: req)
|
||||||
|
} catch is CancellationError {
|
||||||
|
throw DeepSeekError.cancelled
|
||||||
|
} catch {
|
||||||
|
throw DeepSeekError.network(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if Task.isCancelled { throw DeepSeekError.cancelled }
|
||||||
|
|
||||||
|
if let http = resp as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
|
||||||
|
let snippet = String(data: data, encoding: .utf8)?.prefix(240) ?? ""
|
||||||
|
ClipperDebugLog.write("deepseek HTTP \(http.statusCode): \(snippet)")
|
||||||
|
throw DeepSeekError.badStatus(http.statusCode, String(snippet))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Envelope: Decodable {
|
||||||
|
struct Choice: Decodable {
|
||||||
|
struct Message: Decodable { let content: String }
|
||||||
|
let message: Message
|
||||||
|
}
|
||||||
|
let choices: [Choice]
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let env = try JSONDecoder().decode(Envelope.self, from: data)
|
||||||
|
guard let first = env.choices.first?.message.content else {
|
||||||
|
throw DeepSeekError.malformed("no choices")
|
||||||
|
}
|
||||||
|
return first.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
} catch let e as DeepSeekError {
|
||||||
|
throw e
|
||||||
|
} catch {
|
||||||
|
throw DeepSeekError.malformed("decode: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Streaming
|
||||||
|
|
||||||
|
extension DeepSeekClient {
|
||||||
|
/// Server-sent-events streaming variant. Returns an
|
||||||
|
/// AsyncThrowingStream of partial text chunks (one per delta). The
|
||||||
|
/// caller accumulates chunks as they arrive — gives a typewriter
|
||||||
|
/// effect in the UI for long replies (邮件回复, 总结).
|
||||||
|
///
|
||||||
|
/// Static + own URLSession so we don't fight actor isolation when
|
||||||
|
/// passing the stream across MainActor boundaries. Per-call session
|
||||||
|
/// cost is negligible for one-shot AI requests.
|
||||||
|
static func chatStream(
|
||||||
|
systemPrompt: String,
|
||||||
|
userText: String,
|
||||||
|
apiKey: String
|
||||||
|
) -> AsyncThrowingStream<String, Error> {
|
||||||
|
AsyncThrowingStream { continuation in
|
||||||
|
let task = Task.detached {
|
||||||
|
do {
|
||||||
|
guard !apiKey.isEmpty else { throw DeepSeekError.missingKey }
|
||||||
|
|
||||||
|
let cfg = URLSessionConfiguration.default
|
||||||
|
cfg.timeoutIntervalForRequest = 60
|
||||||
|
cfg.timeoutIntervalForResource = 120
|
||||||
|
cfg.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||||
|
let session = URLSession(configuration: cfg)
|
||||||
|
|
||||||
|
let url = URL(string: "https://api.deepseek.com/chat/completions")!
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.httpMethod = "POST"
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
req.setValue("text/event-stream", forHTTPHeaderField: "Accept")
|
||||||
|
req.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
||||||
|
|
||||||
|
let body: [String: Any] = [
|
||||||
|
"model": "deepseek-chat",
|
||||||
|
"stream": true,
|
||||||
|
"temperature": 0.7,
|
||||||
|
"messages": [
|
||||||
|
["role": "system", "content": systemPrompt],
|
||||||
|
["role": "user", "content": userText],
|
||||||
|
],
|
||||||
|
]
|
||||||
|
req.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||||
|
|
||||||
|
let (bytes, resp) = try await session.bytes(for: req)
|
||||||
|
|
||||||
|
if let http = resp as? HTTPURLResponse,
|
||||||
|
!(200..<300).contains(http.statusCode) {
|
||||||
|
// Drain a small prefix of the error body for the message.
|
||||||
|
var buf = Data()
|
||||||
|
for try await b in bytes {
|
||||||
|
buf.append(b)
|
||||||
|
if buf.count > 2048 { break }
|
||||||
|
}
|
||||||
|
let snippet = String(data: buf, encoding: .utf8)?.prefix(240) ?? ""
|
||||||
|
ClipperDebugLog.write("deepseek stream HTTP \(http.statusCode): \(snippet)")
|
||||||
|
throw DeepSeekError.badStatus(http.statusCode, String(snippet))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Delta: Decodable {
|
||||||
|
struct Choice: Decodable {
|
||||||
|
struct DeltaContent: Decodable { let content: String? }
|
||||||
|
let delta: DeltaContent
|
||||||
|
}
|
||||||
|
let choices: [Choice]
|
||||||
|
}
|
||||||
|
|
||||||
|
for try await line in bytes.lines {
|
||||||
|
if Task.isCancelled { throw DeepSeekError.cancelled }
|
||||||
|
// SSE format: "data: {...}\n\n", terminated by "data: [DONE]"
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard trimmed.hasPrefix("data: ") else { continue }
|
||||||
|
let payload = String(trimmed.dropFirst(6))
|
||||||
|
if payload == "[DONE]" { break }
|
||||||
|
guard let payloadData = payload.data(using: .utf8) else { continue }
|
||||||
|
// A malformed chunk shouldn't kill the whole stream;
|
||||||
|
// skip and keep going so the user gets whatever
|
||||||
|
// text was already produced.
|
||||||
|
if let chunk = try? JSONDecoder().decode(Delta.self, from: payloadData),
|
||||||
|
let content = chunk.choices.first?.delta.content,
|
||||||
|
!content.isEmpty {
|
||||||
|
continuation.yield(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continuation.finish()
|
||||||
|
} catch is CancellationError {
|
||||||
|
continuation.finish(throwing: DeepSeekError.cancelled)
|
||||||
|
} catch let e as DeepSeekError {
|
||||||
|
continuation.finish(throwing: e)
|
||||||
|
} catch {
|
||||||
|
continuation.finish(throwing: DeepSeekError.network(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continuation.onTermination = { _ in task.cancel() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
106
Sources/data/TextSource.swift
Normal file
106
Sources/data/TextSource.swift
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
//
|
||||||
|
// TextSource.swift
|
||||||
|
// 划词侠 plugin
|
||||||
|
//
|
||||||
|
// Two source modes:
|
||||||
|
// .clipboard — NSPasteboard.general.string(forType:.string).
|
||||||
|
// No permissions needed.
|
||||||
|
// .selection — Try NSWorkspace + AXUIElement to read the focused
|
||||||
|
// element's selected-text. Requires Accessibility
|
||||||
|
// permission. If denied, return .permissionDenied so
|
||||||
|
// the UI can prompt + fall back to clipboard.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AppKit
|
||||||
|
import ApplicationServices
|
||||||
|
|
||||||
|
enum TextSourceMode: String, CaseIterable, Identifiable {
|
||||||
|
case clipboard
|
||||||
|
case selection
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .clipboard: return "粘贴板"
|
||||||
|
case .selection: return "选中文本"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TextSourceResult {
|
||||||
|
case ok(String)
|
||||||
|
case empty
|
||||||
|
case permissionDenied
|
||||||
|
case error(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TextSource {
|
||||||
|
|
||||||
|
/// Read the system pasteboard (.string type). No permissions.
|
||||||
|
static func readClipboard() -> TextSourceResult {
|
||||||
|
let pb = NSPasteboard.general
|
||||||
|
guard let s = pb.string(forType: .string) else {
|
||||||
|
return .empty
|
||||||
|
}
|
||||||
|
let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmed.isEmpty { return .empty }
|
||||||
|
return .ok(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort read of the *focused* UI element's selected text via
|
||||||
|
/// AX. Requires Accessibility permission for Mio Island's host
|
||||||
|
/// process. Returns .permissionDenied if not granted (caller should
|
||||||
|
/// surface the banner), or .empty if granted-but-nothing-selected.
|
||||||
|
static func readSelectionViaAX() -> TextSourceResult {
|
||||||
|
// AXIsProcessTrustedWithOptions with the prompt option will pop
|
||||||
|
// the system "open System Settings" sheet on first call. We
|
||||||
|
// pass false so we don't trigger the prompt unexpectedly — the
|
||||||
|
// UI banner explicitly tells the user where to enable it.
|
||||||
|
let opts: NSDictionary = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: false]
|
||||||
|
guard AXIsProcessTrustedWithOptions(opts) else {
|
||||||
|
ClipperDebugLog.write("selection: AX not trusted")
|
||||||
|
return .permissionDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
let systemWide = AXUIElementCreateSystemWide()
|
||||||
|
var focused: CFTypeRef?
|
||||||
|
let focusErr = AXUIElementCopyAttributeValue(
|
||||||
|
systemWide,
|
||||||
|
kAXFocusedUIElementAttribute as CFString,
|
||||||
|
&focused
|
||||||
|
)
|
||||||
|
guard focusErr == .success, let element = focused else {
|
||||||
|
ClipperDebugLog.write("selection: no focused element (err=\(focusErr.rawValue))")
|
||||||
|
return .empty
|
||||||
|
}
|
||||||
|
// swiftlint:disable:next force_cast
|
||||||
|
let axElement = element as! AXUIElement
|
||||||
|
|
||||||
|
var selValue: CFTypeRef?
|
||||||
|
let selErr = AXUIElementCopyAttributeValue(
|
||||||
|
axElement,
|
||||||
|
kAXSelectedTextAttribute as CFString,
|
||||||
|
&selValue
|
||||||
|
)
|
||||||
|
guard selErr == .success, let s = selValue as? String else {
|
||||||
|
ClipperDebugLog.write("selection: no selected text (err=\(selErr.rawValue))")
|
||||||
|
return .empty
|
||||||
|
}
|
||||||
|
let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmed.isEmpty { return .empty }
|
||||||
|
return .ok(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience: read whichever source is active, with fallback.
|
||||||
|
static func read(mode: TextSourceMode) -> TextSourceResult {
|
||||||
|
switch mode {
|
||||||
|
case .clipboard:
|
||||||
|
return readClipboard()
|
||||||
|
case .selection:
|
||||||
|
let r = readSelectionViaAX()
|
||||||
|
// If AX gave us empty (granted but nothing selected) we keep
|
||||||
|
// the empty result — caller decides whether to fall back.
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
Sources/engine/ClipperDebugLog.swift
Normal file
41
Sources/engine/ClipperDebugLog.swift
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
//
|
||||||
|
// ClipperDebugLog.swift
|
||||||
|
// 划词侠 plugin
|
||||||
|
//
|
||||||
|
// Mirrors the FundDebugLog pattern — plugin-bundle NSLog calls don't
|
||||||
|
// reliably surface in `log show` from the host process. This helper
|
||||||
|
// appends timestamped lines to /tmp/clipper-plugin.log so we can
|
||||||
|
// `tail -f` during debugging.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum ClipperDebugLog {
|
||||||
|
static let path = "/tmp/clipper-plugin.log"
|
||||||
|
private static let queue = DispatchQueue(label: "clipper.debug.log")
|
||||||
|
private static let dateFmt: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
f.timeZone = TimeZone.current
|
||||||
|
f.dateFormat = "HH:mm:ss.SSS"
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
static func write(_ message: String) {
|
||||||
|
let stamp = dateFmt.string(from: Date())
|
||||||
|
let line = "[\(stamp)] \(message)\n"
|
||||||
|
queue.async {
|
||||||
|
if let data = line.data(using: .utf8) {
|
||||||
|
if FileManager.default.fileExists(atPath: path) {
|
||||||
|
if let handle = try? FileHandle(forWritingTo: URL(fileURLWithPath: path)) {
|
||||||
|
try? handle.seekToEnd()
|
||||||
|
try? handle.write(contentsOf: data)
|
||||||
|
try? handle.close()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try? data.write(to: URL(fileURLWithPath: path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
357
Sources/engine/ClipperStore.swift
Normal file
357
Sources/engine/ClipperStore.swift
Normal file
@ -0,0 +1,357 @@
|
|||||||
|
//
|
||||||
|
// ClipperStore.swift
|
||||||
|
// 划词侠 plugin
|
||||||
|
//
|
||||||
|
// Central ObservableObject. Holds:
|
||||||
|
// • viewState — A/B/C/D/E (state machine)
|
||||||
|
// • sourceText — current input (clipboard or selection)
|
||||||
|
// • sourceMode — clipboard / selection toggle
|
||||||
|
// • lastResult — DeepSeek output for state D
|
||||||
|
// • currentAction — label string for state D / history
|
||||||
|
// • history — last 20 actions
|
||||||
|
// • config — DeepSeek key + defaults (nil = state E)
|
||||||
|
//
|
||||||
|
// Action prompts live here as a static catalog so views just reference
|
||||||
|
// ClipperAction.translateZh etc.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Action catalog
|
||||||
|
|
||||||
|
enum ClipperAction: Hashable {
|
||||||
|
case translate(target: TranslateTarget)
|
||||||
|
case summarize
|
||||||
|
case rewrite(tone: ToneOption)
|
||||||
|
case emailReply
|
||||||
|
|
||||||
|
enum TranslateTarget: String, CaseIterable, Identifiable, Hashable {
|
||||||
|
case zh, en, ja
|
||||||
|
var id: String { rawValue }
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .zh: return "中文"
|
||||||
|
case .en: return "英文"
|
||||||
|
case .ja: return "日文"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ToneOption: String, CaseIterable, Identifiable, Hashable {
|
||||||
|
case formal, casual, friendly, rigorous
|
||||||
|
var id: String { rawValue }
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .formal: return "正式"
|
||||||
|
case .casual: return "口语"
|
||||||
|
case .friendly: return "友好"
|
||||||
|
case .rigorous: return "严谨"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .translate(let t): return "翻译·\(t.label)"
|
||||||
|
case .summarize: return "总结"
|
||||||
|
case .rewrite(let tone): return "改语气·\(tone.label)"
|
||||||
|
case .emailReply: return "邮件回复"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var systemPrompt: String {
|
||||||
|
switch self {
|
||||||
|
case .translate(.zh):
|
||||||
|
return "请把用户输入翻译为流畅自然的简体中文,保持原意,不要解释。直接输出译文。"
|
||||||
|
case .translate(.en):
|
||||||
|
return "Translate the user input to fluent natural English. Output translation only, no commentary."
|
||||||
|
case .translate(.ja):
|
||||||
|
return "ユーザー入力を自然な日本語に翻訳してください。訳文のみを出力してください。"
|
||||||
|
case .summarize:
|
||||||
|
return "请用 3 条要点中文总结用户输入,每条以 - 开头,简洁明了。"
|
||||||
|
case .rewrite(.formal):
|
||||||
|
return "请将用户输入改写为更正式书面的语气,保持原意和长度相近。直接输出改写结果。"
|
||||||
|
case .rewrite(.casual):
|
||||||
|
return "请将用户输入改写为更口语化、轻松的语气,保持原意和长度相近。直接输出改写结果。"
|
||||||
|
case .rewrite(.friendly):
|
||||||
|
return "请将用户输入改写为更友好、有人情味的语气,保持原意和长度相近。直接输出改写结果。"
|
||||||
|
case .rewrite(.rigorous):
|
||||||
|
return "请将用户输入改写为更严谨、不带情绪、用词精确的语气,保持原意和长度相近。直接输出改写结果。"
|
||||||
|
case .emailReply:
|
||||||
|
return "用户提供了一封邮件原文。请用与原文相同的语言起草一封礼貌、专业的回复邮件,正文不超过 5 段。直接输出回复正文,不要包含称呼/署名占位符。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - View state
|
||||||
|
|
||||||
|
enum ClipperViewState: Equatable {
|
||||||
|
case idle // A
|
||||||
|
case input(text: String) // B
|
||||||
|
case loading(action: String) // C
|
||||||
|
case result(action: String, output: String, input: String) // D
|
||||||
|
case missingConfig // E
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ClipperStore: ObservableObject {
|
||||||
|
static let shared = ClipperStore()
|
||||||
|
|
||||||
|
@Published var viewState: ClipperViewState = .idle
|
||||||
|
@Published var sourceMode: TextSourceMode = .clipboard
|
||||||
|
@Published var permissionDenied: Bool = false // for selection mode banner
|
||||||
|
@Published var history: [HistoryEntry] = []
|
||||||
|
@Published var config: ClipperConfig?
|
||||||
|
@Published var lastError: String?
|
||||||
|
@Published var translateTarget: ClipperAction.TranslateTarget = .zh
|
||||||
|
@Published var rewriteTone: ClipperAction.ToneOption = .formal
|
||||||
|
|
||||||
|
private var currentTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: lifecycle
|
||||||
|
|
||||||
|
func activate() {
|
||||||
|
ClipperDebugLog.write("store activate")
|
||||||
|
history = HistoryStore.load()
|
||||||
|
reloadConfig()
|
||||||
|
if let dt = config?.defaultTranslateTarget,
|
||||||
|
let parsed = ClipperAction.TranslateTarget(rawValue: dt) {
|
||||||
|
translateTarget = parsed
|
||||||
|
}
|
||||||
|
// If we already have config, try to seed input from clipboard.
|
||||||
|
if config != nil {
|
||||||
|
seedFromClipboardIfPresent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deactivate() {
|
||||||
|
ClipperDebugLog.write("store deactivate")
|
||||||
|
currentTask?.cancel()
|
||||||
|
currentTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func reloadConfig() {
|
||||||
|
let loaded = ClipperConfig.load()
|
||||||
|
self.config = loaded
|
||||||
|
if loaded == nil {
|
||||||
|
// Drop a stub the user can edit, only if nothing exists yet.
|
||||||
|
ClipperConfig.ensureStubExists()
|
||||||
|
viewState = .missingConfig
|
||||||
|
} else if case .missingConfig = viewState {
|
||||||
|
// Just got a key, recover into idle.
|
||||||
|
viewState = .idle
|
||||||
|
seedFromClipboardIfPresent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: source
|
||||||
|
|
||||||
|
/// Pull whatever is on the clipboard. If non-empty, transition to
|
||||||
|
/// state B so the user immediately sees actionable text.
|
||||||
|
func seedFromClipboardIfPresent() {
|
||||||
|
switch TextSource.readClipboard() {
|
||||||
|
case .ok(let s):
|
||||||
|
viewState = .input(text: s)
|
||||||
|
default:
|
||||||
|
// leave as-is (idle)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshSource() {
|
||||||
|
if config == nil { viewState = .missingConfig; return }
|
||||||
|
switch TextSource.read(mode: sourceMode) {
|
||||||
|
case .ok(let s):
|
||||||
|
permissionDenied = false
|
||||||
|
viewState = .input(text: s)
|
||||||
|
case .empty:
|
||||||
|
permissionDenied = false
|
||||||
|
viewState = .idle
|
||||||
|
case .permissionDenied:
|
||||||
|
// Keep clipboard text if any, but show banner.
|
||||||
|
permissionDenied = true
|
||||||
|
// Fall back to clipboard automatically so user can still act.
|
||||||
|
if case .ok(let s) = TextSource.readClipboard() {
|
||||||
|
viewState = .input(text: s)
|
||||||
|
} else {
|
||||||
|
viewState = .idle
|
||||||
|
}
|
||||||
|
case .error(let msg):
|
||||||
|
lastError = msg
|
||||||
|
permissionDenied = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSourceMode(_ mode: TextSourceMode) {
|
||||||
|
sourceMode = mode
|
||||||
|
refreshSource()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: actions
|
||||||
|
|
||||||
|
/// Trigger one of the four big buttons. Reads input from current
|
||||||
|
/// state.input, transitions to .loading, runs the request, then
|
||||||
|
/// transitions to .result.
|
||||||
|
func runAction(_ action: ClipperAction) {
|
||||||
|
guard let cfg = config else {
|
||||||
|
viewState = .missingConfig
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let inputText: String
|
||||||
|
if case .input(let t) = viewState {
|
||||||
|
inputText = t
|
||||||
|
} else if case .result(_, _, let t) = viewState {
|
||||||
|
inputText = t
|
||||||
|
} else {
|
||||||
|
// No input yet — try clipboard one more time.
|
||||||
|
switch TextSource.readClipboard() {
|
||||||
|
case .ok(let s): inputText = s
|
||||||
|
default:
|
||||||
|
lastError = "没有可处理的文本"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTask?.cancel()
|
||||||
|
let label = action.label
|
||||||
|
viewState = .loading(action: label)
|
||||||
|
lastError = nil
|
||||||
|
|
||||||
|
let prompt = action.systemPrompt
|
||||||
|
let key = cfg.deepseekApiKey
|
||||||
|
// Streaming: accumulate chunks and update viewState live. Each
|
||||||
|
// delta from DeepSeek triggers a SwiftUI re-render, giving the
|
||||||
|
// typewriter effect. On the first non-empty chunk we transition
|
||||||
|
// from .loading → .result (output grows with subsequent ticks).
|
||||||
|
currentTask = Task { [weak self] in
|
||||||
|
var accumulated = ""
|
||||||
|
do {
|
||||||
|
let stream = DeepSeekClient.chatStream(
|
||||||
|
systemPrompt: prompt,
|
||||||
|
userText: inputText,
|
||||||
|
apiKey: key
|
||||||
|
)
|
||||||
|
for try await chunk in stream {
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
accumulated += chunk
|
||||||
|
let snapshot = accumulated
|
||||||
|
await MainActor.run {
|
||||||
|
guard let self else { return }
|
||||||
|
self.viewState = .result(action: label, output: snapshot, input: inputText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If the stream completed but yielded nothing, surface a
|
||||||
|
// helpful error rather than leaving the user with an
|
||||||
|
// empty result panel.
|
||||||
|
if accumulated.isEmpty {
|
||||||
|
await MainActor.run {
|
||||||
|
guard let self else { return }
|
||||||
|
self.lastError = "DeepSeek 未返回内容"
|
||||||
|
self.viewState = .input(text: inputText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch let e as DeepSeekError {
|
||||||
|
if case .cancelled = e {
|
||||||
|
ClipperDebugLog.write("action cancelled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ClipperDebugLog.write("action stream failed: \(e.localizedDescription)")
|
||||||
|
// If we already streamed some text, keep it on screen as a
|
||||||
|
// partial result; otherwise drop back to input + error.
|
||||||
|
if !accumulated.isEmpty {
|
||||||
|
await MainActor.run {
|
||||||
|
guard let self else { return }
|
||||||
|
self.lastError = e.localizedDescription
|
||||||
|
// viewState already in .result with partial output
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await MainActor.run {
|
||||||
|
guard let self else { return }
|
||||||
|
self.lastError = e.localizedDescription
|
||||||
|
self.viewState = .input(text: inputText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ClipperDebugLog.write("action stream failed: \(error.localizedDescription)")
|
||||||
|
await MainActor.run {
|
||||||
|
guard let self else { return }
|
||||||
|
self.lastError = error.localizedDescription
|
||||||
|
if accumulated.isEmpty {
|
||||||
|
self.viewState = .input(text: inputText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelCurrent() {
|
||||||
|
currentTask?.cancel()
|
||||||
|
currentTask = nil
|
||||||
|
if case .loading(_) = viewState {
|
||||||
|
// Restore to whatever input we had — fall back to idle if unknown.
|
||||||
|
switch TextSource.readClipboard() {
|
||||||
|
case .ok(let s): viewState = .input(text: s)
|
||||||
|
default: viewState = .idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called from the result-screen 完成 button. Saves to history and
|
||||||
|
/// returns to idle.
|
||||||
|
func finishResult() {
|
||||||
|
if case .result(let action, let output, let input) = viewState {
|
||||||
|
let entry = HistoryEntry(
|
||||||
|
actionLabel: action,
|
||||||
|
inputPreview: HistoryStore.truncate(input, max: 80),
|
||||||
|
outputPreview: HistoryStore.truncate(output, max: 120)
|
||||||
|
)
|
||||||
|
history = HistoryStore.append(entry)
|
||||||
|
}
|
||||||
|
viewState = .idle
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重新生成 — re-runs the last action with the same input.
|
||||||
|
func regenerate() {
|
||||||
|
guard case .result(let label, _, let input) = viewState else { return }
|
||||||
|
// Reverse-map label → action. We carry the actual action through
|
||||||
|
// via the label; simplest: reuse the input as state.input and let
|
||||||
|
// user click again. To support 1-tap, we re-run by remembering
|
||||||
|
// the last action selection here.
|
||||||
|
if let act = lastActionFromLabel(label) {
|
||||||
|
viewState = .input(text: input)
|
||||||
|
runAction(act)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy a string to the system pasteboard.
|
||||||
|
func copyToPasteboard(_ s: String) {
|
||||||
|
let pb = NSPasteboard.general
|
||||||
|
pb.clearContents()
|
||||||
|
pb.setString(s, forType: .string)
|
||||||
|
ClipperDebugLog.write("copied \(s.count) chars to pasteboard")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper: rebuild a ClipperAction from its display label so 重新生成
|
||||||
|
/// can re-run. Mirror of ClipperAction.label.
|
||||||
|
private func lastActionFromLabel(_ label: String) -> ClipperAction? {
|
||||||
|
if label.hasPrefix("翻译·") {
|
||||||
|
let rest = String(label.dropFirst("翻译·".count))
|
||||||
|
for t in ClipperAction.TranslateTarget.allCases where t.label == rest {
|
||||||
|
return .translate(target: t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if label == "总结" { return .summarize }
|
||||||
|
if label.hasPrefix("改语气·") {
|
||||||
|
let rest = String(label.dropFirst("改语气·".count))
|
||||||
|
for tone in ClipperAction.ToneOption.allCases where tone.label == rest {
|
||||||
|
return .rewrite(tone: tone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if label == "邮件回复" { return .emailReply }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
60
Sources/engine/HistoryStore.swift
Normal file
60
Sources/engine/HistoryStore.swift
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
//
|
||||||
|
// HistoryStore.swift
|
||||||
|
// 划词侠 plugin
|
||||||
|
//
|
||||||
|
// Persists last 20 actions in UserDefaults under a single JSON key.
|
||||||
|
// Cheap, durable, no external deps. Records actionLabel + input
|
||||||
|
// preview + output preview + timestamp.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct HistoryEntry: Codable, Identifiable, Equatable {
|
||||||
|
var id: UUID
|
||||||
|
var actionLabel: String // e.g. "翻译·中文" / "总结" / "改语气·正式"
|
||||||
|
var inputPreview: String // truncated to 80 chars
|
||||||
|
var outputPreview: String // truncated to 120 chars
|
||||||
|
var timestamp: Date
|
||||||
|
|
||||||
|
init(id: UUID = UUID(),
|
||||||
|
actionLabel: String,
|
||||||
|
inputPreview: String,
|
||||||
|
outputPreview: String,
|
||||||
|
timestamp: Date = Date()) {
|
||||||
|
self.id = id
|
||||||
|
self.actionLabel = actionLabel
|
||||||
|
self.inputPreview = inputPreview
|
||||||
|
self.outputPreview = outputPreview
|
||||||
|
self.timestamp = timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum HistoryStore {
|
||||||
|
private static let key = "com.mioisland.plugin.clipper.history.v1"
|
||||||
|
private static let maxEntries = 20
|
||||||
|
|
||||||
|
static func load() -> [HistoryEntry] {
|
||||||
|
guard let data = UserDefaults.standard.data(forKey: key) else { return [] }
|
||||||
|
return (try? JSONDecoder().decode([HistoryEntry].self, from: data)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
static func save(_ entries: [HistoryEntry]) {
|
||||||
|
guard let data = try? JSONEncoder().encode(entries) else { return }
|
||||||
|
UserDefaults.standard.set(data, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func append(_ entry: HistoryEntry) -> [HistoryEntry] {
|
||||||
|
var list = load()
|
||||||
|
list.insert(entry, at: 0)
|
||||||
|
if list.count > maxEntries { list = Array(list.prefix(maxEntries)) }
|
||||||
|
save(list)
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
static func truncate(_ s: String, max: Int) -> String {
|
||||||
|
let collapsed = s.replacingOccurrences(of: "\n", with: " ")
|
||||||
|
if collapsed.count <= max { return collapsed }
|
||||||
|
let i = collapsed.index(collapsed.startIndex, offsetBy: max)
|
||||||
|
return String(collapsed[..<i]) + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
145
Sources/ui/ActionGrid.swift
Normal file
145
Sources/ui/ActionGrid.swift
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
//
|
||||||
|
// ActionGrid.swift
|
||||||
|
// 划词侠 plugin
|
||||||
|
//
|
||||||
|
// 2x2 grid of the four primary actions plus the segmented sub-options
|
||||||
|
// for translate-target and rewrite-tone. Tappable even when input is
|
||||||
|
// empty — store decides whether to short-circuit.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ActionGrid: View {
|
||||||
|
@ObservedObject var store: ClipperStore
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
actionCard(
|
||||||
|
icon: "globe",
|
||||||
|
title: "翻译",
|
||||||
|
subtitle: targetSubLabel,
|
||||||
|
accent: ClipperTheme.cyan
|
||||||
|
) {
|
||||||
|
store.runAction(.translate(target: store.translateTarget))
|
||||||
|
}
|
||||||
|
|
||||||
|
actionCard(
|
||||||
|
icon: "list.bullet.rectangle",
|
||||||
|
title: "总结",
|
||||||
|
subtitle: "3 条要点",
|
||||||
|
accent: ClipperTheme.lime
|
||||||
|
) {
|
||||||
|
store.runAction(.summarize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
actionCard(
|
||||||
|
icon: "bubble.left.and.bubble.right",
|
||||||
|
title: "改语气",
|
||||||
|
subtitle: store.rewriteTone.label,
|
||||||
|
accent: ClipperTheme.amber
|
||||||
|
) {
|
||||||
|
store.runAction(.rewrite(tone: store.rewriteTone))
|
||||||
|
}
|
||||||
|
|
||||||
|
actionCard(
|
||||||
|
icon: "envelope",
|
||||||
|
title: "写邮件回复",
|
||||||
|
subtitle: "礼貌专业",
|
||||||
|
accent: Color(red: 0xFF/255, green: 0x9E/255, blue: 0xC4/255)
|
||||||
|
) {
|
||||||
|
store.runAction(.emailReply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Segmented controls for the two actions that have options.
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
segmented(
|
||||||
|
title: "翻译目标",
|
||||||
|
options: ClipperAction.TranslateTarget.allCases.map { ($0.label, $0) },
|
||||||
|
selection: $store.translateTarget
|
||||||
|
)
|
||||||
|
segmented(
|
||||||
|
title: "改写语气",
|
||||||
|
options: ClipperAction.ToneOption.allCases.map { ($0.label, $0) },
|
||||||
|
selection: $store.rewriteTone
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var targetSubLabel: String {
|
||||||
|
"译为 " + store.translateTarget.label
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: subviews
|
||||||
|
|
||||||
|
private func actionCard(icon: String,
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
accent: Color,
|
||||||
|
action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 18, weight: .semibold))
|
||||||
|
.foregroundColor(accent)
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundColor(ClipperTheme.fgPrimary)
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.system(size: 10.5))
|
||||||
|
.foregroundColor(ClipperTheme.fg55)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(ClipperTheme.overlay06)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(ClipperTheme.overlay08, lineWidth: 0.5)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func segmented<T: Hashable>(
|
||||||
|
title: String,
|
||||||
|
options: [(String, T)],
|
||||||
|
selection: Binding<T>
|
||||||
|
) -> some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 10.5, weight: .medium))
|
||||||
|
.foregroundColor(ClipperTheme.fg45)
|
||||||
|
.frame(width: 56, alignment: .leading)
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
ForEach(options, id: \.1) { (label, value) in
|
||||||
|
let isSel = selection.wrappedValue == value
|
||||||
|
Button {
|
||||||
|
selection.wrappedValue = value
|
||||||
|
} label: {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundColor(isSel
|
||||||
|
? Color(red: 0x0B/255, green: 0x0B/255, blue: 0x0B/255)
|
||||||
|
: ClipperTheme.fg70)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.frame(height: 22)
|
||||||
|
.background(
|
||||||
|
Capsule().fill(isSel ? ClipperTheme.lime : ClipperTheme.overlay04)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
375
Sources/ui/ExpandedView.swift
Normal file
375
Sources/ui/ExpandedView.swift
Normal file
@ -0,0 +1,375 @@
|
|||||||
|
//
|
||||||
|
// ExpandedView.swift
|
||||||
|
// 划词侠 plugin
|
||||||
|
//
|
||||||
|
// Top-level panel container — 380×520 to match design spec. Renders
|
||||||
|
// the title bar + state-driven body + footer source-toggle.
|
||||||
|
//
|
||||||
|
// States (per spec):
|
||||||
|
// A — IDLE last action result + history
|
||||||
|
// B — INPUT picked source preview + 2x2 action grid + segmented opts
|
||||||
|
// C — LOADING spinner + cancel
|
||||||
|
// D — RESULT scrollable text + 复制 / 重新生成 / 完成
|
||||||
|
// E — MISSING CFG onboarding card pointing to clipper-config.json
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
struct ExpandedView: View {
|
||||||
|
@ObservedObject var store: ClipperStore = .shared
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Notch reservation strip — same 40pt pattern as 看盘侠.
|
||||||
|
// Host's floating back-chevron lives in this band.
|
||||||
|
Color.clear.frame(height: 40)
|
||||||
|
topBar
|
||||||
|
body_
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
|
footer
|
||||||
|
}
|
||||||
|
.frame(width: 380, height: 520)
|
||||||
|
.background(
|
||||||
|
ZStack {
|
||||||
|
ClipperTheme.panelBg
|
||||||
|
RadialGradient(
|
||||||
|
colors: [Color.white.opacity(0.04), Color.clear],
|
||||||
|
center: .top,
|
||||||
|
startRadius: 4, endRadius: 220
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.clipShape(
|
||||||
|
UnevenRoundedRectangle(
|
||||||
|
cornerRadii: .init(topLeading: 0, bottomLeading: 28, bottomTrailing: 28, topTrailing: 0)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.onAppear {
|
||||||
|
store.activate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Top bar
|
||||||
|
|
||||||
|
private var topBar: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Circle()
|
||||||
|
.fill(ClipperTheme.lime)
|
||||||
|
.frame(width: 7, height: 7)
|
||||||
|
.shadow(color: ClipperTheme.lime.opacity(0.6), radius: 4)
|
||||||
|
Text("划词侠")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundColor(ClipperTheme.fgPrimary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
store.refreshSource()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrow.down.doc")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(ClipperTheme.fg55)
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("从当前源重新读取文本")
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 14)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Body switch
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var body_: some View {
|
||||||
|
switch store.viewState {
|
||||||
|
case .missingConfig:
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
MissingConfigCard(store: store)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
}
|
||||||
|
case .idle:
|
||||||
|
idleBody
|
||||||
|
case .input(let text):
|
||||||
|
inputBody(text: text)
|
||||||
|
case .loading(let label):
|
||||||
|
loadingBody(action: label)
|
||||||
|
case .result(let label, let output, _):
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
if store.permissionDenied {
|
||||||
|
AccessibilityBanner(onOpenSettings: openAccessibilityPrefs)
|
||||||
|
}
|
||||||
|
ResultView(store: store, actionLabel: label, output: output)
|
||||||
|
.frame(minHeight: 320)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 4)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - State A: idle
|
||||||
|
|
||||||
|
private var idleBody: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
if store.permissionDenied {
|
||||||
|
AccessibilityBanner(onOpenSettings: openAccessibilityPrefs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hint card
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "doc.on.clipboard")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundColor(ClipperTheme.lime)
|
||||||
|
Text("复制文本以激活")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundColor(ClipperTheme.fgPrimary)
|
||||||
|
}
|
||||||
|
Text("先把任意文本复制到粘贴板(⌘C),再点上方的刷新按钮,下面就会出现 4 个动作。")
|
||||||
|
.font(.system(size: 11.5))
|
||||||
|
.foregroundColor(ClipperTheme.fg55)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
Button {
|
||||||
|
store.refreshSource()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "arrow.down.doc")
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
Text("从粘贴板读取")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
}
|
||||||
|
.foregroundColor(Color(red: 0x0B/255, green: 0x0B/255, blue: 0x0B/255))
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.frame(height: 30)
|
||||||
|
.background(Capsule().fill(ClipperTheme.lime))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.fill(ClipperTheme.overlay04)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.stroke(ClipperTheme.overlay08, lineWidth: 0.5)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if let err = store.lastError {
|
||||||
|
errorChip(text: err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !store.history.isEmpty {
|
||||||
|
historySection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 4)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var historySection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("最近 3 条")
|
||||||
|
.font(.system(size: 10.5, weight: .semibold))
|
||||||
|
.foregroundColor(ClipperTheme.fg45)
|
||||||
|
.padding(.leading, 4)
|
||||||
|
ForEach(store.history.prefix(3)) { entry in
|
||||||
|
historyRow(entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func historyRow(_ entry: HistoryEntry) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(entry.actionLabel)
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundColor(ClipperTheme.lime)
|
||||||
|
Spacer()
|
||||||
|
Text(relative(entry.timestamp))
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(ClipperTheme.fg40)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
Text(entry.outputPreview)
|
||||||
|
.font(.system(size: 11.5))
|
||||||
|
.foregroundColor(ClipperTheme.fg70)
|
||||||
|
.lineLimit(2)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.fill(ClipperTheme.overlay04)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(ClipperTheme.overlay08, lineWidth: 0.5)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - State B: input picked
|
||||||
|
|
||||||
|
private func inputBody(text: String) -> some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
if store.permissionDenied {
|
||||||
|
AccessibilityBanner(onOpenSettings: openAccessibilityPrefs)
|
||||||
|
}
|
||||||
|
SourcePreview(text: text)
|
||||||
|
if let err = store.lastError {
|
||||||
|
errorChip(text: err)
|
||||||
|
}
|
||||||
|
ActionGrid(store: store)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 4)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - State C: loading
|
||||||
|
|
||||||
|
private func loadingBody(action: String) -> some View {
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
Spacer()
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(.circular)
|
||||||
|
.scaleEffect(1.2)
|
||||||
|
.tint(ClipperTheme.lime)
|
||||||
|
Text("处理中…")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundColor(ClipperTheme.fgPrimary)
|
||||||
|
Text(action)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(ClipperTheme.fg55)
|
||||||
|
Button {
|
||||||
|
store.cancelCurrent()
|
||||||
|
} label: {
|
||||||
|
Text("取消")
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundColor(ClipperTheme.danger)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.frame(height: 28)
|
||||||
|
.background(
|
||||||
|
Capsule()
|
||||||
|
.stroke(ClipperTheme.danger.opacity(0.5), lineWidth: 0.5)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.top, 4)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - shared bits
|
||||||
|
|
||||||
|
private func errorChip(text: String) -> some View {
|
||||||
|
HStack(alignment: .top, spacing: 6) {
|
||||||
|
Image(systemName: "exclamationmark.circle.fill")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(ClipperTheme.danger)
|
||||||
|
.padding(.top, 1)
|
||||||
|
Text(text)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(ClipperTheme.fg85)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(ClipperTheme.danger.opacity(0.08))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.stroke(ClipperTheme.danger.opacity(0.25), lineWidth: 0.5)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func relative(_ d: Date) -> String {
|
||||||
|
let s = Int(Date().timeIntervalSince(d))
|
||||||
|
if s < 60 { return "\(s)s 前" }
|
||||||
|
if s < 3600 { return "\(s / 60)分前" }
|
||||||
|
if s < 86400 { return "\(s / 3600)时前" }
|
||||||
|
return "\(s / 86400)天前"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openAccessibilityPrefs() {
|
||||||
|
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") {
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Footer (source toggle)
|
||||||
|
|
||||||
|
private var footer: some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "arrow.triangle.2.circlepath")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(ClipperTheme.fg45)
|
||||||
|
Text("源")
|
||||||
|
.font(.system(size: 10.5, weight: .medium))
|
||||||
|
.foregroundColor(ClipperTheme.fg45)
|
||||||
|
|
||||||
|
ForEach(TextSourceMode.allCases) { mode in
|
||||||
|
let isSel = store.sourceMode == mode
|
||||||
|
Button {
|
||||||
|
store.setSourceMode(mode)
|
||||||
|
} label: {
|
||||||
|
Text(mode.label)
|
||||||
|
.font(.system(size: 10.5, weight: .semibold))
|
||||||
|
.foregroundColor(isSel
|
||||||
|
? Color(red: 0x0B/255, green: 0x0B/255, blue: 0x0B/255)
|
||||||
|
: ClipperTheme.fg70)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.frame(height: 22)
|
||||||
|
.background(
|
||||||
|
Capsule().fill(isSel ? ClipperTheme.lime : ClipperTheme.overlay04)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if let cfg = store.config {
|
||||||
|
Text(cfg.deepseekApiKey.isEmpty ? "未配置" : "已配置 ✓")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(cfg.deepseekApiKey.isEmpty ? ClipperTheme.amber : ClipperTheme.fg45)
|
||||||
|
} else {
|
||||||
|
Text("未配置")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(ClipperTheme.amber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.clear)
|
||||||
|
.overlay(
|
||||||
|
Rectangle()
|
||||||
|
.fill(ClipperTheme.overlay04)
|
||||||
|
.frame(height: 0.5),
|
||||||
|
alignment: .top
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
165
Sources/ui/PermissionBanner.swift
Normal file
165
Sources/ui/PermissionBanner.swift
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
//
|
||||||
|
// PermissionBanner.swift
|
||||||
|
// 划词侠 plugin
|
||||||
|
//
|
||||||
|
// Two banners share this file:
|
||||||
|
// • Accessibility-permission warning (when sourceMode = .selection)
|
||||||
|
// • Missing-config (state E) full-card with copy-path helper
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AccessibilityBanner: View {
|
||||||
|
var onOpenSettings: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top, spacing: 8) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundColor(ClipperTheme.amber)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.padding(.top, 1)
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("选中文本需要辅助功能权限")
|
||||||
|
.font(.system(size: 11.5, weight: .semibold))
|
||||||
|
.foregroundColor(ClipperTheme.fg85)
|
||||||
|
Text("前往 系统设置 > 隐私与安全性 > 辅助功能,勾选 Mio Island 后回来重试。当前已自动回退到粘贴板。")
|
||||||
|
.font(.system(size: 10.5))
|
||||||
|
.foregroundColor(ClipperTheme.fg55)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
Button(action: onOpenSettings) {
|
||||||
|
Text("打开 系统设置")
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundColor(ClipperTheme.amber)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.top, 2)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.fill(ClipperTheme.amber.opacity(0.08))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(ClipperTheme.amber.opacity(0.25), lineWidth: 0.5)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MissingConfigCard: View {
|
||||||
|
@ObservedObject var store: ClipperStore
|
||||||
|
@State private var copied = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "key.horizontal")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundColor(ClipperTheme.amber)
|
||||||
|
Text("先填一个 DeepSeek API Key")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundColor(ClipperTheme.fgPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("打开下面的配置文件,把 deepseek_api_key 改成你的真实 key,然后点 重新加载。")
|
||||||
|
.font(.system(size: 11.5))
|
||||||
|
.foregroundColor(ClipperTheme.fg70)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
|
Text(ClipperConfig.configPath)
|
||||||
|
.font(.system(size: 10.5, design: .monospaced))
|
||||||
|
.foregroundColor(ClipperTheme.fg55)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(ClipperTheme.overlay04)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.stroke(ClipperTheme.overlay08, lineWidth: 0.5)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button {
|
||||||
|
store.copyToPasteboard(ClipperConfig.configPath)
|
||||||
|
copied = true
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(nanoseconds: 1_200_000_000)
|
||||||
|
await MainActor.run { copied = false }
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: copied ? "checkmark" : "doc.on.doc")
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
Text(copied ? "路径已复制" : "复制路径")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
}
|
||||||
|
.foregroundColor(copied ? ClipperTheme.lime : ClipperTheme.fg70)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 30)
|
||||||
|
.background(
|
||||||
|
Capsule().fill(ClipperTheme.overlay06)
|
||||||
|
.overlay(Capsule().stroke(ClipperTheme.overlay08, lineWidth: 0.5))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
NSWorkspace.shared.open(URL(fileURLWithPath: ClipperConfig.configDir))
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "folder")
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
Text("打开文件夹")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
}
|
||||||
|
.foregroundColor(ClipperTheme.fg70)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 30)
|
||||||
|
.background(
|
||||||
|
Capsule().fill(ClipperTheme.overlay06)
|
||||||
|
.overlay(Capsule().stroke(ClipperTheme.overlay08, lineWidth: 0.5))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
store.reloadConfig()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
Text("重新加载")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
}
|
||||||
|
.foregroundColor(Color(red: 0x0B/255, green: 0x0B/255, blue: 0x0B/255))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 30)
|
||||||
|
.background(Capsule().fill(ClipperTheme.lime))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("没有 key?去 platform.deepseek.com 注册一个,新用户有免费额度。")
|
||||||
|
.font(.system(size: 10.5))
|
||||||
|
.foregroundColor(ClipperTheme.fg45)
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.fill(ClipperTheme.overlay04)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.stroke(ClipperTheme.overlay08, lineWidth: 0.5)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
108
Sources/ui/ResultView.swift
Normal file
108
Sources/ui/ResultView.swift
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
//
|
||||||
|
// ResultView.swift
|
||||||
|
// 划词侠 plugin
|
||||||
|
//
|
||||||
|
// Scrollable result text + 复制 / 重新生成 / 完成 buttons.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ResultView: View {
|
||||||
|
@ObservedObject var store: ClipperStore
|
||||||
|
let actionLabel: String
|
||||||
|
let output: String
|
||||||
|
|
||||||
|
@State private var copyConfirmed = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
// Action header
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundColor(ClipperTheme.lime)
|
||||||
|
Text(actionLabel)
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundColor(ClipperTheme.fgPrimary)
|
||||||
|
Spacer()
|
||||||
|
Text("\(output.count) 字")
|
||||||
|
.font(.system(size: 10.5))
|
||||||
|
.foregroundColor(ClipperTheme.fg40)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result body — scrollable.
|
||||||
|
ScrollView {
|
||||||
|
Text(output)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundColor(ClipperTheme.fg85)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(ClipperTheme.overlay04)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(ClipperTheme.overlay08, lineWidth: 0.5)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
resultButton(label: copyConfirmed ? "已复制" : "复制",
|
||||||
|
icon: copyConfirmed ? "checkmark" : "doc.on.doc",
|
||||||
|
tint: copyConfirmed ? ClipperTheme.lime : ClipperTheme.fg70) {
|
||||||
|
store.copyToPasteboard(output)
|
||||||
|
copyConfirmed = true
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(nanoseconds: 1_200_000_000)
|
||||||
|
await MainActor.run { copyConfirmed = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resultButton(label: "重新生成",
|
||||||
|
icon: "arrow.clockwise",
|
||||||
|
tint: ClipperTheme.fg70) {
|
||||||
|
store.regenerate()
|
||||||
|
}
|
||||||
|
resultButton(label: "完成",
|
||||||
|
icon: "checkmark.circle.fill",
|
||||||
|
tint: Color(red: 0x0B/255, green: 0x0B/255, blue: 0x0B/255),
|
||||||
|
filled: true) {
|
||||||
|
store.finishResult()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resultButton(label: String,
|
||||||
|
icon: String,
|
||||||
|
tint: Color,
|
||||||
|
filled: Bool = false,
|
||||||
|
action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
}
|
||||||
|
.foregroundColor(tint)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 32)
|
||||||
|
.background(
|
||||||
|
Capsule()
|
||||||
|
.fill(filled ? ClipperTheme.lime : ClipperTheme.overlay06)
|
||||||
|
.overlay(
|
||||||
|
Capsule()
|
||||||
|
.stroke(filled ? Color.clear : ClipperTheme.overlay08, lineWidth: 0.5)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
49
Sources/ui/SourcePreview.swift
Normal file
49
Sources/ui/SourcePreview.swift
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
//
|
||||||
|
// SourcePreview.swift
|
||||||
|
// 划词侠 plugin
|
||||||
|
//
|
||||||
|
// Compact card showing the current source text — 3 lines, soft border,
|
||||||
|
// monospace fallback for long URLs/code.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SourcePreview: View {
|
||||||
|
let text: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "text.alignleft")
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
.foregroundColor(ClipperTheme.fg45)
|
||||||
|
Text("待处理文本")
|
||||||
|
.font(.system(size: 10.5, weight: .semibold))
|
||||||
|
.foregroundColor(ClipperTheme.fg45)
|
||||||
|
Spacer()
|
||||||
|
Text("\(text.count) 字")
|
||||||
|
.font(.system(size: 10, weight: .medium))
|
||||||
|
.foregroundColor(ClipperTheme.fg40)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
Text(text)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(ClipperTheme.fg85)
|
||||||
|
.lineLimit(3)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(ClipperTheme.overlay04)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(ClipperTheme.overlay08, lineWidth: 0.5)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
41
Sources/ui/Theme.swift
Normal file
41
Sources/ui/Theme.swift
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
//
|
||||||
|
// Theme.swift
|
||||||
|
// 划词侠 plugin
|
||||||
|
//
|
||||||
|
// Same visual language as 看盘侠 — pure black panel + lime accent.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
enum ClipperTheme {
|
||||||
|
// Lime accent — same value as 看盘侠 `#d4ff3a`.
|
||||||
|
static let lime = Color(red: 0xD4/255, green: 0xFF/255, blue: 0x3A/255)
|
||||||
|
|
||||||
|
// Soft cyan for "info" affordances (translate-target chips, etc.)
|
||||||
|
static let cyan = Color(red: 0x6E/255, green: 0xE7/255, blue: 0xFF/255)
|
||||||
|
|
||||||
|
// Warning amber for permission banners.
|
||||||
|
static let amber = Color(red: 0xFF/255, green: 0xC4/255, blue: 0x54/255)
|
||||||
|
|
||||||
|
// Soft red for error / cancel states.
|
||||||
|
static let danger = Color(red: 0xFF/255, green: 0x6E/255, blue: 0x6E/255)
|
||||||
|
|
||||||
|
// Panel background.
|
||||||
|
static let panelBg = Color(red: 0x05/255, green: 0x05/255, blue: 0x05/255)
|
||||||
|
|
||||||
|
// Subtle white overlays.
|
||||||
|
static let overlay04 = Color.white.opacity(0.04)
|
||||||
|
static let overlay06 = Color.white.opacity(0.06)
|
||||||
|
static let overlay08 = Color.white.opacity(0.08)
|
||||||
|
static let overlay12 = Color.white.opacity(0.12)
|
||||||
|
static let overlay18 = Color.white.opacity(0.18)
|
||||||
|
|
||||||
|
// Foreground tints.
|
||||||
|
static let fgPrimary = Color(red: 0xF4/255, green: 0xF4/255, blue: 0xF5/255)
|
||||||
|
static let fg85 = Color.white.opacity(0.85)
|
||||||
|
static let fg70 = Color.white.opacity(0.7)
|
||||||
|
static let fg55 = Color.white.opacity(0.55)
|
||||||
|
static let fg45 = Color.white.opacity(0.45)
|
||||||
|
static let fg40 = Color.white.opacity(0.4)
|
||||||
|
static let fg35 = Color.white.opacity(0.35)
|
||||||
|
}
|
||||||
65
build.sh
Executable file
65
build.sh
Executable file
@ -0,0 +1,65 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Build the 划词侠 plugin as a .bundle for Mio Island.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./build.sh # produce build/clipper.bundle + build/clipper.zip
|
||||||
|
# ./build.sh install # also copy bundle to ~/.config/codeisland/plugins/
|
||||||
|
|
||||||
|
set -e
|
||||||
|
set -o pipefail
|
||||||
|
|
||||||
|
PLUGIN_NAME="clipper"
|
||||||
|
MODULE_NAME="ClipperPlugin"
|
||||||
|
BUNDLE_NAME="${PLUGIN_NAME}.bundle"
|
||||||
|
BUILD_DIR="build"
|
||||||
|
|
||||||
|
SOURCES=$(find Sources -name "*.swift" -type f)
|
||||||
|
SOURCE_COUNT=$(echo "$SOURCES" | wc -l | tr -d ' ')
|
||||||
|
|
||||||
|
echo "Building ${PLUGIN_NAME} plugin (${SOURCE_COUNT} swift files)..."
|
||||||
|
|
||||||
|
rm -rf "${BUILD_DIR}"
|
||||||
|
mkdir -p "${BUILD_DIR}/${BUNDLE_NAME}/Contents/MacOS"
|
||||||
|
|
||||||
|
# arm64-only is fine for v0.1 — Mio Island host requires macOS 15+
|
||||||
|
# which means Apple Silicon dominant.
|
||||||
|
swiftc \
|
||||||
|
-emit-library \
|
||||||
|
-module-name "${MODULE_NAME}" \
|
||||||
|
-target arm64-apple-macos15.0 \
|
||||||
|
-sdk "$(xcrun --show-sdk-path)" \
|
||||||
|
-O \
|
||||||
|
-o "${BUILD_DIR}/${BUNDLE_NAME}/Contents/MacOS/${MODULE_NAME}" \
|
||||||
|
${SOURCES}
|
||||||
|
|
||||||
|
cp Info.plist "${BUILD_DIR}/${BUNDLE_NAME}/Contents/"
|
||||||
|
|
||||||
|
if [ -d "Resources" ] && [ "$(ls -A Resources 2>/dev/null)" ]; then
|
||||||
|
mkdir -p "${BUILD_DIR}/${BUNDLE_NAME}/Contents/Resources"
|
||||||
|
cp -R Resources/* "${BUILD_DIR}/${BUNDLE_NAME}/Contents/Resources/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ad-hoc sign the whole bundle.
|
||||||
|
codesign --force --deep --sign - "${BUILD_DIR}/${BUNDLE_NAME}"
|
||||||
|
|
||||||
|
echo "✓ Built ${BUILD_DIR}/${BUNDLE_NAME}"
|
||||||
|
|
||||||
|
# zip for marketplace upload (顶层是 .bundle 目录)
|
||||||
|
cd "${BUILD_DIR}"
|
||||||
|
rm -f "${PLUGIN_NAME}.zip"
|
||||||
|
zip -rq "${PLUGIN_NAME}.zip" "${BUNDLE_NAME}"
|
||||||
|
cd ..
|
||||||
|
echo "✓ Created ${BUILD_DIR}/${PLUGIN_NAME}.zip"
|
||||||
|
|
||||||
|
if [ "${1:-}" = "install" ]; then
|
||||||
|
PLUGIN_DIR="${HOME}/.config/codeisland/plugins"
|
||||||
|
mkdir -p "${PLUGIN_DIR}"
|
||||||
|
rm -rf "${PLUGIN_DIR}/${BUNDLE_NAME}"
|
||||||
|
cp -R "${BUILD_DIR}/${BUNDLE_NAME}" "${PLUGIN_DIR}/"
|
||||||
|
echo "✓ Installed to ${PLUGIN_DIR}/${BUNDLE_NAME}"
|
||||||
|
echo " Restart Mio Island (Cmd+Q + reopen) to load the new build."
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo "Install locally:"
|
||||||
|
echo " ./build.sh install"
|
||||||
|
fi
|
||||||
Loading…
Reference in New Issue
Block a user