diff --git a/Resources/en.lproj/Localizable.strings b/Resources/en.lproj/Localizable.strings index c2d3cf1..df5bd11 100644 --- a/Resources/en.lproj/Localizable.strings +++ b/Resources/en.lproj/Localizable.strings @@ -124,6 +124,8 @@ "USD" = "USD"; "Usage" = "Usage"; "Updates" = "Updates"; +"Local customization build" = "Local customization build"; +"Official automatic updates are disabled to preserve local features. Use the sync script for updates." = "Official automatic updates are disabled to preserve local features. Use the sync script for updates."; "VALUE" = "VALUE"; "Warn" = "Warn"; "Warning" = "Warning"; diff --git a/Resources/zh-Hans.lproj/Localizable.strings b/Resources/zh-Hans.lproj/Localizable.strings index d96e12d..5d9427e 100644 --- a/Resources/zh-Hans.lproj/Localizable.strings +++ b/Resources/zh-Hans.lproj/Localizable.strings @@ -124,6 +124,8 @@ "USD" = "USD"; "Usage" = "用量"; "Updates" = "更新"; +"Local customization build" = "本地定制版"; +"Official automatic updates are disabled to preserve local features. Use the sync script for updates." = "已关闭官方自动更新,以保留本地功能。请使用同步脚本更新。"; "VALUE" = "价值"; "Warn" = "警告"; "Warning" = "警告"; diff --git a/Sources/Update/UpdaterController.swift b/Sources/Update/UpdaterController.swift index 605d7fa..143e9c0 100644 --- a/Sources/Update/UpdaterController.swift +++ b/Sources/Update/UpdaterController.swift @@ -14,21 +14,30 @@ final class UpdaterController: ObservableObject { static let shared = UpdaterController() private let controller: SPUStandardUpdaterController + let updatesEnabled: Bool @Published var automaticallyChecks: Bool { - didSet { controller.updater.automaticallyChecksForUpdates = automaticallyChecks } + didSet { + guard updatesEnabled else { + if automaticallyChecks { automaticallyChecks = false } + return + } + controller.updater.automaticallyChecksForUpdates = automaticallyChecks + } } private init() { + updatesEnabled = Bundle.main.object(forInfoDictionaryKey: "CodexIslandLocalBuild") as? Bool != true controller = SPUStandardUpdaterController( - startingUpdater: true, + startingUpdater: updatesEnabled, updaterDelegate: nil, userDriverDelegate: nil ) - automaticallyChecks = controller.updater.automaticallyChecksForUpdates + automaticallyChecks = updatesEnabled && controller.updater.automaticallyChecksForUpdates } func checkForUpdates() { + guard updatesEnabled else { return } controller.checkForUpdates(nil) } } diff --git a/Sources/Views/SettingsView.swift b/Sources/Views/SettingsView.swift index 2b304f8..8f7e08d 100644 --- a/Sources/Views/SettingsView.swift +++ b/Sources/Views/SettingsView.swift @@ -434,19 +434,30 @@ struct SettingsView: View { private var updatesSection: some View { VStack(alignment: .leading, spacing: 0) { sectionLabel("Updates") - SettingsRow( - title: "Check for updates automatically", - subtitle: "Check for new versions in the background and notify you when one's available." - ) { - SettingsToggle(isOn: updater.automaticallyChecks) { - updater.automaticallyChecks.toggle() + if updater.updatesEnabled { + SettingsRow( + title: "Check for updates automatically", + subtitle: "Check for new versions in the background and notify you when one's available." + ) { + SettingsToggle(isOn: updater.automaticallyChecks) { + updater.automaticallyChecks.toggle() + } + } + SettingsRow( + title: "Check now", + subtitle: "Look for a new version immediately." + ) { + PillButton(label: "Check") { updater.checkForUpdates() } + } + } else { + SettingsRow( + title: "Local customization build", + subtitle: "Official automatic updates are disabled to preserve local features. Use the sync script for updates." + ) { + Image(systemName: "checkmark.shield.fill") + .font(Typography.label) + .foregroundStyle(.green) } - } - SettingsRow( - title: "Check now", - subtitle: "Look for a new version immediately." - ) { - PillButton(label: "Check") { updater.checkForUpdates() } } } .padding(.horizontal, 14) diff --git a/build.sh b/build.sh index efe95c6..a96f858 100755 --- a/build.sh +++ b/build.sh @@ -33,6 +33,24 @@ SPARKLE_FW="$SPARKLE_DIR/Sparkle.framework" SU_PUBLIC_KEY="bz1gwLBKgIL/Y7OO23o3gaMNIeTpvv/C90F9inr9Quo=" SU_FEED_URL="${SU_FEED_URL:-https://github.com/ericjypark/codex-island/releases/latest/download/appcast.xml}" +ENABLE_UPDATES="${ENABLE_UPDATES:-0}" +if [[ "$ENABLE_UPDATES" != "0" && "$ENABLE_UPDATES" != "1" ]]; then + echo "error: ENABLE_UPDATES must be 0 or 1" >&2 + exit 1 +fi + +if [[ "$ENABLE_UPDATES" == "1" ]]; then + SPARKLE_PLIST_CONFIG=" + SUFeedURL$SU_FEED_URL + SUPublicEDKey$SU_PUBLIC_KEY + SUEnableAutomaticChecks" +else + # A locally customized build must not install an official binary over itself. + # Upstream source updates are merged by scripts/sync-upstream.sh instead. + SPARKLE_PLIST_CONFIG=" + CodexIslandLocalBuild + SUEnableAutomaticChecks" +fi rm -rf "$BUILD_DIR" mkdir -p "$MACOS_DIR" "$RES_DIR" "$FRAMEWORKS_DIR" @@ -92,9 +110,7 @@ cat > "$CONTENTS/Info.plist" <NSHighResolutionCapable NSPrincipalClassNSApplication NSHumanReadableCopyrightCopyright © 2026 Eric Park. MIT licensed. - SUFeedURL$SU_FEED_URL - SUPublicEDKey$SU_PUBLIC_KEY - SUEnableAutomaticChecks +$SPARKLE_PLIST_CONFIG EOF diff --git a/docs/UPSTREAM_SYNC.md b/docs/UPSTREAM_SYNC.md new file mode 100644 index 0000000..02e22c7 --- /dev/null +++ b/docs/UPSTREAM_SYNC.md @@ -0,0 +1,59 @@ +# Keeping local features when upstream updates + +Run the synchronization script from the repository root whenever CodexIsland +publishes a new version: + +```bash +./scripts/sync-upstream.sh +``` + +The script fetches `origin/main`, creates a `codex/backup-before-upstream-*` +backup branch, merges instead of replacing local history, and runs the test +suite before committing. To build the app as part of the same operation: + +```bash +SYNC_BUILD_APP=1 ./scripts/sync-upstream.sh +``` + +If Git reports conflicts, review and stage the resolved files, then run: + +```bash +./scripts/sync-upstream.sh --continue +``` + +Use `./scripts/sync-upstream.sh --abort` to return to the exact pre-merge state. +The backup branch remains available even after a successful merge. + +Local builds intentionally omit the official Sparkle update feed. Otherwise an +official release could replace the customized binary after a restart. The +release workflow explicitly enables Sparkle only for official distribution +builds. Your preferences remain in the normal CodexIsland UserDefaults domain. + +## 上游更新时保留本地功能 + +CodexIsland 发布新版本后,在项目根目录执行: + +```bash +./scripts/sync-upstream.sh +``` + +脚本会获取 `origin/main`,自动创建 +`codex/backup-before-upstream-*` 备份分支,通过合并保留本地历史, +并在提交前运行测试。如果希望同时构建应用: + +```bash +SYNC_BUILD_APP=1 ./scripts/sync-upstream.sh +``` + +如果发生冲突,脚本会停下,不会覆盖任何本地代码。解决冲突并将文件 +加入暂存区后,执行: + +```bash +./scripts/sync-upstream.sh --continue +``` + +如果希望取消本次合并,执行 `./scripts/sync-upstream.sh --abort`。 + +本地定制构建会有意关闭官方 Sparkle 更新源,避免重启后官方原版覆盖定制功能。 +只有正式发布脚本才会启用 Sparkle。外观、服务显示和任务提醒等偏好仍保存在 +CodexIsland 原有的 UserDefaults 中。 diff --git a/release.sh b/release.sh index d9bb4ad..e2d11aa 100755 --- a/release.sh +++ b/release.sh @@ -28,7 +28,7 @@ if ! command -v create-dmg >/dev/null 2>&1; then exit 1 fi -./build.sh +ENABLE_UPDATES=1 ./build.sh rm -rf "$DIST" mkdir -p "$DIST" diff --git a/scripts/sync-upstream.sh b/scripts/sync-upstream.sh new file mode 100755 index 0000000..e5e6326 --- /dev/null +++ b/scripts/sync-upstream.sh @@ -0,0 +1,151 @@ +#!/bin/bash +# Merge the latest upstream release into the current customization branch. +# This script never resets or overwrites local history. It requires a clean +# worktree, creates a backup branch, and pauses safely when conflicts occur. + +set -euo pipefail + +cd "$(dirname "$0")/.." + +UPSTREAM_REMOTE="${UPSTREAM_REMOTE:-origin}" +UPSTREAM_BRANCH="${UPSTREAM_BRANCH:-main}" +ACTION="${1:-sync}" + +usage() { + cat <<'EOF' +Usage: + ./scripts/sync-upstream.sh Fetch, test, and merge upstream/main + ./scripts/sync-upstream.sh --continue Continue after resolving conflicts + ./scripts/sync-upstream.sh --abort Abort the current upstream merge + +Optional environment variables: + UPSTREAM_REMOTE=origin + UPSTREAM_BRANCH=main + SYNC_SKIP_TESTS=1 + SYNC_BUILD_APP=1 + SYNC_SDKROOT=/path/to/MacOSX.sdk +EOF +} + +run_validation() { + if [[ "${SYNC_SKIP_TESTS:-0}" != "1" ]]; then + echo "Running tests..." + local sdk="${SYNC_SDKROOT:-}" + if [[ -z "$sdk" && -d /Library/Developer/CommandLineTools/SDKs/MacOSX26.sdk ]]; then + sdk=/Library/Developer/CommandLineTools/SDKs/MacOSX26.sdk + fi + if [[ -n "$sdk" ]]; then + SDKROOT="$sdk" \ + CLANG_MODULE_CACHE_PATH="${TMPDIR:-/tmp}/codex-island-module-cache" \ + ./scripts/run-tests.sh + else + CLANG_MODULE_CACHE_PATH="${TMPDIR:-/tmp}/codex-island-module-cache" \ + ./scripts/run-tests.sh + fi + fi + + if [[ "${SYNC_BUILD_APP:-0}" == "1" ]]; then + echo "Building CodexIsland..." + ./build.sh + fi +} + +case "$ACTION" in + --help|-h) + usage + exit 0 + ;; + --abort) + if git rev-parse -q --verify MERGE_HEAD >/dev/null; then + git merge --abort + echo "Upstream merge aborted; the pre-sync branch is still available." + else + echo "error: no upstream merge is in progress" >&2 + exit 1 + fi + exit 0 + ;; + --continue) + if ! git rev-parse -q --verify MERGE_HEAD >/dev/null; then + echo "error: no upstream merge is in progress" >&2 + exit 1 + fi + if [[ -n "$(git diff --name-only --diff-filter=U)" ]]; then + echo "error: unresolved conflicts remain" >&2 + git diff --name-only --diff-filter=U >&2 + exit 1 + fi + run_validation + git commit --no-edit + echo "Upstream merge completed successfully." + exit 0 + ;; + sync) + ;; + *) + usage >&2 + exit 2 + ;; +esac + +if git rev-parse -q --verify MERGE_HEAD >/dev/null; then + echo "error: a merge is already in progress; use --continue or --abort" >&2 + exit 1 +fi + +if [[ -n "$(git status --porcelain)" ]]; then + echo "error: commit or stash local changes before syncing" >&2 + exit 1 +fi + +current_branch="$(git symbolic-ref --quiet --short HEAD || true)" +if [[ -z "$current_branch" ]]; then + echo "error: switch to your customization branch before syncing" >&2 + exit 1 +fi + +echo "Fetching ${UPSTREAM_REMOTE}/${UPSTREAM_BRANCH}..." +git fetch "$UPSTREAM_REMOTE" "$UPSTREAM_BRANCH" --tags +target="${UPSTREAM_REMOTE}/${UPSTREAM_BRANCH}" + +if git merge-base --is-ancestor "$target" HEAD; then + echo "Already up to date with ${target}." + run_validation + exit 0 +fi + +stamp="$(date +%Y%m%d-%H%M%S)" +short_head="$(git rev-parse --short HEAD)" +backup_branch="codex/backup-before-upstream-${stamp}-${short_head}" +git branch "$backup_branch" HEAD +echo "Backup created: ${backup_branch}" + +if ! git merge --no-ff --no-commit "$target"; then + cat >&2 <&2 </dev/null 2>&1; then + echo "检测到合并冲突,本地功能没有被覆盖。" + echo "解决冲突后可执行:" + echo " ./scripts/sync-upstream.sh --continue" + echo + echo "如需取消这次合并:" + echo " ./scripts/sync-upstream.sh --abort" + else + echo "请查看上方提示;项目文件不会被强制覆盖。" + fi +fi + +echo +read -r -p "按 Enter 键关闭窗口…"