From 1d53c30b93775a2edc608736e17626129f576c4c Mon Sep 17 00:00:00 2001 From: everettjf Date: Tue, 21 Apr 2026 10:28:03 -0700 Subject: [PATCH 01/13] docs: add AI Generate feature design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan for a settings-configured OpenAI key plus an AI-driven widget creation flow that runs an agent loop (generate → run in JSX runtime → feed errors back → regenerate) before handing the result to the user for review, preview, and save. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/ai-generate.md | 469 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 469 insertions(+) create mode 100644 docs/ai-generate.md diff --git a/docs/ai-generate.md b/docs/ai-generate.md new file mode 100644 index 0000000..7d95a60 --- /dev/null +++ b/docs/ai-generate.md @@ -0,0 +1,469 @@ +# AI Generate — 设计文档 + +在 ScriptWidget 中引入 "AI 生成 Widget" 能力:用户在设置里配置 OpenAI (或兼容端点) 的 API key,然后在新建 Widget 流程里输入一段自然语言 prompt,由 LLM 生成 JSX 代码,并在本机 runtime 中自动"跑—看错—修"直至通过,最后进入审阅+预览态,由用户确认落盘。 + +本设计为 `feature/ai-generate` 分支的实施依据。实现阶段按第 9 节里程碑推进。 + +--- + +## 1. 目标 + +- 让不会写 JSX/JS 的用户,用一段描述就能得到一个可运行的 Widget。 +- 生成出的 Widget 必须**真的能跑**,不是"看起来像代码"。通过 runtime 侧自动执行 + 错误回灌的 agent loop 保证。 +- 用户体验接近 Claude Code / Codex:能看到迭代进度,能中断,跑完能审阅修改再保存。 + +### 非目标(本期不做) + +- 多轮自由聊天 / 聊天历史。用户在已生成的 widget 基础上再下一句"优化 prompt"即可,不是多轮。 +- Widget extension 二进制里调 LLM。AI 调用仅发生在主 app 进程。 +- 生成图片素材 / DALL·E。只生成 JSX 代码。 +- Streaming UI 打字效果。见 §8,列为第二期。 + +--- + +## 2. 决策摘要 + +| 项 | 决定 | +|---|---| +| 存储 | `UserDefaults(suiteName: "group.everettjf.scriptwidget")`(第一期)。Keychain 迁移留作后续 | +| OpenAI 客户端 | [SwiftOpenAI](https://github.com/jamesrochabrun/SwiftOpenAI) | +| 默认模型 | `gpt-4o-mini`,用户可自填任意 SwiftOpenAI 支持的 model id | +| 默认 base URL | `https://api.openai.com/v1`,用户可自填(兼容 Azure / DeepSeek / 本地 vLLM 等) | +| 默认迭代上限 | 20,用户可设 30 / 40 / 50 | +| 交互形态 | 一次性 prompt → agent loop → 审阅+预览 → 用户点确认落盘 | +| 优化 | 已生成的 widget 上,用户可追加一段 prompt 触发新一轮 agent loop(单轮单次,不留聊天历史) | +| Agent UI | 进度条 + 当前阶段文本 + 错误日志折叠面板。不做流式打字 | +| Agent 循环位置 | 仅主 app,iOS/macOS 双端。不进 widget/share extension | + +--- + +## 3. 总体架构 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ SwiftUI │ +│ ┌──────────────┐ ┌───────────────────────┐ ┌─────────────┐ │ +│ │ SettingAIView│ │ AIGenerateView │ │ AIReviewView│ │ +│ │ (config) │──▶│ (prompt + progress) │──▶│ (preview + │ │ +│ │ │ │ │ │ confirm / │ │ +│ │ │ │ │ │ refine) │ │ +│ └──────────────┘ └───────────┬───────────┘ └─────────────┘ │ +│ │ │ +└─────────────────────────────────┼────────────────────────────────┘ + │ + ┌──────────────────────────▼─────────────────────────┐ + │ AIGenerateSession (@MainActor ObservableObject) │ + │ - phase / iteration / logs / currentJSX │ + │ - start(prompt) / refine(prompt) / cancel() │ + └───┬─────────────────────┬──────────────────────────┘ + │ │ + ▼ ▼ + ┌────────────────┐ ┌───────────────────────┐ + │ AIClient │ │ AgentLoop │ + │ (SwiftOpenAI) │◀────▶│ plan→gen→run→fix │ + │ baseURL/key/ │ │ terminates on: │ + │ model/usage │ │ pass / max iter / │ + └────────────────┘ │ cancel │ + └──────────┬────────────┘ + │ runs JSX via + ▼ + ┌────────────────────────────┐ + │ ScriptWidgetRuntime │ + │ (existing JavaScriptCore) │ + │ → element? / error? / │ + │ console logs │ + └────────────────────────────┘ +``` + +关键原则: +- **AI 业务逻辑放在 `Shared/ScriptWidgetRuntime/AI/`**,iOS/macOS 共享。 +- **runtime 不动**,当作沙箱直接复用。AI 侧只消费它的输出。 +- SwiftUI view 各端一份(iOS Form 风格;macOS GroupBox 风格,和现有 `SettingsView` 一致)。 + +--- + +## 4. 详细设计 + +### 4.1 配置存储 + +一组 key,挂在 app group `UserDefaults` 上,以便将来 extension 读写元数据(虽然当前 extension 不调 LLM)。 + +```swift +enum AISettingsKey { + static let apiKey = "ai.apiKey" // String + static let baseURL = "ai.baseURL" // String, default "https://api.openai.com/v1" + static let model = "ai.model" // String, default "gpt-4o-mini" + static let maxIterations = "ai.maxIterations" // Int, default 20 + static let temperature = "ai.temperature" // Double, default 0.7 +} +``` + +- 默认值集中在 `AISettings.default` 静态常量里。 +- `AISettings.isConfigured: Bool { !apiKey.isEmpty }`。 +- **注意**:第一期 UserDefaults 明文存储,**在 UI 上显式告知用户**"key 明文存在本机,请勿在共享设备上配置"。TODO: 下一期迁 Keychain。 + +### 4.2 AI 设置页 `SettingAIView` + +挂在 `SettingsView` 新的 `GroupBox(label: SettingsLabelView(title: "AI", image: "sparkles"))` 下,用 `NavigationLink` 跳转。字段: + +- API Key(`SecureField`,带"显示/隐藏"眼睛) +- Base URL(`TextField`,默认占位符展示官方地址) +- Model(`TextField` + 快捷按钮:`gpt-4o-mini` / `gpt-4o` / `gpt-4.1-mini` / 清空) +- Max Iterations(`Stepper`,范围 5...100,默认 20) +- Temperature(`Slider`,0.0...1.5,默认 0.7) +- "Test Connection" 按钮:发一条最小 chat("ping"),成功绿勾、失败错误信息 +- 一段风险说明文字(key 明文存储) + +### 4.3 生成入口与审阅预览页 + +**入口**:在 `CreateGuideView` 的列表最上方插入一条独立行 "✨ Generate with AI"。 + +- 若 `AISettings.isConfigured == false`:点击弹窗"请先到设置 → AI 配置 API Key",带一个按钮直接跳到 `SettingAIView`。 +- 已配置:push `AIGenerateView`。 + +**`AIGenerateView`**(纯输入态): + +- 多行 `TextEditor`(prompt,至少 4 行可见),placeholder "描述一下你想要的 widget(例如:显示当前天气和三天预报,深色背景)"。 +- Widget Size Picker(small / medium / large / extraLarge / accessoryCircular / accessoryRectangular / accessoryInline),默认 `medium`。 +- 主按钮 "Generate"。 +- 下方嵌一个 `AIGenerateProgressView`(见 §4.8),未开始时隐藏。 + +**`AIReviewView`**(agent loop 结束 + 成功时 push 过来): + +- 上半:复用现有 preview 机制,用生成出来的 JSX 建一个 **临时 package**(见下方"临时 package"设计),走 `ScriptWidgetElementView` 渲染,就是现在 `ScriptCodePreviewView` 那个 widget 预览块。 +- 中间:折叠的 Code 查看器(复用现有 CodeMirror 或 `MirrorEditorScriptView`)—— 只读,避免用户在此编辑后状态混乱;要编辑请先点"Save"进入正式编辑态。 +- 下半: + - "Refine" 区:一个 TextField + "Refine" 按钮 → 回到 `AIGenerateView` 的进度流程,但这次初始 JSX 是上一轮的代码,用户 prompt 是"在原有基础上 …"(见 §4.5 refine prompt)。 + - "Save Widget" 主按钮 → 调用 `sharedScriptManager.createScript(content:, recommendPackageName: "AI Generated ", imageCopyPath: nil)`,随后走和 `CreateGuideView` 相同的 dismiss + `ScriptWidgetHomeViewDataObject.scriptCreateNotification` 通知逻辑。 + - "Discard" 次级按钮 → 返回上一页。 +- 顶部 navbar "Logs" 按钮 → 展示本次 agent loop 的完整迭代历史(每轮的错误 + 修改点概述),只读。 + +**临时 package**:AI 跑 JSX 需要一个 `ScriptWidgetPackage` 路径(runtime 强依赖 package 做 `$import / $file` 支持)。用 `NSTemporaryDirectory()/ScriptWidgetAI//` 建一次性目录,只放 `main.jsx`。审阅页完成或取消时清理。 + +### 4.4 AI 服务层 `AIClient` + +薄封装 SwiftOpenAI。位置 `Shared/ScriptWidgetRuntime/AI/AIClient.swift`。 + +```swift +actor AIClient { + struct Config { + let apiKey: String + let baseURL: URL + let model: String + let temperature: Double + } + + struct Message { let role: Role; let content: String } + enum Role: String { case system, user, assistant } + + struct Response { + let content: String + let promptTokens: Int + let completionTokens: Int + } + + func chat(messages: [Message], config: Config) async throws -> Response +} +``` + +- 内部根据 `config` 构造 SwiftOpenAI 的 `OpenAIService`(支持自定义 baseURL)。 +- 不做流式(第一期)。失败直接抛错,由 `AgentLoop` 捕获。 +- 超时(建议 60s / 次)单独在 config 里预留参数(先硬编码 60s)。 + +### 4.5 Prompt 构造 + +位置 `Shared/ScriptWidgetRuntime/AI/PromptBuilder.swift`。 + +**System Prompt** 组成(顺序): + +1. **角色与铁律**(硬编码、稳定): + ``` + You are a ScriptWidget code generator. Output ONLY a single JSX snippet + that calls $render(...) exactly once. No markdown fences, no explanations. + + RULES: + 1. Must call $render(<...>) exactly once. Root must be a layout container + (vstack / hstack / zstack). + 2. Do NOT use `import`, `require`, `module`, Node APIs, or DOM APIs. + 3. Networking is only via `fetch(url)` (returns string) or `$http.*`. + 4. Top-level `await` is allowed; the runtime wraps code in async $main. + 5. Time/date: use the globally injected `moment` or JS Date. + 6. Persistent data: use `$storage.set(key, value)` / `$storage.get(key)`. + 7. Only use tags and APIs listed in the REFERENCE section below. + 8. Keep the widget visually dense but readable for the given size. + 9. When using `fetch`, handle errors so the widget still renders. + ``` + +2. **REFERENCE 段**(动态拼接): + - 启动时读 `Script.bundle/component/*/main.jsx` 和 `Script.bundle/api/*/main.jsx`,每个文件截取首 40 行,前面加 `// === ===`。整份塞进 system。 + - 这样之后新增组件/新增 API 无需改 prompt,AI 自动知道。 + - 体积控制:若总长超过 ~60K chars,按优先级裁剪(component 全保留,api 保留常用 10 个:fetch / http / storage / location / health / device / file / getenv / system / console)。 + +3. **SIZE HINT**:告诉 AI 当前目标 size 的像素范围和设计建议(e.g., `accessoryCircular` 必须极简,`large` 可放多列等)。 + +**User Prompt(首轮)**: + +``` +Widget size: {size} +User description: +{user_prompt} +``` + +**User Prompt(第 N>1 轮,修错)**: + +``` +Your previous code: +```jsx +{last_code} +``` +It failed to run. Runtime feedback: +- Error type: {errorCase} +- Error detail: {errorDetail} +- Last console lines: +{last_10_log_lines} + +Fix the code. Return the FULL corrected JSX only. +``` + +**User Prompt(Refine)**: + +``` +Current working code: +```jsx +{current_code} +``` +Apply this change request from the user: +{refine_prompt} + +Return the FULL updated JSX only. +``` + +**后处理剥壳**:即便明确说了不要 markdown,仍写一个 `stripCodeFences(_ raw: String) -> String`,容错处理 `` ```jsx `` / `` ``` `` 围栏、以及前后解释文字(找到第一个 `<` 到最后一个 `);` 的片段作为兜底)。 + +### 4.6 Agent 自调试循环 + +位置 `Shared/ScriptWidgetRuntime/AI/AgentLoop.swift`。纯逻辑,返回流式结果给 `AIGenerateSession`。 + +**算法**: + +``` +fun run(userPrompt, size, initialCode?): + lastCode = initialCode + for i in 1...maxIterations: + emit(.thinking(i)) + + if lastCode == nil: + messages = [system, user_first(userPrompt, size)] + else if initialCode != nil and i == 1: + messages = [system, user_refine(lastCode, userPrompt)] + else: + messages = [system, user_first(userPrompt, size), + assistant(lastCode), + user_fix(lastCode, lastError, lastLogs)] + + jsx = stripCodeFences(await aiClient.chat(messages)) + lastCode = jsx + emit(.running(i, jsx)) + + (element, err, logs) = runtime.execute(jsx) + if success(element, err, logs): + emit(.done(jsx, element)) + return .success(jsx) + else: + lastError = err + lastLogs = logs + emit(.fixing(i, err)) + if cancelled: return .cancelled + continue + + return .exhausted(lastCode, lastError) +``` + +**成功判定** `success(element, err, logs)`: +- `err == nil` +- `element != nil` +- element 的 tag 不是 fallback (`"#UI Not Found#"` / `"#Loading#"` / `"#Failed#"`) +- logs 里不存在以 `[error]` 开头的条目(可调:`console.error` 调用会被记录) + +**终止路径**: +- 成功:返回 JSX,UI 跳 `AIReviewView`。 +- 达到 `maxIterations`:把最后一版代码和最后的错误信息一起交给用户,UI 上明示 "Did not converge, showing the last attempt",仍允许用户点进 Review 手动修。 +- 取消:回到输入态,保留 prompt。 + +**上下文成本控制**:每轮构造 messages 时只保留 `[system, firstUser, lastAssistant, lastUserFix]`,**不累积**所有历史。这既省 token 又让模型聚焦当前错误。 + +### 4.7 运行时沙箱 + +直接调 `ScriptWidgetRuntime.executeJSXSyncForWidget`。但有两个点要处理: + +1. **阻塞 → 异步**:现有方法是 `DispatchSemaphore` 同步。agent loop 跑在非主线程没问题,但应该包一层: + + ```swift + func runJSX(_ jsx: String, in package: ScriptWidgetPackage, size: String) + async -> (ScriptWidgetRuntimeElement?, ScriptWidgetError?, [String]) + ``` + + 内部 `await withCheckedContinuation { DispatchQueue.global().async { ... } }`。 + +2. **日志采集**:执行前先 `sharedRunningState = ScriptWidgetRunningState(package: tempPackage)`,执行后读 `sharedRunningState.logger.logs`。这与 `ScriptCodePreviewDataObject` 做法一致。 + - 风险:`sharedRunningState` 是全局 var,和主 app 同时运行的 preview 会撞。实现时加一个 serial queue 保证 AI 执行与 preview 执行互斥;或在 runtime 内部改为每次新建 `ScriptWidgetRunningState` 传入 runtime 实例(更正经,但动了 runtime,放第二期)。**第一期用互斥 queue**。 + +### 4.8 状态机 / 进度展示 + +`AIGenerateSession` 是 `@MainActor ObservableObject`。 + +```swift +enum AIGeneratePhase: Equatable { + case idle + case thinking(iteration: Int) // 在等 LLM + case running(iteration: Int) // 在跑 JSX + case fixing(iteration: Int, error: String) + case done(jsx: String) + case exhausted(lastJSX: String?, lastError: String?) + case failed(String) // 网络 / API 错误等非 agent 循环错 + case cancelled +} + +@Published var phase: AIGeneratePhase = .idle +@Published var iterationHistory: [IterationRecord] = [] +@Published var usage: TokenUsage = .zero // 累计 tokens,仅展示 +``` + +UI(`AIGenerateProgressView`): +- 顶部:小 `ProgressView` + 文本 `"Iteration 3 / 20 — running code…"`。 +- 中部:最新错误 `Text` (一行,截断);点开后展开整条。 +- 底部:`Cancel` 按钮。 +- 历史折叠:`DisclosureGroup("History")` 展示每轮的 role/status/error 一行摘要。 + +**进度条**:用 `ProgressView(value: Double(iteration), total: Double(maxIterations))`,虽然实际 iteration 不可预测,但能给用户"大概还能继续多少次"的感知。 + +### 4.9 优化(Refine)交互 + +在 `AIReviewView` 底部: + +- 输入框:"Ask AI to change it (e.g. 'make background darker, show icon on the left')" +- 按钮 "Refine" → 关闭 review,重新 push `AIGenerateView`(或直接原地覆盖),以 `initialCode = currentJSX` 和 `refinePrompt` 为输入启动 agent loop。 +- 完成后再回到 review,循环。 + +注意:Refine 不保留多轮历史,每次 refine 都是"基于当前 code + 本次 prompt"的独立请求。这是为了成本可控,也符合你"不需要多轮聊天"的决策。 + +--- + +## 5. 文件改动清单(实施时的 checklist) + +**新增**: + +``` +Shared/ScriptWidgetRuntime/AI/ +├── AISettings.swift // UserDefaults 封装 + default values +├── AIClient.swift // SwiftOpenAI 封装 (actor) +├── PromptBuilder.swift // system/user prompt 拼接 + stripCodeFences +├── AgentLoop.swift // 核心循环 +├── AgentRuntimeBridge.swift // runJSX(...) async + 互斥 queue + 日志采集 +├── AIGenerateSession.swift // @MainActor ObservableObject +└── AIReferenceSnapshot.swift // 启动时读 Script.bundle 构造 REFERENCE 段(带缓存) + +iOS/ScriptWidget/App/Settings/ +└── SettingAIView.swift // 新设置页 + +iOS/ScriptWidget/View/AIGenerate/ +├── AIGenerateView.swift // prompt 输入 + progress +├── AIGenerateProgressView.swift // 进度条 + 阶段文本 + 历史 +└── AIReviewView.swift // preview + refine + save + +macOS/ScriptWidgetMac/... // 三件与 iOS 对应的 macOS 版本 +(若 view 可完全跨平台,优先跨端复用;不能则分开写,行为一致) +``` + +**修改**: + +``` +iOS/ScriptWidget/App/Settings/SettingsView.swift + - 加一个 GroupBox "AI" → NavigationLink(SettingAIView) + +iOS/ScriptWidget/App/Scripts/CreateGuideView.swift + - 列表顶部插一行 "✨ Generate with AI" + - 未配置时引导到 SettingAIView;已配置 push AIGenerateView + +iOS/ScriptWidget.xcodeproj/project.pbxproj + - 添加 XCRemoteSwiftPackageReference: SwiftOpenAI + - 主 app target 链接 SwiftOpenAI;widget/share extension 不链接 + +macOS/ScriptWidgetMac.xcodeproj/project.pbxproj + - 同上 +``` + +**显式不改**: +- `Shared/ScriptWidgetRuntime/Widget/Runtime/ScriptWidgetRuntime.swift` 保持不变。 +- `ScriptCodePreviewView` / `ScriptCodePreviewDataObject` 不动。 + +--- + +## 6. 安全 / 隐私 / 成本 + +- **Key 存储**:第一期 UserDefaults 明文。设置页必须有一段红色/橙色说明文字。TODO comment 写清楚"迁 Keychain"。 +- **网络**:只向用户配置的 `baseURL` 发请求。禁止默认值之外的任何 hardcoded endpoint。 +- **数据最小化**:prompt 只包含用户输入 + 我们的系统提示 + 当轮错误信息。不上传用户已有 widgets、设备标识、位置、健康数据等。 +- **成本可感**: + - 每轮请求后累加 `usage.prompt_tokens / completion_tokens`,在 progress view 展示 "used ~3.2K tokens so far"。 + - iteration 上限是硬性保险。UI 上明示 "cost scales with iterations"。 +- **取消即止**:`AgentLoop` 内每轮入口检查 `Task.isCancelled`,一旦 cancel 立即返回,不再发下一次请求。 +- **错误暴露**:SwiftOpenAI 抛的错(401 / 429 / 超时)直接以可读文本展示;不吞异常。 + +--- + +## 7. 跨平台 + +- 共享层(`Shared/ScriptWidgetRuntime/AI/`)纯 Swift + SwiftUI 无平台耦合。 +- 三个 SwiftUI view 若能用 `#if os(iOS)` / `#if os(macOS)` 在单文件内分支处理,尽量单文件跨端。iOS 用 `Form`/`sheet`/`.navigationBarTitle`,macOS 用 `ScrollView`+`GroupBox`(和现有 `SettingsView` macOS 版本一致)。 +- widget / share extension **不**链接 SwiftOpenAI。在 target membership 上严格限制。 + +--- + +## 8. 本期不做 / 延后 + +1. **流式 UI**(`LanguageModelChatUI` 风格打字效果):实现成本中等,但需要改 `AIClient` 为流式 + UI 实时拼接 + 中断半完成响应。判断:不做流式 UX 已经够用(每轮 2–5 秒),延后到第二期。 +2. **多轮聊天历史**:用户不需要。 +3. **Keychain 存储**:第二期。 +4. **Extension 内 LLM 调用**:不做(内存限制 + 隐私)。 +5. **生成图片 / 素材**:不做。 +6. **本地模型 / on-device**:先不做,用户想用的话自填本地 `baseURL`(例如 `http://127.0.0.1:11434/v1`)即可。 + +--- + +## 9. 里程碑拆分 + +建议按以下顺序合并小 PR,每步可独立验证: + +- **M1 — 配置通路**:`AISettings` + `SettingAIView` + 设置入口 + "Test Connection"。无 AI 生成能力,只验证 key 配置与网络联通。 +- **M2 — 单次生成**:`AIClient` + `PromptBuilder`(含 REFERENCE 构造)+ `AIGenerateView` 的 prompt 输入和"单次调用 LLM + 拿到 JSX + 直接渲染"路径,**不做 agent loop**,失败就失败。用来验证 prompt 质量。 +- **M3 — Agent loop**:`AgentRuntimeBridge` + `AgentLoop` + `AIGenerateSession` + 进度 UI。 +- **M4 — 审阅页**:`AIReviewView`(preview + logs + save)。 +- **M5 — Refine**:在 M4 上加 refine 输入与再循环。 +- **M6 — macOS 平齐**:确保 macOS 三个 view 功能等同。 + +本文档合入作为 M0。 + +--- + +## 10. 开放问题 / 待实施时确认 + +1. REFERENCE 段的裁剪策略:是否允许用户在设置里勾选"精简 / 完整"两档以控制 prompt token 成本?(当前设计:固定按优先级裁剪) +2. 临时 package 目录是否应该落在 app group 而非 `NSTemporaryDirectory()`?若 runtime 某些 API 依赖 `ScriptManager.scriptDirectory`,需要验证一下——实施时写个小 spike 跑通再定。 +3. "审阅页"的 code 查看器要不要允许编辑?当前设计是只读;若允许编辑,则与进入正式编辑态后的行为边界需要再想清楚(覆盖还是分叉)。 +4. 失败但 iteration 未耗尽时,是否给用户"再试一轮"按钮(单独追加一轮而非重头来)?第一期先不做,等实际用起来再加。 + +--- + +## 附录 A:相关源码锚点 + +- 运行时入口:`Shared/ScriptWidgetRuntime/Widget/Runtime/ScriptWidgetRuntime.swift` — `executeJSXSyncForWidget` (line 181) +- 错误类型:同文件 `ScriptWidgetError` (line 12) +- 日志聚合:`ScriptWidgetRunningState.logger.logs`(全局 `sharedRunningState`) +- 模板清单:`Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/*/main.jsx` +- 组件用法:`Shared/ScriptWidgetRuntime/Resource/Script.bundle/component/*/main.jsx` +- API 用法:`Shared/ScriptWidgetRuntime/Resource/Script.bundle/api/*/main.jsx` +- 创建入口:`iOS/ScriptWidget/App/Scripts/CreateGuideView.swift` +- 编辑/预览:`iOS/ScriptWidget/View/CodeEditor/ScriptCodeEditorView.swift`、`.../Preview/ScriptCodePreviewView.swift` +- 设置页:`iOS/ScriptWidget/App/Settings/SettingsView.swift` +- 包落盘:`Shared/ScriptWidgetRuntime/Common/ScriptManager.swift` — `createScript(content:recommendPackageName:imageCopyPath:)` From 15f1d227816c227bdb59cdc4892a282bf845fda9 Mon Sep 17 00:00:00 2001 From: everettjf Date: Tue, 21 Apr 2026 10:43:07 -0700 Subject: [PATCH 02/13] feat(ai): add shared AI layer and iOS/macOS UI for widget generation - Shared: AISettings store, SwiftOpenAI-backed AIClient, reference snapshot builder (samples Script.bundle for prompt context), PromptBuilder, AgentRuntimeBridge, AgentLoop, AIGenerateSession, cross-platform AIGenerateProgressView. - iOS: SettingAIView, AIGenerateView and AIReviewView; wired into SettingsView (new AI group) and CreateGuideView (new "Generate with AI" entry). - macOS: SettingAIView (hosted in Settings scene, Cmd+,), AIGenerateWindowView (split-view sheet with prompt, live progress, preview, refine, save), and a sparkles toolbar button in SidebarView. Project file (.pbxproj) registration and SwiftOpenAI SPM dependency follow in the next commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- Shared/ScriptWidgetRuntime/AI/AIClient.swift | 110 ++++++ .../AI/AIGenerateProgressView.swift | 158 +++++++++ .../AI/AIGenerateSession.swift | 171 ++++++++++ .../AI/AIReferenceSnapshot.swift | 109 ++++++ .../ScriptWidgetRuntime/AI/AISettings.swift | 106 ++++++ Shared/ScriptWidgetRuntime/AI/AgentLoop.swift | 143 ++++++++ .../AI/AgentRuntimeBridge.swift | 129 +++++++ .../AI/PromptBuilder.swift | 201 +++++++++++ .../App/Scripts/CreateGuideView.swift | 60 +++- .../App/Settings/SettingAIView.swift | 208 ++++++++++++ .../App/Settings/SettingsView.swift | 10 +- .../View/AIGenerate/AIGenerateView.swift | 112 +++++++ .../View/AIGenerate/AIReviewView.swift | 284 ++++++++++++++++ .../AIGenerate/AIGenerateWindowView.swift | 316 ++++++++++++++++++ .../App/ScriptWidgetMacApp.swift | 16 +- .../Settings/SettingAIView.swift | 166 +++++++++ .../ScriptWidgetMac/Sidebar/SidebarView.swift | 32 +- 17 files changed, 2313 insertions(+), 18 deletions(-) create mode 100644 Shared/ScriptWidgetRuntime/AI/AIClient.swift create mode 100644 Shared/ScriptWidgetRuntime/AI/AIGenerateProgressView.swift create mode 100644 Shared/ScriptWidgetRuntime/AI/AIGenerateSession.swift create mode 100644 Shared/ScriptWidgetRuntime/AI/AIReferenceSnapshot.swift create mode 100644 Shared/ScriptWidgetRuntime/AI/AISettings.swift create mode 100644 Shared/ScriptWidgetRuntime/AI/AgentLoop.swift create mode 100644 Shared/ScriptWidgetRuntime/AI/AgentRuntimeBridge.swift create mode 100644 Shared/ScriptWidgetRuntime/AI/PromptBuilder.swift create mode 100644 iOS/ScriptWidget/App/Settings/SettingAIView.swift create mode 100644 iOS/ScriptWidget/View/AIGenerate/AIGenerateView.swift create mode 100644 iOS/ScriptWidget/View/AIGenerate/AIReviewView.swift create mode 100644 macOS/ScriptWidgetMac/AIGenerate/AIGenerateWindowView.swift create mode 100644 macOS/ScriptWidgetMac/Settings/SettingAIView.swift diff --git a/Shared/ScriptWidgetRuntime/AI/AIClient.swift b/Shared/ScriptWidgetRuntime/AI/AIClient.swift new file mode 100644 index 0000000..a087fc0 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/AI/AIClient.swift @@ -0,0 +1,110 @@ +// +// AIClient.swift +// ScriptWidget +// +// Thin wrapper around SwiftOpenAI that performs non-streaming chat +// completions against any OpenAI-compatible endpoint configured in +// AISettings. +// + +import Foundation +import SwiftOpenAI + +struct AITokenUsage: Equatable { + var promptTokens: Int + var completionTokens: Int + var totalTokens: Int + + static let zero = AITokenUsage(promptTokens: 0, completionTokens: 0, totalTokens: 0) + + static func + (lhs: AITokenUsage, rhs: AITokenUsage) -> AITokenUsage { + AITokenUsage( + promptTokens: lhs.promptTokens + rhs.promptTokens, + completionTokens: lhs.completionTokens + rhs.completionTokens, + totalTokens: lhs.totalTokens + rhs.totalTokens + ) + } +} + +struct AIChatResult { + let content: String + let usage: AITokenUsage +} + +enum AIClientError: LocalizedError { + case missingAPIKey + case invalidBaseURL(String) + case emptyResponse + case upstream(String) + + var errorDescription: String? { + switch self { + case .missingAPIKey: + return "API key is not set. Open Settings → AI to configure it." + case .invalidBaseURL(let url): + return "Base URL is invalid: \(url)" + case .emptyResponse: + return "The model returned an empty response." + case .upstream(let message): + return message + } + } +} + +actor AIClient { + static let shared = AIClient() + + func chat(messages: [AIMessage], settings: AISettings) async throws -> AIChatResult { + let trimmedKey = settings.apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedKey.isEmpty else { + throw AIClientError.missingAPIKey + } + let baseURLString = settings.normalizedBaseURL + guard URL(string: baseURLString) != nil else { + throw AIClientError.invalidBaseURL(baseURLString) + } + + let service: OpenAIService + if baseURLString == AISettings.defaultBaseURL { + service = OpenAIServiceFactory.service(apiKey: trimmedKey) + } else { + service = OpenAIServiceFactory.service(apiKey: trimmedKey, baseURL: baseURLString) + } + + let chatMessages: [ChatCompletionParameters.Message] = messages.map { msg in + let role: ChatCompletionParameters.Message.Role + switch msg.role { + case .system: role = .system + case .user: role = .user + case .assistant: role = .assistant + } + return ChatCompletionParameters.Message(role: role, content: .text(msg.content)) + } + + let modelId = settings.model.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedModel: Model = modelId.isEmpty ? .custom(AISettings.defaultModel) : .custom(modelId) + + let parameters = ChatCompletionParameters( + messages: chatMessages, + model: resolvedModel, + temperature: settings.temperature + ) + + do { + let response = try await service.startChat(parameters: parameters) + guard let content = response.choices.first?.message.content, !content.isEmpty else { + throw AIClientError.emptyResponse + } + let usage = AITokenUsage( + promptTokens: response.usage?.promptTokens ?? 0, + completionTokens: response.usage?.completionTokens ?? 0, + totalTokens: response.usage?.totalTokens ?? 0 + ) + return AIChatResult(content: content, usage: usage) + } catch let err as AIClientError { + throw err + } catch { + throw AIClientError.upstream(error.localizedDescription) + } + } +} diff --git a/Shared/ScriptWidgetRuntime/AI/AIGenerateProgressView.swift b/Shared/ScriptWidgetRuntime/AI/AIGenerateProgressView.swift new file mode 100644 index 0000000..909e125 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/AI/AIGenerateProgressView.swift @@ -0,0 +1,158 @@ +// +// AIGenerateProgressView.swift +// ScriptWidget +// +// Live status + history for the agent loop. +// + +import SwiftUI + +struct AIGenerateProgressView: View { + @ObservedObject var session: AIGenerateSession + + @State private var historyExpanded = false + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + phaseHeader + + if let progress = progressFraction { + ProgressView(value: progress) + .tint(tintForPhase) + } + + if let detail = detailLine { + Text(detail) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(3) + } + + HStack { + Label("\(session.usage.totalTokens) tokens", systemImage: "bolt") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + if session.isRunning { + Button(role: .destructive) { + session.cancel() + } label: { + Label("Cancel", systemImage: "xmark.circle") + .font(.caption) + } + .buttonStyle(.bordered) + } + } + + if !session.iterationHistory.isEmpty { + DisclosureGroup(isExpanded: $historyExpanded) { + VStack(alignment: .leading, spacing: 6) { + ForEach(session.iterationHistory) { record in + HStack(alignment: .top, spacing: 8) { + Text("#\(record.iteration)") + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) + .frame(width: 28, alignment: .leading) + if let err = record.errorSummary { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .font(.caption2) + Text(err) + .font(.caption2) + .lineLimit(2) + } else { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.caption2) + Text("ran successfully") + .font(.caption2) + } + Spacer() + } + } + } + .padding(.vertical, 4) + } label: { + Text("History (\(session.iterationHistory.count))") + .font(.footnote) + } + } + } + .padding() + .background(Color.secondary.opacity(0.08)) + .cornerRadius(12) + } + + // MARK: - derived + + private var phaseHeader: some View { + HStack(spacing: 8) { + icon + .font(.title3) + .foregroundStyle(tintForPhase) + Text(title) + .font(.headline) + Spacer() + } + } + + @ViewBuilder private var icon: some View { + switch session.phase { + case .idle: Image(systemName: "sparkles") + case .thinking: Image(systemName: "brain") + case .running: Image(systemName: "play.circle") + case .fixing: Image(systemName: "wrench.and.screwdriver") + case .done: Image(systemName: "checkmark.seal.fill") + case .exhausted: Image(systemName: "exclamationmark.triangle") + case .failed: Image(systemName: "xmark.octagon.fill") + case .cancelled: Image(systemName: "xmark.circle") + } + } + + private var title: String { + let limit = session.maxIterationsForProgress + switch session.phase { + case .idle: return "Ready" + case .thinking(let i): return "Thinking (iteration \(i) / \(limit))" + case .running(let i): return "Running (iteration \(i) / \(limit))" + case .fixing(let i, _): return "Fixing (iteration \(i) / \(limit))" + case .done: return "Done" + case .exhausted: return "Did not converge" + case .failed: return "Failed" + case .cancelled: return "Cancelled" + } + } + + private var detailLine: String? { + switch session.phase { + case .fixing(_, let summary): return summary + case .failed(let msg): return msg + case .exhausted(_, let lastError): return lastError + case .running: return "Executing generated JSX inside the sandbox runtime." + case .thinking: return "Waiting on the model response." + default: return nil + } + } + + private var progressFraction: Double? { + let limit = Double(session.maxIterationsForProgress) + guard limit > 0 else { return nil } + let i = Double(session.currentIteration) + switch session.phase { + case .thinking, .running, .fixing: + return Swift.min(1.0, i / limit) + case .done: return 1.0 + case .exhausted: return 1.0 + default: return nil + } + } + + private var tintForPhase: Color { + switch session.phase { + case .done: return .green + case .failed, .exhausted: return .orange + case .cancelled: return .gray + default: return .accentColor + } + } +} diff --git a/Shared/ScriptWidgetRuntime/AI/AIGenerateSession.swift b/Shared/ScriptWidgetRuntime/AI/AIGenerateSession.swift new file mode 100644 index 0000000..a4cf86b --- /dev/null +++ b/Shared/ScriptWidgetRuntime/AI/AIGenerateSession.swift @@ -0,0 +1,171 @@ +// +// AIGenerateSession.swift +// ScriptWidget +// +// Main-actor-facing state machine that drives the agent loop and +// surfaces progress to SwiftUI views. +// + +import Foundation +#if canImport(Combine) +import Combine +#endif + +@MainActor +final class AIGenerateSession: ObservableObject { + + enum Phase: Equatable { + case idle + case thinking(iteration: Int) + case running(iteration: Int) + case fixing(iteration: Int, errorSummary: String) + case done(jsx: String) + case exhausted(lastJSX: String?, lastError: String?) + case failed(String) + case cancelled + } + + struct IterationRecord: Identifiable, Equatable { + let id = UUID() + let iteration: Int + let jsx: String + let errorSummary: String? // nil = success + let logs: [String] + } + + @Published private(set) var phase: Phase = .idle + @Published private(set) var iterationHistory: [IterationRecord] = [] + @Published private(set) var usage: AITokenUsage = .zero + @Published private(set) var lastJSX: String? + @Published private(set) var resultElement: ScriptWidgetRuntimeElement? + @Published private(set) var isRunning: Bool = false + + @Published var size: AIWidgetSize = .medium + + private var currentTask: Task? + + var maxIterationsForProgress: Int { + AISettingsStore.shared.load().maxIterations + } + + var currentIteration: Int { + switch phase { + case .thinking(let i), .running(let i), .fixing(let i, _): + return i + default: + return 0 + } + } + + func start(userDescription: String) { + let description = userDescription.trimmingCharacters(in: .whitespacesAndNewlines) + guard !description.isEmpty else { return } + let settings = AISettingsStore.shared.load() + let request = AgentLoopRequest( + mode: .fresh(userDescription: description), + size: size, + settings: settings, + maxIterations: settings.maxIterations + ) + kickoff(request: request, initialJSX: nil) + } + + func refine(currentCode: String, refineInstruction: String) { + let trimmedCode = currentCode.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedInstr = refineInstruction.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedCode.isEmpty, !trimmedInstr.isEmpty else { return } + let settings = AISettingsStore.shared.load() + let request = AgentLoopRequest( + mode: .refine(currentCode: trimmedCode, refineInstruction: trimmedInstr), + size: size, + settings: settings, + maxIterations: settings.maxIterations + ) + kickoff(request: request, initialJSX: trimmedCode) + } + + func cancel() { + currentTask?.cancel() + } + + func reset() { + cancel() + phase = .idle + iterationHistory = [] + usage = .zero + lastJSX = nil + resultElement = nil + isRunning = false + } + + private func kickoff(request: AgentLoopRequest, initialJSX: String?) { + currentTask?.cancel() + iterationHistory = [] + usage = .zero + lastJSX = initialJSX + resultElement = nil + isRunning = true + phase = .thinking(iteration: 1) + + let loop = AgentLoop() + currentTask = Task { [weak self] in + guard let self else { return } + let outcome = await loop.run(request) { [weak self] event in + guard let self else { return } + self.apply(event: event) + } + self.apply(outcome: outcome) + } + } + + private func apply(event: AgentLoopEvent) { + switch event { + case .thinking(let i): + phase = .thinking(iteration: i) + case .produced(let i, let jsx): + lastJSX = jsx + // History entry is appended on run result (success or fail). + _ = i + case .running(let i): + phase = .running(iteration: i) + case .ranFailed(let i, let summary, let logs): + phase = .fixing(iteration: i, errorSummary: summary) + iterationHistory.append(IterationRecord( + iteration: i, + jsx: lastJSX ?? "", + errorSummary: summary, + logs: logs + )) + case .ranSucceeded(let i): + iterationHistory.append(IterationRecord( + iteration: i, + jsx: lastJSX ?? "", + errorSummary: nil, + logs: [] + )) + case .tokensUsed(let u): + usage = u + } + } + + private func apply(outcome: AgentLoopOutcome) { + isRunning = false + switch outcome { + case .succeeded(let jsx, let element, let usage): + lastJSX = jsx + resultElement = element + self.usage = usage + phase = .done(jsx: jsx) + case .exhausted(let lastJSX, let lastError, let usage): + if let lastJSX { self.lastJSX = lastJSX } + self.usage = usage + phase = .exhausted(lastJSX: lastJSX, lastError: lastError) + case .cancelled(let usage): + self.usage = usage + phase = .cancelled + case .failed(let message, let usage): + self.usage = usage + phase = .failed(message) + } + } +} diff --git a/Shared/ScriptWidgetRuntime/AI/AIReferenceSnapshot.swift b/Shared/ScriptWidgetRuntime/AI/AIReferenceSnapshot.swift new file mode 100644 index 0000000..1ee090f --- /dev/null +++ b/Shared/ScriptWidgetRuntime/AI/AIReferenceSnapshot.swift @@ -0,0 +1,109 @@ +// +// AIReferenceSnapshot.swift +// ScriptWidget +// +// Builds a compact reference manual for the LLM's system prompt by +// sampling real usage examples from Script.bundle (component / api). +// Cached after first build. +// + +import Foundation + +struct AIReferenceSnapshot { + let componentsBlock: String + let apisBlock: String + + var combined: String { + var out = "" + if !componentsBlock.isEmpty { + out += "=== COMPONENTS (JSX tags) ===\n" + out += componentsBlock + out += "\n" + } + if !apisBlock.isEmpty { + out += "=== APIs (globals) ===\n" + out += apisBlock + } + return out + } +} + +enum AIReferenceSnapshotLoader { + // Cap per-file lines so the prompt stays bounded. + private static let maxLinesPerFile = 40 + + // If the full block is larger than this many chars, fall back to a + // curated subset of APIs. + private static let softCharBudget = 60_000 + + private static let priorityAPIs: [String] = [ + "fetch", "http", "storage", "location", "health", + "device", "file", "getenv", "system", "console", + ] + + private static var cached: AIReferenceSnapshot? + + static func load() -> AIReferenceSnapshot { + if let cached = cached { + return cached + } + let snapshot = build() + cached = snapshot + return snapshot + } + + private static func build() -> AIReferenceSnapshot { + guard let bundleURL = Bundle.main.url(forResource: "Script", withExtension: "bundle") else { + return AIReferenceSnapshot(componentsBlock: "", apisBlock: "") + } + + let componentsBlock = readSection( + rootURL: bundleURL.appendingPathComponent("component"), + whitelist: nil + ) + + // First attempt: all APIs. + var apisBlock = readSection( + rootURL: bundleURL.appendingPathComponent("api"), + whitelist: nil + ) + + let overBudget = (componentsBlock.count + apisBlock.count) > softCharBudget + if overBudget { + apisBlock = readSection( + rootURL: bundleURL.appendingPathComponent("api"), + whitelist: Set(priorityAPIs) + ) + } + + return AIReferenceSnapshot(componentsBlock: componentsBlock, apisBlock: apisBlock) + } + + private static func readSection(rootURL: URL, whitelist: Set?) -> String { + let fm = FileManager.default + guard let entries = try? fm.contentsOfDirectory(atPath: rootURL.path) else { + return "" + } + var pieces: [String] = [] + for name in entries.sorted() { + if let whitelist = whitelist, !whitelist.contains(name) { + continue + } + let mainJsx = rootURL.appendingPathComponent(name).appendingPathComponent("main.jsx") + guard let content = try? String(contentsOf: mainJsx, encoding: .utf8) else { + continue + } + let trimmed = limit(content, lines: maxLinesPerFile) + pieces.append("// === \(name) ===\n\(trimmed)") + } + return pieces.joined(separator: "\n\n") + } + + private static func limit(_ text: String, lines: Int) -> String { + let all = text.split(separator: "\n", omittingEmptySubsequences: false) + if all.count <= lines { + return text + } + return all.prefix(lines).joined(separator: "\n") + "\n// ..." + } +} diff --git a/Shared/ScriptWidgetRuntime/AI/AISettings.swift b/Shared/ScriptWidgetRuntime/AI/AISettings.swift new file mode 100644 index 0000000..1709760 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/AI/AISettings.swift @@ -0,0 +1,106 @@ +// +// AISettings.swift +// ScriptWidget +// +// Persistent configuration for the AI Generate feature. +// Stored in the app-group UserDefaults so both iOS and macOS main apps +// (and potentially extensions) share the same values. +// +// Security note: API key is stored in plain-text UserDefaults in this +// initial revision. A Keychain migration is planned. +// + +import Foundation + +enum AISettingsKey { + static let apiKey = "ai.apiKey" + static let baseURL = "ai.baseURL" + static let model = "ai.model" + static let maxIterations = "ai.maxIterations" + static let temperature = "ai.temperature" +} + +struct AISettings: Equatable { + var apiKey: String + var baseURL: String + var model: String + var maxIterations: Int + var temperature: Double + + static let defaultBaseURL = "https://api.openai.com" + static let defaultModel = "gpt-4o-mini" + static let defaultMaxIterations = 20 + static let defaultTemperature = 0.7 + + static let `default` = AISettings( + apiKey: "", + baseURL: defaultBaseURL, + model: defaultModel, + maxIterations: defaultMaxIterations, + temperature: defaultTemperature + ) + + var isConfigured: Bool { + !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var normalizedBaseURL: String { + let trimmed = baseURL.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + return AISettings.defaultBaseURL + } + // Strip trailing /v1 or / — SwiftOpenAI appends /v1 itself. + var normalized = trimmed + while normalized.hasSuffix("/") { + normalized.removeLast() + } + if normalized.hasSuffix("/v1") { + normalized.removeLast(3) + } + while normalized.hasSuffix("/") { + normalized.removeLast() + } + return normalized + } +} + +final class AISettingsStore { + static let shared = AISettingsStore() + + static let changedNotification = Notification.Name("AISettingsStoreChanged") + + private let defaults: UserDefaults + + private init() { + self.defaults = UserDefaults(suiteName: "group.everettjf.scriptwidget") ?? .standard + } + + func load() -> AISettings { + let apiKey = defaults.string(forKey: AISettingsKey.apiKey) ?? "" + let baseURL = defaults.string(forKey: AISettingsKey.baseURL) ?? AISettings.defaultBaseURL + let model = defaults.string(forKey: AISettingsKey.model) ?? AISettings.defaultModel + + let storedIterations = defaults.object(forKey: AISettingsKey.maxIterations) as? Int + let maxIterations = storedIterations ?? AISettings.defaultMaxIterations + + let storedTemperature = defaults.object(forKey: AISettingsKey.temperature) as? Double + let temperature = storedTemperature ?? AISettings.defaultTemperature + + return AISettings( + apiKey: apiKey, + baseURL: baseURL, + model: model, + maxIterations: maxIterations, + temperature: temperature + ) + } + + func save(_ settings: AISettings) { + defaults.set(settings.apiKey, forKey: AISettingsKey.apiKey) + defaults.set(settings.baseURL, forKey: AISettingsKey.baseURL) + defaults.set(settings.model, forKey: AISettingsKey.model) + defaults.set(settings.maxIterations, forKey: AISettingsKey.maxIterations) + defaults.set(settings.temperature, forKey: AISettingsKey.temperature) + NotificationCenter.default.post(name: AISettingsStore.changedNotification, object: nil) + } +} diff --git a/Shared/ScriptWidgetRuntime/AI/AgentLoop.swift b/Shared/ScriptWidgetRuntime/AI/AgentLoop.swift new file mode 100644 index 0000000..a338b5d --- /dev/null +++ b/Shared/ScriptWidgetRuntime/AI/AgentLoop.swift @@ -0,0 +1,143 @@ +// +// AgentLoop.swift +// ScriptWidget +// +// Core generate → run → fix loop. Stateless; owned by AIGenerateSession. +// + +import Foundation + +enum AgentLoopOutcome { + case succeeded(jsx: String, element: ScriptWidgetRuntimeElement, usage: AITokenUsage) + case exhausted(lastJSX: String?, lastError: String?, usage: AITokenUsage) + case cancelled(usage: AITokenUsage) + case failed(message: String, usage: AITokenUsage) +} + +enum AgentLoopEvent { + case thinking(iteration: Int) + case produced(iteration: Int, jsx: String) + case running(iteration: Int) + case ranFailed(iteration: Int, errorSummary: String, logs: [String]) + case ranSucceeded(iteration: Int) + case tokensUsed(AITokenUsage) // incremental +} + +struct AgentLoopRequest { + enum Mode { + case fresh(userDescription: String) + case refine(currentCode: String, refineInstruction: String) + } + let mode: Mode + let size: AIWidgetSize + let settings: AISettings + let maxIterations: Int +} + +final class AgentLoop { + typealias EventHandler = @MainActor (AgentLoopEvent) -> Void + + private let client: AIClient + private let bridge: AgentRuntimeBridge + + init(client: AIClient = .shared, bridge: AgentRuntimeBridge = .shared) { + self.client = client + self.bridge = bridge + } + + func run(_ request: AgentLoopRequest, onEvent: @escaping EventHandler) async -> AgentLoopOutcome { + var cumulativeUsage = AITokenUsage.zero + + let systemMessage = AIMessage(role: .system, content: PromptBuilder.systemPrompt(reference: AIReferenceSnapshotLoader.load())) + let firstUserMessage: AIMessage + var latestCode: String? + + switch request.mode { + case .fresh(let description): + firstUserMessage = AIMessage(role: .user, content: PromptBuilder.userPromptFirst(userDescription: description, size: request.size)) + latestCode = nil + case .refine(let currentCode, let instruction): + firstUserMessage = AIMessage(role: .user, content: PromptBuilder.userPromptRefine(currentCode: currentCode, refineInstruction: instruction)) + latestCode = currentCode + } + + let package: ScriptWidgetPackage + do { + package = try bridge.makeSandboxPackage() + } catch { + return .failed(message: error.localizedDescription, usage: cumulativeUsage) + } + defer { bridge.cleanupSandboxPackage(package) } + + var lastError: String? + var lastLogs: [String] = [] + + let iterationLimit = max(1, request.maxIterations) + + for iteration in 1...iterationLimit { + if Task.isCancelled { + return .cancelled(usage: cumulativeUsage) + } + + await onEvent(.thinking(iteration: iteration)) + + let messages: [AIMessage] + if let previous = latestCode, iteration > 1, let errMsg = lastError { + messages = [ + systemMessage, + firstUserMessage, + AIMessage(role: .assistant, content: previous), + AIMessage(role: .user, content: PromptBuilder.userPromptFix( + previousCode: previous, errorSummary: errMsg, recentLogs: lastLogs + )), + ] + } else { + messages = [systemMessage, firstUserMessage] + } + + let chatResult: AIChatResult + do { + chatResult = try await client.chat(messages: messages, settings: request.settings) + } catch { + return .failed(message: error.localizedDescription, usage: cumulativeUsage) + } + + cumulativeUsage = cumulativeUsage + chatResult.usage + await onEvent(.tokensUsed(cumulativeUsage)) + + if Task.isCancelled { + return .cancelled(usage: cumulativeUsage) + } + + let jsx = PromptBuilder.stripCodeFences(chatResult.content) + latestCode = jsx + await onEvent(.produced(iteration: iteration, jsx: jsx)) + + await onEvent(.running(iteration: iteration)) + let runResult = await bridge.run(jsx: jsx, in: package, size: request.size) + + if Task.isCancelled { + return .cancelled(usage: cumulativeUsage) + } + + if runResult.didSucceed, let element = runResult.element { + await onEvent(.ranSucceeded(iteration: iteration)) + return .succeeded(jsx: jsx, element: element, usage: cumulativeUsage) + } + + let summary: String + if let err = runResult.error { + summary = err.summaryForPrompt + } else if runResult.element == nil { + summary = "Runtime returned no element." + } else { + summary = "Runtime returned a fallback/placeholder element. The widget did not render real content." + } + lastError = summary + lastLogs = runResult.logs + await onEvent(.ranFailed(iteration: iteration, errorSummary: summary, logs: runResult.logs)) + } + + return .exhausted(lastJSX: latestCode, lastError: lastError, usage: cumulativeUsage) + } +} diff --git a/Shared/ScriptWidgetRuntime/AI/AgentRuntimeBridge.swift b/Shared/ScriptWidgetRuntime/AI/AgentRuntimeBridge.swift new file mode 100644 index 0000000..0345951 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/AI/AgentRuntimeBridge.swift @@ -0,0 +1,129 @@ +// +// AgentRuntimeBridge.swift +// ScriptWidget +// +// Adapts the synchronous ScriptWidgetRuntime.executeJSXSyncForWidget to +// an async interface, persists the generated JSX to a one-shot temp +// package (so $file / $import won't explode), and serializes executions +// (the runtime uses a global `sharedRunningState` for log capture). +// + +import Foundation + +enum AgentRuntimeBridgeError: LocalizedError { + case tempPackageCreationFailed(String) + case writeFailed(String) + + var errorDescription: String? { + switch self { + case .tempPackageCreationFailed(let s): return "Failed to create sandbox package: \(s)" + case .writeFailed(let s): return "Failed to write JSX: \(s)" + } + } +} + +struct AgentRunResult { + let element: ScriptWidgetRuntimeElement? + let error: ScriptWidgetError? + let logs: [String] + + var didSucceed: Bool { + guard error == nil else { return false } + guard let element = element else { return false } + if let tag = element.tagAsString() { + // Fallback sentinels emitted by the runtime when $render is + // missing or the script blew up before reaching it. + if element.children?.contains(where: { value in + if let s = value as? String { + return s == "#UI Not Found#" || s == "#Failed#" || s == "#Loading#" + } + return false + }) ?? false { + return false + } + // Valid widgets start with a layout container or a + // recognized tag; empty tag strings are suspicious. + if tag.isEmpty { return false } + } + // Heuristic: if the only console output was an [error], treat as failed. + if logs.contains(where: { $0.hasPrefix("[error]") || $0.lowercased().contains("uncaught") }) { + return false + } + return true + } +} + +final class AgentRuntimeBridge { + static let shared = AgentRuntimeBridge() + + // Runs are serialized because ScriptWidgetRuntime stores running + // state in a global. + private let serialQueue = DispatchQueue(label: "scriptwidget.ai.runtime.serial") + + private let sessionRoot: URL + + private init() { + let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("ScriptWidgetAI", isDirectory: true) + try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + self.sessionRoot = base + } + + func makeSandboxPackage(prefix: String = "session") throws -> ScriptWidgetPackage { + let dir = sessionRoot.appendingPathComponent("\(prefix)-\(UUID().uuidString)", isDirectory: true) + do { + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + } catch { + throw AgentRuntimeBridgeError.tempPackageCreationFailed(error.localizedDescription) + } + return ScriptWidgetPackage(path: dir, readonly: false) + } + + func cleanupSandboxPackage(_ package: ScriptWidgetPackage) { + try? FileManager.default.removeItem(at: package.path) + } + + func run(jsx: String, in package: ScriptWidgetPackage, size: AIWidgetSize) async -> AgentRunResult { + return await withCheckedContinuation { continuation in + serialQueue.async { + // Persist the JSX so packages that read themselves or + // register support files still work. + let writeResult = package.writeMainFile(content: jsx) + if !writeResult.0 { + continuation.resume(returning: AgentRunResult( + element: nil, + error: .internalError("Failed to write main.jsx: \(writeResult.1)"), + logs: [] + )) + return + } + + // Reset the global running state — the runtime will also + // do this in its init, but clearing here keeps log + // capture scoped to this single execution. + sharedRunningState = ScriptWidgetRunningState(package: package) + + let runtime = ScriptWidgetRuntime(package: package, environments: [ + "widget-size": size.rawValue, + "widget-param": "", + ]) + + let (element, err) = runtime.executeJSXSyncForWidget(jsx) + let logs = sharedRunningState?.logger.logs ?? [] + continuation.resume(returning: AgentRunResult(element: element, error: err, logs: logs)) + } + } + } +} + +extension ScriptWidgetError { + var summaryForPrompt: String { + switch self { + case .undefinedRender(let m): return "undefinedRender: \(m)" + case .internalError(let m): return "internalError: \(m)" + case .transformError(let m): return "transformError: \(m)" + case .scriptError(let m): return "scriptError: \(m)" + case .scriptException(let m): return "scriptException: \(m)" + } + } +} diff --git a/Shared/ScriptWidgetRuntime/AI/PromptBuilder.swift b/Shared/ScriptWidgetRuntime/AI/PromptBuilder.swift new file mode 100644 index 0000000..cb2a7a1 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/AI/PromptBuilder.swift @@ -0,0 +1,201 @@ +// +// PromptBuilder.swift +// ScriptWidget +// +// Constructs system / user messages for the widget-generation agent +// and strips code fences from LLM output. +// + +import Foundation + +#if canImport(CoreGraphics) +import CoreGraphics +#endif + +enum AIWidgetSize: String, CaseIterable, Identifiable { + case small + case medium + case large + case extraLarge + case accessoryInline + case accessoryCircular + case accessoryRectangular + + var id: String { rawValue } + + var displayName: String { + switch self { + case .small: return "Small" + case .medium: return "Medium" + case .large: return "Large" + case .extraLarge: return "Extra Large" + case .accessoryInline: return "Accessory Inline" + case .accessoryCircular: return "Accessory Circular" + case .accessoryRectangular: return "Accessory Rectangular" + } + } + + var previewSize: CGSize { + switch self { + case .small: return CGSize(width: 170, height: 170) + case .medium: return CGSize(width: 329, height: 170) + case .large: return CGSize(width: 329, height: 345) + case .extraLarge: return CGSize(width: 345, height: 329) + case .accessoryInline: return CGSize(width: 250, height: 30) + case .accessoryCircular: return CGSize(width: 72, height: 72) + case .accessoryRectangular: return CGSize(width: 170, height: 72) + } + } + + var previewIsCircular: Bool { self == .accessoryCircular } + + var designHint: String { + switch self { + case .small: + return "Square, ~155x155 px. Keep it to one or two key pieces of information." + case .medium: + return "Wide rectangle, ~329x155 px. Room for a small grid or two columns." + case .large: + return "Square, ~329x345 px. Multiple sections / richer layout." + case .extraLarge: + return "Wide rectangle (iPad), ~639x345 px. Dashboard-style density is fine." + case .accessoryInline: + return "Single line of text only. No colors, no layout containers beyond text." + case .accessoryCircular: + return "Very small round area (~72x72). Icon + a number at most." + case .accessoryRectangular: + return "Small rectangle (~160x72). A few short lines of text." + } + } +} + +struct AIMessage { + enum Role: String { case system, user, assistant } + let role: Role + let content: String +} + +enum PromptBuilder { + static func systemPrompt(reference: AIReferenceSnapshot) -> String { + let rules = """ + You are a ScriptWidget code generator. ScriptWidget runs widgets + written in a constrained JSX dialect inside JavaScriptCore. + Output ONLY a single JSX snippet — no markdown fences, no prose, + no explanations, no surrounding backticks. + + RULES: + 1. Call $render(<...>) exactly once. The root element MUST be a + layout container (vstack / hstack / zstack) unless you are + targeting an accessoryInline widget. + 2. Do NOT use `import`, `require`, `module`, any Node APIs, or + any DOM / browser APIs. + 3. Networking is ONLY via the globally injected `fetch(url)` + (returns a string) or the `$http.*` API. + 4. Top-level `await` is allowed — the runtime wraps your code in + an async `$main` function. + 5. Date/time: the global `moment` library is available. Plain JS + `Date` also works. + 6. Persistent data: `$storage.set(key, value)` and + `$storage.get(key)`. + 7. Only use tags, props, and APIs that appear in the REFERENCE + section below. Do not invent new ones. + 8. When calling `fetch`, always wrap it in try/catch so the + widget still renders something useful on network failure. + 9. Prefer readable typography (`font="title"`, `"headline"`, + `"caption"`, `"caption2"`) and sensible spacing. Match the + visual density to the declared widget size. + 10. Keep the output self-contained — no external files, no + image assets the user hasn't provided. + """ + let reference = reference.combined + return rules + "\n\n" + reference + } + + static func userPromptFirst(userDescription: String, size: AIWidgetSize) -> String { + """ + Widget size: \(size.rawValue) + Size hint: \(size.designHint) + + User description: + \(userDescription) + + Return the complete JSX snippet. No markdown, no explanation. + """ + } + + static func userPromptFix( + previousCode: String, + errorSummary: String, + recentLogs: [String] + ) -> String { + let logBlock: String + if recentLogs.isEmpty { + logBlock = "(no console output)" + } else { + logBlock = recentLogs.suffix(10).joined(separator: "\n") + } + return """ + Your previous code failed to run: + + ```jsx + \(previousCode) + ``` + + Runtime feedback: + \(errorSummary) + + Last console output: + \(logBlock) + + Fix the code and return the FULL corrected JSX only. No markdown, no explanation. + """ + } + + static func userPromptRefine(currentCode: String, refineInstruction: String) -> String { + """ + Current working widget code: + + ```jsx + \(currentCode) + ``` + + Apply this change request from the user: + \(refineInstruction) + + Return the FULL updated JSX only. No markdown, no explanation. + """ + } + + // Best-effort extraction: prefer content between matching ```jsx ... + // ``` fences, then strip any leading/trailing prose. + static func stripCodeFences(_ raw: String) -> String { + var text = raw.trimmingCharacters(in: .whitespacesAndNewlines) + + // Prefer fenced block if present. + if let fenced = extractFencedBlock(text) { + text = fenced + } + + // Drop stray code-fence markers. + text = text.replacingOccurrences(of: "```jsx", with: "") + text = text.replacingOccurrences(of: "```javascript", with: "") + text = text.replacingOccurrences(of: "```js", with: "") + text = text.replacingOccurrences(of: "```", with: "") + + return text.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func extractFencedBlock(_ raw: String) -> String? { + guard let openRange = raw.range(of: "```") else { return nil } + let afterOpen = raw[openRange.upperBound...] + // Skip optional language tag on the same line. + let afterNewline: Substring + if let nl = afterOpen.firstIndex(of: "\n") { + afterNewline = afterOpen[afterOpen.index(after: nl)...] + } else { + afterNewline = afterOpen + } + guard let closeRange = afterNewline.range(of: "```") else { return nil } + return String(afterNewline[.. Void + + @Environment(\.presentationMode) private var presentationMode + + @State private var refineInstruction: String = "" + @State private var showingCodeSheet = false + @State private var showingLogsSheet = false + @State private var showingSaveNamePrompt = false + @State private var saveName: String = "" + @State private var saveError: String? + @State private var isDebugMode = false + @State private var previewPackage: ScriptWidgetPackage? + + private var jsx: String { session.lastJSX ?? "" } + private var isExhausted: Bool { + if case .exhausted = session.phase { return true } + return false + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + banner + previewSection + actionsSection + refineSection + + if session.isRunning { + AIGenerateProgressView(session: session) + } + } + .padding() + } + .navigationTitle("Review") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + menuToolbar + } + } + .sheet(isPresented: $showingCodeSheet) { codeSheet } + .sheet(isPresented: $showingLogsSheet) { logsSheet } + .alert("Save Widget", isPresented: $showingSaveNamePrompt) { + TextField("Widget name", text: $saveName) + .textInputAutocapitalization(.words) + Button("Cancel", role: .cancel) { } + Button("Save") { performSave() } + } message: { + Text("Choose a name for your new widget.") + } + .alert("Save Failed", isPresented: saveErrorBinding) { + Button("OK") { saveError = nil } + } message: { + Text(saveError ?? "") + } + .onAppear(perform: ensurePreviewPackage) + .onChange(of: jsx) { _ in + refreshPreviewPackage() + } + } + + // MARK: - sub-sections + + @ViewBuilder private var banner: some View { + if isExhausted { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + Text("Did not fully converge — showing the last attempt.") + .font(.footnote) + } + .foregroundStyle(.orange) + } + } + + @ViewBuilder private var previewSection: some View { + let size = session.size.previewSize + ZStack { + Rectangle() + .fill(Color.secondary.opacity(0.15)) + previewContent(size: size) + } + .frame(maxWidth: .infinity) + .frame(height: Swift.max(size.height + 40, 200)) + .cornerRadius(12) + } + + @ViewBuilder + private func previewContent(size: CGSize) -> some View { + if let element = session.resultElement, let pkg = previewPackage { + let context = ScriptWidgetElementContext( + runtime: nil, + debugMode: isDebugMode, + scriptName: "AI Preview", + scriptParameter: "", + package: pkg + ) + ScriptWidgetElementView(element: element, context: context) + .frame(width: size.width, height: size.height) + .background(Color(UIColor.systemBackground)) + .cornerRadius(session.size.previewIsCircular ? size.height / 2 : 10) + } else { + Text("No preview available") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + private var actionsSection: some View { + HStack(spacing: 12) { + Button(role: .destructive) { + presentationMode.wrappedValue.dismiss() + } label: { + Label("Discard", systemImage: "trash") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + + Toggle(isOn: $isDebugMode) { + Text("Debug") + .font(.caption) + } + .toggleStyle(.button) + .controlSize(.small) + + Button { + saveName = "AI " + AIReviewView.defaultNameFormatter.string(from: Date()) + showingSaveNamePrompt = true + } label: { + Label("Save Widget", systemImage: "square.and.arrow.down") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(jsx.isEmpty) + } + } + + private var refineSection: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Refine") + .font(.headline) + Text("Ask the AI to change something — it'll iterate again.") + .font(.caption) + .foregroundStyle(.secondary) + HStack { + TextField("e.g. use a darker background and larger title", text: $refineInstruction) + .textFieldStyle(.roundedBorder) + Button { + let instruction = refineInstruction + refineInstruction = "" + session.refine(currentCode: jsx, refineInstruction: instruction) + } label: { + Image(systemName: "arrow.right.circle.fill") + .font(.title3) + } + .disabled(jsx.isEmpty || refineInstruction.trimmingCharacters(in: .whitespaces).isEmpty || session.isRunning) + } + } + } + + @ViewBuilder private var menuToolbar: some View { + Menu { + Button { showingCodeSheet = true } label: { + Label("View Code", systemImage: "curlybraces") + } + Button { showingLogsSheet = true } label: { + Label("Logs", systemImage: "text.alignleft") + } + } label: { + Image(systemName: "ellipsis.circle") + } + } + + @ViewBuilder private var codeSheet: some View { + NavigationView { + ScrollView { + Text(jsx) + .font(.system(.footnote, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .textSelection(.enabled) + } + .navigationTitle("Generated JSX") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { showingCodeSheet = false } + } + } + } + } + + @ViewBuilder private var logsSheet: some View { + NavigationView { + List { + ForEach(session.iterationHistory) { record in + Section("Iteration \(record.iteration)") { + if let err = record.errorSummary { + Label(err, systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + } else { + Label("Success", systemImage: "checkmark.circle") + .foregroundStyle(.green) + } + if !record.logs.isEmpty { + ForEach(Array(record.logs.enumerated()), id: \.offset) { _, line in + Text(line) + .font(.caption.monospaced()) + .textSelection(.enabled) + } + } + } + } + } + .navigationTitle("Iteration Logs") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { showingLogsSheet = false } + } + } + } + } + + // MARK: - helpers + + private var saveErrorBinding: Binding { + Binding( + get: { saveError != nil }, + set: { if !$0 { saveError = nil } } + ) + } + + private func ensurePreviewPackage() { + if previewPackage == nil { + previewPackage = try? AgentRuntimeBridge.shared.makeSandboxPackage(prefix: "preview") + } + refreshPreviewPackage() + } + + private func refreshPreviewPackage() { + guard let pkg = previewPackage else { return } + _ = pkg.writeMainFile(content: jsx) + } + + private func performSave() { + let trimmed = saveName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + saveError = "Widget name can not be empty." + return + } + let result = sharedScriptManager.createScript( + content: jsx, + recommendPackageName: trimmed, + imageCopyPath: nil + ) + if result.0 { + NotificationCenter.default.post(name: ScriptWidgetHomeViewDataObject.scriptCreateNotification, object: nil) + WidgetCenter.shared.reloadAllTimelines() + onSaved() + } else { + saveError = result.1 + } + } + + private static let defaultNameFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd HHmm" + return f + }() +} diff --git a/macOS/ScriptWidgetMac/AIGenerate/AIGenerateWindowView.swift b/macOS/ScriptWidgetMac/AIGenerate/AIGenerateWindowView.swift new file mode 100644 index 0000000..aaa8c67 --- /dev/null +++ b/macOS/ScriptWidgetMac/AIGenerate/AIGenerateWindowView.swift @@ -0,0 +1,316 @@ +// +// AIGenerateWindowView.swift +// ScriptWidgetMac +// +// The AI Generate experience for macOS, hosted inside a sheet. Shows +// prompt input, progress, and — once a widget is produced — an inline +// preview with refine / discard / save actions. +// + +import SwiftUI +import WidgetKit + +struct AIGenerateWindowView: View { + @Environment(\.dismiss) private var dismiss + + @StateObject private var session = AIGenerateSession() + + @State private var prompt: String = "" + @State private var refineInstruction: String = "" + @State private var saveName: String = "" + @State private var saveError: String? + @State private var showingCode: Bool = false + @State private var showingLogs: Bool = false + @State private var isDebugMode: Bool = false + @State private var previewPackage: ScriptWidgetPackage? + + private var jsx: String { session.lastJSX ?? "" } + private var hasResult: Bool { + switch session.phase { + case .done, .exhausted: return true + default: return false + } + } + + var body: some View { + VStack(spacing: 0) { + header + Divider() + HSplitView { + inputSide + .frame(minWidth: 300, idealWidth: 380) + previewSide + .frame(minWidth: 300, idealWidth: 420) + } + Divider() + footer + } + .frame(idealWidth: 860, minHeight: 520, idealHeight: 620) + .frame(minWidth: 720) + .onAppear { ensurePreviewPackage() } + .onChange(of: jsx) { _ in refreshPreviewPackage() } + .sheet(isPresented: $showingCode) { codeSheet } + .sheet(isPresented: $showingLogs) { logsSheet } + .alert("Save Failed", isPresented: saveErrorBinding) { + Button("OK") { saveError = nil } + } message: { + Text(saveError ?? "") + } + } + + // MARK: - layout + + private var header: some View { + HStack(spacing: 10) { + Image(systemName: "sparkles") + .font(.title2) + .foregroundStyle(.tint) + VStack(alignment: .leading, spacing: 2) { + Text("AI Widget Generator").font(.title3.weight(.semibold)) + Text("Describe what you want; the AI will iterate until the widget runs.") + .font(.footnote) + .foregroundStyle(.secondary) + } + Spacer() + Button("Close") { dismiss() } + .keyboardShortcut(.cancelAction) + } + .padding(12) + } + + private var inputSide: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + Text("Prompt").font(.headline) + TextEditor(text: $prompt) + .font(.body) + .frame(minHeight: 140) + .border(Color.secondary.opacity(0.3)) + + HStack { + Text("Size") + Picker("", selection: $session.size) { + ForEach(AIWidgetSize.allCases) { size in + Text(size.displayName).tag(size) + } + } + .labelsHidden() + } + + Button { + session.start(userDescription: prompt) + } label: { + HStack { + Image(systemName: "sparkles") + Text(session.isRunning ? "Generating..." : "Generate") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(session.isRunning || prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + + if session.isRunning || hasResult { + AIGenerateProgressView(session: session) + } + + if hasResult { + Divider().padding(.vertical, 4) + Text("Refine").font(.headline) + Text("Ask the AI to change something — it will iterate again on top of the current code.") + .font(.caption) + .foregroundStyle(.secondary) + HStack { + TextField("e.g. use a darker background", text: $refineInstruction) + .textFieldStyle(.roundedBorder) + Button { + let instruction = refineInstruction + refineInstruction = "" + session.refine(currentCode: jsx, refineInstruction: instruction) + } label: { + Image(systemName: "arrow.right.circle.fill") + } + .disabled(jsx.isEmpty || refineInstruction.trimmingCharacters(in: .whitespaces).isEmpty || session.isRunning) + } + } + } + .padding(12) + } + } + + private var previewSide: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Preview").font(.headline) + Spacer() + Toggle("Debug", isOn: $isDebugMode) + .toggleStyle(.switch) + .controlSize(.small) + } + + ZStack { + Rectangle().fill(Color.secondary.opacity(0.15)) + previewContent + } + .frame(maxWidth: .infinity) + .frame(height: 360) + .cornerRadius(12) + + HStack { + Button { showingCode = true } label: { + Label("Code", systemImage: "curlybraces") + } + Button { showingLogs = true } label: { + Label("Logs", systemImage: "text.alignleft") + } + Spacer() + } + .disabled(jsx.isEmpty) + } + .padding(12) + } + + @ViewBuilder + private var previewContent: some View { + let size = session.size.previewSize + if let element = session.resultElement, let pkg = previewPackage { + let context = ScriptWidgetElementContext( + runtime: nil, + debugMode: isDebugMode, + scriptName: "AI Preview", + scriptParameter: "", + package: pkg + ) + ScriptWidgetElementView(element: element, context: context) + .frame(width: size.width, height: size.height) + .background(Color(NSColor.textBackgroundColor)) + .cornerRadius(session.size.previewIsCircular ? size.height / 2 : 10) + } else { + Text(session.isRunning ? "Generating..." : "No preview yet") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + private var footer: some View { + HStack { + Text("Tokens used: \(session.usage.totalTokens)") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Button(role: .destructive) { + dismiss() + } label: { + Label("Discard", systemImage: "trash") + } + + TextField("Widget name", text: $saveName) + .textFieldStyle(.roundedBorder) + .frame(minWidth: 200) + + Button { + performSave() + } label: { + Label("Save Widget", systemImage: "square.and.arrow.down") + } + .keyboardShortcut(.defaultAction) + .disabled(jsx.isEmpty || saveName.trimmingCharacters(in: .whitespaces).isEmpty) + } + .padding(12) + } + + @ViewBuilder + private var codeSheet: some View { + VStack(alignment: .leading) { + HStack { + Text("Generated JSX").font(.headline) + Spacer() + Button("Done") { showingCode = false } + .keyboardShortcut(.defaultAction) + } + ScrollView { + Text(jsx) + .font(.system(.footnote, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + .textSelection(.enabled) + } + } + .padding(12) + .frame(minWidth: 520, minHeight: 420) + } + + @ViewBuilder + private var logsSheet: some View { + VStack(alignment: .leading) { + HStack { + Text("Iteration Logs").font(.headline) + Spacer() + Button("Done") { showingLogs = false } + .keyboardShortcut(.defaultAction) + } + List { + ForEach(session.iterationHistory) { record in + Section("Iteration \(record.iteration)") { + if let err = record.errorSummary { + Label(err, systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + } else { + Label("Success", systemImage: "checkmark.circle") + .foregroundStyle(.green) + } + if !record.logs.isEmpty { + ForEach(Array(record.logs.enumerated()), id: \.offset) { _, line in + Text(line) + .font(.caption.monospaced()) + .textSelection(.enabled) + } + } + } + } + } + } + .padding(12) + .frame(minWidth: 520, minHeight: 420) + } + + // MARK: - helpers + + private var saveErrorBinding: Binding { + Binding( + get: { saveError != nil }, + set: { if !$0 { saveError = nil } } + ) + } + + private func ensurePreviewPackage() { + if previewPackage == nil { + previewPackage = try? AgentRuntimeBridge.shared.makeSandboxPackage(prefix: "preview") + } + refreshPreviewPackage() + } + + private func refreshPreviewPackage() { + guard let pkg = previewPackage else { return } + _ = pkg.writeMainFile(content: jsx) + } + + private func performSave() { + let trimmed = saveName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + saveError = "Widget name can not be empty." + return + } + let result = sharedScriptManager.createScript( + content: jsx, + recommendPackageName: trimmed, + imageCopyPath: nil + ) + if result.0 { + NotificationCenter.default.post(name: SharedAppStore.scriptCreateNotification, object: nil) + WidgetCenter.shared.reloadAllTimelines() + dismiss() + } else { + saveError = result.1 + } + } +} diff --git a/macOS/ScriptWidgetMac/App/ScriptWidgetMacApp.swift b/macOS/ScriptWidgetMac/App/ScriptWidgetMacApp.swift index 52904e8..18b4dfe 100644 --- a/macOS/ScriptWidgetMac/App/ScriptWidgetMacApp.swift +++ b/macOS/ScriptWidgetMac/App/ScriptWidgetMacApp.swift @@ -9,9 +9,9 @@ import SwiftUI @main struct ScriptWidgetMacApp: App { - + @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate; - + var body: some Scene { WindowGroup { ContentView() @@ -26,20 +26,20 @@ struct ScriptWidgetMacApp: App { NotificationCenter.default.post(name: PreviewService.updateNotification, object: nil, userInfo: nil) } }.keyboardShortcut("s") - + Button("Run") { NotificationCenter.default.post(name: PreviewService.updateNotification, object: nil, userInfo: nil) }.keyboardShortcut("r") - + Button("Open Scripts Directory") { MacKitUtil.revealInFinder(sharedScriptManager.scriptDirectory.path) }.keyboardShortcut("o") - + Button("Update iCloud Scripts") { sharedScriptManager.requestUpdateICloudScripts() }.keyboardShortcut("u") } - + CommandGroup(replacing: .help) { Button("Discord") { NSWorkspace.shared.open(URL(string: "https://discord.gg/eGzEaP6TzR")!) @@ -58,5 +58,9 @@ struct ScriptWidgetMacApp: App { } } } + + Settings { + SettingAIView() + } } } diff --git a/macOS/ScriptWidgetMac/Settings/SettingAIView.swift b/macOS/ScriptWidgetMac/Settings/SettingAIView.swift new file mode 100644 index 0000000..d7ca661 --- /dev/null +++ b/macOS/ScriptWidgetMac/Settings/SettingAIView.swift @@ -0,0 +1,166 @@ +// +// SettingAIView.swift +// ScriptWidgetMac +// +// macOS-flavored AI configuration panel, hosted inside the standard +// Settings scene (Cmd+,). +// + +import SwiftUI + +struct SettingAIView: View { + @State private var apiKey: String = "" + @State private var baseURL: String = AISettings.defaultBaseURL + @State private var model: String = AISettings.defaultModel + @State private var maxIterations: Int = AISettings.defaultMaxIterations + @State private var temperature: Double = AISettings.defaultTemperature + @State private var apiKeyVisible: Bool = false + + @State private var testPhase: TestPhase = .idle + @State private var testMessage: String = "" + + private enum TestPhase { case idle, running, success, failure } + + private let modelPresets = ["gpt-4o-mini", "gpt-4o", "gpt-4.1-mini", "o4-mini"] + + var body: some View { + Form { + Section("API Key") { + HStack { + Group { + if apiKeyVisible { + TextField("sk-...", text: $apiKey) + } else { + SecureField("sk-...", text: $apiKey) + } + } + .textFieldStyle(.roundedBorder) + + Button { + apiKeyVisible.toggle() + } label: { + Image(systemName: apiKeyVisible ? "eye.slash" : "eye") + } + .buttonStyle(.borderless) + } + Text("Stored in plain-text UserDefaults on this device. Do not configure on a shared device.") + .font(.caption) + .foregroundColor(.orange) + } + + Section("Endpoint") { + TextField("https://api.openai.com", text: $baseURL) + .textFieldStyle(.roundedBorder) + } + + Section("Model") { + TextField("gpt-4o-mini", text: $model) + .textFieldStyle(.roundedBorder) + HStack(spacing: 6) { + ForEach(modelPresets, id: \.self) { preset in + Button(preset) { model = preset } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } + + Section("Agent Loop") { + Stepper(value: $maxIterations, in: 5...100, step: 5) { + Text("Max Iterations: \(maxIterations)") + } + HStack { + Text("Temperature") + Slider(value: $temperature, in: 0.0...1.5, step: 0.05) + Text(String(format: "%.2f", temperature)) + .monospacedDigit() + .frame(width: 50, alignment: .trailing) + } + } + + Section("Connection") { + HStack { + Button { + runTest() + } label: { + HStack { + if testPhase == .running { + ProgressView().controlSize(.small) + } + Text("Test Connection") + } + } + .disabled(testPhase == .running || apiKey.trimmingCharacters(in: .whitespaces).isEmpty) + + if !testMessage.isEmpty { + Text(testMessage) + .font(.footnote) + .foregroundStyle(testPhase == .failure ? Color.red : Color.green) + } + Spacer() + } + } + + Section { + HStack { + Spacer() + Button { + persist() + } label: { + Label("Save", systemImage: "checkmark.circle") + } + .keyboardShortcut(.defaultAction) + } + } + } + .formStyle(.grouped) + .padding(12) + .frame(minWidth: 520, minHeight: 520) + .onAppear(perform: loadFromStore) + } + + private func loadFromStore() { + let s = AISettingsStore.shared.load() + apiKey = s.apiKey + baseURL = s.baseURL + model = s.model + maxIterations = s.maxIterations + temperature = s.temperature + } + + private func persist() { + let settings = AISettings( + apiKey: apiKey.trimmingCharacters(in: .whitespacesAndNewlines), + baseURL: baseURL.trimmingCharacters(in: .whitespacesAndNewlines), + model: model.trimmingCharacters(in: .whitespacesAndNewlines), + maxIterations: maxIterations, + temperature: temperature + ) + AISettingsStore.shared.save(settings) + } + + private func runTest() { + persist() + let settings = AISettingsStore.shared.load() + testPhase = .running + testMessage = "" + Task { + do { + let messages = [ + AIMessage(role: .system, content: "You reply with exactly: pong"), + AIMessage(role: .user, content: "ping"), + ] + let result = try await AIClient.shared.chat(messages: messages, settings: settings) + await MainActor.run { + testPhase = .success + testMessage = "OK — \(result.content.prefix(60)) (\(result.usage.totalTokens) tokens)" + } + } catch { + await MainActor.run { + testPhase = .failure + testMessage = error.localizedDescription + } + } + } + } +} diff --git a/macOS/ScriptWidgetMac/Sidebar/SidebarView.swift b/macOS/ScriptWidgetMac/Sidebar/SidebarView.swift index edd5c6e..371cb92 100644 --- a/macOS/ScriptWidgetMac/Sidebar/SidebarView.swift +++ b/macOS/ScriptWidgetMac/Sidebar/SidebarView.swift @@ -14,17 +14,21 @@ struct SidebarView: View { @ObservedObject var store: SharedAppStore // create @State private var createShowingSheet = false - + + // AI generate + @State private var aiGenerateShowingSheet = false + @State private var aiConfigAlertShown = false + // rename @State private var renameCurrentName = "" @State private var renameInputName = "" @State private var renameShowingSheet = false - + // delete @State private var deleteCurrentName = "" @State private var deleteShowingSheet = false - - + + var body: some View { content .frame(minWidth:200, maxWidth: 300, idealHeight: 250) @@ -37,6 +41,14 @@ struct SidebarView: View { .sheet(isPresented: $createShowingSheet) { CreateGuideView() } + .sheet(isPresented: $aiGenerateShowingSheet) { + AIGenerateWindowView() + } + .alert("Configure AI First", isPresented: $aiConfigAlertShown) { + Button("OK", role: .cancel) { } + } message: { + Text("Open Settings (⌘,) → AI to add your OpenAI API key, then come back to generate with AI.") + } .toolbar { ToolbarItem(placement: .automatic) { Button{ @@ -52,6 +64,18 @@ struct SidebarView: View { Image(systemName: "plus.circle") } } + ToolbarItem(placement: .automatic) { + Button { + if AISettingsStore.shared.load().isConfigured { + self.aiGenerateShowingSheet = true + } else { + self.aiConfigAlertShown = true + } + } label: { + Image(systemName: "sparkles") + } + .help("Generate with AI") + } } } From c0a401f950919ce9abb80daa87aa43e97166da50 Mon Sep 17 00:00:00 2001 From: everettjf Date: Tue, 21 Apr 2026 10:58:14 -0700 Subject: [PATCH 03/13] feat(ai): register AI files + SwiftOpenAI SPM dep in iOS/macOS projects Wires the new AI layer into both Xcode projects: - Adds the SwiftOpenAI Swift Package (from: 4.4.9) to the ScriptWidget and ScriptWidgetMac main-app targets. Widget and share extensions intentionally do NOT link it. - Creates a new "AI" group under Shared/ScriptWidgetRuntime pointing at ../Shared/ScriptWidgetRuntime/AI and registers the 8 shared Swift files in both projects. - iOS: registers SettingAIView, plus a new "AIGenerate" group with AIGenerateView and AIReviewView. - macOS: registers new "Settings" and "AIGenerate" groups holding SettingAIView and AIGenerateWindowView respectively. - Adjusts AIClient to the current SwiftOpenAI API: the custom-baseURL path uses overrideBaseURL (String-based apiKey), and response `choices`/`message` are now optionals. iOS: ScriptWidget, ScriptWidgetWidget, and ScriptWidgetShare all build cleanly. macOS: pre-existing Vapor/MultipartKit/swift-nio incompatibility with the current Xcode SDK blocks building ScriptWidgetMac for reasons orthogonal to this feature (the unpatched project.pbxproj fails with the same errors on a clean build). Resolving that is outside the scope of this PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- Shared/ScriptWidgetRuntime/AI/AIClient.swift | 7 +- iOS/ScriptWidget.xcodeproj/project.pbxproj | 78 +++++++ .../xcshareddata/swiftpm/Package.resolved | 209 +++++++++++++++++- .../ScriptWidgetMac.xcodeproj/project.pbxproj | 82 +++++++ .../xcshareddata/swiftpm/Package.resolved | 113 +++++++--- 5 files changed, 452 insertions(+), 37 deletions(-) diff --git a/Shared/ScriptWidgetRuntime/AI/AIClient.swift b/Shared/ScriptWidgetRuntime/AI/AIClient.swift index a087fc0..14c70a8 100644 --- a/Shared/ScriptWidgetRuntime/AI/AIClient.swift +++ b/Shared/ScriptWidgetRuntime/AI/AIClient.swift @@ -68,7 +68,10 @@ actor AIClient { if baseURLString == AISettings.defaultBaseURL { service = OpenAIServiceFactory.service(apiKey: trimmedKey) } else { - service = OpenAIServiceFactory.service(apiKey: trimmedKey, baseURL: baseURLString) + service = OpenAIServiceFactory.service( + apiKey: trimmedKey, + overrideBaseURL: baseURLString + ) } let chatMessages: [ChatCompletionParameters.Message] = messages.map { msg in @@ -92,7 +95,7 @@ actor AIClient { do { let response = try await service.startChat(parameters: parameters) - guard let content = response.choices.first?.message.content, !content.isEmpty else { + guard let content = response.choices?.first?.message?.content, !content.isEmpty else { throw AIClientError.emptyResponse } let usage = AITokenUsage( diff --git a/iOS/ScriptWidget.xcodeproj/project.pbxproj b/iOS/ScriptWidget.xcodeproj/project.pbxproj index f08a127..f6a1ae1 100644 --- a/iOS/ScriptWidget.xcodeproj/project.pbxproj +++ b/iOS/ScriptWidget.xcodeproj/project.pbxproj @@ -308,6 +308,18 @@ F2FC062F27DC749A00A6A99D /* MirrorEditor.bundle in Resources */ = {isa = PBXBuildFile; fileRef = F2FC062E27DC749A00A6A99D /* MirrorEditor.bundle */; }; F2FE0A3525ED3ABF00B4B6B2 /* EmptyListBackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2FE0A3425ED3ABF00B4B6B2 /* EmptyListBackgroundView.swift */; }; F2FE0A3725ED3BFA00B4B6B2 /* ScriptWidgetPlaceholderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2FE0A3625ED3BFA00B4B6B2 /* ScriptWidgetPlaceholderView.swift */; }; + A102000100000000000000B0 /* AISettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A101000100000000000000B0 /* AISettings.swift */; }; + A102000200000000000000B0 /* AIReferenceSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = A101000200000000000000B0 /* AIReferenceSnapshot.swift */; }; + A102000300000000000000B0 /* PromptBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A101000300000000000000B0 /* PromptBuilder.swift */; }; + A102000400000000000000B0 /* AIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A101000400000000000000B0 /* AIClient.swift */; }; + A102000500000000000000B0 /* AgentRuntimeBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A101000500000000000000B0 /* AgentRuntimeBridge.swift */; }; + A102000600000000000000B0 /* AgentLoop.swift in Sources */ = {isa = PBXBuildFile; fileRef = A101000600000000000000B0 /* AgentLoop.swift */; }; + A102000700000000000000B0 /* AIGenerateSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A101000700000000000000B0 /* AIGenerateSession.swift */; }; + A102000800000000000000B0 /* AIGenerateProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A101000800000000000000B0 /* AIGenerateProgressView.swift */; }; + A104000100000000000000B0 /* SettingAIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A103000100000000000000B0 /* SettingAIView.swift */; }; + A104000200000000000000B0 /* AIGenerateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A103000200000000000000B0 /* AIGenerateView.swift */; }; + A104000300000000000000B0 /* AIReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A103000300000000000000B0 /* AIReviewView.swift */; }; + A106000300000000000000B0 /* SwiftOpenAI in Frameworks */ = {isa = PBXBuildFile; productRef = A106000200000000000000B0 /* SwiftOpenAI */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -584,6 +596,17 @@ F2FC062E27DC749A00A6A99D /* MirrorEditor.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = MirrorEditor.bundle; sourceTree = ""; }; F2FE0A3425ED3ABF00B4B6B2 /* EmptyListBackgroundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyListBackgroundView.swift; sourceTree = ""; }; F2FE0A3625ED3BFA00B4B6B2 /* ScriptWidgetPlaceholderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScriptWidgetPlaceholderView.swift; sourceTree = ""; }; + A101000100000000000000B0 /* AISettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AISettings.swift; sourceTree = ""; }; + A101000200000000000000B0 /* AIReferenceSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIReferenceSnapshot.swift; sourceTree = ""; }; + A101000300000000000000B0 /* PromptBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptBuilder.swift; sourceTree = ""; }; + A101000400000000000000B0 /* AIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIClient.swift; sourceTree = ""; }; + A101000500000000000000B0 /* AgentRuntimeBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentRuntimeBridge.swift; sourceTree = ""; }; + A101000600000000000000B0 /* AgentLoop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentLoop.swift; sourceTree = ""; }; + A101000700000000000000B0 /* AIGenerateSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIGenerateSession.swift; sourceTree = ""; }; + A101000800000000000000B0 /* AIGenerateProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIGenerateProgressView.swift; sourceTree = ""; }; + A103000100000000000000B0 /* SettingAIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingAIView.swift; sourceTree = ""; }; + A103000200000000000000B0 /* AIGenerateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIGenerateView.swift; sourceTree = ""; }; + A103000300000000000000B0 /* AIReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIReviewView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -591,6 +614,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + A106000300000000000000B0 /* SwiftOpenAI in Frameworks */, F2997F05274A717A00CD7DD6 /* SwiftUIX in Frameworks */, F2B20D1925D3EAEA00B62DAC /* CloudKit.framework in Frameworks */, 5A912FAA2D2135330050F250 /* ClockHandRotationKit in Frameworks */, @@ -722,6 +746,7 @@ D163031A254C6D62005EEB93 /* View */ = { isa = PBXGroup; children = ( + A105000200000000000000B0 /* AIGenerate */, D1824275254476A200223563 /* CodeEditor */, D1403DA22552FE950076F87C /* PhotoPicker */, F2BC995C25CE452400285C7B /* SettingsLabelView.swift */, @@ -913,6 +938,7 @@ F240AC7F27B2D6AB00D249EA /* Settings */ = { isa = PBXGroup; children = ( + A103000100000000000000B0 /* SettingAIView.swift */, 5AC769482CD370BC0022A138 /* ExportView.swift */, 5AC386D12CDA09760027B976 /* ImportView.swift */, F2C5C5E327AF5B5400797C5B /* AppIconsView.swift */, @@ -955,6 +981,7 @@ F29118E92793033500B860B0 /* ScriptWidgetRuntime */ = { isa = PBXGroup; children = ( + A105000100000000000000B0 /* AI */, F291198E2793039700B860B0 /* Resource */, F29118FF2793035E00B860B0 /* Widget */, F29118EA2793034D00B860B0 /* Common */, @@ -1294,6 +1321,31 @@ path = Bridge; sourceTree = ""; }; + A105000100000000000000B0 /* AI */ = { + isa = PBXGroup; + children = ( + A101000100000000000000B0 /* AISettings.swift */, + A101000200000000000000B0 /* AIReferenceSnapshot.swift */, + A101000300000000000000B0 /* PromptBuilder.swift */, + A101000400000000000000B0 /* AIClient.swift */, + A101000500000000000000B0 /* AgentRuntimeBridge.swift */, + A101000600000000000000B0 /* AgentLoop.swift */, + A101000700000000000000B0 /* AIGenerateSession.swift */, + A101000800000000000000B0 /* AIGenerateProgressView.swift */, + ); + name = AI; + path = ../Shared/ScriptWidgetRuntime/AI; + sourceTree = ""; + }; + A105000200000000000000B0 /* AIGenerate */ = { + isa = PBXGroup; + children = ( + A103000200000000000000B0 /* AIGenerateView.swift */, + A103000300000000000000B0 /* AIReviewView.swift */, + ); + path = AIGenerate; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -1314,6 +1366,7 @@ ); name = ScriptWidget; packageProductDependencies = ( + A106000200000000000000B0 /* SwiftOpenAI */, F245BBEC25B9E5DC00F25F68 /* SDWebImageSwiftUI */, F245BBF725B9E62300F25F68 /* SwiftyJSON */, F2997F04274A717A00CD7DD6 /* SwiftUIX */, @@ -1396,6 +1449,7 @@ ); mainGroup = D18AAC23252A12D10065386A; packageReferences = ( + A106000100000000000000B0 /* XCRemoteSwiftPackageReference "SwiftOpenAI" */, F245BBEB25B9E5DC00F25F68 /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */, F245BBF625B9E62300F25F68 /* XCRemoteSwiftPackageReference "SwiftyJSON" */, F2997F03274A717A00CD7DD6 /* XCRemoteSwiftPackageReference "SwiftUIX" */, @@ -1522,6 +1576,17 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A102000100000000000000B0 /* AISettings.swift in Sources */, + A102000200000000000000B0 /* AIReferenceSnapshot.swift in Sources */, + A102000300000000000000B0 /* PromptBuilder.swift in Sources */, + A102000400000000000000B0 /* AIClient.swift in Sources */, + A102000500000000000000B0 /* AgentRuntimeBridge.swift in Sources */, + A102000600000000000000B0 /* AgentLoop.swift in Sources */, + A102000700000000000000B0 /* AIGenerateSession.swift in Sources */, + A102000800000000000000B0 /* AIGenerateProgressView.swift in Sources */, + A104000100000000000000B0 /* SettingAIView.swift in Sources */, + A104000200000000000000B0 /* AIGenerateView.swift in Sources */, + A104000300000000000000B0 /* AIReviewView.swift in Sources */, F29119702793035E00B860B0 /* ScriptWidgetElementTagZStack.swift in Sources */, D132336528E08C15002C26A2 /* ScriptLiveActivityManager.swift in Sources */, F2B20CC425D2EF5E00B62DAC /* NameAutoImageView.swift in Sources */, @@ -2133,6 +2198,14 @@ minimumVersion = 0.1.0; }; }; + A106000100000000000000B0 /* XCRemoteSwiftPackageReference "SwiftOpenAI" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/jamesrochabrun/SwiftOpenAI"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 4.4.9; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -2189,6 +2262,11 @@ package = F245BBF625B9E62300F25F68 /* XCRemoteSwiftPackageReference "SwiftyJSON" */; productName = SwiftyJSON; }; + A106000200000000000000B0 /* SwiftOpenAI */ = { + isa = XCSwiftPackageProductDependency; + package = A106000100000000000000B0 /* XCRemoteSwiftPackageReference "SwiftOpenAI" */; + productName = SwiftOpenAI; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = D18AAC24252A12D10065386A /* Project object */; diff --git a/iOS/ScriptWidget.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/iOS/ScriptWidget.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index febfe8f..1eb8935 100644 --- a/iOS/ScriptWidget.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/iOS/ScriptWidget.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "a1224478333d09db55982de830ed5b9826df59c20413798533ad720d4cbf103c", + "originHash" : "db1d8a4d8692124f822529811f5107a0a180e2b7ea16f9edb162c2b9c9d17dd1", "pins" : [ + { + "identity" : "async-http-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/async-http-client.git", + "state" : { + "revision" : "3a5b74a58782c3b4c1f0bc75e9b67b10c2494e8f", + "version" : "1.33.1" + } + }, { "identity" : "clockhandrotationkit", "kind" : "remoteSourceControl", @@ -28,6 +37,204 @@ "version" : "1.5.0" } }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms.git", + "state" : { + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "9d349bcc328ac3c31ce40e746b5882742a0d1272", + "version" : "1.1.3" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-certificates", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-certificates.git", + "state" : { + "revision" : "5aa1c0d1bc204908df47c2075bdbb39573d05e8d", + "version" : "1.19.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "6675bc0ff86e61436e615df6fc5174e043e57924", + "version" : "1.4.1" + } + }, + { + "identity" : "swift-configuration", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-configuration.git", + "state" : { + "revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "476538ccb827f2dd18efc5de754cc87d77127a47", + "version" : "4.4.0" + } + }, + { + "identity" : "swift-distributed-tracing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-distributed-tracing.git", + "state" : { + "revision" : "dc4030184203ffafbb2ec614352487235d747fe0", + "version" : "1.4.1" + } + }, + { + "identity" : "swift-http-structured-headers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-structured-headers.git", + "state" : { + "revision" : "933538faa42c432d385f02e07df0ace7c5ecfc47", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "5073617dac96330a486245e4c0179cb0a6fd2256", + "version" : "1.12.0" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "cd6710454f25733900e133c6caf5188952763c36", + "version" : "2.98.0" + } + }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "5a48717e29f62cb8326d6d42e46b562ca93847a6", + "version" : "1.34.0" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "81cc18264f92cd307ff98430f89372711d4f6fe9", + "version" : "1.43.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "3f337058ccd7243c4cac7911477d8ad4c598d4da", + "version" : "2.37.0" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "9d4e67af1eea85967c7de778ad73e7776e5f1f22", + "version" : "1.27.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-service-context", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-service-context.git", + "state" : { + "revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle", + "state" : { + "revision" : "9829955b385e5bb88128b73f1b8389e9b9c3191a", + "version" : "2.11.0" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system", + "state" : { + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" + } + }, + { + "identity" : "swiftopenai", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jamesrochabrun/SwiftOpenAI", + "state" : { + "revision" : "bc6b84767c3a4eb9d48942b86e2417a229ef096c", + "version" : "4.4.9" + } + }, { "identity" : "swiftuix", "kind" : "remoteSourceControl", diff --git a/macOS/ScriptWidgetMac.xcodeproj/project.pbxproj b/macOS/ScriptWidgetMac.xcodeproj/project.pbxproj index ad879d8..654d8f9 100644 --- a/macOS/ScriptWidgetMac.xcodeproj/project.pbxproj +++ b/macOS/ScriptWidgetMac.xcodeproj/project.pbxproj @@ -183,6 +183,17 @@ F2CA7ADB27A23BED00569709 /* ZipArchive in Frameworks */ = {isa = PBXBuildFile; productRef = F2CA7ADA27A23BED00569709 /* ZipArchive */; }; F2D2B24EA420469CBA72564F /* ReloadWidgetAppIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8445DB90E4A34EB19697BAEB /* ReloadWidgetAppIntent.swift */; }; F2F90FC427970E3E003095A9 /* CreateGuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2F90FC327970E3E003095A9 /* CreateGuideView.swift */; }; + A202000100000000000000B0 /* AISettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000100000000000000B0 /* AISettings.swift */; }; + A202000200000000000000B0 /* AIReferenceSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000200000000000000B0 /* AIReferenceSnapshot.swift */; }; + A202000300000000000000B0 /* PromptBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000300000000000000B0 /* PromptBuilder.swift */; }; + A202000400000000000000B0 /* AIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000400000000000000B0 /* AIClient.swift */; }; + A202000500000000000000B0 /* AgentRuntimeBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000500000000000000B0 /* AgentRuntimeBridge.swift */; }; + A202000600000000000000B0 /* AgentLoop.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000600000000000000B0 /* AgentLoop.swift */; }; + A202000700000000000000B0 /* AIGenerateSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000700000000000000B0 /* AIGenerateSession.swift */; }; + A202000800000000000000B0 /* AIGenerateProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000800000000000000B0 /* AIGenerateProgressView.swift */; }; + A204000100000000000000B0 /* SettingAIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A203000100000000000000B0 /* SettingAIView.swift */; }; + A204000200000000000000B0 /* AIGenerateWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A203000200000000000000B0 /* AIGenerateWindowView.swift */; }; + A206000300000000000000B0 /* SwiftOpenAI in Frameworks */ = {isa = PBXBuildFile; productRef = A206000200000000000000B0 /* SwiftOpenAI */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -321,6 +332,16 @@ F29118D92792B90700B860B0 /* WKWebViewJavascriptBridgeJS.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WKWebViewJavascriptBridgeJS.swift; sourceTree = ""; }; F29118E72792CCDE00B860B0 /* PreviewWidgetSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewWidgetSize.swift; sourceTree = ""; }; F2F90FC327970E3E003095A9 /* CreateGuideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateGuideView.swift; sourceTree = ""; }; + A201000100000000000000B0 /* AISettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AISettings.swift; sourceTree = ""; }; + A201000200000000000000B0 /* AIReferenceSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIReferenceSnapshot.swift; sourceTree = ""; }; + A201000300000000000000B0 /* PromptBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptBuilder.swift; sourceTree = ""; }; + A201000400000000000000B0 /* AIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIClient.swift; sourceTree = ""; }; + A201000500000000000000B0 /* AgentRuntimeBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentRuntimeBridge.swift; sourceTree = ""; }; + A201000600000000000000B0 /* AgentLoop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentLoop.swift; sourceTree = ""; }; + A201000700000000000000B0 /* AIGenerateSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIGenerateSession.swift; sourceTree = ""; }; + A201000800000000000000B0 /* AIGenerateProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIGenerateProgressView.swift; sourceTree = ""; }; + A203000100000000000000B0 /* SettingAIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingAIView.swift; sourceTree = ""; }; + A203000200000000000000B0 /* AIGenerateWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIGenerateWindowView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -328,6 +349,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + A206000300000000000000B0 /* SwiftOpenAI in Frameworks */, F2B99C4327B8BE5E009584A3 /* SDWebImageSwiftUI in Frameworks */, F29118252791D38E00B860B0 /* SwiftyJSON in Frameworks */, D1995D0929536CCE00D1BD94 /* Vapor in Frameworks */, @@ -352,6 +374,7 @@ D1995C5029536B7300D1BD94 /* ScriptWidgetRuntime */ = { isa = PBXGroup; children = ( + A205000100000000000000B0 /* AI */, D1995D0429536BF000D1BD94 /* Resource */, D1995CF429536BC700D1BD94 /* Common */, D1995C5529536BB800D1BD94 /* Widget */, @@ -636,6 +659,8 @@ F29117D6279101DE00B860B0 /* ScriptWidgetMac */ = { isa = PBXGroup; children = ( + A205000200000000000000B0 /* AIGenerate */, + A205000300000000000000B0 /* Settings */, F2461643279F87B900C3D90F /* SwiftUIMacKit */, F246163C279E18CE00C3D90F /* Common */, F2F90FC227970DEA003095A9 /* Create */, @@ -773,6 +798,38 @@ path = Create; sourceTree = ""; }; + A205000100000000000000B0 /* AI */ = { + isa = PBXGroup; + children = ( + A201000100000000000000B0 /* AISettings.swift */, + A201000200000000000000B0 /* AIReferenceSnapshot.swift */, + A201000300000000000000B0 /* PromptBuilder.swift */, + A201000400000000000000B0 /* AIClient.swift */, + A201000500000000000000B0 /* AgentRuntimeBridge.swift */, + A201000600000000000000B0 /* AgentLoop.swift */, + A201000700000000000000B0 /* AIGenerateSession.swift */, + A201000800000000000000B0 /* AIGenerateProgressView.swift */, + ); + name = AI; + path = ../Shared/ScriptWidgetRuntime/AI; + sourceTree = ""; + }; + A205000200000000000000B0 /* AIGenerate */ = { + isa = PBXGroup; + children = ( + A203000200000000000000B0 /* AIGenerateWindowView.swift */, + ); + path = AIGenerate; + sourceTree = ""; + }; + A205000300000000000000B0 /* Settings */ = { + isa = PBXGroup; + children = ( + A203000100000000000000B0 /* SettingAIView.swift */, + ); + path = Settings; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -792,6 +849,7 @@ ); name = ScriptWidgetMac; packageProductDependencies = ( + A206000200000000000000B0 /* SwiftOpenAI */, F29118242791D38E00B860B0 /* SwiftyJSON */, F29119982794490100B860B0 /* ZipArchive */, F2B99C4227B8BE5E009584A3 /* SDWebImageSwiftUI */, @@ -850,6 +908,7 @@ ); mainGroup = F29117D1279101DE00B860B0; packageReferences = ( + A206000100000000000000B0 /* XCRemoteSwiftPackageReference "SwiftOpenAI" */, F29118232791D38E00B860B0 /* XCRemoteSwiftPackageReference "SwiftyJSON" */, F29119972794490100B860B0 /* XCRemoteSwiftPackageReference "ZipArchive" */, F2B99C4127B8BE5E009584A3 /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */, @@ -905,6 +964,16 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A202000100000000000000B0 /* AISettings.swift in Sources */, + A202000200000000000000B0 /* AIReferenceSnapshot.swift in Sources */, + A202000300000000000000B0 /* PromptBuilder.swift in Sources */, + A202000400000000000000B0 /* AIClient.swift in Sources */, + A202000500000000000000B0 /* AgentRuntimeBridge.swift in Sources */, + A202000600000000000000B0 /* AgentLoop.swift in Sources */, + A202000700000000000000B0 /* AIGenerateSession.swift in Sources */, + A202000800000000000000B0 /* AIGenerateProgressView.swift in Sources */, + A204000100000000000000B0 /* SettingAIView.swift in Sources */, + A204000200000000000000B0 /* AIGenerateWindowView.swift in Sources */, D1995CFE29536BC700D1BD94 /* ScriptManager.swift in Sources */, D1995CD029536BB800D1BD94 /* ScriptWidgetElementTagStack.swift in Sources */, D1995CA829536BB800D1BD94 /* ScriptWidgetElementView.swift in Sources */, @@ -1403,6 +1472,14 @@ minimumVersion = 2.0.0; }; }; + A206000100000000000000B0 /* XCRemoteSwiftPackageReference "SwiftOpenAI" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/jamesrochabrun/SwiftOpenAI"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 4.4.9; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -1436,6 +1513,11 @@ package = F29119972794490100B860B0 /* XCRemoteSwiftPackageReference "ZipArchive" */; productName = ZipArchive; }; + A206000200000000000000B0 /* SwiftOpenAI */ = { + isa = XCSwiftPackageProductDependency; + package = A206000100000000000000B0 /* XCRemoteSwiftPackageReference "SwiftOpenAI" */; + productName = SwiftOpenAI; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = F29117D2279101DE00B860B0 /* Project object */; diff --git a/macOS/ScriptWidgetMac.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/macOS/ScriptWidgetMac.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 829e13a..af16735 100644 --- a/macOS/ScriptWidgetMac.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/macOS/ScriptWidgetMac.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swift-server/async-http-client.git", "state" : { - "revision" : "5bee16a79922e3efcb5cea06ecd27e6f8048b56b", - "version" : "1.13.1" + "revision" : "333f51104b75d1a5b94cb3b99e4c58a3b442c9f7", + "version" : "1.25.2" } }, { @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/console-kit.git", "state" : { - "revision" : "a7e67a1719933318b5ab7eaaed355cde020465b1", - "version" : "4.5.0" + "revision" : "32ad16dfc7677b927b225595ed18f3debb32f577", + "version" : "4.16.0" } }, { @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/routing-kit.git", "state" : { - "revision" : "ffac7b3a127ce1e85fb232f1a6271164628809ad", - "version" : "4.6.0" + "revision" : "1a10ccea61e4248effd23b6e814999ce7bdf0ee0", + "version" : "4.9.3" } }, { @@ -73,22 +73,31 @@ "version" : "1.0.0" } }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab", + "version" : "1.7.0" + } + }, { "identity" : "swift-atomics", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-atomics.git", "state" : { - "revision" : "ff3d2212b6b093db7f177d0855adbc4ef9c5f036", - "version" : "1.0.3" + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" } }, { - "identity" : "swift-backtrace", + "identity" : "swift-certificates", "kind" : "remoteSourceControl", - "location" : "https://github.com/swift-server/swift-backtrace.git", + "location" : "https://github.com/apple/swift-certificates.git", "state" : { - "revision" : "f25620d5d05e2f1ba27154b40cafea2b67566956", - "version" : "1.3.3" + "revision" : "5aa1c0d1bc204908df47c2075bdbb39573d05e8d", + "version" : "1.19.0" } }, { @@ -96,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2", - "version" : "1.0.4" + "revision" : "6675bc0ff86e61436e615df6fc5174e043e57924", + "version" : "1.4.1" } }, { @@ -105,8 +114,26 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-crypto.git", "state" : { - "revision" : "92a04c10fc5ce0504f8396aac7392126033e547c", - "version" : "2.2.2" + "revision" : "476538ccb827f2dd18efc5de754cc87d77127a47", + "version" : "4.4.0" + } + }, + { + "identity" : "swift-distributed-tracing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-distributed-tracing.git", + "state" : { + "revision" : "dc4030184203ffafbb2ec614352487235d747fe0", + "version" : "1.4.1" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types", + "state" : { + "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", + "version" : "1.5.1" } }, { @@ -114,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "6fe203dc33195667ce1759bf0182975e4653ba1c", - "version" : "1.4.4" + "revision" : "5073617dac96330a486245e4c0179cb0a6fd2256", + "version" : "1.12.0" } }, { @@ -123,8 +150,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-metrics.git", "state" : { - "revision" : "9b39d811a83cf18b79d7d5513b06f8b290198b10", - "version" : "2.3.3" + "revision" : "d51c8d13fa366eec807eedb4e37daa60ff5bfdd5", + "version" : "2.10.1" } }, { @@ -132,8 +159,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "7e3b50b38e4e66f31db6cf4a784c6af148bac846", - "version" : "2.46.0" + "revision" : "cd6710454f25733900e133c6caf5188952763c36", + "version" : "2.98.0" } }, { @@ -141,8 +168,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-extras.git", "state" : { - "revision" : "91dd2d61fb772e1311bb5f13b59266b579d77e42", - "version" : "1.15.0" + "revision" : "2e9746cfc57554f70b650b021b6ae4738abef3e6", + "version" : "1.24.1" } }, { @@ -150,8 +177,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-http2.git", "state" : { - "revision" : "d6656967f33ed8b368b38e4b198631fc7c484a40", - "version" : "1.23.1" + "revision" : "81cc18264f92cd307ff98430f89372711d4f6fe9", + "version" : "1.43.0" } }, { @@ -159,8 +186,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-ssl.git", "state" : { - "revision" : "4fb7ead803e38949eb1d6fabb849206a72c580f3", - "version" : "2.23.0" + "revision" : "3f337058ccd7243c4cac7911477d8ad4c598d4da", + "version" : "2.37.0" } }, { @@ -168,8 +195,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-transport-services.git", "state" : { - "revision" : "c0d9a144cfaec8d3d596aadde3039286a266c15c", - "version" : "1.15.0" + "revision" : "3c394067c08d1225ba8442e9cffb520ded417b64", + "version" : "1.23.1" } }, { @@ -181,6 +208,24 @@ "version" : "1.0.2" } }, + { + "identity" : "swift-service-context", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-service-context.git", + "state" : { + "revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" + } + }, { "identity" : "swiftyjson", "kind" : "remoteSourceControl", @@ -195,8 +240,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/vapor.git", "state" : { - "revision" : "eb2da0d749e185789970c32f7fd9c114a339fa13", - "version" : "4.67.5" + "revision" : "cfd8f434843ac7850e2d97f46c1aa5ddb906cf1c", + "version" : "4.121.4" } }, { @@ -204,8 +249,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/websocket-kit.git", "state" : { - "revision" : "2d9d2188a08eef4a869d368daab21b3c08510991", - "version" : "2.6.1" + "revision" : "90bbbdab3ede12c803cfbe91646f291c092517a3", + "version" : "2.16.2" } }, { From e4fdf89e56e0abe2b670b0a563f65167591c3754 Mon Sep 17 00:00:00 2001 From: everettjf Date: Tue, 21 Apr 2026 21:58:56 -0700 Subject: [PATCH 04/13] feat(ai): starter example prompts on the generate screen Adds AIExamplePrompts with 8 curated starters (Weather, Clock, Countdown, Crypto Price, Battery Ring, Quote, Steps, Habit Grid) that each describe concrete colors, data sources, and layout so the agent loop converges quickly. Surfaced as a horizontal chip row on both iOS AIGenerateView and macOS AIGenerateWindowView; tapping a chip fills the prompt field and sets the recommended widget size. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../AI/AIExamplePrompts.swift | 99 +++++++++++++++++++ iOS/ScriptWidget.xcodeproj/project.pbxproj | 4 + .../View/AIGenerate/AIGenerateView.swift | 33 +++++++ .../ScriptWidgetMac.xcodeproj/project.pbxproj | 4 + .../AIGenerate/AIGenerateWindowView.swift | 28 ++++++ 5 files changed, 168 insertions(+) create mode 100644 Shared/ScriptWidgetRuntime/AI/AIExamplePrompts.swift diff --git a/Shared/ScriptWidgetRuntime/AI/AIExamplePrompts.swift b/Shared/ScriptWidgetRuntime/AI/AIExamplePrompts.swift new file mode 100644 index 0000000..35aa78e --- /dev/null +++ b/Shared/ScriptWidgetRuntime/AI/AIExamplePrompts.swift @@ -0,0 +1,99 @@ +// +// AIExamplePrompts.swift +// ScriptWidget +// +// Curated starter prompts surfaced on the AI Generate screen. Kept +// deliberately concrete (mentioning colors, data sources, layout) +// so the agent loop converges quickly. +// + +import Foundation + +struct AIExamplePrompt: Identifiable { + let id = UUID() + let title: String + let symbol: String // SF Symbol name + let size: AIWidgetSize + let prompt: String +} + +enum AIExamplePrompts { + static let all: [AIExamplePrompt] = [ + AIExamplePrompt( + title: "Weather", + symbol: "cloud.sun.fill", + size: .medium, + prompt: + "Show the current weather for my device location using the Open-Meteo API " + + "(https://api.open-meteo.com/v1/forecast). Dark navy background. " + + "Big temperature in Celsius, feels-like temperature below in a smaller caption, " + + "and the weather code. Handle missing location gracefully with a message." + ), + AIExamplePrompt( + title: "Clock", + symbol: "clock.fill", + size: .small, + prompt: + "A minimalist clock widget. Show the current time as a large HH:mm, " + + "today's weekday and date below in a muted caption. " + + "Dark gradient background from near-black to deep purple." + ), + AIExamplePrompt( + title: "Countdown", + symbol: "calendar.badge.clock", + size: .medium, + prompt: + "A countdown widget to 2026-12-31. Show days remaining as a big number, " + + "with the label 'days until New Year' underneath. " + + "Warm orange-to-red gradient background, light text." + ), + AIExamplePrompt( + title: "Crypto Price", + symbol: "bitcoinsign.circle.fill", + size: .medium, + prompt: + "Fetch the current Bitcoin price in USD from " + + "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd&include_24hr_change=true " + + "and display it. Large USD price, a second line with the 24h change " + + "(green with ▲ if positive, red with ▼ if negative). Black background." + ), + AIExamplePrompt( + title: "Battery Ring", + symbol: "battery.75percent", + size: .small, + prompt: + "Show the device battery percentage via $device as a number in the center " + + "of a circular gauge ring. Use green above 50%, yellow 20-50%, red below 20%. " + + "Dark background." + ), + AIExamplePrompt( + title: "Quote", + symbol: "quote.bubble.fill", + size: .large, + prompt: + "A daily quote widget. Hardcode 7 short inspirational quotes (one per weekday) " + + "and display the one matching today's weekday. " + + "Quote in a readable body font centered, author on a second line in caption. " + + "Soft pastel gradient background." + ), + AIExamplePrompt( + title: "Steps", + symbol: "figure.walk", + size: .small, + prompt: + "Show today's step count from $health. Large number centered, " + + "'steps' label below in caption. Progress bar at the bottom " + + "showing progress toward a 10000-step goal. Dark teal background." + ), + AIExamplePrompt( + title: "Habit Grid", + symbol: "checkmark.square.fill", + size: .large, + prompt: + "A GitHub-style 7x5 habit tracker grid (35 cells). Hardcode a boolean " + + "array of 35 values representing the last 35 days of a 'read 20 mins' habit. " + + "Green filled cells for completed, gray for missed. " + + "Header: 'Reading streak' with current streak count in the top-right." + ), + ] +} diff --git a/iOS/ScriptWidget.xcodeproj/project.pbxproj b/iOS/ScriptWidget.xcodeproj/project.pbxproj index f6a1ae1..42df765 100644 --- a/iOS/ScriptWidget.xcodeproj/project.pbxproj +++ b/iOS/ScriptWidget.xcodeproj/project.pbxproj @@ -320,6 +320,7 @@ A104000200000000000000B0 /* AIGenerateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A103000200000000000000B0 /* AIGenerateView.swift */; }; A104000300000000000000B0 /* AIReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A103000300000000000000B0 /* AIReviewView.swift */; }; A106000300000000000000B0 /* SwiftOpenAI in Frameworks */ = {isa = PBXBuildFile; productRef = A106000200000000000000B0 /* SwiftOpenAI */; }; + A102000900000000000000B0 /* AIExamplePrompts.swift in Sources */ = {isa = PBXBuildFile; fileRef = A101000900000000000000B0 /* AIExamplePrompts.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -607,6 +608,7 @@ A103000100000000000000B0 /* SettingAIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingAIView.swift; sourceTree = ""; }; A103000200000000000000B0 /* AIGenerateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIGenerateView.swift; sourceTree = ""; }; A103000300000000000000B0 /* AIReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIReviewView.swift; sourceTree = ""; }; + A101000900000000000000B0 /* AIExamplePrompts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIExamplePrompts.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1332,6 +1334,7 @@ A101000600000000000000B0 /* AgentLoop.swift */, A101000700000000000000B0 /* AIGenerateSession.swift */, A101000800000000000000B0 /* AIGenerateProgressView.swift */, + A101000900000000000000B0 /* AIExamplePrompts.swift */, ); name = AI; path = ../Shared/ScriptWidgetRuntime/AI; @@ -1576,6 +1579,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A102000900000000000000B0 /* AIExamplePrompts.swift in Sources */, A102000100000000000000B0 /* AISettings.swift in Sources */, A102000200000000000000B0 /* AIReferenceSnapshot.swift in Sources */, A102000300000000000000B0 /* PromptBuilder.swift in Sources */, diff --git a/iOS/ScriptWidget/View/AIGenerate/AIGenerateView.swift b/iOS/ScriptWidget/View/AIGenerate/AIGenerateView.swift index eb605eb..954b1f3 100644 --- a/iOS/ScriptWidget/View/AIGenerate/AIGenerateView.swift +++ b/iOS/ScriptWidget/View/AIGenerate/AIGenerateView.swift @@ -41,6 +41,8 @@ struct AIGenerateView: View { } } + examplesSection + Picker("Size", selection: $session.size) { ForEach(AIWidgetSize.allCases) { size in Text(size.displayName).tag(size) @@ -109,4 +111,35 @@ struct AIGenerateView: View { default: return true } } + + private var examplesSection: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Try an example") + .font(.subheadline.weight(.medium)) + .foregroundStyle(.secondary) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(AIExamplePrompts.all) { example in + Button { + prompt = example.prompt + session.size = example.size + } label: { + HStack(spacing: 6) { + Image(systemName: example.symbol) + Text(example.title) + } + .font(.caption) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.accentColor.opacity(0.12)) + .foregroundColor(.accentColor) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } + } + .padding(.vertical, 2) + } + } + } } diff --git a/macOS/ScriptWidgetMac.xcodeproj/project.pbxproj b/macOS/ScriptWidgetMac.xcodeproj/project.pbxproj index 654d8f9..d2929d9 100644 --- a/macOS/ScriptWidgetMac.xcodeproj/project.pbxproj +++ b/macOS/ScriptWidgetMac.xcodeproj/project.pbxproj @@ -194,6 +194,7 @@ A204000100000000000000B0 /* SettingAIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A203000100000000000000B0 /* SettingAIView.swift */; }; A204000200000000000000B0 /* AIGenerateWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A203000200000000000000B0 /* AIGenerateWindowView.swift */; }; A206000300000000000000B0 /* SwiftOpenAI in Frameworks */ = {isa = PBXBuildFile; productRef = A206000200000000000000B0 /* SwiftOpenAI */; }; + A202000900000000000000B0 /* AIExamplePrompts.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000900000000000000B0 /* AIExamplePrompts.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -342,6 +343,7 @@ A201000800000000000000B0 /* AIGenerateProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIGenerateProgressView.swift; sourceTree = ""; }; A203000100000000000000B0 /* SettingAIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingAIView.swift; sourceTree = ""; }; A203000200000000000000B0 /* AIGenerateWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIGenerateWindowView.swift; sourceTree = ""; }; + A201000900000000000000B0 /* AIExamplePrompts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIExamplePrompts.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -809,6 +811,7 @@ A201000600000000000000B0 /* AgentLoop.swift */, A201000700000000000000B0 /* AIGenerateSession.swift */, A201000800000000000000B0 /* AIGenerateProgressView.swift */, + A201000900000000000000B0 /* AIExamplePrompts.swift */, ); name = AI; path = ../Shared/ScriptWidgetRuntime/AI; @@ -964,6 +967,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A202000900000000000000B0 /* AIExamplePrompts.swift in Sources */, A202000100000000000000B0 /* AISettings.swift in Sources */, A202000200000000000000B0 /* AIReferenceSnapshot.swift in Sources */, A202000300000000000000B0 /* PromptBuilder.swift in Sources */, diff --git a/macOS/ScriptWidgetMac/AIGenerate/AIGenerateWindowView.swift b/macOS/ScriptWidgetMac/AIGenerate/AIGenerateWindowView.swift index aaa8c67..ba83670 100644 --- a/macOS/ScriptWidgetMac/AIGenerate/AIGenerateWindowView.swift +++ b/macOS/ScriptWidgetMac/AIGenerate/AIGenerateWindowView.swift @@ -87,6 +87,8 @@ struct AIGenerateWindowView: View { .frame(minHeight: 140) .border(Color.secondary.opacity(0.3)) + examplesSection + HStack { Text("Size") Picker("", selection: $session.size) { @@ -169,6 +171,32 @@ struct AIGenerateWindowView: View { .padding(12) } + private var examplesSection: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Try an example") + .font(.caption) + .foregroundStyle(.secondary) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(AIExamplePrompts.all) { example in + Button { + prompt = example.prompt + session.size = example.size + } label: { + HStack(spacing: 4) { + Image(systemName: example.symbol) + Text(example.title) + } + .font(.caption) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } + } + } + @ViewBuilder private var previewContent: some View { let size = session.size.previewSize From ff2c77978dd822d9b0469e6aea089f384078632f Mon Sep 17 00:00:00 2001 From: everettjf Date: Tue, 21 Apr 2026 22:32:25 -0700 Subject: [PATCH 05/13] =?UTF-8?q?feat(ai,macos):=20parity=20polish=20?= =?UTF-8?q?=E2=80=94=20exhausted=20banner,=20default=20save=20name,=20menu?= =?UTF-8?q?=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes remaining parity gaps with the iOS flow: - Shows a "Did not fully converge" banner above the preview when the agent loop exhausts its iteration budget, matching AIReviewView on iOS. - Prefills the save-name field with "AI yyyy-MM-dd HHmm" on open and whenever the generated JSX changes (only if still empty), so the Save button is reachable without typing. - Adds File → "Generate Widget with AI..." with the ⌘⇧N shortcut, routed through a notification so SidebarView handles the not-configured case consistently with the toolbar button. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../AIGenerate/AIGenerateWindowView.swift | 36 +++++++++++++++++-- .../App/ScriptWidgetMacApp.swift | 4 +++ .../ScriptWidgetMac/Sidebar/SidebarView.swift | 7 ++++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/macOS/ScriptWidgetMac/AIGenerate/AIGenerateWindowView.swift b/macOS/ScriptWidgetMac/AIGenerate/AIGenerateWindowView.swift index ba83670..7053f3a 100644 --- a/macOS/ScriptWidgetMac/AIGenerate/AIGenerateWindowView.swift +++ b/macOS/ScriptWidgetMac/AIGenerate/AIGenerateWindowView.swift @@ -11,6 +11,8 @@ import SwiftUI import WidgetKit struct AIGenerateWindowView: View { + static let openRequestNotification = Notification.Name("AIGenerateWindowViewOpenRequest") + @Environment(\.dismiss) private var dismiss @StateObject private var session = AIGenerateSession() @@ -31,6 +33,10 @@ struct AIGenerateWindowView: View { default: return false } } + private var isExhausted: Bool { + if case .exhausted = session.phase { return true } + return false + } var body: some View { VStack(spacing: 0) { @@ -47,8 +53,14 @@ struct AIGenerateWindowView: View { } .frame(idealWidth: 860, minHeight: 520, idealHeight: 620) .frame(minWidth: 720) - .onAppear { ensurePreviewPackage() } - .onChange(of: jsx) { _ in refreshPreviewPackage() } + .onAppear { + ensurePreviewPackage() + prefillSaveNameIfNeeded() + } + .onChange(of: jsx) { _ in + refreshPreviewPackage() + prefillSaveNameIfNeeded() + } .sheet(isPresented: $showingCode) { codeSheet } .sheet(isPresented: $showingLogs) { logsSheet } .alert("Save Failed", isPresented: saveErrorBinding) { @@ -149,6 +161,15 @@ struct AIGenerateWindowView: View { .controlSize(.small) } + if isExhausted { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + Text("Did not fully converge — showing the last attempt.") + .font(.caption) + } + .foregroundStyle(.orange) + } + ZStack { Rectangle().fill(Color.secondary.opacity(0.15)) previewContent @@ -322,6 +343,17 @@ struct AIGenerateWindowView: View { _ = pkg.writeMainFile(content: jsx) } + private func prefillSaveNameIfNeeded() { + guard saveName.trimmingCharacters(in: .whitespaces).isEmpty else { return } + saveName = "AI " + AIGenerateWindowView.defaultNameFormatter.string(from: Date()) + } + + private static let defaultNameFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd HHmm" + return f + }() + private func performSave() { let trimmed = saveName.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { diff --git a/macOS/ScriptWidgetMac/App/ScriptWidgetMacApp.swift b/macOS/ScriptWidgetMac/App/ScriptWidgetMacApp.swift index 18b4dfe..da3fd13 100644 --- a/macOS/ScriptWidgetMac/App/ScriptWidgetMacApp.swift +++ b/macOS/ScriptWidgetMac/App/ScriptWidgetMacApp.swift @@ -20,6 +20,10 @@ struct ScriptWidgetMacApp: App { } .commands { CommandGroup(after: .newItem) { + Button("Generate Widget with AI...") { + NotificationCenter.default.post(name: AIGenerateWindowView.openRequestNotification, object: nil) + }.keyboardShortcut("n", modifiers: [.command, .shift]) + Button("Save") { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { NotificationCenter.default.post(name: EditorService.saveNotification, object: nil, userInfo: nil) diff --git a/macOS/ScriptWidgetMac/Sidebar/SidebarView.swift b/macOS/ScriptWidgetMac/Sidebar/SidebarView.swift index 371cb92..48564cc 100644 --- a/macOS/ScriptWidgetMac/Sidebar/SidebarView.swift +++ b/macOS/ScriptWidgetMac/Sidebar/SidebarView.swift @@ -44,6 +44,13 @@ struct SidebarView: View { .sheet(isPresented: $aiGenerateShowingSheet) { AIGenerateWindowView() } + .onReceive(NotificationCenter.default.publisher(for: AIGenerateWindowView.openRequestNotification)) { _ in + if AISettingsStore.shared.load().isConfigured { + aiGenerateShowingSheet = true + } else { + aiConfigAlertShown = true + } + } .alert("Configure AI First", isPresented: $aiConfigAlertShown) { Button("OK", role: .cancel) { } } message: { From c5e386574ac1a6e4b37ee3ba62526f203677d2d8 Mon Sep 17 00:00:00 2001 From: everettjf Date: Tue, 21 Apr 2026 23:09:09 -0700 Subject: [PATCH 06/13] fix(macos): drop Vapor, serve Monaco editor via WKURLSchemeHandler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vapor's only job on macOS was to spin up a localhost HTTP server on port 23355 that served Monaco editor static files from Editor.bundle/static. That came with a large transitive dep graph (swift-nio, MultipartKit, WebSocketKit, etc.), and the version combination compatible with Vapor's open `from: 4.0.0` range no longer builds against the current Xcode SDK under Swift 6 strict concurrency — MultipartKit's retroactive Sendable conformance on FormData types is rejected by the compiler, and older swift-nio pins fail CNIODarwin module imports. This replaces the whole HTTP server with a tiny WKURLSchemeHandler: - Adds EditorSchemeHandler that serves the `scriptwidget-editor://` scheme directly from Editor.bundle/static (with a path-traversal guard and MIME-type mapping). - Registers the handler on WKWebViewConfiguration in EditorInternalWebView.init(). - editorWebServiceUrl() now returns a scriptwidget-editor:// URL. - AppDelegate no longer starts a background HTTP server. - Removes Vapor from the project's SPM graph (XCRemoteSwiftPackage Reference, product dependency, packageProductDependencies entry, and Frameworks build-file entry). Result: ScriptWidgetMac and ScriptWidgetMacWidget both BUILD SUCCEEDED under the current Xcode, and 20+ transitive packages (Vapor, MultipartKit, WebSocketKit, RoutingKit, ConsoleKit, AsyncKit, swift-crypto, swift-certificates, swift-asn1) drop out of the dep graph entirely. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ScriptWidgetMac.xcodeproj/project.pbxproj | 145 ++++++++---------- .../xcshareddata/swiftpm/Package.resolved | 117 ++------------ macOS/ScriptWidgetMac/App/AppDelegate.swift | 4 +- .../Editor/Editor/EditorSchemeHandler.swift | 101 ++++++++++++ .../Editor/Editor/EditorWebSevice.swift | 61 +------- .../Editor/Editor/EditorWebView.swift | 6 +- 6 files changed, 187 insertions(+), 247 deletions(-) create mode 100644 macOS/ScriptWidgetMac/Editor/Editor/EditorSchemeHandler.swift diff --git a/macOS/ScriptWidgetMac.xcodeproj/project.pbxproj b/macOS/ScriptWidgetMac.xcodeproj/project.pbxproj index d2929d9..ea77d4e 100644 --- a/macOS/ScriptWidgetMac.xcodeproj/project.pbxproj +++ b/macOS/ScriptWidgetMac.xcodeproj/project.pbxproj @@ -20,6 +20,18 @@ 75F45276F84EA01B91EC0CEC /* ScriptWidgetRuntimeHealth.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD9F1F298DF5C4FAED8AAD43 /* ScriptWidgetRuntimeHealth.swift */; }; 855878C6168D59CE4C6CB13C /* ScriptWidgetRuntimeSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E3B593386F820DCF99C631E /* ScriptWidgetRuntimeSystem.swift */; }; 966888252E454500AEA7852C /* ScriptWidgetRuntimeLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 124FAA64786342F5A3432631 /* ScriptWidgetRuntimeLocation.swift */; }; + A202000100000000000000B0 /* AISettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000100000000000000B0 /* AISettings.swift */; }; + A202000200000000000000B0 /* AIReferenceSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000200000000000000B0 /* AIReferenceSnapshot.swift */; }; + A202000300000000000000B0 /* PromptBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000300000000000000B0 /* PromptBuilder.swift */; }; + A202000400000000000000B0 /* AIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000400000000000000B0 /* AIClient.swift */; }; + A202000500000000000000B0 /* AgentRuntimeBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000500000000000000B0 /* AgentRuntimeBridge.swift */; }; + A202000600000000000000B0 /* AgentLoop.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000600000000000000B0 /* AgentLoop.swift */; }; + A202000700000000000000B0 /* AIGenerateSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000700000000000000B0 /* AIGenerateSession.swift */; }; + A202000800000000000000B0 /* AIGenerateProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000800000000000000B0 /* AIGenerateProgressView.swift */; }; + A202000900000000000000B0 /* AIExamplePrompts.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000900000000000000B0 /* AIExamplePrompts.swift */; }; + A204000100000000000000B0 /* SettingAIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A203000100000000000000B0 /* SettingAIView.swift */; }; + A204000200000000000000B0 /* AIGenerateWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A203000200000000000000B0 /* AIGenerateWindowView.swift */; }; + A206000300000000000000B0 /* SwiftOpenAI in Frameworks */ = {isa = PBXBuildFile; productRef = A206000200000000000000B0 /* SwiftOpenAI */; }; A621B963FD9840DB82F74813 /* ScriptWidgetRuntimeLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 124FAA64786342F5A3432631 /* ScriptWidgetRuntimeLocation.swift */; }; A990D65181507F3AA8EB7951 /* ScriptWidgetElementTagExtras.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DAACB8F5182390731F6DFE7 /* ScriptWidgetElementTagExtras.swift */; }; D1995C9429536BB800D1BD94 /* MineGaugeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1995C5729536BB800D1BD94 /* MineGaugeView.swift */; }; @@ -121,7 +133,6 @@ D1995CFE29536BC700D1BD94 /* ScriptManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1995CF729536BC700D1BD94 /* ScriptManager.swift */; }; D1995CFF29536BC700D1BD94 /* ScriptManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1995CF729536BC700D1BD94 /* ScriptManager.swift */; }; D1995D0629536BF000D1BD94 /* Script.bundle in Resources */ = {isa = PBXBuildFile; fileRef = D1995D0529536BF000D1BD94 /* Script.bundle */; }; - D1995D0929536CCE00D1BD94 /* Vapor in Frameworks */ = {isa = PBXBuildFile; productRef = D1995D0829536CCE00D1BD94 /* Vapor */; }; F05227BF2AD1D6DF0014BE09 /* ScriptWidgetElementTagToggle.swift in Sources */ = {isa = PBXBuildFile; fileRef = F05227BD2AD1D6DE0014BE09 /* ScriptWidgetElementTagToggle.swift */; }; F05227C02AD1D6DF0014BE09 /* ScriptWidgetElementTagToggle.swift in Sources */ = {isa = PBXBuildFile; fileRef = F05227BD2AD1D6DE0014BE09 /* ScriptWidgetElementTagToggle.swift */; }; F05227C12AD1D6DF0014BE09 /* ScriptWidgetElementTagButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F05227BE2AD1D6DE0014BE09 /* ScriptWidgetElementTagButton.swift */; }; @@ -183,18 +194,7 @@ F2CA7ADB27A23BED00569709 /* ZipArchive in Frameworks */ = {isa = PBXBuildFile; productRef = F2CA7ADA27A23BED00569709 /* ZipArchive */; }; F2D2B24EA420469CBA72564F /* ReloadWidgetAppIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8445DB90E4A34EB19697BAEB /* ReloadWidgetAppIntent.swift */; }; F2F90FC427970E3E003095A9 /* CreateGuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2F90FC327970E3E003095A9 /* CreateGuideView.swift */; }; - A202000100000000000000B0 /* AISettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000100000000000000B0 /* AISettings.swift */; }; - A202000200000000000000B0 /* AIReferenceSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000200000000000000B0 /* AIReferenceSnapshot.swift */; }; - A202000300000000000000B0 /* PromptBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000300000000000000B0 /* PromptBuilder.swift */; }; - A202000400000000000000B0 /* AIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000400000000000000B0 /* AIClient.swift */; }; - A202000500000000000000B0 /* AgentRuntimeBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000500000000000000B0 /* AgentRuntimeBridge.swift */; }; - A202000600000000000000B0 /* AgentLoop.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000600000000000000B0 /* AgentLoop.swift */; }; - A202000700000000000000B0 /* AIGenerateSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000700000000000000B0 /* AIGenerateSession.swift */; }; - A202000800000000000000B0 /* AIGenerateProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000800000000000000B0 /* AIGenerateProgressView.swift */; }; - A204000100000000000000B0 /* SettingAIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A203000100000000000000B0 /* SettingAIView.swift */; }; - A204000200000000000000B0 /* AIGenerateWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A203000200000000000000B0 /* AIGenerateWindowView.swift */; }; - A206000300000000000000B0 /* SwiftOpenAI in Frameworks */ = {isa = PBXBuildFile; productRef = A206000200000000000000B0 /* SwiftOpenAI */; }; - A202000900000000000000B0 /* AIExamplePrompts.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000900000000000000B0 /* AIExamplePrompts.swift */; }; + A207000200000000000000B0 /* EditorSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A207000100000000000000B0 /* EditorSchemeHandler.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -229,6 +229,17 @@ 8445DB90E4A34EB19697BAEB /* ReloadWidgetAppIntent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReloadWidgetAppIntent.swift; sourceTree = ""; }; 8CAE66D10656192ADC2F9019 /* ScriptWidgetRuntimeStorage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScriptWidgetRuntimeStorage.swift; sourceTree = ""; }; 8E3B593386F820DCF99C631E /* ScriptWidgetRuntimeSystem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScriptWidgetRuntimeSystem.swift; sourceTree = ""; }; + A201000100000000000000B0 /* AISettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AISettings.swift; sourceTree = ""; }; + A201000200000000000000B0 /* AIReferenceSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIReferenceSnapshot.swift; sourceTree = ""; }; + A201000300000000000000B0 /* PromptBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptBuilder.swift; sourceTree = ""; }; + A201000400000000000000B0 /* AIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIClient.swift; sourceTree = ""; }; + A201000500000000000000B0 /* AgentRuntimeBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentRuntimeBridge.swift; sourceTree = ""; }; + A201000600000000000000B0 /* AgentLoop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentLoop.swift; sourceTree = ""; }; + A201000700000000000000B0 /* AIGenerateSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIGenerateSession.swift; sourceTree = ""; }; + A201000800000000000000B0 /* AIGenerateProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIGenerateProgressView.swift; sourceTree = ""; }; + A201000900000000000000B0 /* AIExamplePrompts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIExamplePrompts.swift; sourceTree = ""; }; + A203000100000000000000B0 /* SettingAIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingAIView.swift; sourceTree = ""; }; + A203000200000000000000B0 /* AIGenerateWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIGenerateWindowView.swift; sourceTree = ""; }; CD9F1F298DF5C4FAED8AAD43 /* ScriptWidgetRuntimeHealth.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScriptWidgetRuntimeHealth.swift; sourceTree = ""; }; D1995C5729536BB800D1BD94 /* MineGaugeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MineGaugeView.swift; sourceTree = ""; }; D1995C5829536BB800D1BD94 /* ScriptWidgetElementColor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScriptWidgetElementColor.swift; sourceTree = ""; }; @@ -333,17 +344,7 @@ F29118D92792B90700B860B0 /* WKWebViewJavascriptBridgeJS.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WKWebViewJavascriptBridgeJS.swift; sourceTree = ""; }; F29118E72792CCDE00B860B0 /* PreviewWidgetSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewWidgetSize.swift; sourceTree = ""; }; F2F90FC327970E3E003095A9 /* CreateGuideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateGuideView.swift; sourceTree = ""; }; - A201000100000000000000B0 /* AISettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AISettings.swift; sourceTree = ""; }; - A201000200000000000000B0 /* AIReferenceSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIReferenceSnapshot.swift; sourceTree = ""; }; - A201000300000000000000B0 /* PromptBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptBuilder.swift; sourceTree = ""; }; - A201000400000000000000B0 /* AIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIClient.swift; sourceTree = ""; }; - A201000500000000000000B0 /* AgentRuntimeBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentRuntimeBridge.swift; sourceTree = ""; }; - A201000600000000000000B0 /* AgentLoop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentLoop.swift; sourceTree = ""; }; - A201000700000000000000B0 /* AIGenerateSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIGenerateSession.swift; sourceTree = ""; }; - A201000800000000000000B0 /* AIGenerateProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIGenerateProgressView.swift; sourceTree = ""; }; - A203000100000000000000B0 /* SettingAIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingAIView.swift; sourceTree = ""; }; - A203000200000000000000B0 /* AIGenerateWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIGenerateWindowView.swift; sourceTree = ""; }; - A201000900000000000000B0 /* AIExamplePrompts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIExamplePrompts.swift; sourceTree = ""; }; + A207000100000000000000B0 /* EditorSchemeHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorSchemeHandler.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -354,7 +355,6 @@ A206000300000000000000B0 /* SwiftOpenAI in Frameworks */, F2B99C4327B8BE5E009584A3 /* SDWebImageSwiftUI in Frameworks */, F29118252791D38E00B860B0 /* SwiftyJSON in Frameworks */, - D1995D0929536CCE00D1BD94 /* Vapor in Frameworks */, F29119992794490100B860B0 /* ZipArchive in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -373,6 +373,39 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + A205000100000000000000B0 /* AI */ = { + isa = PBXGroup; + children = ( + A201000100000000000000B0 /* AISettings.swift */, + A201000200000000000000B0 /* AIReferenceSnapshot.swift */, + A201000300000000000000B0 /* PromptBuilder.swift */, + A201000400000000000000B0 /* AIClient.swift */, + A201000500000000000000B0 /* AgentRuntimeBridge.swift */, + A201000600000000000000B0 /* AgentLoop.swift */, + A201000700000000000000B0 /* AIGenerateSession.swift */, + A201000800000000000000B0 /* AIGenerateProgressView.swift */, + A201000900000000000000B0 /* AIExamplePrompts.swift */, + ); + name = AI; + path = ../Shared/ScriptWidgetRuntime/AI; + sourceTree = ""; + }; + A205000200000000000000B0 /* AIGenerate */ = { + isa = PBXGroup; + children = ( + A203000200000000000000B0 /* AIGenerateWindowView.swift */, + ); + path = AIGenerate; + sourceTree = ""; + }; + A205000300000000000000B0 /* Settings */ = { + isa = PBXGroup; + children = ( + A203000100000000000000B0 /* SettingAIView.swift */, + ); + path = Settings; + sourceTree = ""; + }; D1995C5029536B7300D1BD94 /* ScriptWidgetRuntime */ = { isa = PBXGroup; children = ( @@ -643,6 +676,7 @@ F29118D52792B8F600B860B0 /* Bridge */, F29118C72791DEC800B860B0 /* EditorWebView.swift */, F29118D32792B32900B860B0 /* EditorWebSevice.swift */, + A207000100000000000000B0 /* EditorSchemeHandler.swift */, ); path = Editor; sourceTree = ""; @@ -800,39 +834,6 @@ path = Create; sourceTree = ""; }; - A205000100000000000000B0 /* AI */ = { - isa = PBXGroup; - children = ( - A201000100000000000000B0 /* AISettings.swift */, - A201000200000000000000B0 /* AIReferenceSnapshot.swift */, - A201000300000000000000B0 /* PromptBuilder.swift */, - A201000400000000000000B0 /* AIClient.swift */, - A201000500000000000000B0 /* AgentRuntimeBridge.swift */, - A201000600000000000000B0 /* AgentLoop.swift */, - A201000700000000000000B0 /* AIGenerateSession.swift */, - A201000800000000000000B0 /* AIGenerateProgressView.swift */, - A201000900000000000000B0 /* AIExamplePrompts.swift */, - ); - name = AI; - path = ../Shared/ScriptWidgetRuntime/AI; - sourceTree = ""; - }; - A205000200000000000000B0 /* AIGenerate */ = { - isa = PBXGroup; - children = ( - A203000200000000000000B0 /* AIGenerateWindowView.swift */, - ); - path = AIGenerate; - sourceTree = ""; - }; - A205000300000000000000B0 /* Settings */ = { - isa = PBXGroup; - children = ( - A203000100000000000000B0 /* SettingAIView.swift */, - ); - path = Settings; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -856,7 +857,6 @@ F29118242791D38E00B860B0 /* SwiftyJSON */, F29119982794490100B860B0 /* ZipArchive */, F2B99C4227B8BE5E009584A3 /* SDWebImageSwiftUI */, - D1995D0829536CCE00D1BD94 /* Vapor */, ); productName = "ScriptWidgetMac (macOS)"; productReference = F29117E4279101DF00B860B0 /* ScriptWidget.app */; @@ -915,7 +915,6 @@ F29118232791D38E00B860B0 /* XCRemoteSwiftPackageReference "SwiftyJSON" */, F29119972794490100B860B0 /* XCRemoteSwiftPackageReference "ZipArchive" */, F2B99C4127B8BE5E009584A3 /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */, - D1995D0729536CCE00D1BD94 /* XCRemoteSwiftPackageReference "vapor" */, ); productRefGroup = F29117DF279101DF00B860B0 /* Products */; projectDirPath = ""; @@ -967,6 +966,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A207000200000000000000B0 /* EditorSchemeHandler.swift in Sources */, A202000900000000000000B0 /* AIExamplePrompts.swift in Sources */, A202000100000000000000B0 /* AISettings.swift in Sources */, A202000200000000000000B0 /* AIReferenceSnapshot.swift in Sources */, @@ -1444,12 +1444,12 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - D1995D0729536CCE00D1BD94 /* XCRemoteSwiftPackageReference "vapor" */ = { + A206000100000000000000B0 /* XCRemoteSwiftPackageReference "SwiftOpenAI" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/vapor/vapor.git"; + repositoryURL = "https://github.com/jamesrochabrun/SwiftOpenAI"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 4.0.0; + minimumVersion = 4.4.9; }; }; F29118232791D38E00B860B0 /* XCRemoteSwiftPackageReference "SwiftyJSON" */ = { @@ -1476,21 +1476,13 @@ minimumVersion = 2.0.0; }; }; - A206000100000000000000B0 /* XCRemoteSwiftPackageReference "SwiftOpenAI" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/jamesrochabrun/SwiftOpenAI"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 4.4.9; - }; - }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - D1995D0829536CCE00D1BD94 /* Vapor */ = { + A206000200000000000000B0 /* SwiftOpenAI */ = { isa = XCSwiftPackageProductDependency; - package = D1995D0729536CCE00D1BD94 /* XCRemoteSwiftPackageReference "vapor" */; - productName = Vapor; + package = A206000100000000000000B0 /* XCRemoteSwiftPackageReference "SwiftOpenAI" */; + productName = SwiftOpenAI; }; F29118242791D38E00B860B0 /* SwiftyJSON */ = { isa = XCSwiftPackageProductDependency; @@ -1517,11 +1509,6 @@ package = F29119972794490100B860B0 /* XCRemoteSwiftPackageReference "ZipArchive" */; productName = ZipArchive; }; - A206000200000000000000B0 /* SwiftOpenAI */ = { - isa = XCSwiftPackageProductDependency; - package = A206000100000000000000B0 /* XCRemoteSwiftPackageReference "SwiftOpenAI" */; - productName = SwiftOpenAI; - }; /* End XCSwiftPackageProductDependency section */ }; rootObject = F29117D2279101DE00B860B0 /* Project object */; diff --git a/macOS/ScriptWidgetMac.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/macOS/ScriptWidgetMac.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index af16735..decd854 100644 --- a/macOS/ScriptWidgetMac.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/macOS/ScriptWidgetMac.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "0e07b28be0ac117755c66dd76199d83f8117b8aedf072fbdd7130daf68cbffb1", + "originHash" : "22b18168779edad97d27dab9485f3dffdc3fd115c971a0b2661304cf4d096eae", "pins" : [ { "identity" : "async-http-client", @@ -10,42 +10,6 @@ "version" : "1.25.2" } }, - { - "identity" : "async-kit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/async-kit.git", - "state" : { - "revision" : "929808e51fea04f01de0e911ce826ef70c4db4ea", - "version" : "1.15.0" - } - }, - { - "identity" : "console-kit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/console-kit.git", - "state" : { - "revision" : "32ad16dfc7677b927b225595ed18f3debb32f577", - "version" : "4.16.0" - } - }, - { - "identity" : "multipart-kit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/multipart-kit.git", - "state" : { - "revision" : "0d55c35e788451ee27222783c7d363cb88092fab", - "version" : "4.5.2" - } - }, - { - "identity" : "routing-kit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/routing-kit.git", - "state" : { - "revision" : "1a10ccea61e4248effd23b6e814999ce7bdf0ee0", - "version" : "4.9.3" - } - }, { "identity" : "sdwebimage", "kind" : "remoteSourceControl", @@ -73,15 +37,6 @@ "version" : "1.0.0" } }, - { - "identity" : "swift-asn1", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-asn1.git", - "state" : { - "revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab", - "version" : "1.7.0" - } - }, { "identity" : "swift-atomics", "kind" : "remoteSourceControl", @@ -91,15 +46,6 @@ "version" : "1.3.0" } }, - { - "identity" : "swift-certificates", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-certificates.git", - "state" : { - "revision" : "5aa1c0d1bc204908df47c2075bdbb39573d05e8d", - "version" : "1.19.0" - } - }, { "identity" : "swift-collections", "kind" : "remoteSourceControl", @@ -109,24 +55,6 @@ "version" : "1.4.1" } }, - { - "identity" : "swift-crypto", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-crypto.git", - "state" : { - "revision" : "476538ccb827f2dd18efc5de754cc87d77127a47", - "version" : "4.4.0" - } - }, - { - "identity" : "swift-distributed-tracing", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-distributed-tracing.git", - "state" : { - "revision" : "dc4030184203ffafbb2ec614352487235d747fe0", - "version" : "1.4.1" - } - }, { "identity" : "swift-http-types", "kind" : "remoteSourceControl", @@ -145,15 +73,6 @@ "version" : "1.12.0" } }, - { - "identity" : "swift-metrics", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-metrics.git", - "state" : { - "revision" : "d51c8d13fa366eec807eedb4e37daa60ff5bfdd5", - "version" : "2.10.1" - } - }, { "identity" : "swift-nio", "kind" : "remoteSourceControl", @@ -208,15 +127,6 @@ "version" : "1.0.2" } }, - { - "identity" : "swift-service-context", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-service-context.git", - "state" : { - "revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29", - "version" : "1.3.0" - } - }, { "identity" : "swift-system", "kind" : "remoteSourceControl", @@ -227,30 +137,21 @@ } }, { - "identity" : "swiftyjson", - "kind" : "remoteSourceControl", - "location" : "https://github.com/SwiftyJSON/SwiftyJSON.git", - "state" : { - "revision" : "b3dcd7dbd0d488e1a7077cb33b00f2083e382f07", - "version" : "5.0.1" - } - }, - { - "identity" : "vapor", + "identity" : "swiftopenai", "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/vapor.git", + "location" : "https://github.com/jamesrochabrun/SwiftOpenAI", "state" : { - "revision" : "cfd8f434843ac7850e2d97f46c1aa5ddb906cf1c", - "version" : "4.121.4" + "revision" : "bc6b84767c3a4eb9d48942b86e2417a229ef096c", + "version" : "4.4.9" } }, { - "identity" : "websocket-kit", + "identity" : "swiftyjson", "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/websocket-kit.git", + "location" : "https://github.com/SwiftyJSON/SwiftyJSON.git", "state" : { - "revision" : "90bbbdab3ede12c803cfbe91646f291c092517a3", - "version" : "2.16.2" + "revision" : "b3dcd7dbd0d488e1a7077cb33b00f2083e382f07", + "version" : "5.0.1" } }, { diff --git a/macOS/ScriptWidgetMac/App/AppDelegate.swift b/macOS/ScriptWidgetMac/App/AppDelegate.swift index 05f5a37..f78d8a1 100644 --- a/macOS/ScriptWidgetMac/App/AppDelegate.swift +++ b/macOS/ScriptWidgetMac/App/AppDelegate.swift @@ -11,8 +11,8 @@ import AppKit class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { print("did finish launching") - - runEditorWebService() + // Monaco editor is now served via a WKURLSchemeHandler — no + // local HTTP server needed. } diff --git a/macOS/ScriptWidgetMac/Editor/Editor/EditorSchemeHandler.swift b/macOS/ScriptWidgetMac/Editor/Editor/EditorSchemeHandler.swift new file mode 100644 index 0000000..d345dc3 --- /dev/null +++ b/macOS/ScriptWidgetMac/Editor/Editor/EditorSchemeHandler.swift @@ -0,0 +1,101 @@ +// +// EditorSchemeHandler.swift +// ScriptWidgetMac +// +// Serves the bundled Monaco editor static files (Editor.bundle/static) +// directly to WKWebView via a custom URL scheme. Replaces the old +// Vapor-based localhost HTTP service. +// + +import Foundation +import WebKit +import UniformTypeIdentifiers + +let kEditorURLScheme = "scriptwidget-editor" + +final class EditorSchemeHandler: NSObject, WKURLSchemeHandler { + + private let staticRoot: URL? + + override init() { + if let bundleURL = Bundle.main.url(forResource: "Editor", withExtension: "bundle") { + self.staticRoot = bundleURL.appendingPathComponent("static", isDirectory: true) + } else { + self.staticRoot = nil + } + super.init() + } + + func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { + guard let url = urlSchemeTask.request.url else { + urlSchemeTask.didFailWithError(URLError(.badURL)) + return + } + guard let root = staticRoot else { + urlSchemeTask.didFailWithError(URLError(.resourceUnavailable)) + return + } + + var relative = url.path + if relative.hasPrefix("/") { relative.removeFirst() } + if relative.isEmpty { relative = "editor-dark.html" } + + let rootPath = root.standardizedFileURL.path + let candidate = root.appendingPathComponent(relative).standardizedFileURL + // Prevent path traversal — candidate must stay inside staticRoot. + guard candidate.path.hasPrefix(rootPath) else { + urlSchemeTask.didFailWithError(URLError(.noPermissionsToReadFile)) + return + } + + guard let data = try? Data(contentsOf: candidate) else { + urlSchemeTask.didFailWithError(URLError(.fileDoesNotExist)) + return + } + + let mime = Self.mimeType(forPathExtension: candidate.pathExtension) + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: [ + "Content-Type": mime, + "Content-Length": "\(data.count)", + "Access-Control-Allow-Origin": "*", + ] + ) ?? URLResponse(url: url, mimeType: mime, expectedContentLength: data.count, textEncodingName: nil) as URLResponse + + urlSchemeTask.didReceive(response) + urlSchemeTask.didReceive(data) + urlSchemeTask.didFinish() + } + + func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) { + // Nothing to cancel — responses are synchronous. + } + + private static func mimeType(forPathExtension ext: String) -> String { + let lower = ext.lowercased() + switch lower { + case "html", "htm": return "text/html; charset=utf-8" + case "js", "mjs": return "application/javascript; charset=utf-8" + case "css": return "text/css; charset=utf-8" + case "json": return "application/json; charset=utf-8" + case "map": return "application/json; charset=utf-8" + case "svg": return "image/svg+xml" + case "png": return "image/png" + case "jpg", "jpeg": return "image/jpeg" + case "gif": return "image/gif" + case "ttf": return "font/ttf" + case "otf": return "font/otf" + case "woff": return "font/woff" + case "woff2": return "font/woff2" + case "wasm": return "application/wasm" + default: + if let type = UTType(filenameExtension: lower), let mime = type.preferredMIMEType { + return mime + } + return "application/octet-stream" + } + } +} diff --git a/macOS/ScriptWidgetMac/Editor/Editor/EditorWebSevice.swift b/macOS/ScriptWidgetMac/Editor/Editor/EditorWebSevice.swift index d4479cf..e828b56 100644 --- a/macOS/ScriptWidgetMac/Editor/Editor/EditorWebSevice.swift +++ b/macOS/ScriptWidgetMac/Editor/Editor/EditorWebSevice.swift @@ -2,65 +2,14 @@ // EditorWebSevice.swift // ScriptWidgetMac // -// Created by everettjf on 2022/1/15. +// Resolves the URL that the Monaco editor WKWebView loads. The assets +// themselves are served from the app bundle via EditorSchemeHandler — +// no localhost HTTP server is required. // import Foundation -import Vapor - -let kEditorWebServiceHost = "127.0.0.1" -let kEditorWebServicePort = 23355 func editorWebServiceUrl() -> String { - var editorName = "" - if MacKitUtil.isSystemThemeDark() { - editorName = "editor-dark.html" - } else { - editorName = "editor-light.html" - } - return "http://\(kEditorWebServiceHost):\(kEditorWebServicePort)/\(editorName)" -} - -func editorWebServiceRoutes(_ app: Application) throws { - app.get("") { req in - return "ScriptWidget Editor Service" - } -} - -// configures your application -public func editorWebServiceAppConfigure(_ app: Application) throws { - // port - app.http.server.configuration.hostname = kEditorWebServiceHost - app.http.server.configuration.port = kEditorWebServicePort - - // serve static files - let resourceDir = Bundle.main.url(forResource: "Editor", withExtension: "bundle") - if let staticDir = resourceDir?.appendingPathComponent("static") { - print("static dir = \(staticDir.path)") - app.middleware.use(FileMiddleware(publicDirectory: staticDir.path)) - } - - // register routes - try editorWebServiceRoutes(app) -} - -func internalRunWebService() { - do { - var env = try Environment.detect() - try LoggingSystem.bootstrap(from: &env) - - let app = Application(env) - defer { app.shutdown() } - - try editorWebServiceAppConfigure(app) - try app.run() - } catch { - print("exception \(error)") - } -} - -func runEditorWebService() { - DispatchQueue.global().async { - internalRunWebService() - } + let editorName = MacKitUtil.isSystemThemeDark() ? "editor-dark.html" : "editor-light.html" + return "\(kEditorURLScheme)://editor/\(editorName)" } diff --git a/macOS/ScriptWidgetMac/Editor/Editor/EditorWebView.swift b/macOS/ScriptWidgetMac/Editor/Editor/EditorWebView.swift index 6ca40ce..36775af 100644 --- a/macOS/ScriptWidgetMac/Editor/Editor/EditorWebView.swift +++ b/macOS/ScriptWidgetMac/Editor/Editor/EditorWebView.swift @@ -27,8 +27,10 @@ class EditorInternalWebView: WKWebView { } init() { - super.init(frame: .zero, configuration: WKWebViewConfiguration()) - + let config = WKWebViewConfiguration() + config.setURLSchemeHandler(EditorSchemeHandler(), forURLScheme: kEditorURLScheme) + super.init(frame: .zero, configuration: config) + self.setValue(false, forKey: "drawsBackground") self.bridge = WKWebViewJavascriptBridge(webView: self) From e07d9f0a34281c006d6a61fb4b26f4aae8969619 Mon Sep 17 00:00:00 2001 From: everettjf Date: Wed, 22 Apr 2026 10:23:55 -0700 Subject: [PATCH 07/13] feat(ai,macos): surface AI generate inside the "+" create sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sparkles toolbar button was easy to miss. Bring the AI entry point into the same flow iOS uses — first thing users see when they open the create sheet. - macOS CreateGuideView now opens with a prominent "Generate with AI" card on top of the existing blank-widget name input. Tapping it dismisses the sheet and posts the open-request notification so SidebarView handles the not-configured alert path uniformly. - Promotes the sidebar toolbar button: now uses `wand.and.stars` with a Label ("Generate with AI" + icon) so it shows a text affordance instead of a bare symbol, placed before the "+" button. Adds ⌘⇧N hint in the help tooltip. - Relabels the existing blank-create button to "Create Blank" so the choice between blank vs AI is obvious. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Create/CreateGuideView.swift | 133 +++++++++++++----- .../ScriptWidgetMac/Sidebar/SidebarView.swift | 19 +-- 2 files changed, 104 insertions(+), 48 deletions(-) diff --git a/macOS/ScriptWidgetMac/Create/CreateGuideView.swift b/macOS/ScriptWidgetMac/Create/CreateGuideView.swift index 764de81..845173c 100644 --- a/macOS/ScriptWidgetMac/Create/CreateGuideView.swift +++ b/macOS/ScriptWidgetMac/Create/CreateGuideView.swift @@ -33,55 +33,110 @@ $render( struct CreateGuideView: View { @Environment(\.dismiss) var dismiss - + @State var enteredText: String = "A New Widget" - + var body: some View { - VStack(alignment:.leading) { - Text("Script name :") - .font(.headline) - - TextField("", text: $enteredText) - + VStack(alignment: .leading, spacing: 16) { + aiCard + + Divider() + + Text("Or start from a blank widget") + .font(.subheadline) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 6) { + Text("Script name") + .font(.headline) + TextField("", text: $enteredText) + .textFieldStyle(.roundedBorder) + } + HStack { Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + Spacer() - Button("Create") { - - let inputText = enteredText.trim() - if inputText.isEmpty { - MacKitUtil.alertWarn(title: "Invalid name", message: "Name can not be empty") - return - } - - if !inputText.checkIfValidFileName() { - MacKitUtil.alertWarn(title: "Invalid name", message: "Please make sure the widget name is an valid file name") - return; - } - - - // image copy path - // todo - - let scriptName = inputText - let result = sharedScriptManager.createScript(content: defaultCreateScriptContent, recommendPackageName: scriptName, imageCopyPath: nil) - - if !result.0 { - print("Create failed : \(result.1)") - MacKitUtil.alertWarn(title: "Create failed", message: "Please retry or relaunch app :)\nError : \(result.1)") - return - } - - NotificationCenter.default.post(name: SharedAppStore.scriptCreateNotification, object: nil) - - dismiss() + + Button("Create Blank") { + createBlank() } + .keyboardShortcut(.defaultAction) } } - .frame(width: 300, height: 100) - .padding() + .frame(width: 420) + .padding(16) + } + + private var aiCard: some View { + Button { + dismiss() + // Hand off to SidebarView's notification listener so we + // reuse the "configure AI first" alert path. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + NotificationCenter.default.post( + name: AIGenerateWindowView.openRequestNotification, + object: nil + ) + } + } label: { + HStack(spacing: 12) { + Image(systemName: "sparkles") + .font(.title) + .frame(width: 44, height: 44) + .background(Color.accentColor.opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + VStack(alignment: .leading, spacing: 2) { + Text("Generate with AI") + .font(.headline) + Text("Describe your widget and let the AI build it.") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(10) + .background(Color.accentColor.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private func createBlank() { + let inputText = enteredText.trim() + if inputText.isEmpty { + MacKitUtil.alertWarn(title: "Invalid name", message: "Name can not be empty") + return + } + + if !inputText.checkIfValidFileName() { + MacKitUtil.alertWarn(title: "Invalid name", message: "Please make sure the widget name is an valid file name") + return + } + + let scriptName = inputText + let result = sharedScriptManager.createScript( + content: defaultCreateScriptContent, + recommendPackageName: scriptName, + imageCopyPath: nil + ) + + if !result.0 { + print("Create failed : \(result.1)") + MacKitUtil.alertWarn(title: "Create failed", message: "Please retry or relaunch app :)\nError : \(result.1)") + return + } + + NotificationCenter.default.post(name: SharedAppStore.scriptCreateNotification, object: nil) + + dismiss() } } diff --git a/macOS/ScriptWidgetMac/Sidebar/SidebarView.swift b/macOS/ScriptWidgetMac/Sidebar/SidebarView.swift index 48564cc..9efe8f6 100644 --- a/macOS/ScriptWidgetMac/Sidebar/SidebarView.swift +++ b/macOS/ScriptWidgetMac/Sidebar/SidebarView.swift @@ -64,13 +64,6 @@ struct SidebarView: View { Image(systemName: "sidebar.left") } } - ToolbarItem(placement: .automatic) { - Button { - self.createShowingSheet.toggle() - } label: { - Image(systemName: "plus.circle") - } - } ToolbarItem(placement: .automatic) { Button { if AISettingsStore.shared.load().isConfigured { @@ -79,9 +72,17 @@ struct SidebarView: View { self.aiConfigAlertShown = true } } label: { - Image(systemName: "sparkles") + Label("Generate with AI", systemImage: "wand.and.stars") + } + .help("Generate with AI (⌘⇧N)") + } + ToolbarItem(placement: .automatic) { + Button { + self.createShowingSheet.toggle() + } label: { + Image(systemName: "plus.circle") } - .help("Generate with AI") + .help("New widget") } } } From b0f444551d38760baab5a624bd23799935fa7b97 Mon Sep 17 00:00:00 2001 From: everettjf Date: Fri, 24 Apr 2026 15:25:44 -0700 Subject: [PATCH 08/13] feat(ios,macos): template gallery, onboarding, and remix (P0 UX) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lower the ramp-up cost for new users by packaging the 45 bundled templates as a browsable gallery with categories, a first-run guide, and a one-tap way to fork existing widgets. - Add ScriptMetadata (category / tags / difficulty / icon / featured) loaded from per-template meta.json; 44 meta files added. - Replace flat "Create from template" list with a searchable grid + category chips on both iOS and macOS. macOS gains a full gallery (was AI + Blank only). - Replace empty-state placeholders with onboarding: hero, how-it-works, featured templates. - Add "Remix" (duplicate) — iOS swipe action, macOS context menu — backed by ScriptManager.duplicateScript. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Common/ScriptManager.swift | 20 ++ .../Common/ScriptMetadata.swift | 87 +++++ .../Common/ScriptModel.swift | 36 +- .../Common/ScriptWidgetPackage.swift | 17 +- .../template/Air Quality Now/meta.json | 7 + .../template/An Empty Widget/meta.json | 8 + .../template/Animation Aquarium/meta.json | 7 + .../template/Animation Clock/meta.json | 7 + .../template/Battery & Brightness/meta.json | 8 + .../template/Check Is Friday Today/meta.json | 7 + .../Check Is Working Day Today/meta.json | 7 + .../template/Condition Content/meta.json | 7 + .../template/Countdown/meta.json | 8 + .../template/Crypto Price Ticker/meta.json | 7 + .../template/Currency Pulse/meta.json | 7 + .../template/Daily Quote/meta.json | 7 + .../template/Datetime Current/meta.json | 8 + .../template/Datetime Timezone/meta.json | 7 + .../template/Device Battery Percent/meta.json | 7 + .../template/Focus Countdown/meta.json | 7 + .../template/Gauge Battery/meta.json | 7 + .../template/GitHub Repo Stats/meta.json | 7 + .../template/Habit Streak Tracker/meta.json | 7 + .../template/Health Steps Ring/meta.json | 7 + .../template/Image Basic Usage/meta.json | 7 + .../template/Image No Margin/meta.json | 7 + .../Script.bundle/template/Image/meta.json | 7 + .../template/Live Activity Demo/meta.json | 7 + .../Local Weather (Location)/meta.json | 7 + .../template/Location Snapshot/meta.json | 7 + .../template/Lunar Date/meta.json | 7 + .../template/Meeting Countdown/meta.json | 7 + .../template/New Episode Tracker/meta.json | 7 + .../Script.bundle/template/Nyan Cat/meta.json | 7 + .../template/Open Link/meta.json | 7 + .../Script.bundle/template/Shape/meta.json | 7 + .../template/Stock Snapshot/meta.json | 7 + .../template/Storage Ring/meta.json | 7 + .../template/Sunrise & Sunset/meta.json | 7 + .../template/System Insights/meta.json | 7 + .../template/System Status Panel/meta.json | 7 + .../Text Days To End Of Month/meta.json | 7 + .../Text Days to End Of Year/meta.json | 7 + .../template/Text Today Week/meta.json | 7 + .../template/Text Year Days Left/meta.json | 7 + .../template/Weather Display/meta.json | 7 + .../Weather Now (Open-Meteo)/meta.json | 8 + .../Script.bundle/template/Weather/meta.json | 7 + iOS/ScriptWidget.xcodeproj/project.pbxproj | 8 + .../App/Scripts/CreateGuideView.swift | 251 +++++++++++-- .../App/Scripts/ScriptWidgetHomeView.swift | 14 +- .../View/EmptyListBackgroundView.swift | 237 ++++++++++++- .../ScriptWidgetMac.xcodeproj/project.pbxproj | 6 + .../ScriptWidgetMac/App/EmptyHelloView.swift | 209 ++++++++++- .../Create/CreateGuideView.swift | 329 +++++++++++++++--- .../Sidebar/EmptyListBackgroundView.swift | 21 +- .../ScriptWidgetMac/Sidebar/SidebarView.swift | 8 + 57 files changed, 1439 insertions(+), 117 deletions(-) create mode 100644 Shared/ScriptWidgetRuntime/Common/ScriptMetadata.swift create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Air Quality Now/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/An Empty Widget/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Animation Aquarium/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Animation Clock/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Battery & Brightness/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Check Is Friday Today/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Check Is Working Day Today/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Condition Content/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Countdown/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Crypto Price Ticker/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Currency Pulse/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Daily Quote/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Datetime Current/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Datetime Timezone/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Device Battery Percent/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Focus Countdown/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Gauge Battery/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/GitHub Repo Stats/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Habit Streak Tracker/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Health Steps Ring/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Image Basic Usage/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Image No Margin/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Image/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Live Activity Demo/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Local Weather (Location)/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Location Snapshot/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Lunar Date/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Meeting Countdown/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/New Episode Tracker/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Nyan Cat/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Open Link/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Shape/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Stock Snapshot/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Storage Ring/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Sunrise & Sunset/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/System Insights/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/System Status Panel/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Text Days To End Of Month/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Text Days to End Of Year/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Text Today Week/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Text Year Days Left/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Weather Display/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Weather Now (Open-Meteo)/meta.json create mode 100644 Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Weather/meta.json diff --git a/Shared/ScriptWidgetRuntime/Common/ScriptManager.swift b/Shared/ScriptWidgetRuntime/Common/ScriptManager.swift index 8d6a4ae..2c5feb1 100644 --- a/Shared/ScriptWidgetRuntime/Common/ScriptManager.swift +++ b/Shared/ScriptWidgetRuntime/Common/ScriptManager.swift @@ -248,6 +248,26 @@ class ScriptManager { let packageName = self.getValidPackageName(recommendPackageName: recommendPackageName) return self.saveScript(packageName: packageName, content: content, imageCopyPath: imageCopyPath) } + + /// Duplicate an existing script into a new package. Returns the new package name on success. + func duplicateScript(sourcePackageName: String) -> (Bool, String) { + let srcPath = self.getPackagePathFromPackageName(packageName: sourcePackageName) + guard FileManager.default.fileExists(atPath: srcPath.path) else { + return (false, "Source not found") + } + let newName = self.getValidPackageName(recommendPackageName: "\(sourcePackageName) Remix") + let destPath = self.getPackagePathFromPackageName(packageName: newName) + do { + try FileManager.default.copyItem(at: srcPath, to: destPath) + } catch { + return (false, "Failed to duplicate: \(error.localizedDescription)") + } + if !self.isBuild { + let package = self.getScriptPackage(packageName: newName) + _ = buildScriptPackage(package: package) + } + return (true, newName) + } func isExist(packageName: String) -> Bool { diff --git a/Shared/ScriptWidgetRuntime/Common/ScriptMetadata.swift b/Shared/ScriptWidgetRuntime/Common/ScriptMetadata.swift new file mode 100644 index 0000000..7247de1 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Common/ScriptMetadata.swift @@ -0,0 +1,87 @@ +// +// ScriptMetadata.swift +// ScriptWidget +// +// Template metadata loaded from meta.json inside a script package. +// + +import Foundation +import SwiftUI + +struct ScriptMetadata: Codable, Equatable { + var description: String? + var category: String? + var tags: [String]? + var difficulty: String? + var icon: String? + var preview: String? + var featured: Bool? + + static let empty = ScriptMetadata() +} + +enum ScriptCategory: String, CaseIterable, Identifiable { + case starter + case time + case weather + case system + case health + case finance + case productivity + case fun + + var id: String { rawValue } + + var displayName: String { + switch self { + case .starter: return "Starter" + case .time: return "Time & Date" + case .weather: return "Weather" + case .system: return "System" + case .health: return "Health" + case .finance: return "Finance" + case .productivity: return "Productivity" + case .fun: return "Fun" + } + } + + var systemImage: String { + switch self { + case .starter: return "square.dashed" + case .time: return "clock.fill" + case .weather: return "cloud.sun.fill" + case .system: return "cpu.fill" + case .health: return "heart.fill" + case .finance: return "chart.line.uptrend.xyaxis" + case .productivity: return "checkmark.circle.fill" + case .fun: return "gamecontroller.fill" + } + } + + var accentColor: Color { + switch self { + case .starter: return .gray + case .time: return .blue + case .weather: return .cyan + case .system: return .indigo + case .health: return .pink + case .finance: return .green + case .productivity: return .orange + case .fun: return .purple + } + } +} + +enum ScriptDifficulty: String, CaseIterable { + case beginner + case medium + case advanced + + var displayName: String { + switch self { + case .beginner: return "Beginner" + case .medium: return "Intermediate" + case .advanced: return "Advanced" + } + } +} diff --git a/Shared/ScriptWidgetRuntime/Common/ScriptModel.swift b/Shared/ScriptWidgetRuntime/Common/ScriptModel.swift index 66508f2..ed35563 100644 --- a/Shared/ScriptWidgetRuntime/Common/ScriptModel.swift +++ b/Shared/ScriptWidgetRuntime/Common/ScriptModel.swift @@ -8,25 +8,53 @@ import SwiftUI struct ScriptModel : Identifiable { - + let id = UUID() let package: ScriptWidgetPackage - + let metadata: ScriptMetadata? + init(package: ScriptWidgetPackage) { self.package = package + self.metadata = package.readMetadata() } - + var name: String { get { self.package.name } } - + var exportFileName: String { get { "\(self.package.name).swt" } } + + var summary: String? { + metadata?.description + } + + var category: ScriptCategory? { + guard let raw = metadata?.category else { return nil } + return ScriptCategory(rawValue: raw) + } + + var tags: [String] { + metadata?.tags ?? [] + } + + var difficulty: ScriptDifficulty? { + guard let raw = metadata?.difficulty else { return nil } + return ScriptDifficulty(rawValue: raw) + } + + var iconSystemName: String { + metadata?.icon ?? category?.systemImage ?? "doc.text.fill" + } + + var isFeatured: Bool { + metadata?.featured ?? false + } } diff --git a/Shared/ScriptWidgetRuntime/Common/ScriptWidgetPackage.swift b/Shared/ScriptWidgetRuntime/Common/ScriptWidgetPackage.swift index 1251ff2..1d13056 100644 --- a/Shared/ScriptWidgetRuntime/Common/ScriptWidgetPackage.swift +++ b/Shared/ScriptWidgetRuntime/Common/ScriptWidgetPackage.swift @@ -30,15 +30,30 @@ struct ScriptWidgetPackage { let name: String let jsxPath: URL let imagePath: URL + let metaPath: URL let readonly: Bool - + init(path: URL, readonly: Bool) { self.readonly = readonly self.path = path self.jsxPath = self.path.appendingPathComponent("main.jsx") self.imagePath = self.path.appendingPathComponent("image") + self.metaPath = self.path.appendingPathComponent("meta.json") self.name = self.path.lastPathComponent } + + func readMetadata() -> ScriptMetadata? { + guard FileManager.default.fileExists(atPath: metaPath.path) else { return nil } + guard let data = try? Data(contentsOf: metaPath) else { return nil } + return try? JSONDecoder().decode(ScriptMetadata.self, from: data) + } + + func previewImageURL() -> URL? { + let meta = readMetadata() + let name = meta?.preview ?? "preview.png" + let url = self.path.appendingPathComponent(name) + return FileManager.default.fileExists(atPath: url.path) ? url : nil + } // readwrite init(path: URL) { diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Air Quality Now/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Air Quality Now/meta.json new file mode 100644 index 0000000..c4ba183 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Air Quality Now/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Current air quality index with a color-coded badge.", + "category": "weather", + "tags": ["aqi","air","network"], + "difficulty": "medium", + "icon": "aqi.medium" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/An Empty Widget/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/An Empty Widget/meta.json new file mode 100644 index 0000000..eb3a406 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/An Empty Widget/meta.json @@ -0,0 +1,8 @@ +{ + "description": "A blank widget to start from scratch — just a hello text.", + "category": "starter", + "tags": ["blank","hello"], + "difficulty": "beginner", + "icon": "square.dashed", + "featured": true +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Animation Aquarium/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Animation Aquarium/meta.json new file mode 100644 index 0000000..5407a72 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Animation Aquarium/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Animated fish swimming across your home screen.", + "category": "fun", + "tags": ["animation","fun"], + "difficulty": "advanced", + "icon": "fish.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Animation Clock/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Animation Clock/meta.json new file mode 100644 index 0000000..a24a58e --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Animation Clock/meta.json @@ -0,0 +1,7 @@ +{ + "description": "A smooth animated analog clock.", + "category": "fun", + "tags": ["animation","clock"], + "difficulty": "advanced", + "icon": "clock.badge.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Battery & Brightness/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Battery & Brightness/meta.json new file mode 100644 index 0000000..bc84d3b --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Battery & Brightness/meta.json @@ -0,0 +1,8 @@ +{ + "description": "Show battery level and screen brightness side by side.", + "category": "system", + "tags": ["battery","brightness","system"], + "difficulty": "beginner", + "icon": "battery.100", + "featured": true +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Check Is Friday Today/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Check Is Friday Today/meta.json new file mode 100644 index 0000000..b7d6af6 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Check Is Friday Today/meta.json @@ -0,0 +1,7 @@ +{ + "description": "A joyful 'Is it Friday?' sign.", + "category": "productivity", + "tags": ["fun","weekday"], + "difficulty": "beginner", + "icon": "face.smiling" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Check Is Working Day Today/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Check Is Working Day Today/meta.json new file mode 100644 index 0000000..5c694ae --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Check Is Working Day Today/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Show whether today is a working day.", + "category": "productivity", + "tags": ["workday","weekday"], + "difficulty": "beginner", + "icon": "briefcase.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Condition Content/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Condition Content/meta.json new file mode 100644 index 0000000..e86800f --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Condition Content/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Swap content based on a condition (if / else).", + "category": "starter", + "tags": ["basics","logic"], + "difficulty": "beginner", + "icon": "arrow.triangle.branch" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Countdown/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Countdown/meta.json new file mode 100644 index 0000000..1ed7c40 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Countdown/meta.json @@ -0,0 +1,8 @@ +{ + "description": "Count down to any future date — birthdays, deadlines, trips.", + "category": "productivity", + "tags": ["countdown","date"], + "difficulty": "beginner", + "icon": "hourglass", + "featured": true +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Crypto Price Ticker/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Crypto Price Ticker/meta.json new file mode 100644 index 0000000..59c276b --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Crypto Price Ticker/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Live crypto prices with 24h change percentage.", + "category": "finance", + "tags": ["crypto","price","network"], + "difficulty": "medium", + "icon": "bitcoinsign.circle.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Currency Pulse/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Currency Pulse/meta.json new file mode 100644 index 0000000..bdeba49 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Currency Pulse/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Exchange rates between fiat currencies.", + "category": "finance", + "tags": ["currency","forex","network"], + "difficulty": "medium", + "icon": "dollarsign.circle.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Daily Quote/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Daily Quote/meta.json new file mode 100644 index 0000000..9663c30 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Daily Quote/meta.json @@ -0,0 +1,7 @@ +{ + "description": "A rotating inspirational quote of the day.", + "category": "productivity", + "tags": ["quote","inspiration"], + "difficulty": "beginner", + "icon": "quote.bubble.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Datetime Current/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Datetime Current/meta.json new file mode 100644 index 0000000..acd49f2 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Datetime Current/meta.json @@ -0,0 +1,8 @@ +{ + "description": "Show the current date and time, updated automatically.", + "category": "time", + "tags": ["time","clock"], + "difficulty": "beginner", + "icon": "clock", + "featured": true +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Datetime Timezone/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Datetime Timezone/meta.json new file mode 100644 index 0000000..3eeba76 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Datetime Timezone/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Display the current time in a specific timezone.", + "category": "time", + "tags": ["time","timezone"], + "difficulty": "medium", + "icon": "globe" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Device Battery Percent/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Device Battery Percent/meta.json new file mode 100644 index 0000000..ae0adc8 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Device Battery Percent/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Just the battery percent — big and readable.", + "category": "system", + "tags": ["battery","system"], + "difficulty": "beginner", + "icon": "battery.75" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Focus Countdown/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Focus Countdown/meta.json new file mode 100644 index 0000000..4718789 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Focus Countdown/meta.json @@ -0,0 +1,7 @@ +{ + "description": "A Pomodoro-style focus timer widget.", + "category": "productivity", + "tags": ["pomodoro","focus"], + "difficulty": "medium", + "icon": "timer" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Gauge Battery/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Gauge Battery/meta.json new file mode 100644 index 0000000..01144c2 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Gauge Battery/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Circular gauge visualizing the current battery level.", + "category": "system", + "tags": ["battery","gauge"], + "difficulty": "beginner", + "icon": "gauge.medium" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/GitHub Repo Stats/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/GitHub Repo Stats/meta.json new file mode 100644 index 0000000..72ee254 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/GitHub Repo Stats/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Stars, forks and issues for any GitHub repository.", + "category": "productivity", + "tags": ["github","stats","api"], + "difficulty": "medium", + "icon": "chevron.left.forwardslash.chevron.right" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Habit Streak Tracker/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Habit Streak Tracker/meta.json new file mode 100644 index 0000000..8217854 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Habit Streak Tracker/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Track your daily habit streak with a visual chain.", + "category": "productivity", + "tags": ["habit","streak"], + "difficulty": "medium", + "icon": "flame.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Health Steps Ring/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Health Steps Ring/meta.json new file mode 100644 index 0000000..cc270c4 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Health Steps Ring/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Daily step count as a ring — pulls from HealthKit.", + "category": "health", + "tags": ["health","steps","healthkit"], + "difficulty": "medium", + "icon": "figure.walk" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Image Basic Usage/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Image Basic Usage/meta.json new file mode 100644 index 0000000..91cc564 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Image Basic Usage/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Learn how to load and display a packaged image asset.", + "category": "starter", + "tags": ["image","basics"], + "difficulty": "beginner", + "icon": "photo.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Image No Margin/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Image No Margin/meta.json new file mode 100644 index 0000000..2abbc62 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Image No Margin/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Fill the whole widget area with an image — no margins.", + "category": "starter", + "tags": ["image","layout"], + "difficulty": "beginner", + "icon": "rectangle.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Image/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Image/meta.json new file mode 100644 index 0000000..c6d230f --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Image/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Render an image with custom size and corner radius.", + "category": "starter", + "tags": ["image","basics"], + "difficulty": "beginner", + "icon": "photo" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Live Activity Demo/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Live Activity Demo/meta.json new file mode 100644 index 0000000..4c80ecf --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Live Activity Demo/meta.json @@ -0,0 +1,7 @@ +{ + "description": "A demo of Live Activity / Dynamic Island rendering.", + "category": "fun", + "tags": ["live-activity","dynamic-island"], + "difficulty": "advanced", + "icon": "sparkles" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Local Weather (Location)/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Local Weather (Location)/meta.json new file mode 100644 index 0000000..b27f571 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Local Weather (Location)/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Use your current location to fetch local weather.", + "category": "weather", + "tags": ["weather","location"], + "difficulty": "medium", + "icon": "location.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Location Snapshot/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Location Snapshot/meta.json new file mode 100644 index 0000000..8cc24cf --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Location Snapshot/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Display current GPS coordinates and a simple map preview.", + "category": "weather", + "tags": ["location","gps"], + "difficulty": "medium", + "icon": "map.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Lunar Date/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Lunar Date/meta.json new file mode 100644 index 0000000..5b77e8b --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Lunar Date/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Show the current Chinese lunar date alongside the Gregorian date.", + "category": "time", + "tags": ["lunar","chinese","date"], + "difficulty": "medium", + "icon": "moon.stars.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Meeting Countdown/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Meeting Countdown/meta.json new file mode 100644 index 0000000..2d07825 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Meeting Countdown/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Show time until the next meeting from your calendar.", + "category": "productivity", + "tags": ["calendar","meeting"], + "difficulty": "medium", + "icon": "person.2.wave.2.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/New Episode Tracker/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/New Episode Tracker/meta.json new file mode 100644 index 0000000..6e39adc --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/New Episode Tracker/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Countdown to your show's next episode.", + "category": "productivity", + "tags": ["tv","countdown"], + "difficulty": "medium", + "icon": "tv.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Nyan Cat/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Nyan Cat/meta.json new file mode 100644 index 0000000..3ee2152 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Nyan Cat/meta.json @@ -0,0 +1,7 @@ +{ + "description": "The classic Nyan Cat, now on your widget.", + "category": "fun", + "tags": ["animation","gif"], + "difficulty": "advanced", + "icon": "pawprint.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Open Link/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Open Link/meta.json new file mode 100644 index 0000000..5649d56 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Open Link/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Open a URL when the widget is tapped.", + "category": "starter", + "tags": ["link","tap"], + "difficulty": "beginner", + "icon": "link" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Shape/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Shape/meta.json new file mode 100644 index 0000000..0f20697 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Shape/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Draw rounded rectangles, circles and capsules with gradients.", + "category": "starter", + "tags": ["shape","drawing"], + "difficulty": "beginner", + "icon": "square.on.circle" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Stock Snapshot/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Stock Snapshot/meta.json new file mode 100644 index 0000000..da14fb3 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Stock Snapshot/meta.json @@ -0,0 +1,7 @@ +{ + "description": "A single stock symbol with price and change.", + "category": "finance", + "tags": ["stock","price","network"], + "difficulty": "medium", + "icon": "chart.line.uptrend.xyaxis" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Storage Ring/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Storage Ring/meta.json new file mode 100644 index 0000000..0c760cb --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Storage Ring/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Free / used storage as a progress ring.", + "category": "system", + "tags": ["storage","ring"], + "difficulty": "medium", + "icon": "internaldrive.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Sunrise & Sunset/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Sunrise & Sunset/meta.json new file mode 100644 index 0000000..46ad8a0 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Sunrise & Sunset/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Today's sunrise and sunset times for your location.", + "category": "weather", + "tags": ["sun","location"], + "difficulty": "medium", + "icon": "sun.horizon.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/System Insights/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/System Insights/meta.json new file mode 100644 index 0000000..4f3e109 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/System Insights/meta.json @@ -0,0 +1,7 @@ +{ + "description": "A dashboard of system stats: battery, storage, memory.", + "category": "system", + "tags": ["system","dashboard"], + "difficulty": "medium", + "icon": "cpu.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/System Status Panel/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/System Status Panel/meta.json new file mode 100644 index 0000000..bfc89f6 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/System Status Panel/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Multi-row system status panel widget.", + "category": "system", + "tags": ["system","panel"], + "difficulty": "medium", + "icon": "square.grid.2x2.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Text Days To End Of Month/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Text Days To End Of Month/meta.json new file mode 100644 index 0000000..579a13a --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Text Days To End Of Month/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Count remaining days until the end of the current month.", + "category": "time", + "tags": ["countdown","days"], + "difficulty": "beginner", + "icon": "calendar.badge.clock" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Text Days to End Of Year/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Text Days to End Of Year/meta.json new file mode 100644 index 0000000..fe07a92 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Text Days to End Of Year/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Count remaining days until the end of the year.", + "category": "time", + "tags": ["countdown","days"], + "difficulty": "beginner", + "icon": "calendar" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Text Today Week/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Text Today Week/meta.json new file mode 100644 index 0000000..11008d6 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Text Today Week/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Show today's weekday name in a friendly format.", + "category": "time", + "tags": ["weekday","date"], + "difficulty": "beginner", + "icon": "calendar.day.timeline.left" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Text Year Days Left/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Text Year Days Left/meta.json new file mode 100644 index 0000000..511242e --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Text Year Days Left/meta.json @@ -0,0 +1,7 @@ +{ + "description": "A big-number widget for days left in the year.", + "category": "time", + "tags": ["countdown","year"], + "difficulty": "beginner", + "icon": "number" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Weather Display/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Weather Display/meta.json new file mode 100644 index 0000000..70c3b54 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Weather Display/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Weather layout showcase with high / low temperature.", + "category": "weather", + "tags": ["weather","layout"], + "difficulty": "beginner", + "icon": "cloud.rain.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Weather Now (Open-Meteo)/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Weather Now (Open-Meteo)/meta.json new file mode 100644 index 0000000..4b3e2c7 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Weather Now (Open-Meteo)/meta.json @@ -0,0 +1,8 @@ +{ + "description": "Live weather via Open-Meteo — no API key required.", + "category": "weather", + "tags": ["weather","network","api"], + "difficulty": "medium", + "icon": "cloud.bolt.rain.fill", + "featured": true +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Weather/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Weather/meta.json new file mode 100644 index 0000000..bc56caf --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Weather/meta.json @@ -0,0 +1,7 @@ +{ + "description": "A classic weather widget with icon, temperature and condition.", + "category": "weather", + "tags": ["weather","temperature"], + "difficulty": "beginner", + "icon": "cloud.sun.fill" +} diff --git a/iOS/ScriptWidget.xcodeproj/project.pbxproj b/iOS/ScriptWidget.xcodeproj/project.pbxproj index 42df765..e9214c7 100644 --- a/iOS/ScriptWidget.xcodeproj/project.pbxproj +++ b/iOS/ScriptWidget.xcodeproj/project.pbxproj @@ -156,6 +156,9 @@ F29118F32793034D00B860B0 /* ScriptModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F29118EC2793034D00B860B0 /* ScriptModel.swift */; }; F29118F42793034D00B860B0 /* ScriptModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F29118EC2793034D00B860B0 /* ScriptModel.swift */; }; F29118F62793034D00B860B0 /* ScriptModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F29118EC2793034D00B860B0 /* ScriptModel.swift */; }; + D1A0000000E0000000000002 /* ScriptMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0000000E0000000000001 /* ScriptMetadata.swift */; }; + D1A0000000E0000000000003 /* ScriptMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0000000E0000000000001 /* ScriptMetadata.swift */; }; + D1A0000000E0000000000004 /* ScriptMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0000000E0000000000001 /* ScriptMetadata.swift */; }; F29118FB2793034D00B860B0 /* ScriptManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F29118EE2793034D00B860B0 /* ScriptManager.swift */; }; F29118FC2793034D00B860B0 /* ScriptManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F29118EE2793034D00B860B0 /* ScriptManager.swift */; }; F29118FE2793034D00B860B0 /* ScriptManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F29118EE2793034D00B860B0 /* ScriptManager.swift */; }; @@ -488,6 +491,7 @@ F28E474225EA92750080A810 /* SettingsICloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsICloudView.swift; sourceTree = ""; }; F28EC0A925EE98150047F1ED /* SettingsGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsGroupView.swift; sourceTree = ""; }; F29118EC2793034D00B860B0 /* ScriptModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScriptModel.swift; sourceTree = ""; }; + D1A0000000E0000000000001 /* ScriptMetadata.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScriptMetadata.swift; sourceTree = ""; }; F29118EE2793034D00B860B0 /* ScriptManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScriptManager.swift; sourceTree = ""; }; F29119012793035E00B860B0 /* MineGaugeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MineGaugeView.swift; sourceTree = ""; }; F29119022793035E00B860B0 /* ScriptWidgetElementColor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScriptWidgetElementColor.swift; sourceTree = ""; }; @@ -996,6 +1000,7 @@ children = ( F29119922793223300B860B0 /* ScriptWidgetPackage.swift */, F29118EC2793034D00B860B0 /* ScriptModel.swift */, + D1A0000000E0000000000001 /* ScriptMetadata.swift */, F29118EE2793034D00B860B0 /* ScriptManager.swift */, ); name = Common; @@ -1601,6 +1606,7 @@ F25ED68F27FDF9DD000089D0 /* ScriptWidgetRunningState.swift in Sources */, F23A40BB262492FF0035CBA7 /* DeepLinkManager.swift in Sources */, F29118F32793034D00B860B0 /* ScriptModel.swift in Sources */, + D1A0000000E0000000000002 /* ScriptMetadata.swift in Sources */, D1403DEB2552FEB40076F87C /* TOCropViewController.m in Sources */, F2C5C5E427AF5B5400797C5B /* AppIconsView.swift in Sources */, F277CBA427CB5A2C003AB97D /* ScriptWidgetAttributeOpacityModifier.swift in Sources */, @@ -1789,6 +1795,7 @@ F08113B42AD0831C00605DE1 /* ScriptWidgetTimelineProvider.swift in Sources */, F277CBA527CB5A2C003AB97D /* ScriptWidgetAttributeOpacityModifier.swift in Sources */, F29118F42793034D00B860B0 /* ScriptModel.swift in Sources */, + D1A0000000E0000000000003 /* ScriptMetadata.swift in Sources */, F29119812793035E00B860B0 /* ScriptWidgetRuntimeConsole.swift in Sources */, F29119732793035E00B860B0 /* ScriptWidgetElementTagText.swift in Sources */, F29119772793035E00B860B0 /* ScriptWidgetElementTagGauge.swift in Sources */, @@ -1804,6 +1811,7 @@ buildActionMask = 2147483647; files = ( F29118F62793034D00B860B0 /* ScriptModel.swift in Sources */, + D1A0000000E0000000000004 /* ScriptMetadata.swift in Sources */, F29118FE2793034D00B860B0 /* ScriptManager.swift in Sources */, F2C6AC7426004B0F009CECE9 /* ShareViewController.swift in Sources */, F29119962793223300B860B0 /* ScriptWidgetPackage.swift in Sources */, diff --git a/iOS/ScriptWidget/App/Scripts/CreateGuideView.swift b/iOS/ScriptWidget/App/Scripts/CreateGuideView.swift index f75aa18..595a5bb 100644 --- a/iOS/ScriptWidget/App/Scripts/CreateGuideView.swift +++ b/iOS/ScriptWidget/App/Scripts/CreateGuideView.swift @@ -12,21 +12,12 @@ class CreateGuideDataObject: ObservableObject { @Published var models = [ScriptModel]() init() { - - DispatchQueue.global().async { [self] in - var items = ScriptManager.listBundleScripts(bundle: "Script", relativePath: "template") - if let index = items.firstIndex(where: { (model) -> Bool in - return model.name == "Empty Script" - }) { - items.move(fromOffsets: [index], toOffset: 0) - } - + let items = ScriptManager.listBundleScripts(bundle: "Script", relativePath: "template") DispatchQueue.main.async { self.models = items } } - } } @@ -38,34 +29,43 @@ struct CreateGuideView: View { @State private var showingAIGenerate = false @State private var showingAIConfigAlert = false + @State private var selectedCategory: ScriptCategory? = nil + @State private var searchText: String = "" var body: some View { NavigationView { - List { - aiRow - - ForEach(dataObject.models) { item in - NavigationLink(destination: ScriptCodeEditorView(mode: .creator,scriptModel:item, actionCreate: { - // create - guard let content = item.package.readMainFile().0 else { return } - - // image copy path - let imageCopyPath = item.package.imagePath - - _ = sharedScriptManager.createScript(content: content, recommendPackageName: item.name, imageCopyPath: imageCopyPath) + ScrollView { + VStack(alignment: .leading, spacing: 16) { + aiRow + .padding(.horizontal) - NotificationCenter.default.post(name: ScriptWidgetHomeViewDataObject.scriptCreateNotification, object: nil) + if !searchText.isEmpty { + // Hide category chips while searching + } else { + categoryChips + } - // dismiss - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: { - self.presentationMode.wrappedValue.dismiss() - }) - })) { - WidgetRowView(model: item) + if filteredModels.isEmpty { + emptyState + .frame(maxWidth: .infinity) + .padding(.top, 40) + } else { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 160), spacing: 12)], spacing: 12) { + ForEach(filteredModels) { item in + NavigationLink(destination: editorDestination(for: item)) { + TemplateCardView(model: item) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal) + .padding(.bottom, 20) } } + .padding(.top, 8) } - .navigationBarTitle(Text("Create from template"), displayMode: .large) + .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "Search templates") + .navigationBarTitle(Text("New Widget"), displayMode: .large) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button(action: { @@ -90,6 +90,22 @@ struct CreateGuideView: View { } } + // MARK: - Derived state + + private var filteredModels: [ScriptModel] { + let q = searchText.trimmingCharacters(in: .whitespaces).lowercased() + return dataObject.models.filter { model in + if !q.isEmpty { + let haystack = ([model.name, model.summary ?? ""] + model.tags).joined(separator: " ").lowercased() + return haystack.contains(q) + } + guard let selected = selectedCategory else { return true } + return model.category == selected + } + } + + // MARK: - Subviews + private var aiRow: some View { Button { if AISettingsStore.shared.load().isConfigured { @@ -101,9 +117,12 @@ struct CreateGuideView: View { HStack(spacing: 12) { Image(systemName: "sparkles") .font(.title2) - .frame(width: 40, height: 40) - .background(Color.accentColor.opacity(0.15)) - .clipShape(RoundedRectangle(cornerRadius: 8)) + .foregroundColor(.white) + .frame(width: 44, height: 44) + .background( + LinearGradient(colors: [.purple, .blue], startPoint: .topLeading, endPoint: .bottomTrailing) + ) + .clipShape(RoundedRectangle(cornerRadius: 10)) VStack(alignment: .leading, spacing: 2) { Text("Generate with AI") .font(.headline) @@ -117,10 +136,176 @@ struct CreateGuideView: View { .font(.caption) .foregroundColor(.secondary) } + .padding(12) + .background(Color.accentColor.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 12)) .contentShape(Rectangle()) } .buttonStyle(.plain) } + + private var categoryChips: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + CategoryChip(title: "All", + systemImage: "square.grid.2x2", + color: .gray, + selected: selectedCategory == nil) { + selectedCategory = nil + } + ForEach(ScriptCategory.allCases) { cat in + CategoryChip(title: cat.displayName, + systemImage: cat.systemImage, + color: cat.accentColor, + selected: selectedCategory == cat) { + selectedCategory = (selectedCategory == cat) ? nil : cat + } + } + } + .padding(.horizontal) + } + } + + private var emptyState: some View { + VStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.system(size: 36)) + .foregroundColor(.secondary) + Text("No templates match").font(.headline) + Text("Try another keyword or category.") + .font(.subheadline) + .foregroundColor(.secondary) + } + } + + @ViewBuilder + private func editorDestination(for item: ScriptModel) -> some View { + ScriptCodeEditorView(mode: .creator, scriptModel: item, actionCreate: { + guard let content = item.package.readMainFile().0 else { return } + let imageCopyPath = item.package.imagePath + _ = sharedScriptManager.createScript(content: content, recommendPackageName: item.name, imageCopyPath: imageCopyPath) + NotificationCenter.default.post(name: ScriptWidgetHomeViewDataObject.scriptCreateNotification, object: nil) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: { + self.presentationMode.wrappedValue.dismiss() + }) + }) + } +} + +// MARK: - Category chip + +struct CategoryChip: View { + let title: String + let systemImage: String + let color: Color + let selected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 4) { + Image(systemName: systemImage) + .font(.caption) + Text(title) + .font(.subheadline) + } + .padding(.horizontal, 12) + .padding(.vertical, 7) + .foregroundColor(selected ? .white : color) + .background(selected ? color : color.opacity(0.12)) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } +} + +// MARK: - Template card + +struct TemplateCardView: View { + let model: ScriptModel + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + // Preview area + ZStack { + RoundedRectangle(cornerRadius: 10) + .fill(cardBackground) + + if let url = model.package.previewImageURL(), + let uiImage = UIImage(contentsOfFile: url.path) { + Image(uiImage: uiImage) + .resizable() + .scaledToFill() + .clipShape(RoundedRectangle(cornerRadius: 10)) + } else { + Image(systemName: model.iconSystemName) + .font(.system(size: 34, weight: .regular)) + .foregroundColor(accentColor) + } + } + .frame(height: 96) + .clipShape(RoundedRectangle(cornerRadius: 10)) + + // Text + VStack(alignment: .leading, spacing: 4) { + Text(model.name) + .font(.subheadline.weight(.semibold)) + .foregroundColor(.primary) + .lineLimit(1) + if let summary = model.summary, !summary.isEmpty { + Text(summary) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + } + if let difficulty = model.difficulty { + DifficultyBadge(difficulty: difficulty) + .padding(.top, 2) + } + } + .padding(.horizontal, 4) + .padding(.bottom, 4) + } + .padding(6) + .background(Color(.systemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.secondary.opacity(0.15), lineWidth: 0.5) + ) + } + + private var accentColor: Color { + model.category?.accentColor ?? .accentColor + } + + private var cardBackground: LinearGradient { + LinearGradient(colors: [accentColor.opacity(0.18), accentColor.opacity(0.06)], + startPoint: .topLeading, endPoint: .bottomTrailing) + } +} + +struct DifficultyBadge: View { + let difficulty: ScriptDifficulty + + var body: some View { + Text(difficulty.displayName) + .font(.system(size: 10, weight: .semibold)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .foregroundColor(color) + .background(color.opacity(0.15)) + .clipShape(Capsule()) + } + + private var color: Color { + switch difficulty { + case .beginner: return .green + case .medium: return .orange + case .advanced: return .red + } + } } struct CreateGuideView_Previews: PreviewProvider { diff --git a/iOS/ScriptWidget/App/Scripts/ScriptWidgetHomeView.swift b/iOS/ScriptWidget/App/Scripts/ScriptWidgetHomeView.swift index 9cd12e4..4f9a1bb 100644 --- a/iOS/ScriptWidget/App/Scripts/ScriptWidgetHomeView.swift +++ b/iOS/ScriptWidget/App/Scripts/ScriptWidgetHomeView.swift @@ -152,14 +152,24 @@ struct ScriptWidgetHomeView: View { Label("Share", systemImage: "square.and.arrow.up") } .tint(.blue) - + Button { self.selectedEditItem = item } label: { Label("Edit", systemImage: "pencil.circle") } .tint(.systemIndigo) - + + Button { + let result = sharedScriptManager.duplicateScript(sourcePackageName: item.name) + if result.0 { + NotificationCenter.default.post(name: ScriptWidgetHomeViewDataObject.scriptCreateNotification, object: nil) + } + } label: { + Label("Remix", systemImage: "square.on.square") + } + .tint(.purple) + Button(role: .destructive) { self.selectedDeleteItem = item self.isShowingDeleteAlert.toggle() diff --git a/iOS/ScriptWidget/View/EmptyListBackgroundView.swift b/iOS/ScriptWidget/View/EmptyListBackgroundView.swift index 6da409f..9e45d07 100644 --- a/iOS/ScriptWidget/View/EmptyListBackgroundView.swift +++ b/iOS/ScriptWidget/View/EmptyListBackgroundView.swift @@ -2,29 +2,238 @@ // EmptyListBackgroundView.swift // ScriptWidget // -// Created by everettjf on 2021/3/1. +// Onboarding shown on first launch when no widgets exist yet. // import SwiftUI +class OnboardingFeaturedDataObject: ObservableObject { + @Published var featured: [ScriptModel] = [] + + init() { + DispatchQueue.global().async { [weak self] in + let all = ScriptManager.listBundleScripts(bundle: "Script", relativePath: "template") + let picked = all.filter { $0.isFeatured } + DispatchQueue.main.async { + self?.featured = picked + } + } + } +} + struct EmptyListBackgroundView: View { + @StateObject private var data = OnboardingFeaturedDataObject() + @State private var showCreate = false + @State private var selectedFeatured: ScriptModel? + var body: some View { - VStack (spacing: 20) { - Image(systemName: "lessthan") - .font(.system(size: 70, weight: .bold, design: .monospaced)) - - Text("ScriptWidget") + ScrollView { + VStack(alignment: .leading, spacing: 24) { + heroSection + .padding(.top, 20) + + howItWorks + + if !data.featured.isEmpty { + featuredSection + } + + Divider().padding(.vertical, 4) + + browseAll + } + .padding(.horizontal, 20) + .padding(.bottom, 40) + } + .fullScreenCover(isPresented: $showCreate) { + CreateGuideView() + } + .sheet(item: $selectedFeatured) { item in + NavigationView { + ScriptCodeEditorView(mode: .creator, scriptModel: item, actionCreate: { + guard let content = item.package.readMainFile().0 else { return } + let imageCopyPath = item.package.imagePath + _ = sharedScriptManager.createScript( + content: content, + recommendPackageName: item.name, + imageCopyPath: imageCopyPath + ) + NotificationCenter.default.post( + name: ScriptWidgetHomeViewDataObject.scriptCreateNotification, + object: nil + ) + selectedFeatured = nil + }) + } + } + } + + // MARK: - Sections + + private var heroSection: some View { + VStack(alignment: .leading, spacing: 10) { + Image(systemName: "sparkles.square.filled.on.square") + .font(.system(size: 48)) + .foregroundStyle( + LinearGradient(colors: [.purple, .blue], + startPoint: .topLeading, + endPoint: .bottomTrailing) + ) + Text("Build widgets with JavaScript") + .font(.title2).bold() + Text("Pick a template, preview it instantly, then add it to your Home Screen. No Xcode required.") + .font(.subheadline) + .foregroundColor(.secondary) + } + } + + private var howItWorks: some View { + VStack(alignment: .leading, spacing: 12) { + Text("How it works") .font(.headline) - .fontWeight(.bold) - - Text("Create your first widget by tapping the plus button upper-right of screen :)") + HStack(alignment: .top, spacing: 12) { + OnboardingStep(number: 1, + icon: "square.grid.2x2.fill", + title: "Pick", + detail: "Choose a ready template.") + OnboardingStep(number: 2, + icon: "play.rectangle.fill", + title: "Preview", + detail: "Live preview in the editor.") + OnboardingStep(number: 3, + icon: "rectangle.stack.badge.plus", + title: "Install", + detail: "Add to Home Screen.") + } + } + } + + private var featuredSection: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Start with one of these") .font(.headline) - .padding(.bottom, 100) - .padding(.leading, 10) - .padding(.trailing, 10) + VStack(spacing: 10) { + ForEach(data.featured.prefix(4)) { item in + Button { + selectedFeatured = item + } label: { + FeaturedRow(model: item) + } + .buttonStyle(.plain) + } + } + } + } + + private var browseAll: some View { + VStack(alignment: .leading, spacing: 10) { + Button { + showCreate = true + } label: { + HStack { + Image(systemName: "square.grid.2x2") + Text("Browse all templates") + .fontWeight(.semibold) + Spacer() + Image(systemName: "chevron.right").font(.caption) + } + .padding(14) + .background(Color.accentColor.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .buttonStyle(.plain) + + Text("Or tap ") + .font(.caption) + .foregroundColor(.secondary) + + Text(Image(systemName: "plus.square")) + .font(.caption) + .foregroundColor(.secondary) + + Text(" in the top-right to create from scratch or with AI.") + .font(.caption) + .foregroundColor(.secondary) } - .foregroundColor(Color.gray.opacity(0.75)) - .padding() + } +} + +// MARK: - Step card + +struct OnboardingStep: View { + let number: Int + let icon: String + let title: String + let detail: String + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + ZStack { + Circle() + .fill(Color.accentColor.opacity(0.15)) + .frame(width: 36, height: 36) + Image(systemName: icon) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.accentColor) + } + Text("\(number). \(title)") + .font(.subheadline).bold() + Text(detail) + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color.secondary.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } +} + +// MARK: - Featured row + +struct FeaturedRow: View { + let model: ScriptModel + + var body: some View { + HStack(spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 10) + .fill(LinearGradient( + colors: [accent.opacity(0.25), accent.opacity(0.08)], + startPoint: .topLeading, endPoint: .bottomTrailing + )) + .frame(width: 50, height: 50) + Image(systemName: model.iconSystemName) + .font(.system(size: 22)) + .foregroundColor(accent) + } + VStack(alignment: .leading, spacing: 3) { + Text(model.name) + .font(.subheadline).bold() + .foregroundColor(.primary) + if let summary = model.summary { + Text(summary) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(2) + .multilineTextAlignment(.leading) + } + } + Spacer() + Image(systemName: "chevron.right") + .font(.caption) + .foregroundColor(.secondary) + } + .padding(12) + .background(Color(.systemBackground)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.secondary.opacity(0.15), lineWidth: 0.5) + ) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + private var accent: Color { + model.category?.accentColor ?? .accentColor } } diff --git a/macOS/ScriptWidgetMac.xcodeproj/project.pbxproj b/macOS/ScriptWidgetMac.xcodeproj/project.pbxproj index ea77d4e..1e61895 100644 --- a/macOS/ScriptWidgetMac.xcodeproj/project.pbxproj +++ b/macOS/ScriptWidgetMac.xcodeproj/project.pbxproj @@ -130,6 +130,8 @@ D1995CF929536BC700D1BD94 /* ScriptWidgetPackage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1995CF529536BC700D1BD94 /* ScriptWidgetPackage.swift */; }; D1995CFB29536BC700D1BD94 /* ScriptModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1995CF629536BC700D1BD94 /* ScriptModel.swift */; }; D1995CFC29536BC700D1BD94 /* ScriptModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1995CF629536BC700D1BD94 /* ScriptModel.swift */; }; + D1A0000000E0000000001001 /* ScriptMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0000000E0000000001000 /* ScriptMetadata.swift */; }; + D1A0000000E0000000001002 /* ScriptMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0000000E0000000001000 /* ScriptMetadata.swift */; }; D1995CFE29536BC700D1BD94 /* ScriptManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1995CF729536BC700D1BD94 /* ScriptManager.swift */; }; D1995CFF29536BC700D1BD94 /* ScriptManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1995CF729536BC700D1BD94 /* ScriptManager.swift */; }; D1995D0629536BF000D1BD94 /* Script.bundle in Resources */ = {isa = PBXBuildFile; fileRef = D1995D0529536BF000D1BD94 /* Script.bundle */; }; @@ -289,6 +291,7 @@ D1995C9229536BB800D1BD94 /* Apple ][.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Apple ][.ttf"; sourceTree = ""; }; D1995CF529536BC700D1BD94 /* ScriptWidgetPackage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScriptWidgetPackage.swift; sourceTree = ""; }; D1995CF629536BC700D1BD94 /* ScriptModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScriptModel.swift; sourceTree = ""; }; + D1A0000000E0000000001000 /* ScriptMetadata.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScriptMetadata.swift; sourceTree = ""; }; D1995CF729536BC700D1BD94 /* ScriptManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScriptManager.swift; sourceTree = ""; }; D1995D0529536BF000D1BD94 /* Script.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Script.bundle; sourceTree = ""; }; F05227BD2AD1D6DE0014BE09 /* ScriptWidgetElementTagToggle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScriptWidgetElementTagToggle.swift; sourceTree = ""; }; @@ -603,6 +606,7 @@ children = ( D1995CF529536BC700D1BD94 /* ScriptWidgetPackage.swift */, D1995CF629536BC700D1BD94 /* ScriptModel.swift */, + D1A0000000E0000000001000 /* ScriptMetadata.swift */, D1995CF729536BC700D1BD94 /* ScriptManager.swift */, ); name = Common; @@ -1036,6 +1040,7 @@ D1995CBC29536BB800D1BD94 /* ScriptWidgetAttributeShadowModifier.swift in Sources */, D1995CA629536BB800D1BD94 /* ScriptWidgetAppState.swift in Sources */, D1995CFB29536BC700D1BD94 /* ScriptModel.swift in Sources */, + D1A0000000E0000000001001 /* ScriptMetadata.swift in Sources */, F29118E82792CCDE00B860B0 /* PreviewWidgetSize.swift in Sources */, F29117E8279101DF00B860B0 /* ScriptWidgetMacApp.swift in Sources */, D1995CDA29536BB800D1BD94 /* ScriptWidgetElementTagText.swift in Sources */, @@ -1078,6 +1083,7 @@ D1995CE129536BB800D1BD94 /* ScriptWidgetElementTagGauge.swift in Sources */, D1995CAB29536BB800D1BD94 /* ScriptWidgetRuntimeElement.swift in Sources */, D1995CFC29536BC700D1BD94 /* ScriptModel.swift in Sources */, + D1A0000000E0000000001002 /* ScriptMetadata.swift in Sources */, D1995CB129536BB800D1BD94 /* ScriptWidgetAttributeClippedModifier.swift in Sources */, D1995C9D29536BB800D1BD94 /* MineRingView.swift in Sources */, D1995CF929536BC700D1BD94 /* ScriptWidgetPackage.swift in Sources */, diff --git a/macOS/ScriptWidgetMac/App/EmptyHelloView.swift b/macOS/ScriptWidgetMac/App/EmptyHelloView.swift index 0343775..51b575f 100644 --- a/macOS/ScriptWidgetMac/App/EmptyHelloView.swift +++ b/macOS/ScriptWidgetMac/App/EmptyHelloView.swift @@ -2,23 +2,214 @@ // EmptyHelloView.swift // ScriptWidgetMac // -// Created by everettjf on 2022/2/26. +// Onboarding / landing pane shown when no widget is selected. // import SwiftUI +import AppKit + +class MacOnboardingFeaturedDataObject: ObservableObject { + @Published var featured: [ScriptModel] = [] + + init() { + DispatchQueue.global().async { [weak self] in + let all = ScriptManager.listBundleScripts(bundle: "Script", relativePath: "template") + let picked = all.filter { $0.isFeatured } + DispatchQueue.main.async { + self?.featured = picked + } + } + } +} struct EmptyHelloView: View { + @StateObject private var data = MacOnboardingFeaturedDataObject() + @State private var showCreate = false + var body: some View { - VStack (spacing: 20) { - Image(systemName: "lessthan") - .font(.system(size: 70, weight: .bold, design: .monospaced)) - - Text("Hello ScriptWidget :)") + ScrollView { + VStack(alignment: .leading, spacing: 28) { + hero + howItWorks + if !data.featured.isEmpty { + featured + } + createRow + } + .padding(32) + .frame(maxWidth: 720, alignment: .leading) + .frame(maxWidth: .infinity) + } + .frame(minWidth: 480, minHeight: 400) + .sheet(isPresented: $showCreate) { + CreateGuideView() + } + } + + private var hero: some View { + VStack(alignment: .leading, spacing: 12) { + Image(systemName: "sparkles.square.filled.on.square") + .font(.system(size: 52)) + .foregroundStyle( + LinearGradient(colors: [.purple, .blue], + startPoint: .topLeading, + endPoint: .bottomTrailing) + ) + Text("Build widgets with JavaScript") + .font(.title).bold() + Text("Pick a template, preview it instantly on your desktop, then add it anywhere widgets go.") + .font(.body) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var howItWorks: some View { + VStack(alignment: .leading, spacing: 12) { + Text("How it works") + .font(.headline) + HStack(alignment: .top, spacing: 14) { + MacOnboardingStep(number: 1, icon: "square.grid.2x2.fill", title: "Pick", detail: "Choose a ready template.") + MacOnboardingStep(number: 2, icon: "play.rectangle.fill", title: "Preview", detail: "Live preview in the editor.") + MacOnboardingStep(number: 3, icon: "rectangle.stack.badge.plus", title: "Install", detail: "Add to Notification Center or Mac home.") + } + } + } + + private var featured: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Start with one of these") .font(.headline) - .fontWeight(.bold) + LazyVGrid(columns: [GridItem(.adaptive(minimum: 260), spacing: 10)], spacing: 10) { + ForEach(data.featured) { item in + Button { + createFromTemplate(item) + } label: { + MacFeaturedRow(model: item) + } + .buttonStyle(.plain) + } + } + } + } + + private var createRow: some View { + HStack(spacing: 10) { + Button { + showCreate = true + } label: { + Label("Browse all templates", systemImage: "square.grid.2x2") + } + .controlSize(.large) + .buttonStyle(.borderedProminent) + + Button { + NotificationCenter.default.post( + name: AIGenerateWindowView.openRequestNotification, + object: nil + ) + } label: { + Label("Generate with AI", systemImage: "sparkles") + } + .controlSize(.large) + } + } + + private func createFromTemplate(_ item: ScriptModel) { + guard let content = item.package.readMainFile().0 else { return } + let result = sharedScriptManager.createScript( + content: content, + recommendPackageName: item.name, + imageCopyPath: item.package.imagePath + ) + if result.0 { + NotificationCenter.default.post(name: SharedAppStore.scriptCreateNotification, object: nil) + } else { + MacKitUtil.alertWarn(title: "Create failed", message: result.1) + } + } +} + +struct MacOnboardingStep: View { + let number: Int + let icon: String + let title: String + let detail: String + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + ZStack { + Circle() + .fill(Color.accentColor.opacity(0.15)) + .frame(width: 38, height: 38) + Image(systemName: icon) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.accentColor) + } + Text("\(number). \(title)") + .font(.subheadline).bold() + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } - .foregroundColor(Color.gray.opacity(0.75)) - .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(Color.secondary.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } +} + +struct MacFeaturedRow: View { + let model: ScriptModel + + @State private var isHovered = false + + var body: some View { + HStack(spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 10) + .fill(LinearGradient( + colors: [accent.opacity(0.25), accent.opacity(0.08)], + startPoint: .topLeading, endPoint: .bottomTrailing + )) + .frame(width: 52, height: 52) + Image(systemName: model.iconSystemName) + .font(.system(size: 22)) + .foregroundColor(accent) + } + VStack(alignment: .leading, spacing: 3) { + Text(model.name) + .font(.subheadline).bold() + .foregroundColor(.primary) + if let summary = model.summary { + Text(summary) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + .multilineTextAlignment(.leading) + } + } + Spacer() + Image(systemName: "plus.circle.fill") + .font(.title3) + .foregroundStyle(accent.opacity(isHovered ? 1.0 : 0.5)) + } + .padding(12) + .background(Color(nsColor: NSColor.controlBackgroundColor)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(isHovered ? accent.opacity(0.7) : Color.secondary.opacity(0.2), lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .contentShape(Rectangle()) + .onHover { hovering in + isHovered = hovering + } + } + + private var accent: Color { + model.category?.accentColor ?? .accentColor } } diff --git a/macOS/ScriptWidgetMac/Create/CreateGuideView.swift b/macOS/ScriptWidgetMac/Create/CreateGuideView.swift index 845173c..6c7b7a5 100644 --- a/macOS/ScriptWidgetMac/Create/CreateGuideView.swift +++ b/macOS/ScriptWidgetMac/Create/CreateGuideView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import AppKit let defaultCreateScriptContent = """ @@ -31,51 +32,97 @@ $render( """ +class MacCreateGuideDataObject: ObservableObject { + @Published var models: [ScriptModel] = [] + + init() { + DispatchQueue.global().async { [weak self] in + let items = ScriptManager.listBundleScripts(bundle: "Script", relativePath: "template") + DispatchQueue.main.async { + self?.models = items + } + } + } +} + struct CreateGuideView: View { @Environment(\.dismiss) var dismiss + @StateObject private var dataObject = MacCreateGuideDataObject() - @State var enteredText: String = "A New Widget" + @State private var selectedCategory: ScriptCategory? = nil + @State private var searchText: String = "" var body: some View { - VStack(alignment: .leading, spacing: 16) { - aiCard + VStack(spacing: 0) { + header Divider() - Text("Or start from a blank widget") - .font(.subheadline) - .foregroundStyle(.secondary) + ScrollView { + VStack(alignment: .leading, spacing: 16) { + aiAndBlankRow - VStack(alignment: .leading, spacing: 6) { - Text("Script name") - .font(.headline) - TextField("", text: $enteredText) - .textFieldStyle(.roundedBorder) - } + if searchText.isEmpty { + categoryChips + } - HStack { - Button("Cancel") { - dismiss() + if filteredModels.isEmpty { + emptyState + .frame(maxWidth: .infinity, minHeight: 180) + } else { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 180), spacing: 12)], spacing: 12) { + ForEach(filteredModels) { item in + MacTemplateCardView(model: item) { + createFromTemplate(item) + } + } + } + } } - .keyboardShortcut(.cancelAction) + .padding(16) + } + } + .frame(minWidth: 720, idealWidth: 840, minHeight: 540, idealHeight: 620) + } - Spacer() + private var header: some View { + HStack(spacing: 12) { + Text("New Widget") + .font(.title2).bold() - Button("Create Blank") { - createBlank() - } - .keyboardShortcut(.defaultAction) + Spacer() + + HStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + TextField("Search templates", text: $searchText) + .textFieldStyle(.plain) + .frame(minWidth: 180) } + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(Color.secondary.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 7)) + + Button("Close") { + dismiss() + } + .keyboardShortcut(.cancelAction) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + + private var aiAndBlankRow: some View { + HStack(spacing: 12) { + aiCard + blankCard } - .frame(width: 420) - .padding(16) } private var aiCard: some View { Button { dismiss() - // Hand off to SidebarView's notification listener so we - // reuse the "configure AI first" alert path. DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { NotificationCenter.default.post( name: AIGenerateWindowView.openRequestNotification, @@ -86,20 +133,18 @@ struct CreateGuideView: View { HStack(spacing: 12) { Image(systemName: "sparkles") .font(.title) + .foregroundStyle(.white) .frame(width: 44, height: 44) - .background(Color.accentColor.opacity(0.15)) + .background(LinearGradient(colors: [.purple, .blue], + startPoint: .topLeading, + endPoint: .bottomTrailing)) .clipShape(RoundedRectangle(cornerRadius: 10)) VStack(alignment: .leading, spacing: 2) { - Text("Generate with AI") - .font(.headline) + Text("Generate with AI").font(.headline) Text("Describe your widget and let the AI build it.") - .font(.caption) - .foregroundStyle(.secondary) + .font(.caption).foregroundStyle(.secondary) } Spacer() - Image(systemName: "chevron.right") - .font(.caption) - .foregroundStyle(.secondary) } .padding(10) .background(Color.accentColor.opacity(0.08)) @@ -109,37 +154,231 @@ struct CreateGuideView: View { .buttonStyle(.plain) } - private func createBlank() { - let inputText = enteredText.trim() - if inputText.isEmpty { - MacKitUtil.alertWarn(title: "Invalid name", message: "Name can not be empty") - return + private var blankCard: some View { + Button { + createBlank() + } label: { + HStack(spacing: 12) { + Image(systemName: "doc.badge.plus") + .font(.title) + .foregroundStyle(.secondary) + .frame(width: 44, height: 44) + .background(Color.secondary.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + VStack(alignment: .leading, spacing: 2) { + Text("Blank Widget").font(.headline) + Text("Start from an empty template.") + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + } + .padding(10) + .background(Color.secondary.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private var categoryChips: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + MacCategoryChip(title: "All", + systemImage: "square.grid.2x2", + color: .gray, + selected: selectedCategory == nil) { + selectedCategory = nil + } + ForEach(ScriptCategory.allCases) { cat in + MacCategoryChip(title: cat.displayName, + systemImage: cat.systemImage, + color: cat.accentColor, + selected: selectedCategory == cat) { + selectedCategory = (selectedCategory == cat) ? nil : cat + } + } + } + } + } + + private var emptyState: some View { + VStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.system(size: 36)) + .foregroundStyle(.secondary) + Text("No templates match").font(.headline) + Text("Try another keyword or category.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + // MARK: - Actions + + private var filteredModels: [ScriptModel] { + let q = searchText.trimmingCharacters(in: .whitespaces).lowercased() + return dataObject.models.filter { model in + if !q.isEmpty { + let haystack = ([model.name, model.summary ?? ""] + model.tags).joined(separator: " ").lowercased() + return haystack.contains(q) + } + guard let selected = selectedCategory else { return true } + return model.category == selected } + } - if !inputText.checkIfValidFileName() { - MacKitUtil.alertWarn(title: "Invalid name", message: "Please make sure the widget name is an valid file name") + private func createFromTemplate(_ item: ScriptModel) { + guard let content = item.package.readMainFile().0 else { + MacKitUtil.alertWarn(title: "Failed to read template", message: "Please retry or relaunch the app.") + return + } + let result = sharedScriptManager.createScript( + content: content, + recommendPackageName: item.name, + imageCopyPath: item.package.imagePath + ) + if !result.0 { + MacKitUtil.alertWarn(title: "Create failed", message: result.1) return } + NotificationCenter.default.post(name: SharedAppStore.scriptCreateNotification, object: nil) + dismiss() + } - let scriptName = inputText + private func createBlank() { + let scriptName = ScriptManager(isBuild: false).getValidPackageName(recommendPackageName: "A New Widget") let result = sharedScriptManager.createScript( content: defaultCreateScriptContent, recommendPackageName: scriptName, imageCopyPath: nil ) - if !result.0 { - print("Create failed : \(result.1)") MacKitUtil.alertWarn(title: "Create failed", message: "Please retry or relaunch app :)\nError : \(result.1)") return } - NotificationCenter.default.post(name: SharedAppStore.scriptCreateNotification, object: nil) - dismiss() } } +// MARK: - Mac template card + +struct MacTemplateCardView: View { + let model: ScriptModel + let action: () -> Void + + @State private var isHovered = false + + var body: some View { + Button(action: action) { + VStack(alignment: .leading, spacing: 8) { + ZStack { + RoundedRectangle(cornerRadius: 10) + .fill(cardBackground) + + if let url = model.package.previewImageURL(), + let nsImage = NSImage(contentsOfFile: url.path) { + Image(nsImage: nsImage) + .resizable() + .scaledToFill() + .clipShape(RoundedRectangle(cornerRadius: 10)) + } else { + Image(systemName: model.iconSystemName) + .font(.system(size: 30)) + .foregroundColor(accentColor) + } + } + .frame(height: 86) + .clipShape(RoundedRectangle(cornerRadius: 10)) + + VStack(alignment: .leading, spacing: 4) { + Text(model.name) + .font(.subheadline.weight(.semibold)) + .foregroundColor(.primary) + .lineLimit(1) + if let summary = model.summary, !summary.isEmpty { + Text(summary) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + } + if let difficulty = model.difficulty { + MacDifficultyBadge(difficulty: difficulty) + .padding(.top, 2) + } + } + } + .padding(8) + .background(Color(nsColor: NSColor.controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(isHovered ? accentColor.opacity(0.6) : Color.secondary.opacity(0.2), lineWidth: 1) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering in + isHovered = hovering + } + } + + private var accentColor: Color { + model.category?.accentColor ?? .accentColor + } + + private var cardBackground: LinearGradient { + LinearGradient(colors: [accentColor.opacity(0.18), accentColor.opacity(0.06)], + startPoint: .topLeading, endPoint: .bottomTrailing) + } +} + +struct MacCategoryChip: View { + let title: String + let systemImage: String + let color: Color + let selected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 4) { + Image(systemName: systemImage).font(.caption) + Text(title).font(.subheadline) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .foregroundColor(selected ? .white : color) + .background(selected ? color : color.opacity(0.12)) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } +} + +struct MacDifficultyBadge: View { + let difficulty: ScriptDifficulty + + var body: some View { + Text(difficulty.displayName) + .font(.system(size: 10, weight: .semibold)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .foregroundColor(color) + .background(color.opacity(0.15)) + .clipShape(Capsule()) + } + + private var color: Color { + switch difficulty { + case .beginner: return .green + case .medium: return .orange + case .advanced: return .red + } + } +} + struct CreateGuideView_Previews: PreviewProvider { static var previews: some View { CreateGuideView() diff --git a/macOS/ScriptWidgetMac/Sidebar/EmptyListBackgroundView.swift b/macOS/ScriptWidgetMac/Sidebar/EmptyListBackgroundView.swift index dcf0bb8..7754ff1 100644 --- a/macOS/ScriptWidgetMac/Sidebar/EmptyListBackgroundView.swift +++ b/macOS/ScriptWidgetMac/Sidebar/EmptyListBackgroundView.swift @@ -2,22 +2,25 @@ // EmptyListBackgroundView.swift // ScriptWidgetMac // -// Created by everettjf on 2022/1/15. +// Compact empty state shown inside the sidebar when no widgets exist. // - import SwiftUI struct EmptyListBackgroundView: View { var body: some View { - VStack (spacing: 5) { - Text("Create your first widget by tapping the plus button upper-right :)") - .multilineTextAlignment(.leading) - .lineLimit(10) - .font(.headline) + VStack(alignment: .leading, spacing: 6) { + Image(systemName: "sparkles") + .font(.system(size: 20)) + .foregroundStyle(.secondary) + Text("No widgets yet") + .font(.subheadline).bold() + Text("Tap the + button above to browse templates or generate with AI.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } - .foregroundColor(Color.gray.opacity(0.75)) - .padding() + .padding(.vertical, 8) } } diff --git a/macOS/ScriptWidgetMac/Sidebar/SidebarView.swift b/macOS/ScriptWidgetMac/Sidebar/SidebarView.swift index 9efe8f6..b60b642 100644 --- a/macOS/ScriptWidgetMac/Sidebar/SidebarView.swift +++ b/macOS/ScriptWidgetMac/Sidebar/SidebarView.swift @@ -106,6 +106,14 @@ struct SidebarView: View { Button("Update") { item.package.updateFiles() } + Button("Remix (Duplicate)") { + let result = sharedScriptManager.duplicateScript(sourcePackageName: item.name) + if result.0 { + NotificationCenter.default.post(name: SharedAppStore.scriptCreateNotification, object: nil) + } else { + MacKitUtil.alertWarn(title: "Remix failed", message: result.1) + } + } Button("Rename") { self.renameCurrentName = item.name self.renameInputName = item.name From c182469183f1cc18b1d18cb3b6395d5fdb6bbeaa Mon Sep 17 00:00:00 2001 From: everettjf Date: Sun, 3 May 2026 19:57:49 -0700 Subject: [PATCH 09/13] feat(ai): multi-profile config + OpenAI OAuth sign-in Replace the single AI-config blob with a list of named profiles (host, model, key, auth method) plus an active-profile pointer, all stored in the app-group UserDefaults under ai.profiles.v2 with a one-shot migration from the legacy single-profile keys. Add an OpenAI OAuth (PKCE) sign-in path as an alternative to API keys, ported from openrocky: Codex CLI client_id, local 127.0.0.1 HTTP listener for the redirect, JWT-decoded chatgpt_account_id, keychain-backed credential vault with refresh-on-expiry. Settings UI rebuilt to manage profiles (sidebar/detail on macOS, list+push-editor on iOS) with provider preset chips, model suggestions, segmented API Key | OAuth picker, per-profile test connection. Generate sheet gains a profile picker when more than one profile exists. Co-Authored-By: Claude Opus 4.7 (1M context) --- Shared/ScriptWidgetRuntime/AI/AIClient.swift | 18 +- .../ScriptWidgetRuntime/AI/AIKeychain.swift | 59 ++ .../AI/AILocalOAuthServer.swift | 211 ++++++++ .../AI/AIOpenAIOAuth.swift | 357 ++++++++++++ .../ScriptWidgetRuntime/AI/AISettings.swift | 256 +++++++-- iOS/ScriptWidget.xcodeproj/project.pbxproj | 12 + .../App/Settings/SettingAIView.swift | 490 +++++++++++++---- .../View/AIGenerate/AIGenerateView.swift | 29 + .../ScriptWidgetMac.xcodeproj/project.pbxproj | 12 + .../AIGenerate/AIGenerateWindowView.swift | 26 + .../Settings/SettingAIView.swift | 512 +++++++++++++++--- 11 files changed, 1758 insertions(+), 224 deletions(-) create mode 100644 Shared/ScriptWidgetRuntime/AI/AIKeychain.swift create mode 100644 Shared/ScriptWidgetRuntime/AI/AILocalOAuthServer.swift create mode 100644 Shared/ScriptWidgetRuntime/AI/AIOpenAIOAuth.swift diff --git a/Shared/ScriptWidgetRuntime/AI/AIClient.swift b/Shared/ScriptWidgetRuntime/AI/AIClient.swift index 14c70a8..fd18574 100644 --- a/Shared/ScriptWidgetRuntime/AI/AIClient.swift +++ b/Shared/ScriptWidgetRuntime/AI/AIClient.swift @@ -64,12 +64,26 @@ actor AIClient { throw AIClientError.invalidBaseURL(baseURLString) } + // For OAuth profiles, refresh the access token if it's near expiry + // and persist the refreshed credential before using it. Plain API + // key profiles pass through unchanged. + let resolvedKey: String + if settings.authMethod == .oauth { + do { + resolvedKey = try await AIOpenAIOAuthVault.resolvedAccessToken(from: trimmedKey) + } catch { + throw AIClientError.upstream("OAuth refresh failed: \(error.localizedDescription)") + } + } else { + resolvedKey = trimmedKey + } + let service: OpenAIService if baseURLString == AISettings.defaultBaseURL { - service = OpenAIServiceFactory.service(apiKey: trimmedKey) + service = OpenAIServiceFactory.service(apiKey: resolvedKey) } else { service = OpenAIServiceFactory.service( - apiKey: trimmedKey, + apiKey: resolvedKey, overrideBaseURL: baseURLString ) } diff --git a/Shared/ScriptWidgetRuntime/AI/AIKeychain.swift b/Shared/ScriptWidgetRuntime/AI/AIKeychain.swift new file mode 100644 index 0000000..542f00c --- /dev/null +++ b/Shared/ScriptWidgetRuntime/AI/AIKeychain.swift @@ -0,0 +1,59 @@ +// +// AIKeychain.swift +// ScriptWidget +// +// Tiny generic-password keychain wrapper used to persist OAuth +// credentials (refresh + access tokens). UserDefaults is intentionally +// not used for these — they are bearer secrets. +// + +import Foundation +import Security + +struct AIKeychain { + static let live = AIKeychain() + + private let service = "com.everettjf.scriptwidget.ai" + + func value(for account: String) -> String? { + var query = lookupQuery(account: account) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status == errSecSuccess, + let data = item as? Data, + let string = String(data: data, encoding: .utf8) + else { + return nil + } + return string + } + + func set(_ value: String, for account: String) { + let data = Data(value.utf8) + let query = lookupQuery(account: account) + let attributes = [kSecValueData as String: data] + let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + if updateStatus == errSecSuccess { + return + } + var addQuery = lookupQuery(account: account) + addQuery[kSecValueData as String] = data + addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + SecItemAdd(addQuery as CFDictionary, nil) + } + + func removeValue(for account: String) { + SecItemDelete(lookupQuery(account: account) as CFDictionary) + } + + private func lookupQuery(account: String) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + } +} diff --git a/Shared/ScriptWidgetRuntime/AI/AILocalOAuthServer.swift b/Shared/ScriptWidgetRuntime/AI/AILocalOAuthServer.swift new file mode 100644 index 0000000..60dbc8d --- /dev/null +++ b/Shared/ScriptWidgetRuntime/AI/AILocalOAuthServer.swift @@ -0,0 +1,211 @@ +// +// AILocalOAuthServer.swift +// ScriptWidget +// +// Lightweight 127.0.0.1 HTTP server used to receive the OAuth +// redirect from the system browser. Mirrors the Codex CLI pattern. +// + +import Foundation +import Network + +actor AILocalOAuthServer { + private var listener: NWListener? + private var continuation: CheckedContinuation? + private let port: UInt16 + + struct OAuthCallbackResult: Sendable { + let code: String + let state: String + } + + enum ServerError: LocalizedError { + case portUnavailable + case serverFailed(String) + case invalidRequest + case missingParameters + case timeout + case oauthError(String) + + var errorDescription: String? { + switch self { + case .portUnavailable: + return "OAuth callback port is unavailable." + case .serverFailed(let reason): + return "OAuth callback server failed: \(reason)" + case .invalidRequest: + return "Invalid OAuth callback request." + case .missingParameters: + return "OAuth callback missing code or state parameter." + case .timeout: + return "OAuth callback timed out." + case .oauthError(let description): + return "OAuth provider returned an error: \(description)" + } + } + } + + init(port: UInt16 = 1455) { + self.port = port + } + + func waitForCallback(timeout: TimeInterval = 300) async throws -> OAuthCallbackResult { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + self.continuation = cont + do { + let params = NWParameters.tcp + params.allowLocalEndpointReuse = true + guard let nwPort = NWEndpoint.Port(rawValue: port) else { + cont.resume(throwing: ServerError.portUnavailable) + self.continuation = nil + return + } + let listener = try NWListener(using: params, on: nwPort) + self.listener = listener + + listener.stateUpdateHandler = { [weak self] state in + guard let self else { return } + if case .failed(let error) = state { + Task { await self.fail(with: .serverFailed(error.localizedDescription)) } + } + } + listener.newConnectionHandler = { [weak self] connection in + guard let self else { return } + Task { await self.handleConnection(connection) } + } + listener.start(queue: .global(qos: .userInitiated)) + + Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + await self?.fail(with: .timeout) + } + } catch { + cont.resume(throwing: ServerError.portUnavailable) + self.continuation = nil + } + } + } + + func stop() { + listener?.cancel() + listener = nil + } + + private func handleConnection(_ connection: NWConnection) { + connection.start(queue: .global(qos: .userInitiated)) + connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { [weak self] data, _, _, _ in + guard let self else { return } + Task { await self.processRequest(data: data, connection: connection) } + } + } + + private func processRequest(data: Data?, connection: NWConnection) { + guard let data, let requestString = String(data: data, encoding: .utf8) else { + sendResponse(connection: connection, statusCode: 400, body: "Bad Request") + return + } + guard let firstLine = requestString.components(separatedBy: "\r\n").first, + firstLine.hasPrefix("GET ") else { + sendResponse(connection: connection, statusCode: 400, body: "Bad Request") + return + } + let parts = firstLine.split(separator: " ") + guard parts.count >= 2 else { + sendResponse(connection: connection, statusCode: 400, body: "Bad Request") + return + } + let pathAndQuery = String(parts[1]) + guard pathAndQuery.hasPrefix("/auth/callback") else { + sendResponse(connection: connection, statusCode: 404, body: "Not Found") + return + } + guard let components = URLComponents(string: "http://localhost\(pathAndQuery)"), + let queryItems = components.queryItems else { + sendResponse(connection: connection, statusCode: 400, body: "Missing parameters") + fail(with: .missingParameters) + return + } + let params = Dictionary(queryItems.compactMap { item -> (String, String)? in + guard let value = item.value else { return nil } + return (item.name, value) + }, uniquingKeysWith: { _, last in last }) + + guard let code = params["code"], !code.isEmpty, + let state = params["state"], !state.isEmpty else { + if let errorMsg = params["error"] { + let description = params["error_description"] ?? errorMsg + sendResponse(connection: connection, statusCode: 200, + body: Self.errorHTML(message: description), + contentType: "text/html") + fail(with: .oauthError(description)) + } else { + sendResponse(connection: connection, statusCode: 400, body: "Missing code or state") + fail(with: .missingParameters) + } + return + } + + sendResponse(connection: connection, statusCode: 200, + body: Self.successHTML(), + contentType: "text/html") + let result = OAuthCallbackResult(code: code, state: state) + if let cont = continuation { + continuation = nil + cont.resume(returning: result) + } + stop() + } + + private func sendResponse(connection: NWConnection, statusCode: Int, body: String, contentType: String = "text/plain") { + let statusText: String + switch statusCode { + case 200: statusText = "OK" + case 400: statusText = "Bad Request" + case 404: statusText = "Not Found" + default: statusText = "Error" + } + let response = """ + HTTP/1.1 \(statusCode) \(statusText)\r + Content-Type: \(contentType); charset=utf-8\r + Content-Length: \(body.utf8.count)\r + Connection: close\r + \r + \(body) + """ + connection.send(content: Data(response.utf8), completion: .contentProcessed { _ in + connection.cancel() + }) + } + + private func fail(with error: ServerError) { + if let cont = continuation { + continuation = nil + cont.resume(throwing: error) + } + stop() + } + + private static func successHTML() -> String { + """ + ScriptWidget + +

Signed In

You can close this page and return to ScriptWidget.

+ """ + } + + private static func errorHTML(message: String) -> String { + let escaped = message + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + return """ + ScriptWidget + +

Sign-In Failed

\(escaped)

+ """ + } +} diff --git a/Shared/ScriptWidgetRuntime/AI/AIOpenAIOAuth.swift b/Shared/ScriptWidgetRuntime/AI/AIOpenAIOAuth.swift new file mode 100644 index 0000000..f380e62 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/AI/AIOpenAIOAuth.swift @@ -0,0 +1,357 @@ +// +// AIOpenAIOAuth.swift +// ScriptWidget +// +// OpenAI OAuth (PKCE) sign-in for the AI Generate feature, ported +// from OpenRocky. +// +// Uses the Codex CLI public client_id; tokens are stored in the +// Keychain (not UserDefaults) keyed by the JWT-embedded +// chatgpt_account_id, and refreshed automatically when within +// `leeway` seconds of expiry. +// + +import CryptoKit +import Foundation +import Security +#if canImport(UIKit) +import UIKit +#endif +#if canImport(AppKit) +import AppKit +#endif + +struct AIOpenAIOAuthCredential: Codable, Equatable, Sendable { + var accessToken: String + var refreshToken: String + var expiresAt: Date + var accountID: String + var authorizedAt: Date + + var isExpired: Bool { expiresAt <= Date() } + + var maskedAccessToken: String { + guard accessToken.count >= 12 else { return "••••" } + return "\(accessToken.prefix(8))••••\(accessToken.suffix(4))" + } +} + +enum AIOpenAIOAuthError: LocalizedError { + case invalidAuthorizeURL + case invalidTokenURL + case stateMismatch + case missingAuthorizationCode + case missingAccountID + case invalidTokenResponse + case randomGenerationFailed + case browserOpenFailed + case tokenExchangeFailed(statusCode: Int, message: String) + + var errorDescription: String? { + switch self { + case .invalidAuthorizeURL: return "Invalid OpenAI authorize URL." + case .invalidTokenURL: return "Invalid OpenAI token URL." + case .stateMismatch: return "OpenAI OAuth state mismatch." + case .missingAuthorizationCode: return "OpenAI OAuth did not return an authorization code." + case .missingAccountID: return "OpenAI OAuth token is missing account information." + case .invalidTokenResponse: return "OpenAI OAuth returned an invalid token response." + case .randomGenerationFailed: return "Failed to generate secure random OAuth parameters." + case .browserOpenFailed: return "Could not open the system browser for sign-in." + case let .tokenExchangeFailed(statusCode, message): + return "OpenAI token exchange failed (\(statusCode)): \(message)" + } + } +} + +@MainActor +enum AIOpenAIOAuthService { + private static let clientID = "app_EMoamEEZ73f0CkXaXp7hrann" + private static let authorizeURL = "https://auth.openai.com/oauth/authorize" + private static let tokenURL = "https://auth.openai.com/oauth/token" + private static let redirectURI = "http://localhost:1455/auth/callback" + private static let callbackPort: UInt16 = 1455 + private static let scope = "openid profile email offline_access api.connectors.read api.connectors.invoke" + nonisolated private static let jwtAuthClaimPath = "https://api.openai.com/auth" + + static func signIn(originator: String = "scriptwidget") async throws -> AIOpenAIOAuthCredential { + let flow = try makeAuthorizationFlow(originator: originator) + let server = AILocalOAuthServer(port: callbackPort) + + guard let authURL = URL(string: flow.url) else { + throw AIOpenAIOAuthError.invalidAuthorizeURL + } + + let opened = await openInBrowser(authURL) + guard opened else { + await server.stop() + throw AIOpenAIOAuthError.browserOpenFailed + } + + let callback: AILocalOAuthServer.OAuthCallbackResult + do { + callback = try await server.waitForCallback(timeout: 300) + } catch { + await server.stop() + throw error + } + + guard callback.state == flow.state else { + throw AIOpenAIOAuthError.stateMismatch + } + + let token = try await exchangeAuthorizationCode(code: callback.code, verifier: flow.verifier) + guard let accountID = extractAccountID(from: token.accessToken) else { + throw AIOpenAIOAuthError.missingAccountID + } + return AIOpenAIOAuthCredential( + accessToken: token.accessToken, + refreshToken: token.refreshToken, + expiresAt: Date().addingTimeInterval(Double(token.expiresIn)), + accountID: accountID, + authorizedAt: Date() + ) + } + + static func refresh(_ credential: AIOpenAIOAuthCredential) async throws -> AIOpenAIOAuthCredential { + let token = try await refreshAccessToken(refreshToken: credential.refreshToken) + guard let accountID = extractAccountID(from: token.accessToken) else { + throw AIOpenAIOAuthError.missingAccountID + } + return AIOpenAIOAuthCredential( + accessToken: token.accessToken, + refreshToken: token.refreshToken, + expiresAt: Date().addingTimeInterval(Double(token.expiresIn)), + accountID: accountID, + authorizedAt: credential.authorizedAt + ) + } + + static func refreshIfNeeded( + _ credential: AIOpenAIOAuthCredential, + leeway: TimeInterval = 60 + ) async throws -> AIOpenAIOAuthCredential { + if credential.expiresAt.timeIntervalSinceNow > leeway { + return credential + } + return try await refresh(credential) + } + + nonisolated static func accountID(fromAccessToken accessToken: String) -> String? { + extractAccountID(from: accessToken) + } + + private static func openInBrowser(_ url: URL) async -> Bool { + #if canImport(UIKit) && !os(watchOS) + return await UIApplication.shared.open(url) + #elseif canImport(AppKit) + return NSWorkspace.shared.open(url) + #else + return false + #endif + } + + private static func makeAuthorizationFlow(originator: String) throws -> AuthorizationFlow { + let verifierData = try randomData(count: 32) + let verifier = base64URLEncoded(verifierData) + let challenge = base64URLEncoded(Data(SHA256.hash(data: Data(verifier.utf8)))) + let state = try randomData(count: 16).map { String(format: "%02x", $0) }.joined() + + var components = URLComponents(string: authorizeURL) + components?.queryItems = [ + .init(name: "response_type", value: "code"), + .init(name: "client_id", value: clientID), + .init(name: "redirect_uri", value: redirectURI), + .init(name: "audience", value: "https://api.openai.com/v1"), + .init(name: "scope", value: scope), + .init(name: "code_challenge", value: challenge), + .init(name: "code_challenge_method", value: "S256"), + .init(name: "state", value: state), + .init(name: "id_token_add_organizations", value: "true"), + .init(name: "codex_cli_simplified_flow", value: "true"), + .init(name: "originator", value: originator), + ] + guard let url = components?.url else { + throw AIOpenAIOAuthError.invalidAuthorizeURL + } + return AuthorizationFlow(url: url.absoluteString, verifier: verifier, state: state) + } + + private static func exchangeAuthorizationCode(code: String, verifier: String) async throws -> OpenAIOAuthTokenResponse { + guard let url = URL(string: tokenURL) else { + throw AIOpenAIOAuthError.invalidTokenURL + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpBody = URLQueryEncoder.encode([ + "grant_type": "authorization_code", + "client_id": clientID, + "code": code, + "code_verifier": verifier, + "redirect_uri": redirectURI, + ]) + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw AIOpenAIOAuthError.invalidTokenResponse + } + guard (200...299).contains(http.statusCode) else { + let message = String(data: data, encoding: .utf8) ?? "unknown error" + throw AIOpenAIOAuthError.tokenExchangeFailed(statusCode: http.statusCode, message: message) + } + let decoded = try JSONDecoder().decode(OpenAIOAuthTokenResponse.self, from: data) + guard !decoded.accessToken.isEmpty, !decoded.refreshToken.isEmpty else { + throw AIOpenAIOAuthError.invalidTokenResponse + } + return decoded + } + + private static func refreshAccessToken(refreshToken: String) async throws -> OpenAIOAuthTokenResponse { + guard let url = URL(string: tokenURL) else { + throw AIOpenAIOAuthError.invalidTokenURL + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpBody = URLQueryEncoder.encode([ + "grant_type": "refresh_token", + "client_id": clientID, + "refresh_token": refreshToken, + ]) + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw AIOpenAIOAuthError.invalidTokenResponse + } + guard (200...299).contains(http.statusCode) else { + let message = String(data: data, encoding: .utf8) ?? "unknown error" + throw AIOpenAIOAuthError.tokenExchangeFailed(statusCode: http.statusCode, message: message) + } + let decoded = try JSONDecoder().decode(OpenAIOAuthTokenResponse.self, from: data) + guard !decoded.accessToken.isEmpty, !decoded.refreshToken.isEmpty else { + throw AIOpenAIOAuthError.invalidTokenResponse + } + return decoded + } + + nonisolated private static func extractAccountID(from accessToken: String) -> String? { + let segments = accessToken.split(separator: ".") + guard segments.count == 3 else { return nil } + guard let payloadData = decodeBase64URL(String(segments[1])), + let root = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any], + let auth = root[jwtAuthClaimPath] as? [String: Any], + let accountID = auth["chatgpt_account_id"] as? String, + !accountID.isEmpty else { + return nil + } + return accountID + } + + private static func randomData(count: Int) throws -> Data { + var data = Data(count: count) + let status = data.withUnsafeMutableBytes { pointer in + SecRandomCopyBytes(kSecRandomDefault, count, pointer.baseAddress!) + } + guard status == errSecSuccess else { + throw AIOpenAIOAuthError.randomGenerationFailed + } + return data + } + + private static func base64URLEncoded(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + nonisolated private static func decodeBase64URL(_ value: String) -> Data? { + var base64 = value + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + while base64.count % 4 != 0 { + base64.append("=") + } + return Data(base64Encoded: base64) + } + + private struct AuthorizationFlow { + var url: String + var verifier: String + var state: String + } + + private struct OpenAIOAuthTokenResponse: Decodable { + var accessToken: String + var refreshToken: String + var expiresIn: Int + + private enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case refreshToken = "refresh_token" + case expiresIn = "expires_in" + } + } +} + +enum AIOpenAIOAuthVault { + private static let keyPrefix = "scriptwidget.openai-oauth.account" + private static let keychain = AIKeychain.live + + static func credential(for accountID: String) -> AIOpenAIOAuthCredential? { + guard let json = keychain.value(for: accountKey(accountID: accountID)), + let data = json.data(using: .utf8), + let credential = try? JSONDecoder().decode(AIOpenAIOAuthCredential.self, from: data) else { + return nil + } + return credential + } + + static func save(_ credential: AIOpenAIOAuthCredential) { + guard let data = try? JSONEncoder().encode(credential), + let json = String(data: data, encoding: .utf8) else { + return + } + keychain.set(json, for: accountKey(accountID: credential.accountID)) + } + + static func remove(accountID: String) { + keychain.removeValue(for: accountKey(accountID: accountID)) + } + + /// Given a stored access token, look up the matching credential by + /// account id, refresh if expired, persist any update, and return + /// the live access token. Falls back to the input if the token has + /// no resolvable account (e.g. plain API key passed by mistake). + static func resolvedAccessToken(from rawCredential: String) async throws -> String { + guard let accountID = AIOpenAIOAuthService.accountID(fromAccessToken: rawCredential), + let stored = credential(for: accountID) else { + return rawCredential + } + let updated = try await AIOpenAIOAuthService.refreshIfNeeded(stored) + if updated != stored { + save(updated) + } + return updated.accessToken + } + + private static func accountKey(accountID: String) -> String { + "\(keyPrefix).\(accountID)" + } +} + +private enum URLQueryEncoder { + static func encode(_ values: [String: String]) -> Data { + let query = values + .map { key, value in + "\(percentEncode(key))=\(percentEncode(value))" + } + .sorted() + .joined(separator: "&") + return Data(query.utf8) + } + + private static func percentEncode(_ string: String) -> String { + var allowed = CharacterSet.urlQueryAllowed + allowed.remove(charactersIn: ":#[]@!$&'()*+,;=") + return string.addingPercentEncoding(withAllowedCharacters: allowed) ?? string + } +} diff --git a/Shared/ScriptWidgetRuntime/AI/AISettings.swift b/Shared/ScriptWidgetRuntime/AI/AISettings.swift index 1709760..80ba2ae 100644 --- a/Shared/ScriptWidgetRuntime/AI/AISettings.swift +++ b/Shared/ScriptWidgetRuntime/AI/AISettings.swift @@ -3,53 +3,69 @@ // ScriptWidget // // Persistent configuration for the AI Generate feature. -// Stored in the app-group UserDefaults so both iOS and macOS main apps -// (and potentially extensions) share the same values. // -// Security note: API key is stored in plain-text UserDefaults in this -// initial revision. A Keychain migration is planned. +// Storage model +// ------------- +// - AIProfile: one provider/model entry. Codable, persisted as JSON in +// the app-group UserDefaults under `ai.profiles.v2`. +// - Active profile id under `ai.activeProfileID`. +// - Agent-loop globals (maxIterations / temperature) are app-wide and +// live on AISettings. +// - Legacy single-profile keys (ai.apiKey, ai.baseURL, ai.model) are +// migrated into a "Default" profile on first load. +// +// Auth methods per profile: +// .apiKey → settings.apiKey holds the literal key. +// .oauth → AIOpenAIOAuthVault holds the credential, keyed by the +// account ID embedded in the access token. settings.apiKey +// stores the (raw) access token at last sign-in so the +// vault can find the matching record. // import Foundation -enum AISettingsKey { - static let apiKey = "ai.apiKey" - static let baseURL = "ai.baseURL" - static let model = "ai.model" - static let maxIterations = "ai.maxIterations" - static let temperature = "ai.temperature" +enum AIAuthMethod: String, Codable { + case apiKey + case oauth } -struct AISettings: Equatable { - var apiKey: String +struct AIProfile: Codable, Identifiable, Equatable { + var id: String + var name: String var baseURL: String var model: String - var maxIterations: Int - var temperature: Double + var apiKey: String + var authMethod: AIAuthMethod static let defaultBaseURL = "https://api.openai.com" static let defaultModel = "gpt-4o-mini" - static let defaultMaxIterations = 20 - static let defaultTemperature = 0.7 - static let `default` = AISettings( - apiKey: "", - baseURL: defaultBaseURL, - model: defaultModel, - maxIterations: defaultMaxIterations, - temperature: defaultTemperature - ) + static func makeDefault(named name: String = "Default") -> AIProfile { + AIProfile( + id: UUID().uuidString, + name: name, + baseURL: defaultBaseURL, + model: defaultModel, + apiKey: "", + authMethod: .apiKey + ) + } var isConfigured: Bool { - !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + switch authMethod { + case .apiKey: + return !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + case .oauth: + return !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } } var normalizedBaseURL: String { let trimmed = baseURL.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { - return AISettings.defaultBaseURL + return AIProfile.defaultBaseURL } - // Strip trailing /v1 or / — SwiftOpenAI appends /v1 itself. + // SwiftOpenAI appends /v1 itself. var normalized = trimmed while normalized.hasSuffix("/") { normalized.removeLast() @@ -62,6 +78,51 @@ struct AISettings: Equatable { } return normalized } + + var isOpenAIHost: Bool { + normalizedBaseURL == AIProfile.defaultBaseURL + } +} + +enum AISettingsKey { + // v2 profile storage + static let profiles = "ai.profiles.v2" + static let activeProfileID = "ai.activeProfileID" + // agent-loop globals + static let maxIterations = "ai.maxIterations" + static let temperature = "ai.temperature" + // legacy keys (read-only; only used during migration) + static let legacyAPIKey = "ai.apiKey" + static let legacyBaseURL = "ai.baseURL" + static let legacyModel = "ai.model" +} + +/// Snapshot consumed by AIClient / AgentLoop. Bakes the active profile +/// together with the agent-loop globals. +struct AISettings: Equatable { + var profile: AIProfile + var maxIterations: Int + var temperature: Double + + static let defaultBaseURL = AIProfile.defaultBaseURL + static let defaultModel = AIProfile.defaultModel + static let defaultMaxIterations = 20 + static let defaultTemperature = 0.7 + + static let `default` = AISettings( + profile: AIProfile.makeDefault(), + maxIterations: defaultMaxIterations, + temperature: defaultTemperature + ) + + var isConfigured: Bool { profile.isConfigured } + var normalizedBaseURL: String { profile.normalizedBaseURL } + + // Convenience pass-throughs (call sites still read these). + var apiKey: String { profile.apiKey } + var baseURL: String { profile.baseURL } + var model: String { profile.model } + var authMethod: AIAuthMethod { profile.authMethod } } final class AISettingsStore { @@ -70,37 +131,134 @@ final class AISettingsStore { static let changedNotification = Notification.Name("AISettingsStoreChanged") private let defaults: UserDefaults + private let queue = DispatchQueue(label: "ai.settings.store") private init() { self.defaults = UserDefaults(suiteName: "group.everettjf.scriptwidget") ?? .standard + migrateLegacyIfNeeded() + } + + // MARK: - Profiles + + func loadProfiles() -> [AIProfile] { + if let data = defaults.data(forKey: AISettingsKey.profiles), + let decoded = try? JSONDecoder().decode([AIProfile].self, from: data), + !decoded.isEmpty { + return decoded + } + // Should be unreachable after migrate(), but be defensive. + let fresh = [AIProfile.makeDefault()] + saveProfiles(fresh, activeID: fresh[0].id, notify: false) + return fresh + } + + func loadActiveProfileID() -> String { + if let stored = defaults.string(forKey: AISettingsKey.activeProfileID), + !stored.isEmpty { + return stored + } + let profiles = loadProfiles() + let id = profiles.first?.id ?? "" + defaults.set(id, forKey: AISettingsKey.activeProfileID) + return id + } + + func loadActiveProfile() -> AIProfile { + let profiles = loadProfiles() + let activeID = loadActiveProfileID() + return profiles.first(where: { $0.id == activeID }) ?? profiles.first ?? AIProfile.makeDefault() } + func saveProfiles(_ profiles: [AIProfile], activeID: String? = nil, notify: Bool = true) { + let data = try? JSONEncoder().encode(profiles) + defaults.set(data, forKey: AISettingsKey.profiles) + if let activeID { + defaults.set(activeID, forKey: AISettingsKey.activeProfileID) + } else if let current = defaults.string(forKey: AISettingsKey.activeProfileID), + !profiles.contains(where: { $0.id == current }) { + defaults.set(profiles.first?.id ?? "", forKey: AISettingsKey.activeProfileID) + } + if notify { + NotificationCenter.default.post(name: AISettingsStore.changedNotification, object: nil) + } + } + + func setActiveProfile(id: String) { + defaults.set(id, forKey: AISettingsKey.activeProfileID) + NotificationCenter.default.post(name: AISettingsStore.changedNotification, object: nil) + } + + func upsertProfile(_ profile: AIProfile) { + var profiles = loadProfiles() + if let idx = profiles.firstIndex(where: { $0.id == profile.id }) { + profiles[idx] = profile + } else { + profiles.append(profile) + } + saveProfiles(profiles) + } + + func deleteProfile(id: String) { + var profiles = loadProfiles() + profiles.removeAll(where: { $0.id == id }) + if profiles.isEmpty { + profiles = [AIProfile.makeDefault()] + } + let active = loadActiveProfileID() + let newActive = profiles.contains(where: { $0.id == active }) ? active : profiles[0].id + saveProfiles(profiles, activeID: newActive) + } + + // MARK: - Agent-loop globals + + func loadMaxIterations() -> Int { + defaults.object(forKey: AISettingsKey.maxIterations) as? Int ?? AISettings.defaultMaxIterations + } + + func loadTemperature() -> Double { + defaults.object(forKey: AISettingsKey.temperature) as? Double ?? AISettings.defaultTemperature + } + + func saveAgentLoop(maxIterations: Int, temperature: Double) { + defaults.set(maxIterations, forKey: AISettingsKey.maxIterations) + defaults.set(temperature, forKey: AISettingsKey.temperature) + NotificationCenter.default.post(name: AISettingsStore.changedNotification, object: nil) + } + + // MARK: - Combined snapshot for AIClient / AgentLoop + func load() -> AISettings { - let apiKey = defaults.string(forKey: AISettingsKey.apiKey) ?? "" - let baseURL = defaults.string(forKey: AISettingsKey.baseURL) ?? AISettings.defaultBaseURL - let model = defaults.string(forKey: AISettingsKey.model) ?? AISettings.defaultModel - - let storedIterations = defaults.object(forKey: AISettingsKey.maxIterations) as? Int - let maxIterations = storedIterations ?? AISettings.defaultMaxIterations - - let storedTemperature = defaults.object(forKey: AISettingsKey.temperature) as? Double - let temperature = storedTemperature ?? AISettings.defaultTemperature - - return AISettings( - apiKey: apiKey, - baseURL: baseURL, - model: model, - maxIterations: maxIterations, - temperature: temperature + AISettings( + profile: loadActiveProfile(), + maxIterations: loadMaxIterations(), + temperature: loadTemperature() ) } - func save(_ settings: AISettings) { - defaults.set(settings.apiKey, forKey: AISettingsKey.apiKey) - defaults.set(settings.baseURL, forKey: AISettingsKey.baseURL) - defaults.set(settings.model, forKey: AISettingsKey.model) - defaults.set(settings.maxIterations, forKey: AISettingsKey.maxIterations) - defaults.set(settings.temperature, forKey: AISettingsKey.temperature) - NotificationCenter.default.post(name: AISettingsStore.changedNotification, object: nil) + // MARK: - Migration + + private func migrateLegacyIfNeeded() { + // Already migrated? bail. + if defaults.data(forKey: AISettingsKey.profiles) != nil { + return + } + let legacyKey = defaults.string(forKey: AISettingsKey.legacyAPIKey) ?? "" + let legacyBase = defaults.string(forKey: AISettingsKey.legacyBaseURL) ?? AIProfile.defaultBaseURL + let legacyModel = defaults.string(forKey: AISettingsKey.legacyModel) ?? AIProfile.defaultModel + + let profile = AIProfile( + id: UUID().uuidString, + name: "Default", + baseURL: legacyBase.isEmpty ? AIProfile.defaultBaseURL : legacyBase, + model: legacyModel.isEmpty ? AIProfile.defaultModel : legacyModel, + apiKey: legacyKey, + authMethod: .apiKey + ) + + if let data = try? JSONEncoder().encode([profile]) { + defaults.set(data, forKey: AISettingsKey.profiles) + } + defaults.set(profile.id, forKey: AISettingsKey.activeProfileID) + // Legacy keys are left in place — harmless. Future writes go to v2. } } diff --git a/iOS/ScriptWidget.xcodeproj/project.pbxproj b/iOS/ScriptWidget.xcodeproj/project.pbxproj index e9214c7..7c954be 100644 --- a/iOS/ScriptWidget.xcodeproj/project.pbxproj +++ b/iOS/ScriptWidget.xcodeproj/project.pbxproj @@ -324,6 +324,9 @@ A104000300000000000000B0 /* AIReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A103000300000000000000B0 /* AIReviewView.swift */; }; A106000300000000000000B0 /* SwiftOpenAI in Frameworks */ = {isa = PBXBuildFile; productRef = A106000200000000000000B0 /* SwiftOpenAI */; }; A102000900000000000000B0 /* AIExamplePrompts.swift in Sources */ = {isa = PBXBuildFile; fileRef = A101000900000000000000B0 /* AIExamplePrompts.swift */; }; + A102000A00000000000000B0 /* AIKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = A101000A00000000000000B0 /* AIKeychain.swift */; }; + A102000B00000000000000B0 /* AILocalOAuthServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A101000B00000000000000B0 /* AILocalOAuthServer.swift */; }; + A102000C00000000000000B0 /* AIOpenAIOAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = A101000C00000000000000B0 /* AIOpenAIOAuth.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -613,6 +616,9 @@ A103000200000000000000B0 /* AIGenerateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIGenerateView.swift; sourceTree = ""; }; A103000300000000000000B0 /* AIReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIReviewView.swift; sourceTree = ""; }; A101000900000000000000B0 /* AIExamplePrompts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIExamplePrompts.swift; sourceTree = ""; }; + A101000A00000000000000B0 /* AIKeychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIKeychain.swift; sourceTree = ""; }; + A101000B00000000000000B0 /* AILocalOAuthServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AILocalOAuthServer.swift; sourceTree = ""; }; + A101000C00000000000000B0 /* AIOpenAIOAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIOpenAIOAuth.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1340,6 +1346,9 @@ A101000700000000000000B0 /* AIGenerateSession.swift */, A101000800000000000000B0 /* AIGenerateProgressView.swift */, A101000900000000000000B0 /* AIExamplePrompts.swift */, + A101000A00000000000000B0 /* AIKeychain.swift */, + A101000B00000000000000B0 /* AILocalOAuthServer.swift */, + A101000C00000000000000B0 /* AIOpenAIOAuth.swift */, ); name = AI; path = ../Shared/ScriptWidgetRuntime/AI; @@ -1585,6 +1594,9 @@ buildActionMask = 2147483647; files = ( A102000900000000000000B0 /* AIExamplePrompts.swift in Sources */, + A102000A00000000000000B0 /* AIKeychain.swift in Sources */, + A102000B00000000000000B0 /* AILocalOAuthServer.swift in Sources */, + A102000C00000000000000B0 /* AIOpenAIOAuth.swift in Sources */, A102000100000000000000B0 /* AISettings.swift in Sources */, A102000200000000000000B0 /* AIReferenceSnapshot.swift in Sources */, A102000300000000000000B0 /* PromptBuilder.swift in Sources */, diff --git a/iOS/ScriptWidget/App/Settings/SettingAIView.swift b/iOS/ScriptWidget/App/Settings/SettingAIView.swift index 7bc10fc..a6d79fa 100644 --- a/iOS/ScriptWidget/App/Settings/SettingAIView.swift +++ b/iOS/ScriptWidget/App/Settings/SettingAIView.swift @@ -2,73 +2,219 @@ // SettingAIView.swift // ScriptWidget // -// Lets the user configure the OpenAI (or compatible) endpoint used by -// the AI Generate feature. Values are persisted to the app-group -// UserDefaults via AISettingsStore. +// Lets the user manage one or more AI provider profiles plus the +// global agent-loop knobs. Each profile has its own (host, model, +// apiKey | OAuth credential). // import SwiftUI struct SettingAIView: View { - @State private var apiKey: String = "" - @State private var baseURL: String = AISettings.defaultBaseURL - @State private var model: String = AISettings.defaultModel + @State private var profiles: [AIProfile] = [] + @State private var activeID: String = "" @State private var maxIterations: Int = AISettings.defaultMaxIterations @State private var temperature: Double = AISettings.defaultTemperature - @State private var apiKeyVisible: Bool = false - - @State private var testPhase: TestPhase = .idle - @State private var testMessage: String = "" - - @State private var showingSavedToast = false - private enum TestPhase { case idle, running, success, failure } - - private let modelPresets = ["gpt-4o-mini", "gpt-4o", "gpt-4.1-mini", "o4-mini"] + @State private var showingNewProfile = false + @State private var newProfileTarget: AIProfile? var body: some View { Form { Section { - HStack { - if apiKeyVisible { - TextField("sk-...", text: $apiKey) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - } else { - SecureField("sk-...", text: $apiKey) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - } - Button { - apiKeyVisible.toggle() + ForEach(profiles) { profile in + NavigationLink { + AIProfileEditorView(profileID: profile.id) { + reload() + } } label: { - Image(systemName: apiKeyVisible ? "eye.slash" : "eye") + profileRow(profile) } - .buttonStyle(.borderless) + } + .onDelete(perform: deleteProfiles) + + Button { + addProfile() + } label: { + Label("Add Profile", systemImage: "plus.circle") } } header: { - Text("API Key") + Text("Profiles") } footer: { - Text("Stored in plain-text UserDefaults on this device. Do not configure on a shared device.") - .foregroundColor(.orange) + Text("Tap a profile to edit. The active profile is used by AI Generate.") } - Section("Endpoint") { + Section("Agent Loop") { + Stepper(value: $maxIterations, in: 5...100, step: 5) { + HStack { + Text("Max Iterations") + Spacer() + Text("\(maxIterations)") + .foregroundStyle(.secondary) + } + } + .onChange(of: maxIterations) { _ in saveAgentLoop() } + + VStack(alignment: .leading) { + HStack { + Text("Temperature") + Spacer() + Text(String(format: "%.2f", temperature)) + .foregroundStyle(.secondary) + } + Slider(value: $temperature, in: 0.0...1.5, step: 0.05) + .onChange(of: temperature) { _ in saveAgentLoop() } + } + } + } + .navigationTitle("AI") + .navigationBarTitleDisplayMode(.inline) + .onAppear(perform: reload) + .onReceive(NotificationCenter.default.publisher(for: AISettingsStore.changedNotification)) { _ in + reload() + } + } + + private func profileRow(_ profile: AIProfile) -> some View { + HStack { + Image(systemName: profile.id == activeID ? "checkmark.circle.fill" : "circle") + .foregroundStyle(profile.id == activeID ? Color.accentColor : Color.secondary) + .onTapGesture { + AISettingsStore.shared.setActiveProfile(id: profile.id) + activeID = profile.id + } + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(profile.name.isEmpty ? "Unnamed" : profile.name) + .font(.body) + if profile.authMethod == .oauth { + Text("OAuth") + .font(.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background(Color.accentColor.opacity(0.18)) + .foregroundColor(.accentColor) + .clipShape(Capsule()) + } + } + Text(profileSubtitle(profile)) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + + private func profileSubtitle(_ profile: AIProfile) -> String { + let host = URL(string: profile.normalizedBaseURL)?.host ?? profile.normalizedBaseURL + let model = profile.model.isEmpty ? "—" : profile.model + return "\(host) · \(model)" + } + + private func reload() { + profiles = AISettingsStore.shared.loadProfiles() + activeID = AISettingsStore.shared.loadActiveProfileID() + maxIterations = AISettingsStore.shared.loadMaxIterations() + temperature = AISettingsStore.shared.loadTemperature() + } + + private func addProfile() { + let new = AIProfile.makeDefault(named: "New Profile") + AISettingsStore.shared.upsertProfile(new) + AISettingsStore.shared.setActiveProfile(id: new.id) + reload() + } + + private func deleteProfiles(at offsets: IndexSet) { + for index in offsets { + let id = profiles[index].id + AISettingsStore.shared.deleteProfile(id: id) + } + reload() + } + + private func saveAgentLoop() { + AISettingsStore.shared.saveAgentLoop(maxIterations: maxIterations, temperature: temperature) + } +} + +// MARK: - Profile editor + +struct AIProfileEditorView: View { + let profileID: String + let onChange: () -> Void + + @Environment(\.dismiss) private var dismiss + + @State private var name: String = "" + @State private var baseURL: String = AIProfile.defaultBaseURL + @State private var model: String = AIProfile.defaultModel + @State private var apiKey: String = "" + @State private var authMethod: AIAuthMethod = .apiKey + @State private var apiKeyVisible: Bool = false + + @State private var oauthAccountID: String = "" + @State private var oauthExpiresAt: Date? + @State private var oauthState: OAuthState = .idle + @State private var oauthError: String? + + @State private var testPhase: TestPhase = .idle + @State private var testMessage: String = "" + + @State private var showingDeleteConfirm = false + @State private var notFound = false + + enum TestPhase { case idle, running, success, failure } + enum OAuthState { case idle, signingIn, signedIn } + + private static let providerPresets: [(label: String, host: String, models: [String])] = [ + ("OpenAI", "https://api.openai.com", ["gpt-4o-mini", "gpt-4o", "gpt-4.1-mini", "gpt-4.1", "o4-mini"]), + ("DeepSeek", "https://api.deepseek.com", ["deepseek-chat", "deepseek-reasoner"]), + ("xAI", "https://api.x.ai", ["grok-2-latest", "grok-2-mini"]), + ("Local", "http://localhost:11434", ["llama3.2", "qwen2.5-coder"]), + ] + + var body: some View { + Form { + Section("Name") { + TextField("Profile name", text: $name) + .textInputAutocapitalization(.words) + .onChange(of: name) { _ in persist() } + } + + Section("Provider") { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(Self.providerPresets, id: \.label) { preset in + Button(preset.label) { + baseURL = preset.host + if let first = preset.models.first { + model = first + } + persist() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } TextField("https://api.openai.com", text: $baseURL) .textInputAutocapitalization(.never) .autocorrectionDisabled() .keyboardType(.URL) + .onChange(of: baseURL) { _ in persist() } } Section("Model") { TextField("gpt-4o-mini", text: $model) .textInputAutocapitalization(.never) .autocorrectionDisabled() + .onChange(of: model) { _ in persist() } ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - ForEach(modelPresets, id: \.self) { preset in + HStack(spacing: 6) { + ForEach(modelSuggestions, id: \.self) { preset in Button(preset) { model = preset + persist() } .buttonStyle(.bordered) .controlSize(.small) @@ -77,113 +223,224 @@ struct SettingAIView: View { } } - Section("Agent Loop") { - Stepper(value: $maxIterations, in: 5...100, step: 5) { - HStack { - Text("Max Iterations") - Spacer() - Text("\(maxIterations)") - .foregroundStyle(.secondary) - } + Section { + Picker("Method", selection: $authMethod) { + Text("API Key").tag(AIAuthMethod.apiKey) + Text("OpenAI OAuth").tag(AIAuthMethod.oauth) } - VStack(alignment: .leading) { + .pickerStyle(.segmented) + .onChange(of: authMethod) { _ in persist() } + + if authMethod == .apiKey { HStack { - Text("Temperature") - Spacer() - Text(String(format: "%.2f", temperature)) - .foregroundStyle(.secondary) + if apiKeyVisible { + TextField("sk-...", text: $apiKey) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } else { + SecureField("sk-...", text: $apiKey) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + Button { + apiKeyVisible.toggle() + } label: { + Image(systemName: apiKeyVisible ? "eye.slash" : "eye") + } + .buttonStyle(.borderless) } - Slider(value: $temperature, in: 0.0...1.5, step: 0.05) + .onChange(of: apiKey) { _ in persist() } + } else { + oauthSection + } + } header: { + Text("Authentication") + } footer: { + if authMethod == .apiKey { + Text("API key is stored in plain-text UserDefaults. OAuth credentials, when used, are stored in the Keychain instead.") + .foregroundColor(.orange) + } else { + Text("OAuth uses the Codex CLI client and only works with the OpenAI host (api.openai.com). The token is stored in the Keychain and refreshed automatically.") + .foregroundColor(.secondary) } } - Section { + Section("Connection") { Button { runTest() } label: { HStack { - if testPhase == .running { - ProgressView().controlSize(.small) - } - Text(testButtonLabel) + if testPhase == .running { ProgressView().controlSize(.small) } + Text("Test Connection") } } - .disabled(testPhase == .running || apiKey.trimmingCharacters(in: .whitespaces).isEmpty) - + .disabled(testPhase == .running || !configured) if !testMessage.isEmpty { Text(testMessage) .font(.footnote) .foregroundStyle(testPhase == .failure ? Color.red : Color.green) } - } header: { - Text("Connection") } Section { - Button { - persist() + Button(role: .destructive) { + showingDeleteConfirm = true } label: { - HStack { - Image(systemName: "checkmark.circle") - Text("Save") - .fontWeight(.semibold) - } + Label("Delete Profile", systemImage: "trash") } } } - .navigationTitle("AI") + .navigationTitle(name.isEmpty ? "Profile" : name) .navigationBarTitleDisplayMode(.inline) - .onAppear(perform: loadFromStore) - .overlay(alignment: .bottom) { - if showingSavedToast { - Text("Saved") - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(Color.green.opacity(0.9)) - .foregroundColor(.white) - .cornerRadius(16) - .padding(.bottom, 24) - .transition(.opacity) + .onAppear(perform: load) + .alert("Delete this profile?", isPresented: $showingDeleteConfirm) { + Button("Delete", role: .destructive) { deleteSelf() } + Button("Cancel", role: .cancel) { } + } message: { + Text("This cannot be undone.") + } + .alert("Profile not found", isPresented: $notFound) { + Button("OK") { dismiss() } + } + } + + private var modelSuggestions: [String] { + let host = URL(string: AIProfile( + id: "", name: "", baseURL: baseURL, model: "", apiKey: "", authMethod: .apiKey + ).normalizedBaseURL)?.host ?? "" + if host.contains("deepseek") { + return ["deepseek-chat", "deepseek-reasoner"] + } else if host.contains("x.ai") { + return ["grok-2-latest", "grok-2-mini"] + } else if host.contains("localhost") || host.contains("127.0.0.1") { + return ["llama3.2", "qwen2.5-coder", "mistral"] + } else { + return ["gpt-4o-mini", "gpt-4o", "gpt-4.1-mini", "gpt-4.1", "o4-mini"] + } + } + + @ViewBuilder + private var oauthSection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Status") + Spacer() + switch oauthState { + case .idle: Text("Not signed in").foregroundStyle(.secondary) + case .signingIn: Text("Signing in…").foregroundStyle(.secondary) + case .signedIn: Text("Signed in").foregroundColor(.green) + } + } + if oauthState == .signedIn { + if !oauthAccountID.isEmpty { + HStack { + Text("Account").foregroundStyle(.secondary) + Spacer() + Text(oauthAccountID) + .font(.footnote.monospaced()) + .lineLimit(1) + .truncationMode(.middle) + } + } + if let expires = oauthExpiresAt { + HStack { + Text("Expires").foregroundStyle(.secondary) + Spacer() + Text(expires, style: .relative) + .font(.footnote) + } + } + } + if let oauthError { + Text(oauthError) + .font(.footnote) + .foregroundColor(.red) + } + HStack { + Button { + Task { await runOAuth() } + } label: { + HStack { + if oauthState == .signingIn { ProgressView().controlSize(.small) } + Image(systemName: "person.badge.key") + Text(oauthState == .signedIn ? "Re-Sign In" : "Sign in with OpenAI") + } + } + .disabled(oauthState == .signingIn) + + if oauthState == .signedIn { + Button(role: .destructive) { + signOut() + } label: { + Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right") + } + } } } } - private var testButtonLabel: String { - switch testPhase { - case .idle: return "Test Connection" - case .running: return "Testing..." - case .success: return "Test Connection" - case .failure: return "Test Connection" + private var configured: Bool { + if authMethod == .apiKey { + return !apiKey.trimmingCharacters(in: .whitespaces).isEmpty + } else { + return oauthState == .signedIn } } - private func loadFromStore() { - let s = AISettingsStore.shared.load() - apiKey = s.apiKey - baseURL = s.baseURL - model = s.model - maxIterations = s.maxIterations - temperature = s.temperature + private func load() { + guard let profile = AISettingsStore.shared.loadProfiles().first(where: { $0.id == profileID }) else { + notFound = true + return + } + name = profile.name + baseURL = profile.baseURL + model = profile.model + apiKey = profile.apiKey + authMethod = profile.authMethod + if profile.authMethod == .oauth, !profile.apiKey.isEmpty { + if let accountID = AIOpenAIOAuthService.accountID(fromAccessToken: profile.apiKey), + let stored = AIOpenAIOAuthVault.credential(for: accountID) { + oauthState = .signedIn + oauthAccountID = accountID + oauthExpiresAt = stored.expiresAt + } else { + oauthState = .idle + } + } else { + oauthState = .idle + } } - private func persist() { - let settings = AISettings( - apiKey: apiKey.trimmingCharacters(in: .whitespacesAndNewlines), + private func currentSnapshot() -> AIProfile { + AIProfile( + id: profileID, + name: name.trimmingCharacters(in: .whitespacesAndNewlines), baseURL: baseURL.trimmingCharacters(in: .whitespacesAndNewlines), model: model.trimmingCharacters(in: .whitespacesAndNewlines), - maxIterations: maxIterations, - temperature: temperature + apiKey: apiKey.trimmingCharacters(in: .whitespacesAndNewlines), + authMethod: authMethod ) - AISettingsStore.shared.save(settings) - withAnimation { showingSavedToast = true } - DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { - withAnimation { showingSavedToast = false } - } + } + + private func persist() { + AISettingsStore.shared.upsertProfile(currentSnapshot()) + onChange() + } + + private func deleteSelf() { + AISettingsStore.shared.deleteProfile(id: profileID) + onChange() + dismiss() } private func runTest() { + // Persist first so the snapshot the AIClient picks up is current. persist() - let settings = AISettingsStore.shared.load() + let combined = AISettings( + profile: currentSnapshot(), + maxIterations: AISettingsStore.shared.loadMaxIterations(), + temperature: AISettingsStore.shared.loadTemperature() + ) testPhase = .running testMessage = "" Task { @@ -192,7 +449,7 @@ struct SettingAIView: View { AIMessage(role: .system, content: "You reply with exactly: pong"), AIMessage(role: .user, content: "ping"), ] - let result = try await AIClient.shared.chat(messages: messages, settings: settings) + let result = try await AIClient.shared.chat(messages: messages, settings: combined) await MainActor.run { testPhase = .success testMessage = "OK — \(result.content.prefix(60)) (\(result.usage.totalTokens) tokens)" @@ -205,4 +462,33 @@ struct SettingAIView: View { } } } + + @MainActor + private func runOAuth() async { + oauthState = .signingIn + oauthError = nil + do { + let credential = try await AIOpenAIOAuthService.signIn(originator: "scriptwidget") + AIOpenAIOAuthVault.save(credential) + apiKey = credential.accessToken + oauthAccountID = credential.accountID + oauthExpiresAt = credential.expiresAt + oauthState = .signedIn + persist() + } catch { + oauthError = error.localizedDescription + oauthState = .idle + } + } + + private func signOut() { + if !oauthAccountID.isEmpty { + AIOpenAIOAuthVault.remove(accountID: oauthAccountID) + } + apiKey = "" + oauthAccountID = "" + oauthExpiresAt = nil + oauthState = .idle + persist() + } } diff --git a/iOS/ScriptWidget/View/AIGenerate/AIGenerateView.swift b/iOS/ScriptWidget/View/AIGenerate/AIGenerateView.swift index 954b1f3..53d75b8 100644 --- a/iOS/ScriptWidget/View/AIGenerate/AIGenerateView.swift +++ b/iOS/ScriptWidget/View/AIGenerate/AIGenerateView.swift @@ -14,6 +14,8 @@ struct AIGenerateView: View { @State private var prompt: String = "" @State private var showReview = false + @State private var profiles: [AIProfile] = [] + @State private var activeProfileID: String = "" private let placeholderPrompt = "e.g. Show the current weather for my location, with a minimalist dark background." @@ -43,6 +45,24 @@ struct AIGenerateView: View { examplesSection + if profiles.count > 1 { + HStack { + Text("Profile") + .font(.subheadline) + Spacer() + Picker("", selection: $activeProfileID) { + ForEach(profiles) { profile in + Text(profile.name.isEmpty ? "Unnamed" : profile.name).tag(profile.id) + } + } + .pickerStyle(.menu) + .labelsHidden() + .onChange(of: activeProfileID) { newValue in + AISettingsStore.shared.setActiveProfile(id: newValue) + } + } + } + Picker("Size", selection: $session.size) { ForEach(AIWidgetSize.allCases) { size in Text(size.displayName).tag(size) @@ -72,6 +92,10 @@ struct AIGenerateView: View { } .navigationTitle("Generate") .navigationBarTitleDisplayMode(.inline) + .onAppear(perform: loadProfiles) + .onReceive(NotificationCenter.default.publisher(for: AISettingsStore.changedNotification)) { _ in + loadProfiles() + } .onChange(of: session.phase) { newPhase in if case .done = newPhase { showReview = true @@ -105,6 +129,11 @@ struct AIGenerateView: View { } } + private func loadProfiles() { + profiles = AISettingsStore.shared.loadProfiles() + activeProfileID = AISettingsStore.shared.loadActiveProfileID() + } + private var hasOutcome: Bool { switch session.phase { case .idle: return false diff --git a/macOS/ScriptWidgetMac.xcodeproj/project.pbxproj b/macOS/ScriptWidgetMac.xcodeproj/project.pbxproj index 1e61895..489ccc1 100644 --- a/macOS/ScriptWidgetMac.xcodeproj/project.pbxproj +++ b/macOS/ScriptWidgetMac.xcodeproj/project.pbxproj @@ -29,6 +29,9 @@ A202000700000000000000B0 /* AIGenerateSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000700000000000000B0 /* AIGenerateSession.swift */; }; A202000800000000000000B0 /* AIGenerateProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000800000000000000B0 /* AIGenerateProgressView.swift */; }; A202000900000000000000B0 /* AIExamplePrompts.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000900000000000000B0 /* AIExamplePrompts.swift */; }; + A202000A00000000000000B0 /* AIKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000A00000000000000B0 /* AIKeychain.swift */; }; + A202000B00000000000000B0 /* AILocalOAuthServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000B00000000000000B0 /* AILocalOAuthServer.swift */; }; + A202000C00000000000000B0 /* AIOpenAIOAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000C00000000000000B0 /* AIOpenAIOAuth.swift */; }; A204000100000000000000B0 /* SettingAIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A203000100000000000000B0 /* SettingAIView.swift */; }; A204000200000000000000B0 /* AIGenerateWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A203000200000000000000B0 /* AIGenerateWindowView.swift */; }; A206000300000000000000B0 /* SwiftOpenAI in Frameworks */ = {isa = PBXBuildFile; productRef = A206000200000000000000B0 /* SwiftOpenAI */; }; @@ -240,6 +243,9 @@ A201000700000000000000B0 /* AIGenerateSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIGenerateSession.swift; sourceTree = ""; }; A201000800000000000000B0 /* AIGenerateProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIGenerateProgressView.swift; sourceTree = ""; }; A201000900000000000000B0 /* AIExamplePrompts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIExamplePrompts.swift; sourceTree = ""; }; + A201000A00000000000000B0 /* AIKeychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIKeychain.swift; sourceTree = ""; }; + A201000B00000000000000B0 /* AILocalOAuthServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AILocalOAuthServer.swift; sourceTree = ""; }; + A201000C00000000000000B0 /* AIOpenAIOAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIOpenAIOAuth.swift; sourceTree = ""; }; A203000100000000000000B0 /* SettingAIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingAIView.swift; sourceTree = ""; }; A203000200000000000000B0 /* AIGenerateWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIGenerateWindowView.swift; sourceTree = ""; }; CD9F1F298DF5C4FAED8AAD43 /* ScriptWidgetRuntimeHealth.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScriptWidgetRuntimeHealth.swift; sourceTree = ""; }; @@ -388,6 +394,9 @@ A201000700000000000000B0 /* AIGenerateSession.swift */, A201000800000000000000B0 /* AIGenerateProgressView.swift */, A201000900000000000000B0 /* AIExamplePrompts.swift */, + A201000A00000000000000B0 /* AIKeychain.swift */, + A201000B00000000000000B0 /* AILocalOAuthServer.swift */, + A201000C00000000000000B0 /* AIOpenAIOAuth.swift */, ); name = AI; path = ../Shared/ScriptWidgetRuntime/AI; @@ -972,6 +981,9 @@ files = ( A207000200000000000000B0 /* EditorSchemeHandler.swift in Sources */, A202000900000000000000B0 /* AIExamplePrompts.swift in Sources */, + A202000A00000000000000B0 /* AIKeychain.swift in Sources */, + A202000B00000000000000B0 /* AILocalOAuthServer.swift in Sources */, + A202000C00000000000000B0 /* AIOpenAIOAuth.swift in Sources */, A202000100000000000000B0 /* AISettings.swift in Sources */, A202000200000000000000B0 /* AIReferenceSnapshot.swift in Sources */, A202000300000000000000B0 /* PromptBuilder.swift in Sources */, diff --git a/macOS/ScriptWidgetMac/AIGenerate/AIGenerateWindowView.swift b/macOS/ScriptWidgetMac/AIGenerate/AIGenerateWindowView.swift index 7053f3a..821633f 100644 --- a/macOS/ScriptWidgetMac/AIGenerate/AIGenerateWindowView.swift +++ b/macOS/ScriptWidgetMac/AIGenerate/AIGenerateWindowView.swift @@ -25,6 +25,8 @@ struct AIGenerateWindowView: View { @State private var showingLogs: Bool = false @State private var isDebugMode: Bool = false @State private var previewPackage: ScriptWidgetPackage? + @State private var profiles: [AIProfile] = [] + @State private var activeProfileID: String = "" private var jsx: String { session.lastJSX ?? "" } private var hasResult: Bool { @@ -56,6 +58,10 @@ struct AIGenerateWindowView: View { .onAppear { ensurePreviewPackage() prefillSaveNameIfNeeded() + loadProfiles() + } + .onReceive(NotificationCenter.default.publisher(for: AISettingsStore.changedNotification)) { _ in + loadProfiles() } .onChange(of: jsx) { _ in refreshPreviewPackage() @@ -101,6 +107,21 @@ struct AIGenerateWindowView: View { examplesSection + if profiles.count > 1 { + HStack { + Text("Profile") + Picker("", selection: $activeProfileID) { + ForEach(profiles) { profile in + Text(profile.name.isEmpty ? "Unnamed" : profile.name).tag(profile.id) + } + } + .labelsHidden() + .onChange(of: activeProfileID) { newValue in + AISettingsStore.shared.setActiveProfile(id: newValue) + } + } + } + HStack { Text("Size") Picker("", selection: $session.size) { @@ -331,6 +352,11 @@ struct AIGenerateWindowView: View { ) } + private func loadProfiles() { + profiles = AISettingsStore.shared.loadProfiles() + activeProfileID = AISettingsStore.shared.loadActiveProfileID() + } + private func ensurePreviewPackage() { if previewPackage == nil { previewPackage = try? AgentRuntimeBridge.shared.makeSandboxPackage(prefix: "preview") diff --git a/macOS/ScriptWidgetMac/Settings/SettingAIView.swift b/macOS/ScriptWidgetMac/Settings/SettingAIView.swift index d7ca661..68359e1 100644 --- a/macOS/ScriptWidgetMac/Settings/SettingAIView.swift +++ b/macOS/ScriptWidgetMac/Settings/SettingAIView.swift @@ -2,79 +2,331 @@ // SettingAIView.swift // ScriptWidgetMac // -// macOS-flavored AI configuration panel, hosted inside the standard -// Settings scene (Cmd+,). +// Profile-aware AI settings panel. Sidebar lists profiles; the +// detail pane edits the selected profile. Agent-loop knobs are +// global and live in their own section. // import SwiftUI struct SettingAIView: View { - @State private var apiKey: String = "" - @State private var baseURL: String = AISettings.defaultBaseURL - @State private var model: String = AISettings.defaultModel + @State private var profiles: [AIProfile] = [] + @State private var activeID: String = "" + @State private var selectedID: String = "" + @State private var maxIterations: Int = AISettings.defaultMaxIterations @State private var temperature: Double = AISettings.defaultTemperature + + var body: some View { + VStack(spacing: 0) { + HSplitView { + sidebar + .frame(minWidth: 200, idealWidth: 220) + detail + .frame(minWidth: 360) + } + Divider() + agentLoopBar + } + .padding(0) + .frame(minWidth: 720, minHeight: 540) + .onAppear(perform: reload) + .onReceive(NotificationCenter.default.publisher(for: AISettingsStore.changedNotification)) { _ in + reload() + } + } + + // MARK: - sidebar + + private var sidebar: some View { + VStack(spacing: 0) { + HStack { + Text("Profiles").font(.headline) + Spacer() + Button { + addProfile() + } label: { + Image(systemName: "plus") + } + .buttonStyle(.borderless) + .help("New profile") + } + .padding(.horizontal, 12).padding(.top, 12).padding(.bottom, 6) + + List(selection: $selectedID) { + ForEach(profiles) { profile in + profileRow(profile).tag(profile.id) + } + } + .listStyle(.sidebar) + + HStack(spacing: 4) { + Button { + duplicateSelected() + } label: { + Image(systemName: "plus.square.on.square") + } + .buttonStyle(.borderless) + .disabled(selectedID.isEmpty) + .help("Duplicate profile") + + Button { + deleteSelected() + } label: { + Image(systemName: "minus") + } + .buttonStyle(.borderless) + .disabled(selectedID.isEmpty || profiles.count <= 1) + .help("Delete profile") + + Spacer() + } + .padding(.horizontal, 8).padding(.bottom, 8) + } + } + + private func profileRow(_ profile: AIProfile) -> some View { + HStack(spacing: 8) { + Button { + AISettingsStore.shared.setActiveProfile(id: profile.id) + activeID = profile.id + } label: { + Image(systemName: profile.id == activeID ? "checkmark.circle.fill" : "circle") + .foregroundStyle(profile.id == activeID ? Color.accentColor : Color.secondary) + } + .buttonStyle(.plain) + .help(profile.id == activeID ? "Active" : "Set active") + + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 4) { + Text(profile.name.isEmpty ? "Unnamed" : profile.name) + .font(.body) + if profile.authMethod == .oauth { + Text("OAuth") + .font(.caption2) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(Color.accentColor.opacity(0.18)) + .foregroundColor(.accentColor) + .clipShape(Capsule()) + } + } + Text(profileSubtitle(profile)) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + } + + private func profileSubtitle(_ profile: AIProfile) -> String { + let host = URL(string: profile.normalizedBaseURL)?.host ?? profile.normalizedBaseURL + let model = profile.model.isEmpty ? "—" : profile.model + return "\(host) · \(model)" + } + + // MARK: - detail + + @ViewBuilder + private var detail: some View { + if let profile = profiles.first(where: { $0.id == selectedID }) { + AIProfileEditorPane( + profileID: profile.id, + onChange: reload + ) + .id(profile.id) + } else { + VStack { + Spacer() + Text("Select a profile or create a new one.") + .foregroundStyle(.secondary) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + // MARK: - agent loop bar + + private var agentLoopBar: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Agent Loop (global)").font(.headline) + HStack(spacing: 24) { + HStack { + Text("Max Iterations") + Spacer() + Stepper(value: $maxIterations, in: 5...100, step: 5) { + Text("\(maxIterations)").monospacedDigit() + } + .onChange(of: maxIterations) { _ in saveAgentLoop() } + } + + HStack { + Text("Temperature") + Slider(value: $temperature, in: 0.0...1.5, step: 0.05) + .frame(maxWidth: 200) + .onChange(of: temperature) { _ in saveAgentLoop() } + Text(String(format: "%.2f", temperature)) + .monospacedDigit() + .frame(width: 50, alignment: .trailing) + } + } + } + .padding(12) + } + + // MARK: - actions + + private func reload() { + profiles = AISettingsStore.shared.loadProfiles() + activeID = AISettingsStore.shared.loadActiveProfileID() + if selectedID.isEmpty || !profiles.contains(where: { $0.id == selectedID }) { + selectedID = activeID.isEmpty ? (profiles.first?.id ?? "") : activeID + } + maxIterations = AISettingsStore.shared.loadMaxIterations() + temperature = AISettingsStore.shared.loadTemperature() + } + + private func addProfile() { + let new = AIProfile.makeDefault(named: "New Profile") + AISettingsStore.shared.upsertProfile(new) + AISettingsStore.shared.setActiveProfile(id: new.id) + selectedID = new.id + reload() + } + + private func duplicateSelected() { + guard let original = profiles.first(where: { $0.id == selectedID }) else { return } + var copy = original + copy.id = UUID().uuidString + copy.name = original.name + " Copy" + AISettingsStore.shared.upsertProfile(copy) + selectedID = copy.id + reload() + } + + private func deleteSelected() { + guard !selectedID.isEmpty, profiles.count > 1 else { return } + AISettingsStore.shared.deleteProfile(id: selectedID) + selectedID = "" + reload() + } + + private func saveAgentLoop() { + AISettingsStore.shared.saveAgentLoop(maxIterations: maxIterations, temperature: temperature) + } +} + +// MARK: - Editor pane (macOS) + +private struct AIProfileEditorPane: View { + let profileID: String + let onChange: () -> Void + + @State private var name: String = "" + @State private var baseURL: String = AIProfile.defaultBaseURL + @State private var model: String = AIProfile.defaultModel + @State private var apiKey: String = "" + @State private var authMethod: AIAuthMethod = .apiKey @State private var apiKeyVisible: Bool = false + @State private var oauthAccountID: String = "" + @State private var oauthExpiresAt: Date? + @State private var oauthState: OAuthState = .idle + @State private var oauthError: String? + @State private var testPhase: TestPhase = .idle @State private var testMessage: String = "" - private enum TestPhase { case idle, running, success, failure } + enum TestPhase { case idle, running, success, failure } + enum OAuthState { case idle, signingIn, signedIn } - private let modelPresets = ["gpt-4o-mini", "gpt-4o", "gpt-4.1-mini", "o4-mini"] + private static let providerPresets: [(label: String, host: String, models: [String])] = [ + ("OpenAI", "https://api.openai.com", ["gpt-4o-mini", "gpt-4o", "gpt-4.1-mini", "gpt-4.1", "o4-mini"]), + ("DeepSeek", "https://api.deepseek.com", ["deepseek-chat", "deepseek-reasoner"]), + ("xAI", "https://api.x.ai", ["grok-2-latest", "grok-2-mini"]), + ("Local", "http://localhost:11434", ["llama3.2", "qwen2.5-coder"]), + ] var body: some View { Form { - Section("API Key") { - HStack { - Group { - if apiKeyVisible { - TextField("sk-...", text: $apiKey) - } else { - SecureField("sk-...", text: $apiKey) - } - } + Section("Name") { + TextField("Profile name", text: $name) .textFieldStyle(.roundedBorder) + .onChange(of: name) { _ in persist() } + } - Button { - apiKeyVisible.toggle() - } label: { - Image(systemName: apiKeyVisible ? "eye.slash" : "eye") + Section("Provider") { + HStack(spacing: 6) { + ForEach(Self.providerPresets, id: \.label) { preset in + Button(preset.label) { + baseURL = preset.host + if let first = preset.models.first { + model = first + } + persist() + } + .buttonStyle(.bordered) + .controlSize(.small) } - .buttonStyle(.borderless) + Spacer() } - Text("Stored in plain-text UserDefaults on this device. Do not configure on a shared device.") - .font(.caption) - .foregroundColor(.orange) - } - - Section("Endpoint") { TextField("https://api.openai.com", text: $baseURL) .textFieldStyle(.roundedBorder) + .onChange(of: baseURL) { _ in persist() } } Section("Model") { TextField("gpt-4o-mini", text: $model) .textFieldStyle(.roundedBorder) + .onChange(of: model) { _ in persist() } HStack(spacing: 6) { - ForEach(modelPresets, id: \.self) { preset in - Button(preset) { model = preset } - .buttonStyle(.bordered) - .controlSize(.small) + ForEach(modelSuggestions, id: \.self) { preset in + Button(preset) { + model = preset + persist() + } + .buttonStyle(.bordered) + .controlSize(.small) } + Spacer() } } - Section("Agent Loop") { - Stepper(value: $maxIterations, in: 5...100, step: 5) { - Text("Max Iterations: \(maxIterations)") + Section("Authentication") { + Picker("Method", selection: $authMethod) { + Text("API Key").tag(AIAuthMethod.apiKey) + Text("OpenAI OAuth").tag(AIAuthMethod.oauth) } - HStack { - Text("Temperature") - Slider(value: $temperature, in: 0.0...1.5, step: 0.05) - Text(String(format: "%.2f", temperature)) - .monospacedDigit() - .frame(width: 50, alignment: .trailing) + .pickerStyle(.segmented) + .onChange(of: authMethod) { _ in persist() } + + if authMethod == .apiKey { + HStack { + Group { + if apiKeyVisible { + TextField("sk-...", text: $apiKey) + } else { + SecureField("sk-...", text: $apiKey) + } + } + .textFieldStyle(.roundedBorder) + Button { + apiKeyVisible.toggle() + } label: { + Image(systemName: apiKeyVisible ? "eye.slash" : "eye") + } + .buttonStyle(.borderless) + } + .onChange(of: apiKey) { _ in persist() } + Text("API key is stored in plain-text UserDefaults.") + .font(.caption).foregroundColor(.orange) + } else { + oauthSection + Text("OAuth uses the Codex CLI client and the OpenAI host (api.openai.com). Token lives in the Keychain and refreshes automatically.") + .font(.caption).foregroundStyle(.secondary) } } @@ -84,64 +336,153 @@ struct SettingAIView: View { runTest() } label: { HStack { - if testPhase == .running { - ProgressView().controlSize(.small) - } + if testPhase == .running { ProgressView().controlSize(.small) } Text("Test Connection") } } - .disabled(testPhase == .running || apiKey.trimmingCharacters(in: .whitespaces).isEmpty) - + .disabled(testPhase == .running || !configured) if !testMessage.isEmpty { Text(testMessage) .font(.footnote) .foregroundStyle(testPhase == .failure ? Color.red : Color.green) + .lineLimit(2) } Spacer() } } + } + .formStyle(.grouped) + .padding(12) + .onAppear(perform: load) + } - Section { - HStack { - Spacer() - Button { - persist() + private var modelSuggestions: [String] { + let host = URL(string: AIProfile( + id: "", name: "", baseURL: baseURL, model: "", apiKey: "", authMethod: .apiKey + ).normalizedBaseURL)?.host ?? "" + if host.contains("deepseek") { + return ["deepseek-chat", "deepseek-reasoner"] + } else if host.contains("x.ai") { + return ["grok-2-latest", "grok-2-mini"] + } else if host.contains("localhost") || host.contains("127.0.0.1") { + return ["llama3.2", "qwen2.5-coder", "mistral"] + } else { + return ["gpt-4o-mini", "gpt-4o", "gpt-4.1-mini", "gpt-4.1", "o4-mini"] + } + } + + @ViewBuilder + private var oauthSection: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Status") + Spacer() + switch oauthState { + case .idle: Text("Not signed in").foregroundStyle(.secondary) + case .signingIn: Text("Signing in…").foregroundStyle(.secondary) + case .signedIn: Text("Signed in").foregroundColor(.green) + } + } + if oauthState == .signedIn { + if !oauthAccountID.isEmpty { + HStack { + Text("Account").foregroundStyle(.secondary) + Spacer() + Text(oauthAccountID) + .font(.footnote.monospaced()) + .lineLimit(1) + .truncationMode(.middle) + } + } + if let expires = oauthExpiresAt { + HStack { + Text("Expires").foregroundStyle(.secondary) + Spacer() + Text(expires, style: .relative) + .font(.footnote) + } + } + } + if let oauthError { + Text(oauthError) + .font(.footnote) + .foregroundColor(.red) + } + HStack { + Button { + Task { await runOAuth() } + } label: { + HStack { + if oauthState == .signingIn { ProgressView().controlSize(.small) } + Image(systemName: "person.badge.key") + Text(oauthState == .signedIn ? "Re-Sign In" : "Sign in with OpenAI") + } + } + .disabled(oauthState == .signingIn) + + if oauthState == .signedIn { + Button(role: .destructive) { + signOut() } label: { - Label("Save", systemImage: "checkmark.circle") + Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right") } - .keyboardShortcut(.defaultAction) } } } - .formStyle(.grouped) - .padding(12) - .frame(minWidth: 520, minHeight: 520) - .onAppear(perform: loadFromStore) } - private func loadFromStore() { - let s = AISettingsStore.shared.load() - apiKey = s.apiKey - baseURL = s.baseURL - model = s.model - maxIterations = s.maxIterations - temperature = s.temperature + private var configured: Bool { + if authMethod == .apiKey { + return !apiKey.trimmingCharacters(in: .whitespaces).isEmpty + } else { + return oauthState == .signedIn + } + } + + private func load() { + guard let profile = AISettingsStore.shared.loadProfiles().first(where: { $0.id == profileID }) else { return } + name = profile.name + baseURL = profile.baseURL + model = profile.model + apiKey = profile.apiKey + authMethod = profile.authMethod + if profile.authMethod == .oauth, !profile.apiKey.isEmpty { + if let accountID = AIOpenAIOAuthService.accountID(fromAccessToken: profile.apiKey), + let stored = AIOpenAIOAuthVault.credential(for: accountID) { + oauthState = .signedIn + oauthAccountID = accountID + oauthExpiresAt = stored.expiresAt + } else { + oauthState = .idle + } + } else { + oauthState = .idle + } } - private func persist() { - let settings = AISettings( - apiKey: apiKey.trimmingCharacters(in: .whitespacesAndNewlines), + private func currentSnapshot() -> AIProfile { + AIProfile( + id: profileID, + name: name.trimmingCharacters(in: .whitespacesAndNewlines), baseURL: baseURL.trimmingCharacters(in: .whitespacesAndNewlines), model: model.trimmingCharacters(in: .whitespacesAndNewlines), - maxIterations: maxIterations, - temperature: temperature + apiKey: apiKey.trimmingCharacters(in: .whitespacesAndNewlines), + authMethod: authMethod ) - AISettingsStore.shared.save(settings) + } + + private func persist() { + AISettingsStore.shared.upsertProfile(currentSnapshot()) + onChange() } private func runTest() { persist() - let settings = AISettingsStore.shared.load() + let combined = AISettings( + profile: currentSnapshot(), + maxIterations: AISettingsStore.shared.loadMaxIterations(), + temperature: AISettingsStore.shared.loadTemperature() + ) testPhase = .running testMessage = "" Task { @@ -150,7 +491,7 @@ struct SettingAIView: View { AIMessage(role: .system, content: "You reply with exactly: pong"), AIMessage(role: .user, content: "ping"), ] - let result = try await AIClient.shared.chat(messages: messages, settings: settings) + let result = try await AIClient.shared.chat(messages: messages, settings: combined) await MainActor.run { testPhase = .success testMessage = "OK — \(result.content.prefix(60)) (\(result.usage.totalTokens) tokens)" @@ -163,4 +504,33 @@ struct SettingAIView: View { } } } + + @MainActor + private func runOAuth() async { + oauthState = .signingIn + oauthError = nil + do { + let credential = try await AIOpenAIOAuthService.signIn(originator: "scriptwidget") + AIOpenAIOAuthVault.save(credential) + apiKey = credential.accessToken + oauthAccountID = credential.accountID + oauthExpiresAt = credential.expiresAt + oauthState = .signedIn + persist() + } catch { + oauthError = error.localizedDescription + oauthState = .idle + } + } + + private func signOut() { + if !oauthAccountID.isEmpty { + AIOpenAIOAuthVault.remove(accountID: oauthAccountID) + } + apiKey = "" + oauthAccountID = "" + oauthExpiresAt = nil + oauthState = .idle + persist() + } } From 9ef8974aafbdffcb8bcb1491228731ddae131361 Mon Sep 17 00:00:00 2001 From: everettjf Date: Sun, 3 May 2026 20:08:11 -0700 Subject: [PATCH 10/13] fix(ai): move profile apiKey from UserDefaults to Keychain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The apiKey field on AIProfile was being JSON-encoded into the app-group UserDefaults alongside non-secret fields. Move it into the Keychain (account scriptwidget.profile.apiKey.) and hydrate it back onto AIProfile on read. Encoding now omits apiKey; the decoder still accepts it as a legacy fallback during migration. Migration runs once per install, gated by ai.profiles.apiKeyKeychainMigrated.v1: existing JSON profiles are decoded with apiKey intact, then re-saved through the new path which writes the keys to Keychain and rewrites the JSON without them. Update settings copy on both targets to match — drop the orange "plain-text UserDefaults" warning. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ScriptWidgetRuntime/AI/AISettings.swift | 129 ++++++++++++++---- .../App/Settings/SettingAIView.swift | 4 +- .../Settings/SettingAIView.swift | 4 +- 3 files changed, 110 insertions(+), 27 deletions(-) diff --git a/Shared/ScriptWidgetRuntime/AI/AISettings.swift b/Shared/ScriptWidgetRuntime/AI/AISettings.swift index 80ba2ae..803ec3c 100644 --- a/Shared/ScriptWidgetRuntime/AI/AISettings.swift +++ b/Shared/ScriptWidgetRuntime/AI/AISettings.swift @@ -6,13 +6,18 @@ // // Storage model // ------------- -// - AIProfile: one provider/model entry. Codable, persisted as JSON in -// the app-group UserDefaults under `ai.profiles.v2`. +// - AIProfile: one provider/model entry. Non-secret fields persist as +// JSON in the app-group UserDefaults under `ai.profiles.v2`. The +// apiKey field is held separately in the Keychain (account +// `scriptwidget.profile.apiKey.`) and never written to +// UserDefaults. // - Active profile id under `ai.activeProfileID`. -// - Agent-loop globals (maxIterations / temperature) are app-wide and -// live on AISettings. -// - Legacy single-profile keys (ai.apiKey, ai.baseURL, ai.model) are -// migrated into a "Default" profile on first load. +// - Agent-loop globals (maxIterations / temperature) are app-wide. +// - Legacy single-profile UserDefaults keys (ai.apiKey, ai.baseURL, +// ai.model) are migrated into a "Default" profile on first load. +// - Profiles persisted before the Keychain migration carry their key +// in JSON; on first load with this build the keys are moved into +// Keychain and the JSON re-written without them. // // Auth methods per profile: // .apiKey → settings.apiKey holds the literal key. @@ -29,11 +34,13 @@ enum AIAuthMethod: String, Codable { case oauth } -struct AIProfile: Codable, Identifiable, Equatable { +struct AIProfile: Identifiable, Equatable { var id: String var name: String var baseURL: String var model: String + /// Lives in Keychain, hydrated by `AISettingsStore` after JSON decode + /// and stripped before encode. Never persisted to UserDefaults. var apiKey: String var authMethod: AIAuthMethod @@ -52,12 +59,7 @@ struct AIProfile: Codable, Identifiable, Equatable { } var isConfigured: Bool { - switch authMethod { - case .apiKey: - return !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - case .oauth: - return !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } + !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } var normalizedBaseURL: String { @@ -84,13 +86,43 @@ struct AIProfile: Codable, Identifiable, Equatable { } } +extension AIProfile: Codable { + private enum CodingKeys: String, CodingKey { + case id, name, baseURL, model, authMethod + // Legacy: older builds stored apiKey in JSON. Read it back during + // migration; never write it. + case apiKey + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + name = try c.decode(String.self, forKey: .name) + baseURL = try c.decode(String.self, forKey: .baseURL) + model = try c.decode(String.self, forKey: .model) + authMethod = try c.decode(AIAuthMethod.self, forKey: .authMethod) + apiKey = try c.decodeIfPresent(String.self, forKey: .apiKey) ?? "" + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(id, forKey: .id) + try c.encode(name, forKey: .name) + try c.encode(baseURL, forKey: .baseURL) + try c.encode(model, forKey: .model) + try c.encode(authMethod, forKey: .authMethod) + // apiKey is intentionally omitted — Keychain owns it. + } +} + enum AISettingsKey { // v2 profile storage - static let profiles = "ai.profiles.v2" - static let activeProfileID = "ai.activeProfileID" + static let profiles = "ai.profiles.v2" + static let activeProfileID = "ai.activeProfileID" + static let apiKeyKeychainMigratedFlag = "ai.profiles.apiKeyKeychainMigrated.v1" // agent-loop globals - static let maxIterations = "ai.maxIterations" - static let temperature = "ai.temperature" + static let maxIterations = "ai.maxIterations" + static let temperature = "ai.temperature" // legacy keys (read-only; only used during migration) static let legacyAPIKey = "ai.apiKey" static let legacyBaseURL = "ai.baseURL" @@ -130,12 +162,15 @@ final class AISettingsStore { static let changedNotification = Notification.Name("AISettingsStoreChanged") + private static let apiKeyAccountPrefix = "scriptwidget.profile.apiKey" + private let defaults: UserDefaults - private let queue = DispatchQueue(label: "ai.settings.store") + private let keychain = AIKeychain.live private init() { self.defaults = UserDefaults(suiteName: "group.everettjf.scriptwidget") ?? .standard migrateLegacyIfNeeded() + migrateAPIKeysToKeychainIfNeeded() } // MARK: - Profiles @@ -144,7 +179,7 @@ final class AISettingsStore { if let data = defaults.data(forKey: AISettingsKey.profiles), let decoded = try? JSONDecoder().decode([AIProfile].self, from: data), !decoded.isEmpty { - return decoded + return decoded.map { hydrateAPIKey($0) } } // Should be unreachable after migrate(), but be defensive. let fresh = [AIProfile.makeDefault()] @@ -170,6 +205,12 @@ final class AISettingsStore { } func saveProfiles(_ profiles: [AIProfile], activeID: String? = nil, notify: Bool = true) { + // Persist secrets to Keychain first so a crash between the two + // writes leaves us with extra Keychain entries (harmless) rather + // than a JSON that references missing keys. + for profile in profiles { + persistAPIKey(profile) + } let data = try? JSONEncoder().encode(profiles) defaults.set(data, forKey: AISettingsKey.profiles) if let activeID { @@ -199,6 +240,7 @@ final class AISettingsStore { } func deleteProfile(id: String) { + keychain.removeValue(for: apiKeyAccount(for: id)) var profiles = loadProfiles() profiles.removeAll(where: { $0.id == id }) if profiles.isEmpty { @@ -235,6 +277,27 @@ final class AISettingsStore { ) } + // MARK: - Keychain plumbing + + private func apiKeyAccount(for id: String) -> String { + "\(Self.apiKeyAccountPrefix).\(id)" + } + + private func hydrateAPIKey(_ profile: AIProfile) -> AIProfile { + var hydrated = profile + hydrated.apiKey = keychain.value(for: apiKeyAccount(for: profile.id)) ?? "" + return hydrated + } + + private func persistAPIKey(_ profile: AIProfile) { + let trimmed = profile.apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + keychain.removeValue(for: apiKeyAccount(for: profile.id)) + } else { + keychain.set(trimmed, for: apiKeyAccount(for: profile.id)) + } + } + // MARK: - Migration private func migrateLegacyIfNeeded() { @@ -255,10 +318,30 @@ final class AISettingsStore { authMethod: .apiKey ) - if let data = try? JSONEncoder().encode([profile]) { - defaults.set(data, forKey: AISettingsKey.profiles) + // Route through saveProfiles so the Keychain receives the key and + // the JSON ends up clean. Mark the keychain-migration flag too, + // because the data we just wrote already conforms to the new + // shape (no apiKey embedded). + saveProfiles([profile], activeID: profile.id, notify: false) + defaults.set(true, forKey: AISettingsKey.apiKeyKeychainMigratedFlag) + } + + private func migrateAPIKeysToKeychainIfNeeded() { + if defaults.bool(forKey: AISettingsKey.apiKeyKeychainMigratedFlag) { + return + } + guard let data = defaults.data(forKey: AISettingsKey.profiles), + let decoded = try? JSONDecoder().decode([AIProfile].self, from: data), + !decoded.isEmpty else { + // Nothing to migrate; mark done so we don't re-check on every + // launch. + defaults.set(true, forKey: AISettingsKey.apiKeyKeychainMigratedFlag) + return } - defaults.set(profile.id, forKey: AISettingsKey.activeProfileID) - // Legacy keys are left in place — harmless. Future writes go to v2. + // `decoded` profiles carry the legacy apiKey in-memory because the + // decoder reads it when present. Persist them: saveProfiles writes + // each apiKey to Keychain and re-encodes the JSON without it. + saveProfiles(decoded, notify: false) + defaults.set(true, forKey: AISettingsKey.apiKeyKeychainMigratedFlag) } } diff --git a/iOS/ScriptWidget/App/Settings/SettingAIView.swift b/iOS/ScriptWidget/App/Settings/SettingAIView.swift index a6d79fa..2ae0a45 100644 --- a/iOS/ScriptWidget/App/Settings/SettingAIView.swift +++ b/iOS/ScriptWidget/App/Settings/SettingAIView.swift @@ -257,8 +257,8 @@ struct AIProfileEditorView: View { Text("Authentication") } footer: { if authMethod == .apiKey { - Text("API key is stored in plain-text UserDefaults. OAuth credentials, when used, are stored in the Keychain instead.") - .foregroundColor(.orange) + Text("API key is stored in the Keychain on this device.") + .foregroundColor(.secondary) } else { Text("OAuth uses the Codex CLI client and only works with the OpenAI host (api.openai.com). The token is stored in the Keychain and refreshed automatically.") .foregroundColor(.secondary) diff --git a/macOS/ScriptWidgetMac/Settings/SettingAIView.swift b/macOS/ScriptWidgetMac/Settings/SettingAIView.swift index 68359e1..b5d7123 100644 --- a/macOS/ScriptWidgetMac/Settings/SettingAIView.swift +++ b/macOS/ScriptWidgetMac/Settings/SettingAIView.swift @@ -321,8 +321,8 @@ private struct AIProfileEditorPane: View { .buttonStyle(.borderless) } .onChange(of: apiKey) { _ in persist() } - Text("API key is stored in plain-text UserDefaults.") - .font(.caption).foregroundColor(.orange) + Text("API key is stored in the Keychain on this device.") + .font(.caption).foregroundColor(.secondary) } else { oauthSection Text("OAuth uses the Codex CLI client and the OpenAI host (api.openai.com). Token lives in the Keychain and refreshes automatically.") From e55ac1e4fcae4b37f66c805ae15cdbd3506b1da9 Mon Sep 17 00:00:00 2001 From: everettjf Date: Sun, 3 May 2026 20:14:58 -0700 Subject: [PATCH 11/13] chore: clear safe deprecation warnings under iOS 26 / macOS 14 SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix the deprecation warnings that have a one-line, behavior-preserving replacement: - onChange(of:perform:) → zero/two-arg closure form (deployment targets are iOS 18 and macOS 14, both ≥17/14). - String(contentsOfFile:) → with explicit utf8 encoding. - NSLocale.current.languageCode → Locale.current.language.languageCode.identifier. - kUTTypeURL / CoreServices → UTType.url / UniformTypeIdentifiers. - Text + Text(Image(...)) + Text → string interpolation. - Move CLLocationManager guards under #else so the IsWidgetTarget branch's `return` no longer leaves dead code on the other path. Skipped (need their own commits or architectural decisions): - UIScreen.main inside the bundled TOCropViewController fork. - NavigationLink(isActive:destination:label:) → NavigationStack migration. - Sendable / strict concurrency warnings on ScriptManager and AnyShape. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../API/ScriptWidgetRuntimeDevice.swift | 4 ++-- .../API/ScriptWidgetRuntimeLocation.swift | 19 ++++++------------- .../App/Settings/SettingAIView.swift | 14 +++++++------- .../View/AIGenerate/AIGenerateView.swift | 4 ++-- .../View/AIGenerate/AIReviewView.swift | 2 +- .../MirrorEditorInternalView.swift | 2 +- .../Preview/ScriptCodePreviewView.swift | 6 +++--- .../View/EmptyListBackgroundView.swift | 8 +------- iOS/ScriptWidget/View/FileDetailView.swift | 4 ++-- .../ShareViewController.swift | 4 ++-- .../AIGenerate/AIGenerateWindowView.swift | 4 ++-- .../Editor/Preview/PreviewView.swift | 6 +++--- .../Settings/SettingAIView.swift | 14 +++++++------- 13 files changed, 39 insertions(+), 52 deletions(-) diff --git a/Shared/ScriptWidgetRuntime/Widget/API/ScriptWidgetRuntimeDevice.swift b/Shared/ScriptWidgetRuntime/Widget/API/ScriptWidgetRuntimeDevice.swift index 061f660..f1e28f3 100644 --- a/Shared/ScriptWidgetRuntime/Widget/API/ScriptWidgetRuntimeDevice.swift +++ b/Shared/ScriptWidgetRuntime/Widget/API/ScriptWidgetRuntimeDevice.swift @@ -66,7 +66,7 @@ import UIKit } static func language() -> String { - return NSLocale.current.languageCode ?? "" + return Locale.current.language.languageCode?.identifier ?? "" } static func model() -> String { @@ -138,7 +138,7 @@ import UIKit } static func language() -> String { - return NSLocale.current.languageCode ?? "" + return Locale.current.language.languageCode?.identifier ?? "" } static func model() -> String { diff --git a/Shared/ScriptWidgetRuntime/Widget/API/ScriptWidgetRuntimeLocation.swift b/Shared/ScriptWidgetRuntime/Widget/API/ScriptWidgetRuntimeLocation.swift index fe2e3be..8b9d45d 100644 --- a/Shared/ScriptWidgetRuntime/Widget/API/ScriptWidgetRuntimeLocation.swift +++ b/Shared/ScriptWidgetRuntime/Widget/API/ScriptWidgetRuntimeLocation.swift @@ -40,13 +40,13 @@ import CoreLocation return "authorizedWhenInUse" } return "notDetermined" - #endif - + #else guard CLLocationManager.locationServicesEnabled() else { return "disabled" } let manager = CLLocationManager() return statusString(for: manager.authorizationStatus) + #endif } static func requestAuthorization() -> ScriptWidgetRuntimePromise { @@ -57,9 +57,7 @@ import CoreLocation return ScriptWidgetRuntimePromise { resolve, reject in #if IsWidgetTarget resolve.call(withArguments: [cachedLocationPayload(maxAgeSeconds: nil) != nil]) - return - #endif - + #else guard CLLocationManager.locationServicesEnabled() else { reject.call(withArguments: ["Location services are disabled on this device"]) return @@ -74,12 +72,7 @@ import CoreLocation resolve.call(withArguments: [false]) return case .notDetermined: - #if IsWidgetTarget - resolve.call(withArguments: [false]) - return - #else break - #endif @unknown default: resolve.call(withArguments: [false]) return @@ -97,6 +90,7 @@ import CoreLocation ) activeRequests.append(request) request.start() + #endif } } @@ -113,9 +107,7 @@ import CoreLocation } else { reject.call(withArguments: ["Location data unavailable. Open the main app to refresh location."]) } - return - #endif - + #else guard CLLocationManager.locationServicesEnabled() else { reject.call(withArguments: ["Location services are disabled on this device"]) return @@ -150,6 +142,7 @@ import CoreLocation ) activeRequests.append(request) request.start() + #endif } } diff --git a/iOS/ScriptWidget/App/Settings/SettingAIView.swift b/iOS/ScriptWidget/App/Settings/SettingAIView.swift index 2ae0a45..bee0abc 100644 --- a/iOS/ScriptWidget/App/Settings/SettingAIView.swift +++ b/iOS/ScriptWidget/App/Settings/SettingAIView.swift @@ -52,7 +52,7 @@ struct SettingAIView: View { .foregroundStyle(.secondary) } } - .onChange(of: maxIterations) { _ in saveAgentLoop() } + .onChange(of: maxIterations) { saveAgentLoop() } VStack(alignment: .leading) { HStack { @@ -62,7 +62,7 @@ struct SettingAIView: View { .foregroundStyle(.secondary) } Slider(value: $temperature, in: 0.0...1.5, step: 0.05) - .onChange(of: temperature) { _ in saveAgentLoop() } + .onChange(of: temperature) { saveAgentLoop() } } } } @@ -178,7 +178,7 @@ struct AIProfileEditorView: View { Section("Name") { TextField("Profile name", text: $name) .textInputAutocapitalization(.words) - .onChange(of: name) { _ in persist() } + .onChange(of: name) { persist() } } Section("Provider") { @@ -201,14 +201,14 @@ struct AIProfileEditorView: View { .textInputAutocapitalization(.never) .autocorrectionDisabled() .keyboardType(.URL) - .onChange(of: baseURL) { _ in persist() } + .onChange(of: baseURL) { persist() } } Section("Model") { TextField("gpt-4o-mini", text: $model) .textInputAutocapitalization(.never) .autocorrectionDisabled() - .onChange(of: model) { _ in persist() } + .onChange(of: model) { persist() } ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 6) { ForEach(modelSuggestions, id: \.self) { preset in @@ -229,7 +229,7 @@ struct AIProfileEditorView: View { Text("OpenAI OAuth").tag(AIAuthMethod.oauth) } .pickerStyle(.segmented) - .onChange(of: authMethod) { _ in persist() } + .onChange(of: authMethod) { persist() } if authMethod == .apiKey { HStack { @@ -249,7 +249,7 @@ struct AIProfileEditorView: View { } .buttonStyle(.borderless) } - .onChange(of: apiKey) { _ in persist() } + .onChange(of: apiKey) { persist() } } else { oauthSection } diff --git a/iOS/ScriptWidget/View/AIGenerate/AIGenerateView.swift b/iOS/ScriptWidget/View/AIGenerate/AIGenerateView.swift index 53d75b8..087c9d0 100644 --- a/iOS/ScriptWidget/View/AIGenerate/AIGenerateView.swift +++ b/iOS/ScriptWidget/View/AIGenerate/AIGenerateView.swift @@ -57,7 +57,7 @@ struct AIGenerateView: View { } .pickerStyle(.menu) .labelsHidden() - .onChange(of: activeProfileID) { newValue in + .onChange(of: activeProfileID) { _, newValue in AISettingsStore.shared.setActiveProfile(id: newValue) } } @@ -96,7 +96,7 @@ struct AIGenerateView: View { .onReceive(NotificationCenter.default.publisher(for: AISettingsStore.changedNotification)) { _ in loadProfiles() } - .onChange(of: session.phase) { newPhase in + .onChange(of: session.phase) { _, newPhase in if case .done = newPhase { showReview = true } else if case .exhausted = newPhase { diff --git a/iOS/ScriptWidget/View/AIGenerate/AIReviewView.swift b/iOS/ScriptWidget/View/AIGenerate/AIReviewView.swift index 4a774ed..743f1c3 100644 --- a/iOS/ScriptWidget/View/AIGenerate/AIReviewView.swift +++ b/iOS/ScriptWidget/View/AIGenerate/AIReviewView.swift @@ -68,7 +68,7 @@ struct AIReviewView: View { Text(saveError ?? "") } .onAppear(perform: ensurePreviewPackage) - .onChange(of: jsx) { _ in + .onChange(of: jsx) { refreshPreviewPackage() } } diff --git a/iOS/ScriptWidget/View/CodeEditor/MirrorEditor/MirrorEditorInternalView.swift b/iOS/ScriptWidget/View/CodeEditor/MirrorEditor/MirrorEditorInternalView.swift index 515ad03..6132389 100644 --- a/iOS/ScriptWidget/View/CodeEditor/MirrorEditor/MirrorEditorInternalView.swift +++ b/iOS/ScriptWidget/View/CodeEditor/MirrorEditor/MirrorEditorInternalView.swift @@ -117,7 +117,7 @@ class MirrorEditorInternalView: WKWebView { let baseUrl = bundle.resourceURL!.appendingPathComponent("build") print("base url = \(baseUrl)") - var html = try! String(contentsOfFile: indexPath) + var html = try! String(contentsOfFile: indexPath, encoding: .utf8) if AppHelper.isdarkmode() { html = html.replacingOccurrences(of: "theme:light", with: "theme:dark") } diff --git a/iOS/ScriptWidget/View/CodeEditor/Preview/ScriptCodePreviewView.swift b/iOS/ScriptWidget/View/CodeEditor/Preview/ScriptCodePreviewView.swift index c7990b7..f8713a7 100644 --- a/iOS/ScriptWidget/View/CodeEditor/Preview/ScriptCodePreviewView.swift +++ b/iOS/ScriptWidget/View/CodeEditor/Preview/ScriptCodePreviewView.swift @@ -122,11 +122,11 @@ struct ScriptCodePreviewView: View { Text("AccessoryCircular").tag(5) Text("AccessoryRectangular").tag(6) } - .onChange(of: widgetSizeType, perform: { value in + .onChange(of: widgetSizeType) { _, value in print("preview size changed : \(value)") - + self.state.changeWidgetSizeType(value) - }) + } Toggle("Debug Border", isOn:$isDebugMode) HStack { diff --git a/iOS/ScriptWidget/View/EmptyListBackgroundView.swift b/iOS/ScriptWidget/View/EmptyListBackgroundView.swift index 9e45d07..b44b5a5 100644 --- a/iOS/ScriptWidget/View/EmptyListBackgroundView.swift +++ b/iOS/ScriptWidget/View/EmptyListBackgroundView.swift @@ -143,13 +143,7 @@ struct EmptyListBackgroundView: View { } .buttonStyle(.plain) - Text("Or tap ") - .font(.caption) - .foregroundColor(.secondary) - + Text(Image(systemName: "plus.square")) - .font(.caption) - .foregroundColor(.secondary) - + Text(" in the top-right to create from scratch or with AI.") + Text("Or tap \(Image(systemName: "plus.square")) in the top-right to create from scratch or with AI.") .font(.caption) .foregroundColor(.secondary) } diff --git a/iOS/ScriptWidget/View/FileDetailView.swift b/iOS/ScriptWidget/View/FileDetailView.swift index 9d96f90..752d6c0 100644 --- a/iOS/ScriptWidget/View/FileDetailView.swift +++ b/iOS/ScriptWidget/View/FileDetailView.swift @@ -63,12 +63,12 @@ struct FileDetailView: View { }) // on file renamed - .onChange(of: onFileRenamed, perform: { value in + .onChange(of: onFileRenamed) { _, value in print("on file renamed : \(value)") if value { presentationMode.dismiss() } - }) + } .navigationBarItems( trailing: HStack { diff --git a/iOS/ScriptWidgetShare/ShareViewController.swift b/iOS/ScriptWidgetShare/ShareViewController.swift index 390cb14..e51aa0f 100644 --- a/iOS/ScriptWidgetShare/ShareViewController.swift +++ b/iOS/ScriptWidgetShare/ShareViewController.swift @@ -6,11 +6,11 @@ // import UIKit -import CoreServices +import UniformTypeIdentifiers class ShareViewController: UIViewController { - private let typeURL = String(kUTTypeURL) + private let typeURL = UTType.url.identifier override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) diff --git a/macOS/ScriptWidgetMac/AIGenerate/AIGenerateWindowView.swift b/macOS/ScriptWidgetMac/AIGenerate/AIGenerateWindowView.swift index 821633f..b7ee54d 100644 --- a/macOS/ScriptWidgetMac/AIGenerate/AIGenerateWindowView.swift +++ b/macOS/ScriptWidgetMac/AIGenerate/AIGenerateWindowView.swift @@ -63,7 +63,7 @@ struct AIGenerateWindowView: View { .onReceive(NotificationCenter.default.publisher(for: AISettingsStore.changedNotification)) { _ in loadProfiles() } - .onChange(of: jsx) { _ in + .onChange(of: jsx) { refreshPreviewPackage() prefillSaveNameIfNeeded() } @@ -116,7 +116,7 @@ struct AIGenerateWindowView: View { } } .labelsHidden() - .onChange(of: activeProfileID) { newValue in + .onChange(of: activeProfileID) { _, newValue in AISettingsStore.shared.setActiveProfile(id: newValue) } } diff --git a/macOS/ScriptWidgetMac/Editor/Preview/PreviewView.swift b/macOS/ScriptWidgetMac/Editor/Preview/PreviewView.swift index 6cd2c9c..4d42a16 100644 --- a/macOS/ScriptWidgetMac/Editor/Preview/PreviewView.swift +++ b/macOS/ScriptWidgetMac/Editor/Preview/PreviewView.swift @@ -291,11 +291,11 @@ struct PreviewView: View { Text("Preview Size") } .pickerStyle(SegmentedPickerStyle()) - .onChange(of: widgetSizeType, perform: { value in + .onChange(of: widgetSizeType) { _, value in print("preview size changed : \(value)") - + self.data.changeWidgetSizeType(value) - }) + } } .padding(.top, 5) diff --git a/macOS/ScriptWidgetMac/Settings/SettingAIView.swift b/macOS/ScriptWidgetMac/Settings/SettingAIView.swift index b5d7123..a2d32ba 100644 --- a/macOS/ScriptWidgetMac/Settings/SettingAIView.swift +++ b/macOS/ScriptWidgetMac/Settings/SettingAIView.swift @@ -160,14 +160,14 @@ struct SettingAIView: View { Stepper(value: $maxIterations, in: 5...100, step: 5) { Text("\(maxIterations)").monospacedDigit() } - .onChange(of: maxIterations) { _ in saveAgentLoop() } + .onChange(of: maxIterations) { saveAgentLoop() } } HStack { Text("Temperature") Slider(value: $temperature, in: 0.0...1.5, step: 0.05) .frame(maxWidth: 200) - .onChange(of: temperature) { _ in saveAgentLoop() } + .onChange(of: temperature) { saveAgentLoop() } Text(String(format: "%.2f", temperature)) .monospacedDigit() .frame(width: 50, alignment: .trailing) @@ -255,7 +255,7 @@ private struct AIProfileEditorPane: View { Section("Name") { TextField("Profile name", text: $name) .textFieldStyle(.roundedBorder) - .onChange(of: name) { _ in persist() } + .onChange(of: name) { persist() } } Section("Provider") { @@ -275,13 +275,13 @@ private struct AIProfileEditorPane: View { } TextField("https://api.openai.com", text: $baseURL) .textFieldStyle(.roundedBorder) - .onChange(of: baseURL) { _ in persist() } + .onChange(of: baseURL) { persist() } } Section("Model") { TextField("gpt-4o-mini", text: $model) .textFieldStyle(.roundedBorder) - .onChange(of: model) { _ in persist() } + .onChange(of: model) { persist() } HStack(spacing: 6) { ForEach(modelSuggestions, id: \.self) { preset in Button(preset) { @@ -301,7 +301,7 @@ private struct AIProfileEditorPane: View { Text("OpenAI OAuth").tag(AIAuthMethod.oauth) } .pickerStyle(.segmented) - .onChange(of: authMethod) { _ in persist() } + .onChange(of: authMethod) { persist() } if authMethod == .apiKey { HStack { @@ -320,7 +320,7 @@ private struct AIProfileEditorPane: View { } .buttonStyle(.borderless) } - .onChange(of: apiKey) { _ in persist() } + .onChange(of: apiKey) { persist() } Text("API key is stored in the Keychain on this device.") .font(.caption).foregroundColor(.secondary) } else { From 2bfac824b7f7015420791ff1c7011f17c5ece006 Mon Sep 17 00:00:00 2001 From: everettjf Date: Sun, 3 May 2026 20:19:04 -0700 Subject: [PATCH 12/13] fix(runtime): replace global sharedRunningState with per-JSContext state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-execution state (package handle + console logger) used to live in a process-wide `var sharedRunningState`. Two paths could touch it concurrently — the editor preview and the AI agent loop — and would overwrite each other's logger and package, mixing log lines or stealing the file root mid-run. AgentRuntimeBridge worked around this with a serial DispatchQueue, but the editor preview path didn't, so a preview running while AI iterated could still race. Hang the running state off the owning JSContext via an associated object. JSExport callbacks (`$file`, `$console`) read it back through JSContext.current(), so each execution sees only its own state. Drop the global, drop the AI bridge's serial queue (now genuinely safe to run concurrent generations), and switch the iOS / macOS preview data objects to read logs from `runtime.runningState?.logger.logs`. The preview paths now also keep their runtime alive on failure so logs remain readable after a failed run (matching prior behavior). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../AI/AgentRuntimeBridge.swift | 25 +++++++------- .../API/ScriptWidgetRuntimeConsole.swift | 10 ++---- .../Widget/API/ScriptWidgetRuntimeFile.swift | 6 ++-- .../Runtime/ScriptWidgetRunningState.swift | 34 ++++++++++++++++--- .../Widget/Runtime/ScriptWidgetRuntime.swift | 12 +++++-- .../Preview/ScriptCodePreviewDataObject.swift | 19 +++++------ .../Editor/Preview/PreviewView.swift | 14 ++++---- 7 files changed, 72 insertions(+), 48 deletions(-) diff --git a/Shared/ScriptWidgetRuntime/AI/AgentRuntimeBridge.swift b/Shared/ScriptWidgetRuntime/AI/AgentRuntimeBridge.swift index 0345951..8edcd67 100644 --- a/Shared/ScriptWidgetRuntime/AI/AgentRuntimeBridge.swift +++ b/Shared/ScriptWidgetRuntime/AI/AgentRuntimeBridge.swift @@ -3,9 +3,11 @@ // ScriptWidget // // Adapts the synchronous ScriptWidgetRuntime.executeJSXSyncForWidget to -// an async interface, persists the generated JSX to a one-shot temp -// package (so $file / $import won't explode), and serializes executions -// (the runtime uses a global `sharedRunningState` for log capture). +// an async interface and persists the generated JSX to a one-shot temp +// package (so $file / $import won't explode). Each execution gets its +// own ScriptWidgetRuntime — running state hangs off that runtime's +// JSContext, so concurrent runs (e.g. agent loop in the background +// while the editor preview is also running) don't trample each other. // import Foundation @@ -56,9 +58,11 @@ struct AgentRunResult { final class AgentRuntimeBridge { static let shared = AgentRuntimeBridge() - // Runs are serialized because ScriptWidgetRuntime stores running - // state in a global. - private let serialQueue = DispatchQueue(label: "scriptwidget.ai.runtime.serial") + private let runQueue = DispatchQueue( + label: "scriptwidget.ai.runtime", + qos: .userInitiated, + attributes: .concurrent + ) private let sessionRoot: URL @@ -85,7 +89,7 @@ final class AgentRuntimeBridge { func run(jsx: String, in package: ScriptWidgetPackage, size: AIWidgetSize) async -> AgentRunResult { return await withCheckedContinuation { continuation in - serialQueue.async { + runQueue.async { // Persist the JSX so packages that read themselves or // register support files still work. let writeResult = package.writeMainFile(content: jsx) @@ -98,18 +102,13 @@ final class AgentRuntimeBridge { return } - // Reset the global running state — the runtime will also - // do this in its init, but clearing here keeps log - // capture scoped to this single execution. - sharedRunningState = ScriptWidgetRunningState(package: package) - let runtime = ScriptWidgetRuntime(package: package, environments: [ "widget-size": size.rawValue, "widget-param": "", ]) let (element, err) = runtime.executeJSXSyncForWidget(jsx) - let logs = sharedRunningState?.logger.logs ?? [] + let logs = runtime.runningState?.logger.logs ?? [] continuation.resume(returning: AgentRunResult(element: element, error: err, logs: logs)) } } diff --git a/Shared/ScriptWidgetRuntime/Widget/API/ScriptWidgetRuntimeConsole.swift b/Shared/ScriptWidgetRuntime/Widget/API/ScriptWidgetRuntimeConsole.swift index f32bfc1..185b240 100644 --- a/Shared/ScriptWidgetRuntime/Widget/API/ScriptWidgetRuntimeConsole.swift +++ b/Shared/ScriptWidgetRuntime/Widget/API/ScriptWidgetRuntimeConsole.swift @@ -17,16 +17,12 @@ import JavaScriptCore @objc public class ScriptWidgetRuntimeConsole: NSObject, ScriptWidgetRuntimeConsoleExports { class func log(_ string: String) { - if let runningState = sharedRunningState { - runningState.logger.addLog(string) - } + JSContext.current()?.scriptWidgetRunningState?.logger.addLog(string) print("console log : \(string)") } - + class func error(_ string: String) { - if let runningState = sharedRunningState { - runningState.logger.addLog(string) - } + JSContext.current()?.scriptWidgetRunningState?.logger.addLog(string) print("console error : \(string)") } } diff --git a/Shared/ScriptWidgetRuntime/Widget/API/ScriptWidgetRuntimeFile.swift b/Shared/ScriptWidgetRuntime/Widget/API/ScriptWidgetRuntimeFile.swift index 4feaa12..52a7df4 100644 --- a/Shared/ScriptWidgetRuntime/Widget/API/ScriptWidgetRuntimeFile.swift +++ b/Shared/ScriptWidgetRuntime/Widget/API/ScriptWidgetRuntimeFile.swift @@ -16,7 +16,7 @@ import JavaScriptCore @objc public class ScriptWidgetRuntimeFile: NSObject, ScriptWidgetRuntimeFileExports { static func read(_ relativePath: String) -> String { - guard let runningState = sharedRunningState else { + guard let runningState = JSContext.current()?.scriptWidgetRunningState else { return "" } guard let content = runningState.package.readFile(relativePath: relativePath).0 else { @@ -24,9 +24,9 @@ import JavaScriptCore } return content } - + static func readJSON(_ relativePath: String) -> [AnyHashable: Any]! { - guard let runningState = sharedRunningState else { + guard let runningState = JSContext.current()?.scriptWidgetRunningState else { return [:] } guard let content = runningState.package.readFile(relativePath: relativePath).0 else { diff --git a/Shared/ScriptWidgetRuntime/Widget/Runtime/ScriptWidgetRunningState.swift b/Shared/ScriptWidgetRuntime/Widget/Runtime/ScriptWidgetRunningState.swift index 5873394..aa02f98 100644 --- a/Shared/ScriptWidgetRuntime/Widget/Runtime/ScriptWidgetRunningState.swift +++ b/Shared/ScriptWidgetRuntime/Widget/Runtime/ScriptWidgetRunningState.swift @@ -4,19 +4,26 @@ // // Created by everettjf on 2022/4/7. // +// Per-execution state — package handle and console logger — that the +// JS runtime APIs (`$file`, `$console`) need to reach. The state hangs +// off the owning `JSContext` via an associated object; JSExport +// callbacks find it by way of `JSContext.current()`. There is no +// global; concurrent runtimes do not see each other's state. +// import Foundation +import JavaScriptCore class ScriptWidgetConsoleLogger { var logs: [String] = [] - + func addLog(_ log: String) { DispatchQueue.main.async { self.logs.append(log) } } - + func clear() { DispatchQueue.main.async { self.logs.removeAll() @@ -26,15 +33,32 @@ class ScriptWidgetConsoleLogger { class ScriptWidgetRunningState { - + var logger: ScriptWidgetConsoleLogger var package: ScriptWidgetPackage - + init(package: ScriptWidgetPackage) { self.logger = ScriptWidgetConsoleLogger() self.package = package } } -var sharedRunningState: ScriptWidgetRunningState? = nil +private var scriptWidgetRunningStateAssociationKey: UInt8 = 0 +extension JSContext { + /// Per-runtime running state. Read inside JSExport callbacks via + /// `JSContext.current()?.scriptWidgetRunningState`. + var scriptWidgetRunningState: ScriptWidgetRunningState? { + get { + objc_getAssociatedObject(self, &scriptWidgetRunningStateAssociationKey) as? ScriptWidgetRunningState + } + set { + objc_setAssociatedObject( + self, + &scriptWidgetRunningStateAssociationKey, + newValue, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + } +} diff --git a/Shared/ScriptWidgetRuntime/Widget/Runtime/ScriptWidgetRuntime.swift b/Shared/ScriptWidgetRuntime/Widget/Runtime/ScriptWidgetRuntime.swift index 2a7208f..db28fe7 100644 --- a/Shared/ScriptWidgetRuntime/Widget/Runtime/ScriptWidgetRuntime.swift +++ b/Shared/ScriptWidgetRuntime/Widget/Runtime/ScriptWidgetRuntime.swift @@ -87,9 +87,15 @@ class ScriptWidgetRuntime { init(package: ScriptWidgetPackage, environments: [String:String]) { self.package = package self.environments = environments - // APIs like $file/$console read runtime data from sharedRunningState. - // Ensure widget/extension runtime also initializes it. - sharedRunningState = ScriptWidgetRunningState(package: package) + // APIs like $file/$console read per-execution state from the + // owning JSContext. + runtimeContext.scriptWidgetRunningState = ScriptWidgetRunningState(package: package) + } + + /// Per-execution state attached to this runtime's JSContext. Use + /// after `executeJSXSyncForWidget` to read captured logs. + var runningState: ScriptWidgetRunningState? { + runtimeContext.scriptWidgetRunningState } public func setEnvironment(_ key: String, _ value: String) { diff --git a/iOS/ScriptWidget/View/CodeEditor/Preview/ScriptCodePreviewDataObject.swift b/iOS/ScriptWidget/View/CodeEditor/Preview/ScriptCodePreviewDataObject.swift index 807fe33..fe46a00 100644 --- a/iOS/ScriptWidget/View/CodeEditor/Preview/ScriptCodePreviewDataObject.swift +++ b/iOS/ScriptWidget/View/CodeEditor/Preview/ScriptCodePreviewDataObject.swift @@ -111,9 +111,7 @@ class ScriptCodePreviewDataObject : ObservableObject { self.previewQueue.async { self.setPreviewStatus("Running...") print("start preview") - // new running state - sharedRunningState = ScriptWidgetRunningState(package: self.model.package) - + self.systemLog("START") guard let JSX = self.model.package.readFile(fullPath: self.filePath).0 else { @@ -142,19 +140,21 @@ class ScriptCodePreviewDataObject : ObservableObject { ]) let result = runtime.executeJSXSyncForWidget(JSX) - + + // Keep the runtime alive in both success and failure paths + // so loadScriptConsoleLogs() can still read this run's logs + // off its JSContext. + self.runtime = runtime + if let element = result.0 { // succeed DispatchQueue.main.async { self.rootElement = element - self.runtime = runtime - + completion(true) } } else { // error - self.runtime = nil - if let error = result.1 { switch error { case .undefinedRender(let msg): @@ -178,8 +178,7 @@ class ScriptCodePreviewDataObject : ObservableObject { } func loadScriptConsoleLogs() { - if let runningState = sharedRunningState { - let logs = runningState.logger.logs + if let logs = self.runtime?.runningState?.logger.logs { for log in logs { self.scriptLog(log) } diff --git a/macOS/ScriptWidgetMac/Editor/Preview/PreviewView.swift b/macOS/ScriptWidgetMac/Editor/Preview/PreviewView.swift index 4d42a16..09f74d4 100644 --- a/macOS/ScriptWidgetMac/Editor/Preview/PreviewView.swift +++ b/macOS/ScriptWidgetMac/Editor/Preview/PreviewView.swift @@ -134,7 +134,6 @@ class ScriptCodeRunnerDataObject : ObservableObject { } func runScript() -> Bool { - sharedRunningState = ScriptWidgetRunningState(package: self.package) self.clearLogs() self.systemLog("START") @@ -161,15 +160,18 @@ class ScriptCodeRunnerDataObject : ObservableObject { ]) let result = runtime.executeJSXSyncForWidget(JSX) - + + // Keep the runtime alive in both success and failure paths so + // loadScriptConsoleLogs() can still read this run's logs off + // its JSContext. + self.runtime = runtime + if let element = result.0 { // succeed self.rootElement = element - self.runtime = runtime returnValue = true } else { // error - self.runtime = nil returnValue = false if let error = result.1 { switch error { @@ -196,13 +198,11 @@ class ScriptCodeRunnerDataObject : ObservableObject { func loadScriptConsoleLogs() { print("console log (list logs)"); - if let runningState = sharedRunningState { - let logs = runningState.logger.logs + if let logs = self.runtime?.runningState?.logger.logs { for log in logs { self.scriptLog(log) } } - } func clearLogs() { From 949580a5cf7d8006fb83633157e55e0eaa48fdfe Mon Sep 17 00:00:00 2001 From: everettjf Date: Sun, 3 May 2026 21:48:50 -0700 Subject: [PATCH 13/13] refactor(ai): drop migration code, Keychain-only from day one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AI feature has not shipped yet (still on feature/ai-generate), so there is no installed user base whose apiKey lives in UserDefaults. Drop both migration paths: - migrateLegacyIfNeeded (legacy ai.apiKey/ai.baseURL/ai.model → Default profile) - migrateAPIKeysToKeychainIfNeeded (existing JSON profiles carrying apiKey → Keychain) Also remove the related UserDefaults keys (legacyAPIKey, legacyBaseURL, legacyModel, apiKeyKeychainMigratedFlag) and the legacy `case apiKey` in AIProfile's CodingKeys (apiKey was decoded as a fallback). The decoder now sets apiKey to "" and AISettingsStore hydrates it from Keychain on load. First-launch behavior is unchanged: loadProfiles() seeds one default profile when ai.profiles.v2 is absent. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ScriptWidgetRuntime/AI/AISettings.swift | 76 ++----------------- 1 file changed, 6 insertions(+), 70 deletions(-) diff --git a/Shared/ScriptWidgetRuntime/AI/AISettings.swift b/Shared/ScriptWidgetRuntime/AI/AISettings.swift index 803ec3c..467ef62 100644 --- a/Shared/ScriptWidgetRuntime/AI/AISettings.swift +++ b/Shared/ScriptWidgetRuntime/AI/AISettings.swift @@ -13,11 +13,6 @@ // UserDefaults. // - Active profile id under `ai.activeProfileID`. // - Agent-loop globals (maxIterations / temperature) are app-wide. -// - Legacy single-profile UserDefaults keys (ai.apiKey, ai.baseURL, -// ai.model) are migrated into a "Default" profile on first load. -// - Profiles persisted before the Keychain migration carry their key -// in JSON; on first load with this build the keys are moved into -// Keychain and the JSON re-written without them. // // Auth methods per profile: // .apiKey → settings.apiKey holds the literal key. @@ -89,9 +84,6 @@ struct AIProfile: Identifiable, Equatable { extension AIProfile: Codable { private enum CodingKeys: String, CodingKey { case id, name, baseURL, model, authMethod - // Legacy: older builds stored apiKey in JSON. Read it back during - // migration; never write it. - case apiKey } init(from decoder: Decoder) throws { @@ -101,7 +93,7 @@ extension AIProfile: Codable { baseURL = try c.decode(String.self, forKey: .baseURL) model = try c.decode(String.self, forKey: .model) authMethod = try c.decode(AIAuthMethod.self, forKey: .authMethod) - apiKey = try c.decodeIfPresent(String.self, forKey: .apiKey) ?? "" + apiKey = "" // hydrated from Keychain by AISettingsStore } func encode(to encoder: Encoder) throws { @@ -116,17 +108,10 @@ extension AIProfile: Codable { } enum AISettingsKey { - // v2 profile storage - static let profiles = "ai.profiles.v2" - static let activeProfileID = "ai.activeProfileID" - static let apiKeyKeychainMigratedFlag = "ai.profiles.apiKeyKeychainMigrated.v1" - // agent-loop globals - static let maxIterations = "ai.maxIterations" - static let temperature = "ai.temperature" - // legacy keys (read-only; only used during migration) - static let legacyAPIKey = "ai.apiKey" - static let legacyBaseURL = "ai.baseURL" - static let legacyModel = "ai.model" + static let profiles = "ai.profiles.v2" + static let activeProfileID = "ai.activeProfileID" + static let maxIterations = "ai.maxIterations" + static let temperature = "ai.temperature" } /// Snapshot consumed by AIClient / AgentLoop. Bakes the active profile @@ -169,8 +154,6 @@ final class AISettingsStore { private init() { self.defaults = UserDefaults(suiteName: "group.everettjf.scriptwidget") ?? .standard - migrateLegacyIfNeeded() - migrateAPIKeysToKeychainIfNeeded() } // MARK: - Profiles @@ -181,7 +164,7 @@ final class AISettingsStore { !decoded.isEmpty { return decoded.map { hydrateAPIKey($0) } } - // Should be unreachable after migrate(), but be defensive. + // First launch: seed with one default profile. let fresh = [AIProfile.makeDefault()] saveProfiles(fresh, activeID: fresh[0].id, notify: false) return fresh @@ -297,51 +280,4 @@ final class AISettingsStore { keychain.set(trimmed, for: apiKeyAccount(for: profile.id)) } } - - // MARK: - Migration - - private func migrateLegacyIfNeeded() { - // Already migrated? bail. - if defaults.data(forKey: AISettingsKey.profiles) != nil { - return - } - let legacyKey = defaults.string(forKey: AISettingsKey.legacyAPIKey) ?? "" - let legacyBase = defaults.string(forKey: AISettingsKey.legacyBaseURL) ?? AIProfile.defaultBaseURL - let legacyModel = defaults.string(forKey: AISettingsKey.legacyModel) ?? AIProfile.defaultModel - - let profile = AIProfile( - id: UUID().uuidString, - name: "Default", - baseURL: legacyBase.isEmpty ? AIProfile.defaultBaseURL : legacyBase, - model: legacyModel.isEmpty ? AIProfile.defaultModel : legacyModel, - apiKey: legacyKey, - authMethod: .apiKey - ) - - // Route through saveProfiles so the Keychain receives the key and - // the JSON ends up clean. Mark the keychain-migration flag too, - // because the data we just wrote already conforms to the new - // shape (no apiKey embedded). - saveProfiles([profile], activeID: profile.id, notify: false) - defaults.set(true, forKey: AISettingsKey.apiKeyKeychainMigratedFlag) - } - - private func migrateAPIKeysToKeychainIfNeeded() { - if defaults.bool(forKey: AISettingsKey.apiKeyKeychainMigratedFlag) { - return - } - guard let data = defaults.data(forKey: AISettingsKey.profiles), - let decoded = try? JSONDecoder().decode([AIProfile].self, from: data), - !decoded.isEmpty else { - // Nothing to migrate; mark done so we don't re-check on every - // launch. - defaults.set(true, forKey: AISettingsKey.apiKeyKeychainMigratedFlag) - return - } - // `decoded` profiles carry the legacy apiKey in-memory because the - // decoder reads it when present. Persist them: saveProfiles writes - // each apiKey to Keychain and re-encodes the JSON without it. - saveProfiles(decoded, notify: false) - defaults.set(true, forKey: AISettingsKey.apiKeyKeychainMigratedFlag) - } }