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 键关闭窗口…"