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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ jobs:
- name: Dependency resolution locks
run: make dependency-check

- name: Sparkle fetch cache regressions
run: bash scripts/test-fetch-sparkle.sh

- name: Website structure
run: make site-check

Expand Down
8 changes: 4 additions & 4 deletions Dependencies/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Packages/GanchoKit/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 17 additions & 8 deletions Packages/GanchoKit/Tests/GanchoKitTests/GRDBEncryptionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -223,10 +223,14 @@ import Testing
"a non-fresh failure must leave the store untouched")
}

@Test("A pre-encryption plaintext store migrates in place without losing clips")
func migratesPlaintextStore() async throws {
let dir = tempDir()
defer { try? FileManager.default.removeItem(at: dir) }
@Test(
"A pre-encryption plaintext store migrates in place without losing clips",
arguments: [false, true])
func migratesPlaintextStore(quotedDirectory: Bool) async throws {
let root = tempDir()
let dir = root.appendingPathComponent(
quotedDirectory ? "Owner's archive" : "archive", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }

// 1. Seed a plaintext store (passphrase nil ⇒ no encryption).
var plaintext: GRDBClipboardStore? = try GRDBClipboardStore(
Expand All @@ -245,11 +249,16 @@ import Testing

// 2. Reopen WITH a key ⇒ in-place re-encryption runs.
let key = try testKey()
let encrypted = try GRDBClipboardStore(directory: dir, passphrase: key)
var encrypted: GRDBClipboardStore? = try GRDBClipboardStore(
directory: dir, passphrase: key)

// Clip survived.
#expect(try await encrypted.content(for: item.id) == .text(Self.needle))
#expect(try await encrypted.count() == 1)
// Clip survived the export, including an apostrophe in its file path.
#expect(try await encrypted?.content(for: item.id) == .text(Self.needle))
#expect(try await encrypted?.count() == 1)
encrypted = nil
let reopened = try GRDBClipboardStore(directory: dir, passphrase: key)
#expect(try await reopened.content(for: item.id) == .text(Self.needle))
#expect(try await reopened.count() == 1)

// And the file is now encrypted.
let after = try databaseBytes(in: dir)
Expand Down
62 changes: 61 additions & 1 deletion docs/DEPENDENCIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ initial panel binding; changing those raw names would orphan existing user
preferences. Feature controllers register handlers, while Settings and
onboarding use the package's recorder UI.

Version 3.0.1 is the current baseline. Its Swift 6 implementation replaced the
Version 3.1.0 is the current baseline. The 3.0 Swift 6 implementation replaced the
registration engine and renamed `Name(default:)` to `Name(initial:)` without
changing Gancho's stored Carbon key-code/modifier representation. Signed UI
coverage must prove that an existing shortcut restores and has an active
Expand All @@ -108,3 +108,63 @@ release tarball and verifies a pinned SHA-256. Updating = new version + new
checksum in that script (the canary's `SPARKLE` pin cross-checks it), then the
signed direct-download DMG must build with re-signed helpers and pass
`codesign --verify --deep --strict` before merging.

## October 2026 maintenance review

The adopted stable releases match the October 5 canary. This is a dependency
update, not a GRDB fork rebase or an application schema migration.

- **SQLCipher.swift 4.17.0 → 4.19.0.** The existing GRDB fork requirement
(`from: "4.17.0"`) already admits this version. The annotated release tag
peels to `39f212458aeb88e33bdac2200a793a3f0d55d32b`; both canonical locks
use that commit. Its Swift 6.0 / macOS 10.13 / iOS 12 floors fit Gancho.
[Zetetic's advisory](https://www.zetetic.net/blog/2026/09/08/sqlcipher-4.19.0-release/)
describes two low-risk issues in older versions: unquoted attached schema
aliases in `sqlcipher_export` and invalid `hexkey` URI input. Gancho uses a
fixed `encrypted` alias and `usePassphrase`, not a `hexkey` URI. The reviewed
paths do not establish exploitability in Gancho; the update still brings
upstream error-handling and migration fixes. The encryption migration test
now covers an apostrophe in the directory name and reopening after export.
- **KeyboardShortcuts 3.0.1 → 3.1.0.** The existing app requirement already
admits it. The annotated tag peels to
`772133d9dbe800fdac0473226822994c5c162c58`. Swift 6.2 and macOS 10.15 remain
compatible with the supported build toolchain and deployment floor.
[The upstream delta](https://github.com/sindresorhus/KeyboardShortcuts/compare/3.0.1...3.1.0)
changes menu tracking, synthesized Fn handling, and recorder pause/resume;
it does not require changing Gancho's stable names or stored key codes.
- **Sparkle 2.9.6 → 2.10.0.**
[The release](https://github.com/sparkle-project/Sparkle/releases/tag/2.10.0)
raises its macOS floor to 12, below Gancho's 15.4, and fixes update progress,
delta handling, and signed-feed diagnostics. The tarball checksum is the
official release asset's SHA-256. The fetch cache now records both version
and checksum; an old unmarked framework can no longer silently survive a
pin update. Offline fixture tests cover reuse, invalidation, missing tools,
forced downloads, and preservation after failed verification.

### Verification and merge gates

The lock revisions above were checked against the upstream annotated tags and
immutable commit manifests; they were not regenerated locally with Xcode.
Manifest requirements and origin hashes are unchanged. CI must consume both
locks with automatic resolution disabled and pass dependency coherence,
formatting, lint, package encryption/raw-key/migration/durability tests,
StoreKit tests, and both app builds. A local fixture test is not evidence that
the real Sparkle binary was downloaded or signed correctly.

Before merge, also record the results that ordinary unsigned PR CI cannot
supply:

- `GANCHO_PERF=1 make bench` for storage/search budgets.
- A signed build reopening a backed-up, existing store produced by the old
SQLCipher version, checking clips and binary payloads; fresh test databases
created and reopened with the new version do not prove cross-version upgrade.
- A signed direct-download DMG with re-signed helpers and
`codesign --verify --deep --strict`, plus the signed updater smoke check.
- Signed shortcut restoration without preference changes, re-recording the
existing shortcut, cancelling/switching recorders, conflict rejection, and
function-key activation with a menu open. Check the lowest supported macOS
and a current supported system. Package conflict tests do not exercise the
global registration or AppKit recorder lifecycle.

Keep this work in draft until those gates have evidence. Do not close the
upstream canary issue merely because its original body mentions older releases.
27 changes: 22 additions & 5 deletions scripts/fetch-sparkle.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,27 @@ set -euo pipefail
repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
cd "$repo_root"

SPARKLE_VERSION="${SPARKLE_VERSION:-2.9.6}"
SPARKLE_SHA256="${SPARKLE_SHA256:-52bf9e88cdd972fc0c81501377a880e90d47031bd8ca5462488f843e2609e192}"
SPARKLE_VERSION="${SPARKLE_VERSION:-2.10.0}"
SPARKLE_SHA256="${SPARKLE_SHA256:-c2bf58aa8387266ac179357b1415d6f2635f044da8be41042af32425dae6da0c}"
VENDOR_DIR="${VENDOR_DIR:-Vendor}"
FRAMEWORK="$VENDOR_DIR/Sparkle.framework"
TOOLS="$VENDOR_DIR/bin"
RECEIPT="$VENDOR_DIR/.sparkle-receipt"
expected_receipt="$SPARKLE_VERSION $SPARKLE_SHA256"

if [ -d "$FRAMEWORK" ] && [ -x "$TOOLS/sign_update" ] && [ -z "${FORCE:-}" ]; then
# A cache from another pin (or before receipts existed) must not bypass the
# checksum-verified download. The receipt records provenance, not a signature
# check of mutable local files; release packaging still verifies code signing.
if [ -d "$FRAMEWORK" ] && [ -x "$TOOLS/sign_update" ] \
&& [ -x "$TOOLS/generate_appcast" ] && [ -x "$TOOLS/generate_keys" ] \
&& [ -f "$RECEIPT" ] && [ "$(cat "$RECEIPT")" = "$expected_receipt" ] \
&& [ -z "${FORCE:-}" ]; then
printf '✓ Sparkle.framework + bin/ already present in %s/ (set FORCE=1 to refetch)\n' "$VENDOR_DIR"
exit 0
fi

url="https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-${SPARKLE_VERSION}.tar.xz"
tmp="$(mktemp -d -t sparkle)"
tmp="$(mktemp -d "${TMPDIR:-/tmp}/sparkle.XXXXXX")"
trap 'rm -rf "$tmp"' EXIT

printf '==> Downloading Sparkle %s\n' "$SPARKLE_VERSION"
Expand All @@ -36,9 +44,15 @@ if [ "$actual" != "$SPARKLE_SHA256" ]; then
fi

printf '==> Extracting Sparkle.framework into %s/\n' "$VENDOR_DIR"
tar -xf "$tmp/sparkle.tar.xz" -C "$tmp"
# Reject incomplete archives before touching the working installation.
[ -d "$tmp/Sparkle.framework" ] || { echo "error: missing Sparkle.framework" >&2; exit 1; }
for tool in sign_update generate_appcast generate_keys; do
[ -x "$tmp/bin/$tool" ] || { echo "error: missing Sparkle tool: $tool" >&2; exit 1; }
done
mkdir -p "$VENDOR_DIR"
rm -f "$RECEIPT"
rm -rf "$FRAMEWORK"
tar -xf "$tmp/sparkle.tar.xz" -C "$tmp"
cp -R "$tmp/Sparkle.framework" "$FRAMEWORK"
printf '✓ %s (Sparkle %s)\n' "$FRAMEWORK" "$SPARKLE_VERSION"

Expand All @@ -51,3 +65,6 @@ if [ -d "$tmp/bin" ]; then
cp -R "$tmp/bin" "$TOOLS"
printf '✓ %s (sign_update, generate_appcast, generate_keys)\n' "$TOOLS"
fi

# Publish only after both the framework and tools were installed successfully.
printf '%s\n' "$expected_receipt" >"$RECEIPT"
82 changes: 82 additions & 0 deletions scripts/test-fetch-sparkle.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# Offline integration tests for fetch-sparkle.sh. Only curl is replaced; the
# production script hashes, extracts, installs, and reuses real fixture archives.
set -euo pipefail
root="$(cd "$(dirname "$0")/.." && pwd)"
tmp="$(mktemp -d "${TMPDIR:-/tmp}/gancho-sparkle-test.XXXXXX")"
trap 'rm -rf "$tmp"' EXIT
mkdir -p "$tmp/repo/scripts" "$tmp/mock" "$tmp/archive/Sparkle.framework" "$tmp/archive/bin"
git init -q "$tmp/repo"
cp "$root/scripts/fetch-sparkle.sh" "$tmp/repo/scripts/"
for tool in sign_update generate_appcast generate_keys; do
printf '#!/bin/sh\nexit 0\n' >"$tmp/archive/bin/$tool"
chmod +x "$tmp/archive/bin/$tool"
done
printf 'fixture framework\n' >"$tmp/archive/Sparkle.framework/Sparkle"
tar -cf "$tmp/good.tar" -C "$tmp/archive" Sparkle.framework bin
export FIXTURE_ARCHIVE="$tmp/good.tar" DOWNLOAD_LOG="$tmp/downloads"
: >"$DOWNLOAD_LOG"
cat >"$tmp/mock/curl" <<'CURL'
#!/usr/bin/env bash
set -euo pipefail
printf 'download\n' >>"$DOWNLOAD_LOG"
while [ "$#" -gt 0 ]; do
if [ "$1" = -o ]; then cp "$FIXTURE_ARCHIVE" "$2"; exit 0; fi
shift
done
exit 2
CURL
chmod +x "$tmp/mock/curl"
export PATH="$tmp/mock:$PATH"
export SPARKLE_VERSION="fixture-1"
export SPARKLE_SHA256="$(shasum -a 256 "$FIXTURE_ARCHIVE" | awk '{print $1}')"
export VENDOR_DIR="$tmp/repo/Vendor"
unset FORCE
cd "$tmp/repo"
fetch() { bash scripts/fetch-sparkle.sh >"$tmp/output" 2>&1; }
expect_downloads() {
actual="$(wc -l <"$DOWNLOAD_LOG" | tr -d ' ')"
[ "$actual" = "$1" ] || { cat "$tmp/output"; echo "expected $1 downloads, got $actual" >&2; exit 1; }
}
# An existing unmarked framework used to suppress every future version bump.
mkdir -p "$VENDOR_DIR/Sparkle.framework" "$VENDOR_DIR/bin"
printf 'old framework\n' >"$VENDOR_DIR/Sparkle.framework/Sparkle"
cp "$tmp/archive/bin/sign_update" "$VENDOR_DIR/bin/"
fetch
expect_downloads 1
cmp "$tmp/archive/Sparkle.framework/Sparkle" "$VENDOR_DIR/Sparkle.framework/Sparkle"
fetch
expect_downloads 1
# Either half of the pin changing invalidates the cache.
export SPARKLE_VERSION="fixture-2"
fetch
expect_downloads 2
printf 'replacement framework\n' >"$tmp/archive/Sparkle.framework/Sparkle"
tar -cf "$tmp/updated.tar" -C "$tmp/archive" Sparkle.framework bin
export FIXTURE_ARCHIVE="$tmp/updated.tar"
export SPARKLE_SHA256="$(shasum -a 256 "$FIXTURE_ARCHIVE" | awk '{print $1}')"
fetch
expect_downloads 3
cmp "$tmp/archive/Sparkle.framework/Sparkle" "$VENDOR_DIR/Sparkle.framework/Sparkle"
# Incomplete local tools are repaired; FORCE still deliberately refetches.
rm "$VENDOR_DIR/bin/generate_appcast"
fetch
expect_downloads 4
FORCE=1 fetch
expect_downloads 5
cp "$VENDOR_DIR/.sparkle-receipt" "$tmp/receipt"
# A bad checksum must preserve the usable installation and its receipt.
export SPARKLE_SHA256="invalid"
if fetch; then echo 'expected checksum rejection' >&2; exit 1; fi
expect_downloads 6
cmp "$tmp/receipt" "$VENDOR_DIR/.sparkle-receipt"
cmp "$tmp/archive/Sparkle.framework/Sparkle" "$VENDOR_DIR/Sparkle.framework/Sparkle"
# A checksum-valid but incomplete release must also preserve the installation.
tar -cf "$tmp/incomplete.tar" -C "$tmp/archive" Sparkle.framework
export FIXTURE_ARCHIVE="$tmp/incomplete.tar"
export SPARKLE_SHA256="$(shasum -a 256 "$FIXTURE_ARCHIVE" | awk '{print $1}')"
if fetch; then echo 'expected missing-tool rejection' >&2; exit 1; fi
expect_downloads 7
cmp "$tmp/receipt" "$VENDOR_DIR/.sparkle-receipt"
[ -x "$VENDOR_DIR/bin/generate_appcast" ]
printf 'PASS: legacy cache, matching pin, version, checksum, missing tool, FORCE, checksum failure, incomplete archive\n'
6 changes: 3 additions & 3 deletions scripts/upstream-pins.env
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
# KEYBOARDSHORTCUTS ↔ Dependencies/Package.resolved (app-wide graph)
# GRDB tracks the UPSTREAM groue tag on which the exact fork revision is based.
GRDB=7.11.1
SQLCIPHER=4.17.0
SPARKLE=2.9.6
SQLCIPHER=4.19.0
SPARKLE=2.10.0
SAUCE=2.5.2
KEYBOARDSHORTCUTS=3.0.1
KEYBOARDSHORTCUTS=3.1.0
Loading