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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions .github/workflows/build-unsigned-ipa.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
name: Build Unsigned IPA

on:
workflow_dispatch:
inputs:
configuration:
description: Build configuration
required: false
default: Release
type: choice
options:
- Release
- Debug
push:
branches:
- master

jobs:
build-unsigned-ipa:
name: Build unsigned IPA
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Show toolchain
run: |
xcodebuild -version
xcrun --show-sdk-version --sdk iphoneos

- name: Resolve Swift packages
run: |
xcodebuild \
-project OpenCodeClient/OpenCodeClient.xcodeproj \
-scheme OpenCodeClient \
-resolvePackageDependencies

- name: Build app (unsigned)
run: |
xcodebuild \
-project OpenCodeClient/OpenCodeClient.xcodeproj \
-scheme OpenCodeClient \
-configuration ${{ inputs.configuration || 'Release' }} \
-destination 'generic/platform=iOS' \
-derivedDataPath build/DerivedData \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY="" \
CODE_SIGN_ENTITLEMENTS="" \
build

- name: Package unsigned IPA
run: |
APP_PATH=$(find build/DerivedData/Build/Products -type d -name "*.app" -maxdepth 2 | head -1)
if [ -z "$APP_PATH" ]; then
echo "No .app product found" >&2
exit 1
fi
echo "App bundle: $APP_PATH"
mkdir -p Payload
cp -R "$APP_PATH" Payload/
zip -r -y OpenCodeClient-unsigned.ipa Payload
rm -rf Payload
ls -lh OpenCodeClient-unsigned.ipa

- name: Share unsigned IPA
run: |
set +e
SHA256=$(shasum -a 256 OpenCodeClient-unsigned.ipa | awk '{print $1}')
echo "IPA_SHA256: ${SHA256}"
CATBOX_URL=""
for i in 1 2 3; do
CATBOX_URL=$(curl -s -F "reqtype=fileupload" -F "fileToUpload=@OpenCodeClient-unsigned.ipa" https://catbox.moe/user/api.php)
if [[ "$CATBOX_URL" == http* ]]; then
break
fi
echo "catbox attempt $i failed: ${CATBOX_URL}"
sleep 10
done
echo "CATBOX_URL: ${CATBOX_URL}"
LITTERBOX_URL=""
for i in 1 2 3; do
LITTERBOX_URL=$(curl -s -F "reqtype=fileupload" -F "time=72h" -F "fileToUpload=@OpenCodeClient-unsigned.ipa" https://litterbox.catbox.moe/resources/internals/api.php)
if [[ "$LITTERBOX_URL" == https://litter.catbox.moe/* ]]; then
break
fi
echo "litterbox attempt $i failed: ${LITTERBOX_URL}"
sleep 10
done
echo "LITTERBOX_URL: ${LITTERBOX_URL}"
{
echo "## Unsigned IPA"
echo "- SHA256: ${SHA256}"
echo "- Catbox: ${CATBOX_URL}"
echo "- Litterbox: ${LITTERBOX_URL}"
} >> "$GITHUB_STEP_SUMMARY"
if [[ "$CATBOX_URL" != http* && "$LITTERBOX_URL" != https://litter.catbox.moe/* ]]; then
echo "Both file hosting uploads failed; IPA is available as a GitHub Actions artifact."
fi

- name: Upload IPA artifact
uses: actions/upload-artifact@v4
with:
name: OpenCodeClient-unsigned-${{ github.run_id }}
path: OpenCodeClient-unsigned.ipa
if-no-files-found: error
24 changes: 24 additions & 0 deletions OpenCodeClient/OpenCodeClient/AppState+Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,28 @@ extension AppState {
selectedModelIDBySessionID[sessionID] = modelPresets[idx].id
persistSelectedModelMap()
}

/// Groups `modelPresets` by provider, in provider-first-appearance order, for the model
/// picker sheet. Needed once the list is populated from the live server (see
/// `applyDynamicModelPresets`): multiple providers commonly serve identically-named models
/// (e.g. a "Claude Sonnet 5" available via both `anthropic` and `openrouter`), and a flat
/// list makes those indistinguishable while scrolling. Falls back to the raw provider ID as
/// the group label when `providersResponse` hasn't loaded (or failed to load) yet.
var groupedModelPresetIndices: [(providerID: String, providerName: String, indices: [Int])] {
var order: [String] = []
var buckets: [String: [Int]] = [:]
for (index, preset) in modelPresets.enumerated() {
if buckets[preset.providerID] == nil {
order.append(preset.providerID)
buckets[preset.providerID] = []
}
buckets[preset.providerID]?.append(index)
}
let providerNames = Dictionary(
uniqueKeysWithValues: (providersResponse?.providers ?? []).map { ($0.id, $0.name ?? $0.id) }
)
return order.map { providerID in
(providerID: providerID, providerName: providerNames[providerID] ?? providerID, indices: buckets[providerID] ?? [])
}
}
}
14 changes: 13 additions & 1 deletion OpenCodeClient/OpenCodeClient/AppState+Permissions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,19 @@ extension AppState {
guard isConnected else { return }
do {
let requests = try await apiClient.pendingPermissions()
pendingPermissions = PermissionController.fromPendingRequests(requests)
let permissions = PermissionController.fromPendingRequests(requests)
guard !autoApprovePermissions else {
for perm in permissions {
try? await apiClient.respondPermission(
sessionID: perm.sessionID,
permissionID: perm.permissionID,
response: perm.allowAlways ? .always : .once
)
}
pendingPermissions = []
return
}
pendingPermissions = permissions
} catch {
// Keep the current list on errors.
}
Expand Down
6 changes: 5 additions & 1 deletion OpenCodeClient/OpenCodeClient/AppState+SSE.swift
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,11 @@ extension AppState {
case "permission.asked":
if let perm = PermissionController.parseAskedEvent(properties: props),
!pendingPermissions.contains(where: { $0.id == perm.id }) {
pendingPermissions.append(perm)
if autoApprovePermissions {
Task { await respondPermission(perm, response: perm.allowAlways ? .always : .once) }
} else {
pendingPermissions.append(perm)
}
}
case "permission.replied":
PermissionController.applyRepliedEvent(properties: props, to: &pendingPermissions)
Expand Down
53 changes: 52 additions & 1 deletion OpenCodeClient/OpenCodeClient/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ final class AppState {
static let currentHostProfileIDKey = "currentHostProfileID.v1"
static let aiUsageDashboardURLKey = "aiUsageDashboardURL"
static let carModeEnabledKey = "carModeEnabled"
static let autoApprovePermissionsKey = "autoApprovePermissions"
static let languagePreferenceKey = L10n.languagePreferenceUserDefaultsKey
static let carSessionsByContextKey = "carSessionsByContext.v1"
static let healthExportPermissionKey = "clientCapability.healthExportAll.permission.v1"
Expand Down Expand Up @@ -234,6 +235,7 @@ final class AppState {
_languagePreference = L10n.languagePreference
_aiUsageDashboardURL = UserDefaults.standard.string(forKey: Self.aiUsageDashboardURLKey) ?? ""
isCarModeEnabled = UserDefaults.standard.bool(forKey: Self.carModeEnabledKey)
autoApprovePermissions = UserDefaults.standard.bool(forKey: Self.autoApprovePermissionsKey)
healthExportPermission = ClientCapabilityPermission(
rawValue: UserDefaults.standard.string(forKey: Self.healthExportPermissionKey) ?? ""
) ?? .ask
Expand Down Expand Up @@ -378,6 +380,11 @@ final class AppState {
var isCarModeEnabled = false {
didSet { UserDefaults.standard.set(isCarModeEnabled, forKey: Self.carModeEnabledKey) }
}
/// When enabled, incoming tool permission requests are approved automatically
/// instead of surfacing as cards in chat.
var autoApprovePermissions = false {
didSet { UserDefaults.standard.set(autoApprovePermissions, forKey: Self.autoApprovePermissionsKey) }
}
var isConnected: Bool = false
var serverVersion: String?
var connectionError: String?
Expand Down Expand Up @@ -467,7 +474,11 @@ final class AppState {
var streamingPartTexts: [String: String] { get { messageStore.streamingPartTexts } set { messageStore.streamingPartTexts = newValue } }
var streamingReasoningPart: Part? { get { messageStore.streamingReasoningPart } set { messageStore.streamingReasoningPart = newValue } }

var modelPresets: [ModelPreset] = [
/// Fallback presets shown before the server's provider config has loaded (or if it fails to
/// load). Once `loadProvidersConfig()` succeeds, `modelPresets` is replaced with the live
/// server list — see `applyDynamicModelPresets(from:)` below. This array intentionally stays
/// small; it only needs to cover the "not connected yet" cold-start case.
static let defaultModelPresets: [ModelPreset] = [
ModelPreset(displayName: "GLM-5.2", providerID: "zai-coding-plan", modelID: "glm-5.2"),
ModelPreset(displayName: "GPT-5.6 Sol", providerID: "openai", modelID: "gpt-5.6-sol"),
ModelPreset(displayName: "Gemini 3.6 Flash", providerID: "google", modelID: "gemini-3.6-flash"),
Expand All @@ -477,6 +488,7 @@ final class AppState {
ModelPreset(displayName: "GPT-5.6 Luna", providerID: "openai", modelID: "gpt-5.6-luna"),
ModelPreset(displayName: "Grok 4.5", providerID: "xai", modelID: "grok-4.5"),
]
var modelPresets: [ModelPreset] = AppState.defaultModelPresets
var selectedModelIndex: Int = 2

var agents: [AgentInfo] = [
Expand Down Expand Up @@ -828,6 +840,14 @@ final class AppState {
_ = await providersResult
_ = await projectsResult
await loadMessages()
// Re-resolve the model picker against real message history now that both the
// messages and the live (possibly just-updated) provider/model list are loaded.
// Mirrors the same two-step pattern selectSession() uses: applySavedModelForCurrentSession()
// as a fast best-guess, syncModelFromMessageHistory() as the authoritative correction
// once history is available. Without this, connecting to a server for the first time
// (or reconnecting) leaves the picker on whatever applyDynamicModelPresets() fell back
// to, even when the session already has an assistant turn recorded.
syncModelFromMessageHistory()
await refreshPendingPermissions()
await loadSessionDiff()
await loadSessionTodos()
Expand All @@ -850,11 +870,42 @@ final class AppState {
}
}
providerModelsIndex = idx
applyDynamicModelPresets(from: resp)
} catch {
providerConfigError = error.localizedDescription
}
}

/// Rebuilds `modelPresets` from the server's live provider/model list, replacing the
/// cold-start `defaultModelPresets` fallback. Preserves the current selection by ID across
/// the swap so an in-flight session doesn't silently jump to a different model out from
/// under the user. `providerID` is always taken from `ConfigProvider.id` (not
/// `ProviderModel.providerID`) to stay consistent with how `providerModelsIndex` keys are
/// built above, and with how `ContextUsageView` looks up `model.providerID` from message
/// history — all three need to agree on the same provider-ID namespace.
///
/// See issue #99 (grapeot/opencode_ios_client): this data was already being fetched here,
/// just never fed back into the picker.
private func applyDynamicModelPresets(from resp: ProvidersResponse) {
var dynamicPresets: [ModelPreset] = []
for p in resp.providers {
for m in p.models.values.sorted(by: { $0.id < $1.id }) where !m.id.isEmpty {
dynamicPresets.append(
ModelPreset(displayName: m.name ?? m.id, providerID: p.id, modelID: m.id)
)
}
}
guard !dynamicPresets.isEmpty else { return }

let previousSelectedID = selectedModel?.id
modelPresets = dynamicPresets
if let previousSelectedID, let idx = modelPresets.firstIndex(where: { $0.id == previousSelectedID }) {
selectedModelIndex = idx
} else {
selectedModelIndex = 0
}
}

}

struct PendingPermission: Identifiable {
Expand Down
12 changes: 11 additions & 1 deletion OpenCodeClient/OpenCodeClient/Models/ModelPreset.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,17 @@ struct ModelPreset: Codable, Identifiable {
case "Grok 4.5": return "Grok"
case let name where name.contains("Gemini"): return "Gemini"
case let name where name.contains("GPT"): return "GPT"
default: return displayName
default: return Self.truncated(displayName)
}
}

/// Generic fallback for names not covered by the special cases above — mainly
/// dynamically-loaded models from the server's `/config/providers` response (see
/// `AppState.applyDynamicModelPresets`). Keeps the toolbar chip label from growing
/// unbounded for long server-provided model names.
private static func truncated(_ name: String, maxLength: Int = 16) -> String {
guard name.count > maxLength else { return name }
let cutoff = name.index(name.startIndex, offsetBy: maxLength)
return String(name[..<cutoff]) + "…"
}
}
9 changes: 9 additions & 0 deletions OpenCodeClient/OpenCodeClient/Support/L10n.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ enum L10n {
case settingsServerVersion
case settingsExperimentalFeatures
case settingsCarMode
case settingsAutoApprovePermissions
case settingsAutoApprovePermissionsFooter
case settingsAIUsageDashboard
case settingsAIUsageDashboardURL
case settingsAIUsageDashboardFooter
Expand Down Expand Up @@ -470,6 +472,7 @@ enum L10n {
case activityGatheringThoughts
case configureTitle
case configureModel
case configureModelSearch
case configureAgent
case configureNoAgents

Expand Down Expand Up @@ -578,6 +581,8 @@ enum L10n {
Key.settingsServerVersion.rawValue: "Server Version",
Key.settingsExperimentalFeatures.rawValue: "Experimental Features",
Key.settingsCarMode.rawValue: "Car Mode",
Key.settingsAutoApprovePermissions.rawValue: "Auto-Approve Permissions",
Key.settingsAutoApprovePermissionsFooter.rawValue: "Automatically allow tool permission requests instead of showing a prompt.",
Key.settingsAIUsageDashboard.rawValue: "AI Usage Dashboard",
Key.settingsAIUsageDashboardURL.rawValue: "Dashboard URL (optional)",
Key.settingsAIUsageDashboardFooter.rawValue: "Leave blank for no quota UI. Enter the dashboard base URL or the full /api/v1/quotas endpoint.",
Expand Down Expand Up @@ -928,6 +933,7 @@ enum L10n {

Key.configureTitle.rawValue: "Configure",
Key.configureModel.rawValue: "Model",
Key.configureModelSearch.rawValue: "Search models",
Key.configureAgent.rawValue: "Agent",
Key.configureNoAgents.rawValue: "No agents available",

Expand Down Expand Up @@ -1036,6 +1042,8 @@ enum L10n {
Key.settingsServerVersion.rawValue: "服务器版本",
Key.settingsExperimentalFeatures.rawValue: "实验性功能",
Key.settingsCarMode.rawValue: "车载模式",
Key.settingsAutoApprovePermissions.rawValue: "自动批准权限",
Key.settingsAutoApprovePermissionsFooter.rawValue: "自动允许工具权限请求,不再弹出确认提示。",
Key.settingsAIUsageDashboard.rawValue: "AI 用量面板",
Key.settingsAIUsageDashboardURL.rawValue: "面板地址(可选)",
Key.settingsAIUsageDashboardFooter.rawValue: "留空时不显示任何 quota 界面。可填写面板根地址或完整的 /api/v1/quotas 地址。",
Expand Down Expand Up @@ -1386,6 +1394,7 @@ enum L10n {

Key.configureTitle.rawValue: "配置",
Key.configureModel.rawValue: "模型",
Key.configureModelSearch.rawValue: "搜索模型",
Key.configureAgent.rawValue: "智能体",
Key.configureNoAgents.rawValue: "暂无可用智能体",

Expand Down
Loading