mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12610faba0 | |||
| 73b44202ba | |||
| eed55cbb0f | |||
| 14c97bee62 | |||
| 8d3fe70e2c | |||
| da88c98c7a | |||
| b7ad01f9da | |||
| 868e61979e | |||
| 9bdd928469 | |||
| 75e489e39c | |||
| 41ea3aeb83 |
@@ -1,5 +1,6 @@
|
|||||||
# Xcode
|
# Xcode
|
||||||
build/
|
build/
|
||||||
|
.gh-pages-worktree/
|
||||||
DerivedData/
|
DerivedData/
|
||||||
*.pbxuser
|
*.pbxuser
|
||||||
!default.pbxuser
|
!default.pbxuser
|
||||||
@@ -46,3 +47,8 @@ scarf/standards/backups/
|
|||||||
|
|
||||||
# Scarf project dashboards (user-specific)
|
# Scarf project dashboards (user-specific)
|
||||||
.scarf/
|
.scarf/
|
||||||
|
|
||||||
|
# Release artifacts — GitHub Releases hosts the binaries; no need to bloat git
|
||||||
|
# history. RELEASE_NOTES.md stays tracked (committed with the version bump).
|
||||||
|
releases/v*/*.zip
|
||||||
|
releases/v*/appcast-entry.xml
|
||||||
|
|||||||
@@ -39,6 +39,26 @@ scarf/scarf/ Xcode project root (PBXFileSystemSynchronizedRootGroup
|
|||||||
xcodebuild -project scarf/scarf.xcodeproj -scheme scarf -configuration Debug build
|
xcodebuild -project scarf/scarf.xcodeproj -scheme scarf -configuration Debug build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Releases
|
||||||
|
|
||||||
|
Shipped via a single local script. **Never run manual `xcodebuild archive` / `notarytool` / `gh release create` steps — use the script so nothing is skipped or misordered.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/release.sh <version> # full release: notarize → appcast → gh-pages → tag
|
||||||
|
./scripts/release.sh <version> --draft # draft: everything builds + notarizes, but appcast/tag are skipped
|
||||||
|
```
|
||||||
|
|
||||||
|
The script bumps version, archives Universal (arm64 + x86_64) + ARM64-only variants, signs with Developer ID, notarizes via `xcrun notarytool` (keychain profile `scarf-notary`), staples, EdDSA-signs the appcast entry with Sparkle's key, pushes the appcast to `gh-pages`, and creates a GitHub release with both zips attached. Draft mode stops after the release is uploaded so the current version stays "latest" until explicitly promoted.
|
||||||
|
|
||||||
|
**Release notes convention:** write them to `releases/v<version>/RELEASE_NOTES.md` BEFORE running the script — it's auto-included in the version-bump commit and used as the GitHub release body. If absent, a placeholder is used.
|
||||||
|
|
||||||
|
**Canonical prompts (any of these trigger the flow):**
|
||||||
|
- "Release v1.6.2" — full release
|
||||||
|
- "Release v1.6.2 as draft" — draft mode
|
||||||
|
- "Prepare v1.6.2 release notes from recent commits, then release" — generate notes first, then run
|
||||||
|
|
||||||
|
**Prerequisites (one-time, already set up on Alan's machine):** Developer ID Application cert in login Keychain (team `3Q6X2L86C4`), notarytool keychain profile `scarf-notary`, Sparkle EdDSA private key in Keychain item `https://sparkle-project.org`, `gh-pages` branch + GitHub Pages enabled. See the header of [scripts/release.sh](scripts/release.sh) and the Releases section in [README.md](README.md) for details.
|
||||||
|
|
||||||
## Hermes Version
|
## Hermes Version
|
||||||
|
|
||||||
Targets Hermes v0.9.0 (v2026.4.13). Log lines may carry an optional `[session_id]` tag between the level and logger name — `HermesLogService.parseLine` treats the session tag as an optional capture group, so older untagged lines still parse.
|
Targets Hermes v0.9.0 (v2026.4.13). Log lines may carry an optional `[session_id]` tag between the level and logger name — `HermesLogService.parseLine` treats the session tag as an optional capture group, so older untagged lines still parse.
|
||||||
|
|||||||
@@ -101,10 +101,12 @@ If a Hermes update changes the database schema or CLI output format, Scarf may n
|
|||||||
Download the latest build from [Releases](https://github.com/awizemann/scarf/releases):
|
Download the latest build from [Releases](https://github.com/awizemann/scarf/releases):
|
||||||
|
|
||||||
- `Scarf-vX.X.X-Universal.zip` — Apple Silicon + Intel (recommended)
|
- `Scarf-vX.X.X-Universal.zip` — Apple Silicon + Intel (recommended)
|
||||||
- `Scarf-vX.X.X-ARM64.zip` — Apple Silicon only (smaller)
|
- `Scarf-vX.X.X-ARM64.zip` — Apple Silicon only (smaller download)
|
||||||
|
|
||||||
1. Unzip and drag **Scarf.app** to Applications
|
1. Unzip and drag **Scarf.app** to Applications
|
||||||
2. On first launch, right-click and choose **Open** (or go to System Settings → Privacy & Security → Open Anyway)
|
2. Launch normally — builds are Developer ID signed and notarized, so Gatekeeper accepts them on first launch
|
||||||
|
|
||||||
|
Scarf checks for updates automatically on launch via [Sparkle](https://sparkle-project.org) and daily thereafter. You can disable automatic checks or trigger a manual check from **Settings → General → Updates** or the menu bar icon.
|
||||||
|
|
||||||
### Build from Source
|
### Build from Source
|
||||||
|
|
||||||
@@ -178,6 +180,7 @@ The app opens `state.db` in read-only mode to avoid WAL contention with Hermes.
|
|||||||
| Package | Purpose |
|
| Package | Purpose |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
| [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm) | Terminal emulator for the Chat feature |
|
| [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm) | Terminal emulator for the Chat feature |
|
||||||
|
| [Sparkle](https://github.com/sparkle-project/Sparkle) | Auto-updates from the GitHub-hosted appcast |
|
||||||
|
|
||||||
Everything else uses system frameworks: SQLite3 C API, Foundation JSON, AttributedString markdown, SwiftUI Charts, GCD file watching.
|
Everything else uses system frameworks: SQLite3 C API, Foundation JSON, AttributedString markdown, SwiftUI Charts, GCD file watching.
|
||||||
|
|
||||||
@@ -327,6 +330,20 @@ Your agent can update the dashboard as part of cron jobs, after builds, or whene
|
|||||||
|
|
||||||
Each section defines a grid with 1–4 columns. Widgets flow left-to-right, wrapping to new rows. See [DASHBOARD_SCHEMA.md](scarf/docs/DASHBOARD_SCHEMA.md) for the full schema reference with examples of every widget type.
|
Each section defines a grid with 1–4 columns. Widgets flow left-to-right, wrapping to new rows. See [DASHBOARD_SCHEMA.md](scarf/docs/DASHBOARD_SCHEMA.md) for the full schema reference with examples of every widget type.
|
||||||
|
|
||||||
|
## Releases
|
||||||
|
|
||||||
|
Scarf ships through GitHub releases — the App Store is not supported because Scarf spawns the user-installed `hermes` binary and reads `~/.hermes/` directly, both of which App Sandbox forbids.
|
||||||
|
|
||||||
|
Each release goes through a single local script: [scripts/release.sh](scripts/release.sh). The script archives a universal binary, signs it with the Developer ID Application cert, submits to `notarytool`, staples the ticket, produces the distribution zip, signs an appcast entry with Sparkle's EdDSA key, pushes an updated `appcast.xml` to the `gh-pages` branch, creates the GitHub release, and tags `main`.
|
||||||
|
|
||||||
|
The Sparkle appcast is served from [awizemann.github.io/scarf/appcast.xml](https://awizemann.github.io/scarf/appcast.xml).
|
||||||
|
|
||||||
|
Signing prerequisites (one-time):
|
||||||
|
|
||||||
|
- `Developer ID Application` certificate in the login Keychain
|
||||||
|
- `scarf-notary` keychain profile registered via `xcrun notarytool store-credentials`
|
||||||
|
- Sparkle EdDSA private key in Keychain item `https://sparkle-project.org` (back this up — without it, shipped apps can never receive updates)
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Contributions are welcome. Please open an issue to discuss what you'd like to change before submitting a PR.
|
Contributions are welcome. Please open an issue to discuss what you'd like to change before submitting a PR.
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
## What's New in 1.6.1
|
||||||
|
|
||||||
|
### Auto-updates
|
||||||
|
|
||||||
|
Scarf now ships with [Sparkle](https://sparkle-project.org). On launch (and daily thereafter) it checks an EdDSA-signed appcast at [awizemann.github.io/scarf/appcast.xml](https://awizemann.github.io/scarf/appcast.xml). When a new version is available you'll get an in-app update prompt — no more manually downloading zips and dragging into Applications.
|
||||||
|
|
||||||
|
You can disable automatic checks or trigger a manual one from **Settings → General → Updates**, the menu bar icon, or the **Scarf → Check for Updates…** menu item.
|
||||||
|
|
||||||
|
### Notarized & Developer ID signed
|
||||||
|
|
||||||
|
This is the first release that's properly Developer ID signed and notarized by Apple. Gatekeeper accepts it on first launch — no more right-click → Open dance, no more "Scarf cannot be opened because the developer cannot be verified" warnings.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- Chat works correctly when no terminal hermes session is running, and surfaces the real error when it can't reach the agent (b6df…)
|
||||||
|
|
||||||
|
### Under the hood
|
||||||
|
|
||||||
|
- Tracked `Info.plist` (replacing auto-generation) so signing-relevant keys live in version control
|
||||||
|
- New `UpdaterService` wraps Sparkle and is injected via SwiftUI `.environment()`
|
||||||
|
- One-command release pipeline at [scripts/release.sh](https://github.com/awizemann/scarf/blob/main/scripts/release.sh) handles archive → sign → notarize → staple → appcast → GitHub release → tag
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Migrating from 1.6.0:** unzip and replace your existing `Scarf.app` in `/Applications`. After this release, future updates install in-place via Sparkle.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
## What's New in 1.6.2
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **No more bogus "missing credentials" banner on Chat.** The orange "No AI provider credentials detected" warning was firing on the Chat tab whenever no session was selected, even for users whose credentials were configured and working. Root cause: the preflight check only inspected `~/.hermes/.env` and shell environment variables, missing the Credential Pools file at `~/.hermes/auth.json` (the in-app flow introduced in 1.6.0) and `api_key:` fields in `config.yaml`. The check now covers all four locations Hermes itself reads at runtime, so if you've added credentials via **Configure → Credential Pools**, the warning stays hidden.
|
||||||
|
|
||||||
|
### Polish
|
||||||
|
|
||||||
|
- Banner subtitle updated to point users at the in-app Credential Pools flow first, rather than prescribing `.env` edits.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Upgrading from 1.6.1:** Sparkle will offer the update automatically. You can also trigger a check via **Scarf → Check for Updates…** or the menu bar icon.
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
53495AB62F7B992C00BD31AD /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 53SWIFTTERM0001 /* SwiftTerm */; };
|
53495AB62F7B992C00BD31AD /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 53SWIFTTERM0001 /* SwiftTerm */; };
|
||||||
|
53SPARKLE00010 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 53SPARKLE00011 /* Sparkle */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -33,9 +34,22 @@
|
|||||||
534959592F7B83B700BD31AD /* scarfUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = scarfUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
534959592F7B83B700BD31AD /* scarfUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = scarfUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
534959AA2F7B83B600BD31AD /* Exceptions for "scarf" folder in "scarf" target */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
|
membershipExceptions = (
|
||||||
|
Info.plist,
|
||||||
|
);
|
||||||
|
target = 5349593F2F7B83B600BD31AD /* scarf */;
|
||||||
|
};
|
||||||
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
534959422F7B83B600BD31AD /* scarf */ = {
|
534959422F7B83B600BD31AD /* scarf */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
534959AA2F7B83B600BD31AD /* Exceptions for "scarf" folder in "scarf" target */,
|
||||||
|
);
|
||||||
path = scarf;
|
path = scarf;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -57,6 +71,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
53495AB62F7B992C00BD31AD /* SwiftTerm in Frameworks */,
|
53495AB62F7B992C00BD31AD /* SwiftTerm in Frameworks */,
|
||||||
|
53SPARKLE00010 /* Sparkle in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -118,6 +133,7 @@
|
|||||||
name = scarf;
|
name = scarf;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
53SWIFTTERM0001 /* SwiftTerm */,
|
53SWIFTTERM0001 /* SwiftTerm */,
|
||||||
|
53SPARKLE00011 /* Sparkle */,
|
||||||
);
|
);
|
||||||
productName = scarf;
|
productName = scarf;
|
||||||
productReference = 534959402F7B83B600BD31AD /* scarf.app */;
|
productReference = 534959402F7B83B600BD31AD /* scarf.app */;
|
||||||
@@ -203,6 +219,7 @@
|
|||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */,
|
53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */,
|
||||||
|
53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = 534959412F7B83B600BD31AD /* Products */;
|
productRefGroup = 534959412F7B83B600BD31AD /* Products */;
|
||||||
@@ -407,23 +424,20 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 11;
|
CURRENT_PROJECT_VERSION = 18;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Scarf;
|
INFOPLIST_FILE = scarf/Info.plist;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
|
||||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Scarf uses the microphone for Hermes voice chat.";
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||||
MARKETING_VERSION = 1.6.0;
|
MARKETING_VERSION = 1.6.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -444,23 +458,20 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 11;
|
CURRENT_PROJECT_VERSION = 18;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Scarf;
|
INFOPLIST_FILE = scarf/Info.plist;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
|
||||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Scarf uses the microphone for Hermes voice chat.";
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||||
MARKETING_VERSION = 1.6.0;
|
MARKETING_VERSION = 1.6.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -477,11 +488,11 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 4;
|
CURRENT_PROJECT_VERSION = 18;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||||
MARKETING_VERSION = 1.5.0;
|
MARKETING_VERSION = 1.6.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
@@ -498,11 +509,11 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 4;
|
CURRENT_PROJECT_VERSION = 18;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||||
MARKETING_VERSION = 1.5.0;
|
MARKETING_VERSION = 1.6.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
@@ -518,10 +529,10 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 4;
|
CURRENT_PROJECT_VERSION = 18;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.5.0;
|
MARKETING_VERSION = 1.6.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
@@ -537,10 +548,10 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 4;
|
CURRENT_PROJECT_VERSION = 18;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.5.0;
|
MARKETING_VERSION = 1.6.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
@@ -594,6 +605,14 @@
|
|||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCRemoteSwiftPackageReference section */
|
/* Begin XCRemoteSwiftPackageReference section */
|
||||||
|
53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */ = {
|
||||||
|
isa = XCRemoteSwiftPackageReference;
|
||||||
|
repositoryURL = "https://github.com/sparkle-project/Sparkle";
|
||||||
|
requirement = {
|
||||||
|
kind = upToNextMajorVersion;
|
||||||
|
minimumVersion = 2.6.0;
|
||||||
|
};
|
||||||
|
};
|
||||||
53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */ = {
|
53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/migueldeicaza/SwiftTerm.git";
|
repositoryURL = "https://github.com/migueldeicaza/SwiftTerm.git";
|
||||||
@@ -605,6 +624,11 @@
|
|||||||
/* End XCRemoteSwiftPackageReference section */
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
53SPARKLE00011 /* Sparkle */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = 53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */;
|
||||||
|
productName = Sparkle;
|
||||||
|
};
|
||||||
53SWIFTTERM0001 /* SwiftTerm */ = {
|
53SWIFTTERM0001 /* SwiftTerm */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = 53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */;
|
package = 53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */;
|
||||||
|
|||||||
@@ -19,10 +19,30 @@ enum HermesPaths: Sendable {
|
|||||||
nonisolated static let errorsLog: String = home + "/logs/errors.log"
|
nonisolated static let errorsLog: String = home + "/logs/errors.log"
|
||||||
nonisolated static let agentLog: String = home + "/logs/agent.log"
|
nonisolated static let agentLog: String = home + "/logs/agent.log"
|
||||||
nonisolated static let gatewayLog: String = home + "/logs/gateway.log"
|
nonisolated static let gatewayLog: String = home + "/logs/gateway.log"
|
||||||
nonisolated static let hermesBinary: String = userHome + "/.local/bin/hermes"
|
|
||||||
nonisolated static let scarfDir: String = home + "/scarf"
|
nonisolated static let scarfDir: String = home + "/scarf"
|
||||||
nonisolated static let projectsRegistry: String = scarfDir + "/projects.json"
|
nonisolated static let projectsRegistry: String = scarfDir + "/projects.json"
|
||||||
nonisolated static let mcpTokensDir: String = home + "/mcp-tokens"
|
nonisolated static let mcpTokensDir: String = home + "/mcp-tokens"
|
||||||
|
|
||||||
|
/// Install locations we look for the `hermes` binary in, in priority order.
|
||||||
|
/// Checked every access so a user installing via a different method doesn't
|
||||||
|
/// need to relaunch Scarf.
|
||||||
|
nonisolated static let hermesBinaryCandidates: [String] = [
|
||||||
|
userHome + "/.local/bin/hermes", // pipx / pip --user (default)
|
||||||
|
"/opt/homebrew/bin/hermes", // Homebrew on Apple Silicon
|
||||||
|
"/usr/local/bin/hermes", // Homebrew on Intel / manual install
|
||||||
|
userHome + "/.hermes/bin/hermes" // Some self-install layouts
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Resolved path to the `hermes` executable. Returns the first candidate
|
||||||
|
/// that exists and is executable; falls back to the pipx default so error
|
||||||
|
/// messages ("Expected at …") still make sense on a fresh machine.
|
||||||
|
nonisolated static var hermesBinary: String {
|
||||||
|
for path in hermesBinaryCandidates
|
||||||
|
where FileManager.default.isExecutableFile(atPath: path) {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
return hermesBinaryCandidates[0]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - SQLite Constants
|
// MARK: - SQLite Constants
|
||||||
|
|||||||
@@ -24,6 +24,27 @@ actor ACPClient {
|
|||||||
private(set) var currentSessionId: String?
|
private(set) var currentSessionId: String?
|
||||||
private(set) var statusMessage = ""
|
private(set) var statusMessage = ""
|
||||||
|
|
||||||
|
/// Ring buffer of recent stderr lines from `hermes acp` — used to attach
|
||||||
|
/// a diagnostic tail to user-visible errors. Capped to avoid unbounded
|
||||||
|
/// growth when the subprocess logs heavily.
|
||||||
|
private var stderrBuffer: [String] = []
|
||||||
|
private static let stderrBufferMaxLines = 50
|
||||||
|
|
||||||
|
/// Returns the last ~`stderrBufferMaxLines` stderr lines captured from the
|
||||||
|
/// `hermes acp` subprocess, joined by newlines.
|
||||||
|
var recentStderr: String {
|
||||||
|
stderrBuffer.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate func appendStderr(_ text: String) {
|
||||||
|
for line in text.split(separator: "\n", omittingEmptySubsequences: true) {
|
||||||
|
stderrBuffer.append(String(line))
|
||||||
|
}
|
||||||
|
if stderrBuffer.count > Self.stderrBufferMaxLines {
|
||||||
|
stderrBuffer.removeFirst(stderrBuffer.count - Self.stderrBufferMaxLines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if the underlying process is still alive and connected.
|
/// Check if the underlying process is still alive and connected.
|
||||||
var isHealthy: Bool {
|
var isHealthy: Bool {
|
||||||
guard isConnected, let process else { return false }
|
guard isConnected, let process else { return false }
|
||||||
@@ -398,7 +419,8 @@ actor ACPClient {
|
|||||||
await self?.handleReadLoopEnded()
|
await self?.handleReadLoopEnded()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read stderr in background for diagnostic logging
|
// Read stderr in background for diagnostic logging AND ring-buffer
|
||||||
|
// capture so we can attach a tail to user-visible errors.
|
||||||
stderrTask = Task.detached { [weak self] in
|
stderrTask = Task.detached { [weak self] in
|
||||||
let handle = stderr.fileHandleForReading
|
let handle = stderr.fileHandleForReading
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
@@ -407,6 +429,7 @@ actor ACPClient {
|
|||||||
if let text = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
if let text = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
!text.isEmpty {
|
!text.isEmpty {
|
||||||
await self?.logger.info("ACP stderr: \(text.prefix(500))")
|
await self?.logger.info("ACP stderr: \(text.prefix(500))")
|
||||||
|
await self?.appendStderr(text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -516,3 +539,35 @@ enum ACPClientError: Error, LocalizedError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Maps a raw error message (RPC message or captured stderr) to a short
|
||||||
|
/// human-readable hint for the chat UI. Pattern-matches the most common
|
||||||
|
/// fresh-install failure modes. Returns nil when no known pattern matches.
|
||||||
|
enum ACPErrorHint {
|
||||||
|
static func classify(errorMessage: String, stderrTail: String) -> String? {
|
||||||
|
let haystack = errorMessage + "\n" + stderrTail
|
||||||
|
if haystack.range(of: #"No\s+(Anthropic|OpenAI|OpenRouter|Gemini|Google|Groq|Mistral|XAI)?\s*credentials\s+found"#,
|
||||||
|
options: .regularExpression) != nil
|
||||||
|
|| haystack.contains("ANTHROPIC_API_KEY")
|
||||||
|
|| haystack.contains("ANTHROPIC_TOKEN")
|
||||||
|
|| haystack.contains("claude setup-token")
|
||||||
|
|| haystack.contains("claude /login") {
|
||||||
|
return "Hermes can't find your AI provider credentials. Set `ANTHROPIC_API_KEY` (or similar) in `~/.hermes/.env` or your shell profile, then restart Scarf."
|
||||||
|
}
|
||||||
|
if let match = haystack.range(of: #"No such file or directory:\s*'([^']+)'"#,
|
||||||
|
options: .regularExpression) {
|
||||||
|
let matched = String(haystack[match])
|
||||||
|
if let nameStart = matched.range(of: "'"),
|
||||||
|
let nameEnd = matched.range(of: "'", range: nameStart.upperBound..<matched.endIndex) {
|
||||||
|
let name = String(matched[nameStart.upperBound..<nameEnd.lowerBound])
|
||||||
|
return "Hermes couldn't find `\(name)` on PATH. If you use nvm/asdf/mise, make sure it's exported in `~/.zprofile` (not only `~/.zshrc`), then restart Scarf."
|
||||||
|
}
|
||||||
|
return "Hermes couldn't find a required binary on PATH. Check that your shell's PATH is exported in `~/.zprofile`, then restart Scarf."
|
||||||
|
}
|
||||||
|
if haystack.localizedCaseInsensitiveContains("rate limit")
|
||||||
|
|| haystack.localizedCaseInsensitiveContains("429") {
|
||||||
|
return "Your AI provider returned a rate-limit error. Try again in a moment."
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1203,58 +1203,66 @@ struct HermesFileService: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func hermesBinaryPath() -> String? {
|
nonisolated func hermesBinaryPath() -> String? {
|
||||||
let candidates = [
|
// Single source of truth for install-location candidates lives in
|
||||||
("\(NSHomeDirectory())/.local/bin/hermes"),
|
// HermesPaths.hermesBinaryCandidates — keeps pipx/brew/manual lookups
|
||||||
"/opt/homebrew/bin/hermes",
|
// consistent across the app.
|
||||||
"/usr/local/bin/hermes"
|
return HermesPaths.hermesBinaryCandidates
|
||||||
]
|
.first { FileManager.default.isExecutableFile(atPath: $0) }
|
||||||
return candidates.first { FileManager.default.isExecutableFile(atPath: $0) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// PATH cobbled together from the user's login shell — needed because
|
/// Keys queried from the user's login shell. PATH is needed because .app
|
||||||
/// .app bundles launched from Finder/Dock get a minimal PATH (no Homebrew,
|
/// bundles launched from Finder/Dock get a minimal PATH (no Homebrew, no
|
||||||
/// no nvm, no asdf, no mise). Without this, MCP servers using `npx`,
|
/// nvm, no asdf, no mise). The credential keys are needed because Hermes
|
||||||
/// `node`, `python`, `uv`, etc. fail to launch with `[Errno 2] No such
|
/// resolves AI provider auth by reading env vars — a GUI-launched Scarf
|
||||||
/// file or directory`. Computed once and cached.
|
/// subprocess sees none of the `export ANTHROPIC_API_KEY=…` lines from
|
||||||
private static let enrichedPath: String = {
|
/// the user's shell init files.
|
||||||
let pipe = Pipe()
|
private static let shellEnvKeys: [String] = [
|
||||||
let errPipe = Pipe()
|
"PATH",
|
||||||
let process = Process()
|
"ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "ANTHROPIC_BASE_URL",
|
||||||
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
|
"OPENAI_API_KEY", "OPENAI_BASE_URL",
|
||||||
// -l sources the user's login files (.zprofile, .zshrc via /etc/zshrc
|
"OPENROUTER_API_KEY",
|
||||||
// chain on macOS) so PATH manipulations made there are picked up.
|
"GEMINI_API_KEY", "GOOGLE_API_KEY",
|
||||||
// Skip -i to avoid hangs from interactive prompts.
|
"GROQ_API_KEY", "MISTRAL_API_KEY", "XAI_API_KEY",
|
||||||
process.arguments = ["-l", "-c", "echo $PATH"]
|
"CLAUDE_CODE_OAUTH_TOKEN"
|
||||||
process.standardOutput = pipe
|
]
|
||||||
process.standardError = errPipe
|
|
||||||
defer {
|
/// Env vars harvested from the user's login shell. Computed once and cached.
|
||||||
try? pipe.fileHandleForReading.close()
|
///
|
||||||
try? pipe.fileHandleForWriting.close()
|
/// Probing strategy — two attempts, best result wins:
|
||||||
try? errPipe.fileHandleForReading.close()
|
/// 1. `zsh -l -i` (login + interactive) — sources BOTH `.zprofile` and
|
||||||
try? errPipe.fileHandleForWriting.close()
|
/// `.zshrc`, which is required for nvm/asdf/mise PATH on most setups
|
||||||
|
/// (those tools inject PATH from `.zshrc`, not `.zprofile`).
|
||||||
|
/// Interactive mode can hang on prompt frameworks (oh-my-zsh,
|
||||||
|
/// powerlevel10k, starship) so we suppress prompts via env and bound
|
||||||
|
/// with a 5-second timeout.
|
||||||
|
/// 2. If that yields no PATH (timed out / prompt framework broke it),
|
||||||
|
/// fall back to `zsh -l` (login only) with a 3-second timeout.
|
||||||
|
/// 3. If that also fails, hardcoded sane-default PATH; no credentials.
|
||||||
|
private static let enrichedShellEnv: [String: String] = {
|
||||||
|
// Build a shell script that prints `KEY\0VALUE\0` for each key.
|
||||||
|
// Using printf with \0 as separator lets us unambiguously split the
|
||||||
|
// output even if a value contains newlines.
|
||||||
|
let script = shellEnvKeys.map { key in
|
||||||
|
#"printf '%s\0%s\0' "\#(key)" "$\#(key)""#
|
||||||
|
}.joined(separator: "; ")
|
||||||
|
|
||||||
|
// Attempt 1: login + interactive (covers nvm/asdf/mise in .zshrc).
|
||||||
|
if let result = runShellProbe(script: script, interactive: true, timeout: 5.0),
|
||||||
|
result["PATH"] != nil {
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
do {
|
// Attempt 2: login only (safe fallback if interactive hangs).
|
||||||
try process.run()
|
if let result = runShellProbe(script: script, interactive: false, timeout: 3.0),
|
||||||
let deadline = Date().addingTimeInterval(3)
|
result["PATH"] != nil {
|
||||||
while process.isRunning && Date() < deadline {
|
return result
|
||||||
Thread.sleep(forTimeInterval: 0.05)
|
|
||||||
}
|
|
||||||
if process.isRunning { process.terminate() }
|
|
||||||
process.waitUntilExit()
|
|
||||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
||||||
let path = (String(data: data, encoding: .utf8) ?? "")
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if process.terminationStatus == 0 && !path.isEmpty {
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Fall through to default below.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback when the login shell can't be queried (zsh missing,
|
// Fallback when the login shell can't be queried (zsh missing,
|
||||||
// sandbox restriction, timeout). Covers Apple Silicon + Intel
|
// sandbox restriction, timeout). Covers Apple Silicon + Intel
|
||||||
// Homebrew plus the standard system paths.
|
// Homebrew plus the standard system paths. No credential env is
|
||||||
|
// inferred — the user will see the missing-credentials hint instead.
|
||||||
let home = NSHomeDirectory()
|
let home = NSHomeDirectory()
|
||||||
return [
|
let fallbackPath = [
|
||||||
"\(home)/.local/bin",
|
"\(home)/.local/bin",
|
||||||
"/opt/homebrew/bin",
|
"/opt/homebrew/bin",
|
||||||
"/usr/local/bin",
|
"/usr/local/bin",
|
||||||
@@ -1263,18 +1271,160 @@ struct HermesFileService: Sendable {
|
|||||||
"/usr/sbin",
|
"/usr/sbin",
|
||||||
"/sbin"
|
"/sbin"
|
||||||
].joined(separator: ":")
|
].joined(separator: ":")
|
||||||
|
return ["PATH": fallbackPath]
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
/// Runs a zsh probe with the given script and returns the parsed
|
||||||
|
/// `KEY\0VALUE\0`-delimited output. Returns nil on timeout/failure.
|
||||||
|
/// When `interactive` is true, injects env vars that suppress common
|
||||||
|
/// prompt frameworks so the shell doesn't hang waiting for terminal setup.
|
||||||
|
private static func runShellProbe(script: String, interactive: Bool, timeout: TimeInterval) -> [String: String]? {
|
||||||
|
let pipe = Pipe()
|
||||||
|
let errPipe = Pipe()
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
|
||||||
|
process.arguments = interactive ? ["-l", "-i", "-c", script] : ["-l", "-c", script]
|
||||||
|
process.standardOutput = pipe
|
||||||
|
process.standardError = errPipe
|
||||||
|
|
||||||
|
if interactive {
|
||||||
|
// Defang prompt frameworks so -i doesn't hang on async prompt init.
|
||||||
|
// We still inherit the parent env (HOME, USER etc.) so rc files resolve.
|
||||||
|
var env = ProcessInfo.processInfo.environment
|
||||||
|
env["TERM"] = "dumb" // disables fancy prompt setup
|
||||||
|
env["PS1"] = ""
|
||||||
|
env["PROMPT"] = ""
|
||||||
|
env["RPROMPT"] = ""
|
||||||
|
env["POWERLEVEL9K_INSTANT_PROMPT"] = "off" // p10k
|
||||||
|
env["STARSHIP_DISABLE"] = "1" // starship (some versions)
|
||||||
|
env["ZSH_DISABLE_COMPFIX"] = "true" // oh-my-zsh compaudit hang
|
||||||
|
process.environment = env
|
||||||
|
}
|
||||||
|
|
||||||
|
defer {
|
||||||
|
try? pipe.fileHandleForReading.close()
|
||||||
|
try? pipe.fileHandleForWriting.close()
|
||||||
|
try? errPipe.fileHandleForReading.close()
|
||||||
|
try? errPipe.fileHandleForWriting.close()
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while process.isRunning && Date() < deadline {
|
||||||
|
Thread.sleep(forTimeInterval: 0.05)
|
||||||
|
}
|
||||||
|
if process.isRunning {
|
||||||
|
process.terminate()
|
||||||
|
// Brief grace period for SIGTERM to take; then the defer
|
||||||
|
// cleanup closes the pipes regardless.
|
||||||
|
Thread.sleep(forTimeInterval: 0.1)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
process.waitUntilExit()
|
||||||
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
guard process.terminationStatus == 0, !data.isEmpty else { return nil }
|
||||||
|
var result: [String: String] = [:]
|
||||||
|
let parts = data.split(separator: 0, omittingEmptySubsequences: false)
|
||||||
|
var i = 0
|
||||||
|
while i + 1 < parts.count {
|
||||||
|
if let key = String(data: Data(parts[i]), encoding: .utf8),
|
||||||
|
let value = String(data: Data(parts[i + 1]), encoding: .utf8),
|
||||||
|
!key.isEmpty, !value.isEmpty {
|
||||||
|
result[key] = value
|
||||||
|
}
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
return result.isEmpty ? nil : result
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Environment to hand any subprocess that may itself spawn user-installed
|
/// Environment to hand any subprocess that may itself spawn user-installed
|
||||||
/// binaries (Hermes spawning MCP servers, ACP tool calls, etc.). Identical
|
/// binaries (Hermes spawning MCP servers, ACP tool calls, etc.). Starts
|
||||||
/// to ProcessInfo.processInfo.environment but with PATH replaced by the
|
/// from ProcessInfo.environment and overlays PATH + allowlisted credential
|
||||||
/// login-shell PATH.
|
/// env vars harvested from the user's login shell.
|
||||||
nonisolated static func enrichedEnvironment() -> [String: String] {
|
nonisolated static func enrichedEnvironment() -> [String: String] {
|
||||||
var env = ProcessInfo.processInfo.environment
|
var env = ProcessInfo.processInfo.environment
|
||||||
env["PATH"] = enrichedPath
|
for (key, value) in enrichedShellEnv where !value.isEmpty {
|
||||||
|
// Shell wins for PATH (we explicitly want the enriched one). For
|
||||||
|
// credential keys, also let the shell win — GUI env rarely has
|
||||||
|
// them, and if it does, the shell-exported value is usually the
|
||||||
|
// one the user actually maintains.
|
||||||
|
env[key] = value
|
||||||
|
}
|
||||||
return env
|
return env
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True if any known AI-provider credential is reachable. Hermes itself
|
||||||
|
/// resolves credentials from four locations at runtime, so the preflight
|
||||||
|
/// mirrors that set to avoid false "no credentials" warnings:
|
||||||
|
/// 1. Current process env + login-shell env (queried once at startup)
|
||||||
|
/// 2. `~/.hermes/.env`
|
||||||
|
/// 3. `~/.hermes/auth.json` — Credential Pools (v1.6+ blessed flow)
|
||||||
|
/// 4. `~/.hermes/config.yaml` — embedded `api_key:` for auxiliary /
|
||||||
|
/// delegation tasks
|
||||||
|
/// Used by Chat to warn the user before `hermes acp` fails on send with
|
||||||
|
/// "No Anthropic credentials found".
|
||||||
|
nonisolated static func hasAnyAICredential() -> Bool {
|
||||||
|
let credentialKeys = shellEnvKeys.filter { $0 != "PATH" && $0 != "ANTHROPIC_BASE_URL" && $0 != "OPENAI_BASE_URL" }
|
||||||
|
let env = enrichedEnvironment()
|
||||||
|
for key in credentialKeys {
|
||||||
|
if let value = env[key], !value.isEmpty {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Scan ~/.hermes/.env for KEY= lines. Uses a simple substring check —
|
||||||
|
// good enough for a preflight hint; hermes itself does the real parse.
|
||||||
|
let envPath = HermesPaths.home + "/.env"
|
||||||
|
if let data = try? String(contentsOfFile: envPath, encoding: .utf8) {
|
||||||
|
for line in data.split(separator: "\n") {
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
|
||||||
|
for key in credentialKeys where trimmed.hasPrefix("\(key)=") || trimmed.hasPrefix("export \(key)=") {
|
||||||
|
// Must have a non-empty value after `=`
|
||||||
|
if let eq = trimmed.firstIndex(of: "="),
|
||||||
|
trimmed.index(after: eq) < trimmed.endIndex {
|
||||||
|
let value = trimmed[trimmed.index(after: eq)...]
|
||||||
|
.trimmingCharacters(in: CharacterSet(charactersIn: "\"' "))
|
||||||
|
if !value.isEmpty { return true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Scan ~/.hermes/auth.json — the Credential Pools file written by the
|
||||||
|
// Configure → Credential Pools UI. Schema is
|
||||||
|
// { "credential_pool": { "<provider>": [ { "access_token": "...", ... }, ... ] } }
|
||||||
|
// Defensive parse: any malformed input falls through to the next check.
|
||||||
|
let authPath = HermesPaths.home + "/auth.json"
|
||||||
|
if let data = try? Data(contentsOf: URL(fileURLWithPath: authPath)),
|
||||||
|
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let pool = root["credential_pool"] as? [String: Any] {
|
||||||
|
for (_, entries) in pool {
|
||||||
|
guard let list = entries as? [[String: Any]] else { continue }
|
||||||
|
for cred in list {
|
||||||
|
if let token = cred["access_token"] as? String, !token.isEmpty {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Scan ~/.hermes/config.yaml for `api_key:` lines with a non-empty
|
||||||
|
// value. Covers both `auxiliary.<task>.api_key` and `delegation.api_key`
|
||||||
|
// without needing to parse the YAML structure — any leaf `api_key: ...`
|
||||||
|
// with a value means Hermes has a credential to fall back on.
|
||||||
|
if let text = try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8) {
|
||||||
|
for line in text.split(separator: "\n") {
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard trimmed.hasPrefix("api_key:") else { continue }
|
||||||
|
let value = trimmed.dropFirst("api_key:".count)
|
||||||
|
.trimmingCharacters(in: CharacterSet(charactersIn: "\"' "))
|
||||||
|
if !value.isEmpty { return true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
nonisolated func runHermesCLI(args: [String], timeout: TimeInterval = 60, stdinInput: String? = nil) -> (exitCode: Int32, output: String) {
|
nonisolated func runHermesCLI(args: [String], timeout: TimeInterval = 60, stdinInput: String? = nil) -> (exitCode: Int32, output: String) {
|
||||||
guard let binary = hermesBinaryPath() else { return (-1, "") }
|
guard let binary = hermesBinaryPath() else { return (-1, "") }
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import Foundation
|
||||||
|
import Sparkle
|
||||||
|
|
||||||
|
/// Thin wrapper around Sparkle's `SPUStandardUpdaterController`.
|
||||||
|
///
|
||||||
|
/// Sparkle reads `SUFeedURL`, `SUPublicEDKey`, and check-interval defaults from Info.plist.
|
||||||
|
/// This service exposes the bits the UI needs: a "check now" trigger, a toggle for automatic
|
||||||
|
/// checks, and observable state for the Settings screen.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class UpdaterService: NSObject {
|
||||||
|
private let controller: SPUStandardUpdaterController
|
||||||
|
|
||||||
|
/// User-facing toggle. Mirrors `updater.automaticallyChecksForUpdates`.
|
||||||
|
var automaticallyChecksForUpdates: Bool {
|
||||||
|
get { controller.updater.automaticallyChecksForUpdates }
|
||||||
|
set { controller.updater.automaticallyChecksForUpdates = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Last time Sparkle checked the appcast (nil before the first check).
|
||||||
|
var lastUpdateCheckDate: Date? {
|
||||||
|
controller.updater.lastUpdateCheckDate
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
// startingUpdater: true → Sparkle scans for updates on launch per Info.plist schedule.
|
||||||
|
// Default delegates are sufficient for a non-sandboxed app.
|
||||||
|
self.controller = SPUStandardUpdaterController(
|
||||||
|
startingUpdater: true,
|
||||||
|
updaterDelegate: nil,
|
||||||
|
userDriverDelegate: nil
|
||||||
|
)
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triggers a user-initiated update check. Sparkle handles the UI (alert, progress, install).
|
||||||
|
func checkForUpdates() {
|
||||||
|
controller.checkForUpdates(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,14 @@ final class ChatViewModel {
|
|||||||
var isACPConnected: Bool { acpClient != nil && hasActiveProcess }
|
var isACPConnected: Bool { acpClient != nil && hasActiveProcess }
|
||||||
var acpStatus: String = ""
|
var acpStatus: String = ""
|
||||||
var acpError: String?
|
var acpError: String?
|
||||||
|
/// Human-readable hint derived from error + stderr (e.g. "set ANTHROPIC_API_KEY").
|
||||||
|
/// Shown above the raw error in the UI when present.
|
||||||
|
var acpErrorHint: String?
|
||||||
|
/// Tail of stderr captured from `hermes acp` at the time of the last
|
||||||
|
/// failure — shown in a collapsible details section so users can copy/paste.
|
||||||
|
var acpErrorDetails: String?
|
||||||
|
/// True when `hasAnyAICredential()` returned false at last preflight.
|
||||||
|
var missingCredentials: Bool = false
|
||||||
|
|
||||||
private static let maxReconnectAttempts = 5
|
private static let maxReconnectAttempts = 5
|
||||||
private static let reconnectBaseDelay: UInt64 = 1_000_000_000 // 1 second
|
private static let reconnectBaseDelay: UInt64 = 1_000_000_000 // 1 second
|
||||||
@@ -39,6 +47,34 @@ final class ChatViewModel {
|
|||||||
FileManager.default.fileExists(atPath: HermesPaths.hermesBinary)
|
FileManager.default.fileExists(atPath: HermesPaths.hermesBinary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Re-checks env + `~/.hermes/.env` for AI-provider credentials and
|
||||||
|
/// updates `missingCredentials`. Cheap — safe to call from view `.task`.
|
||||||
|
func refreshCredentialPreflight() {
|
||||||
|
missingCredentials = !HermesFileService.hasAnyAICredential()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears the error/hint/details triplet so future failures overwrite
|
||||||
|
/// cleanly instead of stacking on top of stale state.
|
||||||
|
private func clearACPErrorState() {
|
||||||
|
acpError = nil
|
||||||
|
acpErrorHint = nil
|
||||||
|
acpErrorDetails = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Populates acpError, acpErrorHint, acpErrorDetails from an error + the
|
||||||
|
/// stderr tail the ACP client captured, and logs the failure with a
|
||||||
|
/// site-specific context label. Call on any failure path.
|
||||||
|
@MainActor
|
||||||
|
private func recordACPFailure(_ error: Error, client: ACPClient?, context: String) async {
|
||||||
|
let msg = error.localizedDescription
|
||||||
|
logger.error("\(context): \(msg)")
|
||||||
|
let stderrTail = await client?.recentStderr ?? ""
|
||||||
|
let hint = ACPErrorHint.classify(errorMessage: msg, stderrTail: stderrTail)
|
||||||
|
acpError = msg
|
||||||
|
acpErrorHint = hint
|
||||||
|
acpErrorDetails = stderrTail.isEmpty ? nil : stderrTail
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Session Lifecycle
|
// MARK: - Session Lifecycle
|
||||||
|
|
||||||
func startNewSession() {
|
func startNewSession() {
|
||||||
@@ -157,10 +193,8 @@ final class ChatViewModel {
|
|||||||
// Now send the queued prompt
|
// Now send the queued prompt
|
||||||
sendViaACP(client: client, text: text)
|
sendViaACP(client: client, text: text)
|
||||||
} catch {
|
} catch {
|
||||||
let msg = error.localizedDescription
|
|
||||||
logger.error("Auto-start ACP failed: \(msg)")
|
|
||||||
acpStatus = "Failed"
|
acpStatus = "Failed"
|
||||||
acpError = msg
|
await recordACPFailure(error, client: client, context: "Auto-start ACP failed")
|
||||||
hasActiveProcess = false
|
hasActiveProcess = false
|
||||||
acpClient = nil
|
acpClient = nil
|
||||||
}
|
}
|
||||||
@@ -169,6 +203,7 @@ final class ChatViewModel {
|
|||||||
|
|
||||||
private func sendViaACP(client: ACPClient, text: String) {
|
private func sendViaACP(client: ACPClient, text: String) {
|
||||||
guard let sessionId = richChatViewModel.sessionId else {
|
guard let sessionId = richChatViewModel.sessionId else {
|
||||||
|
clearACPErrorState()
|
||||||
acpError = "No session ID — cannot send"
|
acpError = "No session ID — cannot send"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -192,10 +227,8 @@ final class ChatViewModel {
|
|||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
acpStatus = "Cancelled"
|
acpStatus = "Cancelled"
|
||||||
} catch {
|
} catch {
|
||||||
let msg = error.localizedDescription
|
|
||||||
logger.error("ACP prompt failed: \(msg)")
|
|
||||||
acpStatus = "Error"
|
acpStatus = "Error"
|
||||||
acpError = msg
|
await recordACPFailure(error, client: client, context: "ACP prompt failed")
|
||||||
richChatViewModel.handleACPEvent(
|
richChatViewModel.handleACPEvent(
|
||||||
.promptComplete(sessionId: sessionId, response: ACPPromptResult(
|
.promptComplete(sessionId: sessionId, response: ACPPromptResult(
|
||||||
stopReason: "error",
|
stopReason: "error",
|
||||||
@@ -211,7 +244,7 @@ final class ChatViewModel {
|
|||||||
|
|
||||||
private func startACPSession(resume sessionId: String?) {
|
private func startACPSession(resume sessionId: String?) {
|
||||||
stopACP()
|
stopACP()
|
||||||
acpError = nil
|
clearACPErrorState()
|
||||||
acpStatus = "Starting..."
|
acpStatus = "Starting..."
|
||||||
|
|
||||||
let client = ACPClient()
|
let client = ACPClient()
|
||||||
@@ -259,10 +292,8 @@ final class ChatViewModel {
|
|||||||
|
|
||||||
logger.info("ACP session ready: \(resolvedSessionId)")
|
logger.info("ACP session ready: \(resolvedSessionId)")
|
||||||
} catch {
|
} catch {
|
||||||
let msg = error.localizedDescription
|
|
||||||
logger.error("Failed to start ACP session: \(msg)")
|
|
||||||
acpStatus = "Failed"
|
acpStatus = "Failed"
|
||||||
acpError = msg
|
await recordACPFailure(error, client: client, context: "Failed to start ACP session")
|
||||||
hasActiveProcess = false
|
hasActiveProcess = false
|
||||||
acpClient = nil
|
acpClient = nil
|
||||||
}
|
}
|
||||||
@@ -333,7 +364,7 @@ final class ChatViewModel {
|
|||||||
|
|
||||||
private func attemptReconnect(sessionId: String) {
|
private func attemptReconnect(sessionId: String) {
|
||||||
reconnectTask?.cancel()
|
reconnectTask?.cancel()
|
||||||
acpError = nil
|
clearACPErrorState()
|
||||||
|
|
||||||
reconnectTask = Task { @MainActor [weak self] in
|
reconnectTask = Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
@@ -379,7 +410,7 @@ final class ChatViewModel {
|
|||||||
await richChatViewModel.reconcileWithDB(sessionId: resolvedSessionId)
|
await richChatViewModel.reconcileWithDB(sessionId: resolvedSessionId)
|
||||||
|
|
||||||
acpStatus = "Reconnected (\(resolvedSessionId.prefix(12)))"
|
acpStatus = "Reconnected (\(resolvedSessionId.prefix(12)))"
|
||||||
acpError = nil
|
clearACPErrorState()
|
||||||
|
|
||||||
startACPEventLoop(client: client)
|
startACPEventLoop(client: client)
|
||||||
startHealthMonitor(client: client)
|
startHealthMonitor(client: client)
|
||||||
@@ -404,6 +435,7 @@ final class ChatViewModel {
|
|||||||
private func showConnectionFailure() {
|
private func showConnectionFailure() {
|
||||||
richChatViewModel.handleACPEvent(.connectionLost(reason: "The ACP process terminated unexpectedly"))
|
richChatViewModel.handleACPEvent(.connectionLost(reason: "The ACP process terminated unexpectedly"))
|
||||||
acpStatus = "Connection lost"
|
acpStatus = "Connection lost"
|
||||||
|
clearACPErrorState()
|
||||||
acpError = "Connection lost. Use the Session menu to reconnect."
|
acpError = "Connection lost. Use the Session menu to reconnect."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,18 +3,113 @@ import SwiftUI
|
|||||||
struct ChatView: View {
|
struct ChatView: View {
|
||||||
@Environment(ChatViewModel.self) private var viewModel
|
@Environment(ChatViewModel.self) private var viewModel
|
||||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||||
|
@State private var showErrorDetails = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@Bindable var vm = viewModel
|
@Bindable var vm = viewModel
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
toolbar
|
toolbar
|
||||||
Divider()
|
Divider()
|
||||||
|
errorBanner
|
||||||
chatArea
|
chatArea
|
||||||
}
|
}
|
||||||
.navigationTitle("Chat")
|
.navigationTitle("Chat")
|
||||||
.task { await viewModel.loadRecentSessions() }
|
.task {
|
||||||
|
await viewModel.loadRecentSessions()
|
||||||
|
viewModel.refreshCredentialPreflight()
|
||||||
|
}
|
||||||
.onChange(of: fileWatcher.lastChangeDate) {
|
.onChange(of: fileWatcher.lastChangeDate) {
|
||||||
Task { await viewModel.loadRecentSessions() }
|
Task { await viewModel.loadRecentSessions() }
|
||||||
|
viewModel.refreshCredentialPreflight()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Banner rendered between the toolbar and the chat area when either
|
||||||
|
/// (a) a preflight credential check failed, or (b) the ACP subprocess
|
||||||
|
/// returned an error we captured. Shows a short hint + expandable raw
|
||||||
|
/// details (stderr tail) that the user can copy to the clipboard.
|
||||||
|
@ViewBuilder
|
||||||
|
private var errorBanner: some View {
|
||||||
|
if let err = viewModel.acpError {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(alignment: .top, spacing: 8) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
if let hint = viewModel.acpErrorHint {
|
||||||
|
Text(hint)
|
||||||
|
.font(.callout)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
Text(err)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.lineLimit(showErrorDetails ? nil : 2)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if viewModel.acpErrorDetails != nil {
|
||||||
|
Button(showErrorDetails ? "Hide details" : "Show details") {
|
||||||
|
showErrorDetails.toggle()
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
let payload = [viewModel.acpErrorHint, err, viewModel.acpErrorDetails]
|
||||||
|
.compactMap { $0 }
|
||||||
|
.joined(separator: "\n\n")
|
||||||
|
let pb = NSPasteboard.general
|
||||||
|
pb.clearContents()
|
||||||
|
pb.setString(payload, forType: .string)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "doc.on.doc")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.help("Copy error details")
|
||||||
|
}
|
||||||
|
if showErrorDetails, let details = viewModel.acpErrorDetails {
|
||||||
|
ScrollView {
|
||||||
|
Text(details)
|
||||||
|
.font(.system(.caption2, design: .monospaced))
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.frame(maxHeight: 160)
|
||||||
|
.padding(8)
|
||||||
|
.background(Color(nsColor: .textBackgroundColor))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.background(Color.orange.opacity(0.08))
|
||||||
|
.overlay(
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.orange.opacity(0.25))
|
||||||
|
.frame(height: 1),
|
||||||
|
alignment: .bottom
|
||||||
|
)
|
||||||
|
} else if viewModel.missingCredentials && !viewModel.hasActiveProcess {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "key.fill")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("No AI provider credentials detected")
|
||||||
|
.font(.callout)
|
||||||
|
Text("Add credentials in **Configure → Credential Pools**, set `ANTHROPIC_API_KEY` (or similar) in `~/.hermes/.env`, or export it in your shell profile, then restart Scarf.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.background(Color.orange.opacity(0.08))
|
||||||
|
.overlay(
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.orange.opacity(0.25))
|
||||||
|
.frame(height: 1),
|
||||||
|
alignment: .bottom
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Updates section for the General tab. Wraps the Sparkle-backed `UpdaterService`
|
||||||
|
/// in the same row idioms used elsewhere in Settings (per CLAUDE.md guidance —
|
||||||
|
/// extract sections so individual tab bodies stay small).
|
||||||
|
struct UpdatesSection: View {
|
||||||
|
@Environment(UpdaterService.self) private var updater
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
SettingsSection(title: "Updates", icon: "arrow.down.circle") {
|
||||||
|
ReadOnlyRow(label: "Current Version", value: versionString)
|
||||||
|
ToggleRow(
|
||||||
|
label: "Check Automatically",
|
||||||
|
isOn: updater.automaticallyChecksForUpdates
|
||||||
|
) { newValue in
|
||||||
|
updater.automaticallyChecksForUpdates = newValue
|
||||||
|
}
|
||||||
|
ReadOnlyRow(label: "Last Checked", value: lastCheckedString)
|
||||||
|
checkNowRow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var versionString: String {
|
||||||
|
let info = Bundle.main.infoDictionary
|
||||||
|
let short = info?["CFBundleShortVersionString"] as? String ?? "?"
|
||||||
|
let build = info?["CFBundleVersion"] as? String ?? "?"
|
||||||
|
return "\(short) (\(build))"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var lastCheckedString: String {
|
||||||
|
guard let date = updater.lastUpdateCheckDate else { return "Never" }
|
||||||
|
let formatter = RelativeDateTimeFormatter()
|
||||||
|
formatter.unitsStyle = .full
|
||||||
|
return formatter.localizedString(for: date, relativeTo: Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
private var checkNowRow: some View {
|
||||||
|
HStack {
|
||||||
|
Text("Check Now")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 160, alignment: .trailing)
|
||||||
|
Button("Check for Updates…") { updater.checkForUpdates() }
|
||||||
|
.controlSize(.small)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(.quaternary.opacity(0.3))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,8 @@ struct GeneralTab: View {
|
|||||||
SettingsSection(title: "Locale", icon: "globe.americas") {
|
SettingsSection(title: "Locale", icon: "globe.americas") {
|
||||||
EditableTextField(label: "Timezone (IANA)", value: viewModel.config.timezone) { viewModel.setTimezone($0) }
|
EditableTextField(label: "Timezone (IANA)", value: viewModel.config.timezone) { viewModel.setTimezone($0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
UpdatesSection()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Breadcrumb-style row that points users to the Credential Pools sidebar
|
/// Breadcrumb-style row that points users to the Credential Pools sidebar
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Scarf</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(MARKETING_VERSION)</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>LSApplicationCategoryType</key>
|
||||||
|
<string>public.app-category.utilities</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||||
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
<string></string>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>Scarf uses the microphone for Hermes voice chat.</string>
|
||||||
|
<key>SUFeedURL</key>
|
||||||
|
<string>https://awizemann.github.io/scarf/appcast.xml</string>
|
||||||
|
<key>SUPublicEDKey</key>
|
||||||
|
<string>sxHR0OGLmx9I4Fyx1GdPANR9WUiVAz/rI38x3cLYnMU=</string>
|
||||||
|
<key>SUEnableAutomaticChecks</key>
|
||||||
|
<true/>
|
||||||
|
<key>SUScheduledCheckInterval</key>
|
||||||
|
<integer>86400</integer>
|
||||||
|
<key>SUEnableInstallerLauncherService</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -4,5 +4,7 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.device.audio-input</key>
|
<key>com.apple.security.device.audio-input</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ struct ScarfApp: App {
|
|||||||
@State private var fileWatcher = HermesFileWatcher()
|
@State private var fileWatcher = HermesFileWatcher()
|
||||||
@State private var menuBarStatus = MenuBarStatus()
|
@State private var menuBarStatus = MenuBarStatus()
|
||||||
@State private var chatViewModel = ChatViewModel()
|
@State private var chatViewModel = ChatViewModel()
|
||||||
|
@State private var updater = UpdaterService()
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
@@ -13,6 +14,7 @@ struct ScarfApp: App {
|
|||||||
.environment(coordinator)
|
.environment(coordinator)
|
||||||
.environment(fileWatcher)
|
.environment(fileWatcher)
|
||||||
.environment(chatViewModel)
|
.environment(chatViewModel)
|
||||||
|
.environment(updater)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
fileWatcher.startWatching()
|
fileWatcher.startWatching()
|
||||||
menuBarStatus.startPolling()
|
menuBarStatus.startPolling()
|
||||||
@@ -23,9 +25,14 @@ struct ScarfApp: App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.defaultSize(width: 1100, height: 700)
|
.defaultSize(width: 1100, height: 700)
|
||||||
|
.commands {
|
||||||
|
CommandGroup(after: .appInfo) {
|
||||||
|
Button("Check for Updates…") { updater.checkForUpdates() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
MenuBarExtra("Scarf", systemImage: menuBarStatus.icon) {
|
MenuBarExtra("Scarf", systemImage: menuBarStatus.icon) {
|
||||||
MenuBarMenu(status: menuBarStatus, coordinator: coordinator)
|
MenuBarMenu(status: menuBarStatus, coordinator: coordinator, updater: updater)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,6 +97,7 @@ final class MenuBarStatus {
|
|||||||
struct MenuBarMenu: View {
|
struct MenuBarMenu: View {
|
||||||
let status: MenuBarStatus
|
let status: MenuBarStatus
|
||||||
let coordinator: AppCoordinator
|
let coordinator: AppCoordinator
|
||||||
|
let updater: UpdaterService
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
@@ -116,6 +124,8 @@ struct MenuBarMenu: View {
|
|||||||
NSApplication.shared.activate()
|
NSApplication.shared.activate()
|
||||||
}
|
}
|
||||||
Divider()
|
Divider()
|
||||||
|
Button("Check for Updates…") { updater.checkForUpdates() }
|
||||||
|
Divider()
|
||||||
Button("Quit Scarf") {
|
Button("Quit Scarf") {
|
||||||
NSApplication.shared.terminate(nil)
|
NSApplication.shared.terminate(nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>method</key>
|
||||||
|
<string>developer-id</string>
|
||||||
|
<key>teamID</key>
|
||||||
|
<string>3Q6X2L86C4</string>
|
||||||
|
<key>signingStyle</key>
|
||||||
|
<string>manual</string>
|
||||||
|
<key>signingCertificate</key>
|
||||||
|
<string>Developer ID Application</string>
|
||||||
|
<key>destination</key>
|
||||||
|
<string>export</string>
|
||||||
|
<key>stripSwiftSymbols</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
Executable
+290
@@ -0,0 +1,290 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Scarf release pipeline — local, manual, repeatable.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/release.sh 1.7.0 # full release: build, sign, notarize,
|
||||||
|
# # appcast push, GitHub release, tag
|
||||||
|
# ./scripts/release.sh 1.7.0 --draft # everything builds + notarizes, but the
|
||||||
|
# # GitHub release is created as draft, the
|
||||||
|
# # appcast is NOT updated, and main is NOT
|
||||||
|
# # tagged. Promote later with --promote.
|
||||||
|
#
|
||||||
|
# Release notes:
|
||||||
|
# If `releases/v<VERSION>/RELEASE_NOTES.md` exists, it is committed alongside the
|
||||||
|
# version bump and used as the GitHub release body. Otherwise a minimal autogenerated
|
||||||
|
# note is used.
|
||||||
|
#
|
||||||
|
# Prerequisites (one-time setup):
|
||||||
|
# 1. Developer ID Application cert installed in login Keychain.
|
||||||
|
# security find-identity -v -p codesigning | grep "Developer ID Application"
|
||||||
|
# 2. App Store Connect API key stored for notarytool as profile "scarf-notary":
|
||||||
|
# xcrun notarytool store-credentials "scarf-notary" \
|
||||||
|
# --key ~/.private/AuthKey_XXXX.p8 --key-id <KEY_ID> --issuer <ISSUER_ID>
|
||||||
|
# 3. Sparkle EdDSA keypair generated (private key in Keychain item "https://sparkle-project.org"):
|
||||||
|
# ./scripts/sparkle/generate_keys # or similar, from Sparkle SPM artifacts
|
||||||
|
# 4. gh-pages branch exists with an appcast.xml and GitHub Pages enabled.
|
||||||
|
# 5. gh CLI authed: `gh auth status`.
|
||||||
|
# 6. GH_PAGES_WORKTREE env var pointing at a gh-pages checkout, OR let the
|
||||||
|
# script create one automatically at .gh-pages-worktree/ via `git worktree add`.
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ---------- arg parsing ----------
|
||||||
|
VERSION=""
|
||||||
|
DRAFT=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--draft) DRAFT=1 ;;
|
||||||
|
-h|--help) sed -n '2,30p' "$0"; exit 0 ;;
|
||||||
|
-*) printf '[ERR] unknown flag: %s\n' "$arg" >&2; exit 1 ;;
|
||||||
|
*) [[ -z "$VERSION" ]] && VERSION="$arg" || { printf '[ERR] unexpected arg: %s\n' "$arg" >&2; exit 1; } ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[[ -n "$VERSION" ]] || { printf 'usage: ./scripts/release.sh <marketing-version> [--draft]\n' >&2; exit 1; }
|
||||||
|
|
||||||
|
# ---------- config ----------
|
||||||
|
TEAM_ID="3Q6X2L86C4"
|
||||||
|
BUNDLE_ID="com.scarf.app"
|
||||||
|
SCHEME="scarf"
|
||||||
|
PROJECT="scarf/scarf.xcodeproj"
|
||||||
|
NOTARY_PROFILE="scarf-notary"
|
||||||
|
SIGNING_IDENTITY="Developer ID Application"
|
||||||
|
APPCAST_URL="https://awizemann.github.io/scarf/appcast.xml"
|
||||||
|
DOWNLOAD_URL_BASE="https://github.com/awizemann/scarf/releases/download"
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
BUILD_DIR="$REPO_ROOT/build"
|
||||||
|
EXPORT_OPTIONS="$REPO_ROOT/scripts/ExportOptions.plist"
|
||||||
|
RELEASE_DIR="$REPO_ROOT/releases/v${VERSION}"
|
||||||
|
GH_PAGES_WORKTREE="${GH_PAGES_WORKTREE:-$REPO_ROOT/.gh-pages-worktree}"
|
||||||
|
|
||||||
|
# ---------- helpers ----------
|
||||||
|
log() { printf '\033[1;34m==> %s\033[0m\n' "$*"; }
|
||||||
|
warn() { printf '\033[1;33m[WARN] %s\033[0m\n' "$*" >&2; }
|
||||||
|
die() { printf '\033[1;31m[ERR] %s\033[0m\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
require_cmd() { command -v "$1" >/dev/null 2>&1 || die "missing required tool: $1"; }
|
||||||
|
|
||||||
|
# ---------- preflight ----------
|
||||||
|
log "Preflight checks"
|
||||||
|
require_cmd xcodebuild
|
||||||
|
require_cmd xcrun
|
||||||
|
require_cmd ditto
|
||||||
|
require_cmd gh
|
||||||
|
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
# git must be clean and on main. The one exception: the release dir
|
||||||
|
# (releases/v<VERSION>/) may already exist and be untracked — the user may
|
||||||
|
# have written RELEASE_NOTES.md there ahead of time, and the rest of the dir
|
||||||
|
# is auto-populated + gitignored anyway. Git status abbreviates to the dir
|
||||||
|
# path when all contents are untracked, so the whitelist matches both forms.
|
||||||
|
ALLOW="^\?\? releases/v${VERSION}/"
|
||||||
|
DIRTY="$(git status --porcelain | grep -Ev "$ALLOW" || true)"
|
||||||
|
if [[ -n "$DIRTY" ]]; then
|
||||||
|
die "working tree not clean — commit or stash first"
|
||||||
|
fi
|
||||||
|
CUR_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
|
||||||
|
[[ "$CUR_BRANCH" == "main" ]] || die "not on main (on $CUR_BRANCH)"
|
||||||
|
|
||||||
|
# identity present
|
||||||
|
security find-identity -v -p codesigning | grep -q "$SIGNING_IDENTITY" \
|
||||||
|
|| die "'$SIGNING_IDENTITY' certificate not in Keychain — create at developer.apple.com"
|
||||||
|
|
||||||
|
# notary profile present (can't list, only test by dry-running submit help)
|
||||||
|
xcrun notarytool history --keychain-profile "$NOTARY_PROFILE" --output-format json >/dev/null 2>&1 \
|
||||||
|
|| die "notarytool profile '$NOTARY_PROFILE' not set up — see script header"
|
||||||
|
|
||||||
|
# locate sign_update (ships with Sparkle SPM artifacts)
|
||||||
|
SIGN_UPDATE="$(find ~/Library/Developer/Xcode/DerivedData -name sign_update -type f -perm +111 2>/dev/null | head -n1 || true)"
|
||||||
|
[[ -x "${SIGN_UPDATE:-}" ]] || die "sign_update not found — build the project once in Xcode so Sparkle artifacts resolve, then re-run"
|
||||||
|
|
||||||
|
# ---------- bump version ----------
|
||||||
|
log "Bumping version to $VERSION"
|
||||||
|
PBXPROJ="$PROJECT/project.pbxproj"
|
||||||
|
# CURRENT_PROJECT_VERSION (build number) bumps by 1 from existing
|
||||||
|
CUR_BUILD="$(awk -F'= ' '/CURRENT_PROJECT_VERSION/ {gsub(/[; ]/,"",$2); print $2; exit}' "$PBXPROJ")"
|
||||||
|
NEW_BUILD=$((CUR_BUILD + 1))
|
||||||
|
sed -i '' -E "s/MARKETING_VERSION = [0-9]+\.[0-9]+\.[0-9]+;/MARKETING_VERSION = ${VERSION};/g" "$PBXPROJ"
|
||||||
|
sed -i '' -E "s/CURRENT_PROJECT_VERSION = [0-9]+;/CURRENT_PROJECT_VERSION = ${NEW_BUILD};/g" "$PBXPROJ"
|
||||||
|
git add "$PBXPROJ"
|
||||||
|
# Include release notes in the bump commit if user prepared them ahead of time.
|
||||||
|
NOTES_FILE="$RELEASE_DIR/RELEASE_NOTES.md"
|
||||||
|
if [[ -f "$NOTES_FILE" ]]; then
|
||||||
|
git add "$NOTES_FILE"
|
||||||
|
fi
|
||||||
|
git commit -m "chore: Bump version to ${VERSION}"
|
||||||
|
|
||||||
|
# ---------- build variants ----------
|
||||||
|
# Each release produces two zips: a Universal binary (recommended — works on
|
||||||
|
# both Apple Silicon and Intel) and an ARM64-only variant (smaller download for
|
||||||
|
# users who know they're on M-series silicon). Each variant is independently
|
||||||
|
# notarized and stapled. The appcast only references the Universal zip since
|
||||||
|
# it works everywhere; ARM64 is an alternative manual download.
|
||||||
|
|
||||||
|
log "Clean build directory"
|
||||||
|
rm -rf "$BUILD_DIR"
|
||||||
|
mkdir -p "$BUILD_DIR" "$RELEASE_DIR"
|
||||||
|
|
||||||
|
# build_variant <label> <archs> <output_zip>
|
||||||
|
# label e.g. "Universal" or "ARM64" (used as subdir name + log prefix)
|
||||||
|
# archs e.g. "arm64 x86_64" or "arm64" (space-separated ARCHS value)
|
||||||
|
# output_zip absolute path where the stapled, distribution-ready zip is written
|
||||||
|
build_variant() {
|
||||||
|
local label="$1"
|
||||||
|
local archs="$2"
|
||||||
|
local out_zip="$3"
|
||||||
|
local variant_dir="$BUILD_DIR/$label"
|
||||||
|
local archive_path="$variant_dir/scarf.xcarchive"
|
||||||
|
local export_dir="$variant_dir/export"
|
||||||
|
local app_path="$export_dir/Scarf.app"
|
||||||
|
local notarize_zip="$variant_dir/Scarf-notarize.zip"
|
||||||
|
|
||||||
|
mkdir -p "$variant_dir"
|
||||||
|
|
||||||
|
log "[$label] Archive (archs: $archs)"
|
||||||
|
xcodebuild \
|
||||||
|
-project "$PROJECT" \
|
||||||
|
-scheme "$SCHEME" \
|
||||||
|
-configuration Release \
|
||||||
|
-archivePath "$archive_path" \
|
||||||
|
-destination "generic/platform=macOS" \
|
||||||
|
ONLY_ACTIVE_ARCH=NO \
|
||||||
|
ARCHS="$archs" \
|
||||||
|
archive
|
||||||
|
|
||||||
|
log "[$label] Export signed .app"
|
||||||
|
xcodebuild \
|
||||||
|
-exportArchive \
|
||||||
|
-archivePath "$archive_path" \
|
||||||
|
-exportPath "$export_dir" \
|
||||||
|
-exportOptionsPlist "$EXPORT_OPTIONS"
|
||||||
|
|
||||||
|
# Xcode exports as scarf.app (PRODUCT_NAME = $TARGET_NAME = "scarf"). Rename so
|
||||||
|
# users see properly-cased Scarf.app in /Applications. Renaming the bundle
|
||||||
|
# wrapper does NOT invalidate the signature — codesign signs contents, not the
|
||||||
|
# wrapper folder name.
|
||||||
|
if [[ -d "$export_dir/scarf.app" && ! -d "$app_path" ]]; then
|
||||||
|
mv "$export_dir/scarf.app" "$app_path"
|
||||||
|
fi
|
||||||
|
[[ -d "$app_path" ]] || die "[$label] exported app not found at $app_path"
|
||||||
|
|
||||||
|
log "[$label] Verify signature"
|
||||||
|
codesign --verify --deep --strict --verbose=2 "$app_path"
|
||||||
|
|
||||||
|
log "[$label] Zip for notarization"
|
||||||
|
ditto -c -k --keepParent "$app_path" "$notarize_zip"
|
||||||
|
|
||||||
|
log "[$label] Submit to notarytool (blocking)"
|
||||||
|
xcrun notarytool submit "$notarize_zip" \
|
||||||
|
--keychain-profile "$NOTARY_PROFILE" \
|
||||||
|
--wait \
|
||||||
|
--timeout 30m
|
||||||
|
|
||||||
|
log "[$label] Staple + validate"
|
||||||
|
xcrun stapler staple "$app_path"
|
||||||
|
xcrun stapler validate "$app_path"
|
||||||
|
spctl --assess --type execute --verbose "$app_path"
|
||||||
|
|
||||||
|
log "[$label] Package $(basename "$out_zip")"
|
||||||
|
ditto -c -k --keepParent "$app_path" "$out_zip"
|
||||||
|
}
|
||||||
|
|
||||||
|
UNIVERSAL_ZIP="$RELEASE_DIR/Scarf-v${VERSION}-Universal.zip"
|
||||||
|
ARM64_ZIP="$RELEASE_DIR/Scarf-v${VERSION}-ARM64.zip"
|
||||||
|
|
||||||
|
build_variant "Universal" "arm64 x86_64" "$UNIVERSAL_ZIP"
|
||||||
|
build_variant "ARM64" "arm64" "$ARM64_ZIP"
|
||||||
|
|
||||||
|
# ---------- sign appcast entry ----------
|
||||||
|
log "Sign appcast entry with EdDSA"
|
||||||
|
# sign_update prints: sparkle:edSignature="..." length="..."
|
||||||
|
SIG_OUTPUT="$("$SIGN_UPDATE" "$UNIVERSAL_ZIP")"
|
||||||
|
ED_SIGNATURE="$(echo "$SIG_OUTPUT" | sed -nE 's/.*sparkle:edSignature="([^"]+)".*/\1/p')"
|
||||||
|
FILE_LENGTH="$(echo "$SIG_OUTPUT" | sed -nE 's/.*length="([^"]+)".*/\1/p')"
|
||||||
|
[[ -n "$ED_SIGNATURE" && -n "$FILE_LENGTH" ]] || die "sign_update did not produce signature: $SIG_OUTPUT"
|
||||||
|
|
||||||
|
DOWNLOAD_URL="$DOWNLOAD_URL_BASE/v${VERSION}/Scarf-v${VERSION}-Universal.zip"
|
||||||
|
PUB_DATE="$(LC_TIME=en_US.UTF-8 date -u +"%a, %d %b %Y %H:%M:%S +0000")"
|
||||||
|
APPCAST_ITEM=$(cat <<EOF
|
||||||
|
<item>
|
||||||
|
<title>Version ${VERSION}</title>
|
||||||
|
<sparkle:version>${NEW_BUILD}</sparkle:version>
|
||||||
|
<sparkle:shortVersionString>${VERSION}</sparkle:shortVersionString>
|
||||||
|
<sparkle:minimumSystemVersion>14.6</sparkle:minimumSystemVersion>
|
||||||
|
<pubDate>${PUB_DATE}</pubDate>
|
||||||
|
<enclosure url="${DOWNLOAD_URL}"
|
||||||
|
sparkle:edSignature="${ED_SIGNATURE}"
|
||||||
|
length="${FILE_LENGTH}"
|
||||||
|
type="application/octet-stream" />
|
||||||
|
</item>
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------- update appcast on gh-pages (skipped for drafts) ----------
|
||||||
|
if [[ $DRAFT -eq 0 ]]; then
|
||||||
|
log "Update appcast.xml on gh-pages worktree"
|
||||||
|
if [[ ! -d "$GH_PAGES_WORKTREE" ]]; then
|
||||||
|
git worktree add "$GH_PAGES_WORKTREE" gh-pages
|
||||||
|
fi
|
||||||
|
(
|
||||||
|
cd "$GH_PAGES_WORKTREE"
|
||||||
|
git pull --ff-only origin gh-pages
|
||||||
|
# Insert new item after <language>en</language> line
|
||||||
|
python3 - "$APPCAST_ITEM" <<'PY'
|
||||||
|
import sys, pathlib
|
||||||
|
new_item = sys.argv[1]
|
||||||
|
p = pathlib.Path("appcast.xml")
|
||||||
|
xml = p.read_text()
|
||||||
|
marker = "<language>en</language>"
|
||||||
|
if marker not in xml:
|
||||||
|
sys.exit("appcast.xml missing <language>en</language> marker")
|
||||||
|
xml = xml.replace(marker, marker + "\n" + new_item, 1)
|
||||||
|
p.write_text(xml)
|
||||||
|
PY
|
||||||
|
git add appcast.xml
|
||||||
|
git commit -m "release: v${VERSION}"
|
||||||
|
git push origin gh-pages
|
||||||
|
)
|
||||||
|
else
|
||||||
|
log "Draft mode — skipping appcast push. Saving entry to $RELEASE_DIR/appcast-entry.xml for later promotion."
|
||||||
|
printf '%s\n' "$APPCAST_ITEM" > "$RELEASE_DIR/appcast-entry.xml"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- github release ----------
|
||||||
|
log "Create GitHub release and upload artifacts"
|
||||||
|
GH_FLAGS=()
|
||||||
|
[[ $DRAFT -eq 1 ]] && GH_FLAGS+=(--draft)
|
||||||
|
if [[ -f "$NOTES_FILE" ]]; then
|
||||||
|
GH_FLAGS+=(--notes-file "$NOTES_FILE")
|
||||||
|
else
|
||||||
|
GH_FLAGS+=(--notes "Release v${VERSION}. See commit history for details.")
|
||||||
|
fi
|
||||||
|
gh release create "v${VERSION}" \
|
||||||
|
--title "Scarf v${VERSION}" \
|
||||||
|
"${GH_FLAGS[@]}" \
|
||||||
|
"$UNIVERSAL_ZIP" \
|
||||||
|
"$ARM64_ZIP"
|
||||||
|
|
||||||
|
# ---------- tag main (skipped for drafts) ----------
|
||||||
|
if [[ $DRAFT -eq 0 ]]; then
|
||||||
|
log "Tag main and push"
|
||||||
|
git tag "v${VERSION}"
|
||||||
|
git push origin main --tags
|
||||||
|
else
|
||||||
|
log "Draft mode — skipping tag. Bump commit is local only; push manually with: git push origin main"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $DRAFT -eq 1 ]]; then
|
||||||
|
log "Draft release v${VERSION} ready"
|
||||||
|
log " Review: https://github.com/awizemann/scarf/releases"
|
||||||
|
log " Promote: in GitHub UI, edit the draft and uncheck 'Set as a pre-release / draft' → Publish."
|
||||||
|
log " Then commit + push appcast-entry.xml to gh-pages, and tag main:"
|
||||||
|
log " git push origin main"
|
||||||
|
log " git tag v${VERSION} && git push origin v${VERSION}"
|
||||||
|
log " (manually merge $RELEASE_DIR/appcast-entry.xml into gh-pages branch's appcast.xml after <language>en</language>)"
|
||||||
|
else
|
||||||
|
log "Release v${VERSION} complete"
|
||||||
|
log " Download: $DOWNLOAD_URL"
|
||||||
|
log " Appcast: $APPCAST_URL"
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user