Skip to content
Closed
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: 2 additions & 1 deletion .github/workflows/homebrew.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,6 @@ jobs:
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git commit -am "digup $version"
git add Casks/digup.rb # only the cask: never commit unrelated files sitting in the tap
git commit -m "digup $version"
git push
36 changes: 36 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Lint the shell scripts and the Python worker on Ubuntu: shellcheck and ruff. The build/test CI (ci.yml) does not
# lint, so this runs alongside it on every pull request and push to main.
name: Lint

on:
pull_request:
push:
branches: [main]
workflow_dispatch:

permissions:
contents: read

jobs:
lint:
name: shellcheck + ruff
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Install shellcheck and ruff
run: |
set -euo pipefail
sudo apt-get update -qq
sudo apt-get install -y -qq shellcheck
python3 -m pip install --quiet --user ruff
echo "$HOME/.local/bin" >> "$GITHUB_PATH"

- name: shellcheck
run: |
set -euo pipefail
shopt -s globstar nullglob
shellcheck -S warning build.sh scripts/**/*.sh

- name: ruff
run: ruff check worker/ scripts/appcast.py
7 changes: 5 additions & 2 deletions build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ cp Resources/AppIcon/tray.png Resources/AppIcon/tray-dim.png Resources/AppIcon/t
# sandboxed apps and its headers for building against it: left out.
FW="$APP/Contents/Frameworks/Sparkle.framework"
mkdir -p "$APP/Contents/Frameworks"
ditto .build/artifacts/sparkle/Sparkle/Sparkle.xcframework/macos-arm64_x86_64/Sparkle.framework "$FW"
# Located by pattern, not one hardcoded slice path: Sparkle packaging changes the folder name between releases.
SPARKLE=$(find .build/artifacts/sparkle -type d -path '*/Sparkle.xcframework/*/Sparkle.framework' -not -path '*/Versions/*' | head -1)
[[ -n "$SPARKLE" ]] || { echo "Sparkle.framework not found under .build/artifacts/sparkle (does the Swift package resolve it?)" >&2; exit 1; }
ditto "$SPARKLE" "$FW"
for part in XPCServices Headers PrivateHeaders Modules; do rm -rf "${FW:?}/$part" "${FW:?}/Versions/B/$part"; done

PLIST="$APP/Contents/Info.plist"
Expand All @@ -65,7 +68,7 @@ PLIST="$APP/Contents/Info.plist"
# Nested code first, then the bundle. The helper needs no entitlements: llama.cpp compiles its Metal shaders at run
# time through Metal's own compiler service, which the hardened runtime allows (checked 2026-10-08). The app's one
# entitlement lets it ask QuickTime Player to open a recording at a moment.
IDENTITY="${SIGN_ID:-$( [[ -f .sign-id ]] && head -1 .sign-id || echo - )}"
IDENTITY="${SIGN_ID:-$( [[ -s .sign-id ]] && head -1 .sign-id || echo - )}"
TIMESTAMP=$([[ "${RELEASE:-}" == 1 ]] && echo --timestamp || echo --timestamp=none)
codesign --force --sign "$IDENTITY" --options runtime $TIMESTAMP "$APP/Contents/Helpers/digup"
# Sparkle comes ad-hoc signed: its helpers, then the framework, with this identity (the app may only load code signed
Expand Down
30 changes: 27 additions & 3 deletions scripts/appcast.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import re
import subprocess
import sys
import xml.etree.ElementTree as ET

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SIGN_UPDATE = os.path.join(ROOT, ".build/artifacts/sparkle/Sparkle/bin/sign_update")
Expand All @@ -29,6 +30,20 @@
"""


def download_base(root, version):
"""The download URL for this version, from the origin remote so forks get their own URLs."""
try:
remote = subprocess.run(["git", "-C", root, "remote", "get-url", "origin"],
check=True, capture_output=True, text=True).stdout.strip()
repo = re.sub(r"^(?:https?://|git@)", "", remote)
repo = re.sub(r"\.git$", "", repo).replace(":", "/", 1)
if repo.count("/") == 2 and repo.startswith("github.com"):
return f"https://{repo}/releases/download/v{version}/"
except Exception:
pass
return f"https://github.com/ARahim3/DigUp/releases/download/v{version}/"


def main():
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("dmg")
Expand All @@ -50,12 +65,13 @@ def main():
if not re.fullmatch(r'sparkle:edSignature="[^"]+" length="\d+"', signature):
sys.exit(f"sign_update said: {signature}")

base = os.environ.get("DOWNLOAD_BASE", f"https://github.com/ARahim3/DigUp/releases/download/v{version}/")
base = os.environ.get("DOWNLOAD_BASE") or download_base(ROOT, version)
url = base.rstrip("/") + "/" + os.path.basename(args.dmg)
notes = ""
if args.notes:
with open(args.notes) as f:
notes = f"\n <description><![CDATA[{f.read().strip()}]]></description>"
body = f.read().strip().replace("]]>", "]]]]><![CDATA[>") # keep the CDATA section closed
notes = f"\n <description><![CDATA[{body}]]></description>"
item = f""" <item>
<title>{version}</title>
<pubDate>{email.utils.formatdate(usegmt=True)}</pubDate>
Expand All @@ -66,12 +82,20 @@ def main():
</item>
"""

feed = open(args.appcast).read() if os.path.exists(args.appcast) else SKELETON
if os.path.exists(args.appcast):
with open(args.appcast) as f:
feed = f.read()
else:
feed = SKELETON
# Running a release again replaces its item.
feed = re.sub(r" <item>\n(?:(?!</item>).)*?<sparkle:version>" + re.escape(build) + r"</sparkle:version>.*?</item>\n",
"", feed, flags=re.S)
# Newest first: after the channel's title.
feed = re.sub(r"(<channel>\n\s*<title>[^<]*</title>\n)", lambda m: m.group(1) + item, feed, count=1)
try:
ET.fromstring(feed)
except ET.ParseError as error:
sys.exit(f"the new appcast would not parse ({error}); {args.appcast} left unchanged")
with open(args.appcast, "w") as f:
f.write(feed)
print(f"{args.appcast}: {version} ({build}) → {url}")
Expand Down
2 changes: 2 additions & 0 deletions scripts/build-llama.sh
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ if [[ "$(git -C "$SOURCE" rev-parse HEAD)" != "$COMMIT" ]]; then
fi
# Without the patch, every input token costs 1 MiB of logits that an embedding model never produces (262k vocabulary).
# Its path is absolute: git -C reads a relative one from the source folder, which LLAMA_SOURCE can put anywhere.
# A build that silently skipped the patch would still be stamped as patched and cached forever, so a patch that
# does not apply (after a TAG bump) is an error, not a warning.
if git -C "$SOURCE" apply --check "$PWD/$PATCH" 2>/dev/null; then
git -C "$SOURCE" apply "$PWD/$PATCH"
elif ! git -C "$SOURCE" apply --reverse --check "$PWD/$PATCH" 2>/dev/null; then
Expand Down
2 changes: 1 addition & 1 deletion scripts/dev-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ SNAPSHOT="$HOME/.cache/huggingface/hub/models--ggml-org--embeddinggemma-2-GGUF/s
if [[ "$MODELS" == "$PWD/.dev/models" ]]; then
mkdir -p "$MODELS"
for file in embeddinggemma-2-Q8_0.gguf mmproj-embeddinggemma-2-Q8_0.gguf; do
[[ -f "$MODELS/$file" || ! -e "$SNAPSHOT/$file" ]] || cp -cL "$SNAPSHOT/$file" "$MODELS/$file"
[[ -f "$MODELS/$file" || ! -e "$SNAPSHOT/$file" ]] || cp -cL "$SNAPSHOT/$file" "$MODELS/$file" 2>/dev/null || cp -L "$SNAPSHOT/$file" "$MODELS/$file" # -c needs APFS; fall back elsewhere
done
fi

Expand Down
1 change: 1 addition & 0 deletions scripts/make-testbed.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
# scripts/make-testbed.sh [dir] default: ~/DigUpTestbed
# Delete it any time: rm -rf ~/DigUpTestbed
set -euo pipefail
shopt -s nullglob # the screenshots glob below is empty until make_screenshots.swift has run
TB="${1:-$HOME/DigUpTestbed}"
REPO="$(cd "$(dirname "$0")/.." && pwd)"
export DEVELOPER_DIR="${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer}"
Expand Down
19 changes: 15 additions & 4 deletions scripts/release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,24 @@ set -euo pipefail
cd "$(dirname "$0")/.."
: "${SIGN_ID:?set SIGN_ID to a Developer ID Application identity (security find-identity -v -p codesigning)}"
: "${NOTARY_PROFILE:?set NOTARY_PROFILE to a notarytool keychain profile (see the top of this script)}"
# Checked now, not after a multi-minute build and notarization: Sparkle's key is needed to sign the DMG.
if [[ -n "${APPCAST:-}" && -z "${SPARKLE_KEY_FILE:-}" ]]; then
security find-generic-password -s "Sparkle EdDSA key" > /dev/null 2>&1 \
|| { echo "Sparkle EdDSA key not in the login keychain (run Sparkle's generate_keys once, or set SPARKLE_KEY_FILE=<path>)." >&2; exit 1; }
fi
VERSION=$(/usr/libexec/PlistBuddy -c "Print CFBundleShortVersionString" Resources/Info.plist)
APP=build.noindex/DigUp.app
OUT=build.noindex/release
rm -rf "$OUT"
mkdir -p "$OUT"

RELEASE=1 ./build.sh
codesign --verify --strict --deep --verbose=2 "$APP"
# --deep is deprecated; build.sh signs each nested item explicitly, so verify them explicitly too.
codesign --verify --strict --verbose=2 "$APP/Contents/Helpers/digup" "$APP/Contents/Frameworks/Sparkle.framework" "$APP"

# The app itself is notarized and stapled first, so a copy dragged out of the DMG passes Gatekeeper offline too.
ditto -c -k --keepParent "$APP" "$OUT/DigUp.zip"
xcrun notarytool submit "$OUT/DigUp.zip" --keychain-profile "$NOTARY_PROFILE" --wait
xcrun notarytool submit "$OUT/DigUp.zip" --keychain-profile "$NOTARY_PROFILE" --wait --output "$OUT/notary-app.json"
xcrun stapler staple "$APP"
rm "$OUT/DigUp.zip"

Expand All @@ -34,12 +40,17 @@ DMG="$OUT/DigUp-$VERSION.dmg"
hdiutil create -quiet -volname "DigUp" -srcfolder "$STAGE" -fs HFS+ -format ULFO -ov "$DMG"
rm -rf "$STAGE"
codesign --force --sign "$SIGN_ID" --timestamp "$DMG"
xcrun notarytool submit "$DMG" --keychain-profile "$NOTARY_PROFILE" --wait
xcrun notarytool submit "$DMG" --keychain-profile "$NOTARY_PROFILE" --wait --output "$OUT/notary-dmg.json"
xcrun stapler staple "$DMG"

spctl --assess --type execute --verbose=2 "$APP"
spctl --assess --type open --context context:primary-signature --verbose=2 "$DMG"
if [[ -n "${APPCAST:-}" ]]; then
scripts/appcast.py "$DMG" "$APPCAST" ${NOTES:+--notes "$NOTES"}
scripts/appcast.py "$DMG" "$APPCAST" ${NOTES:+--notes "$NOTES"} ${SPARKLE_KEY_FILE:+--key-file "$SPARKLE_KEY_FILE"}
elif [[ "${SKIP_APPCAST:-}" == 1 ]]; then
echo "APPCAST skipped (SKIP_APPCAST=1): this DMG is not Sparkle-signed; in-app updates will not see it."
else
echo "Set APPCAST=<path to appcast.xml> to Sparkle-sign the DMG, or SKIP_APPCAST=1 to build without a feed." >&2
exit 1
fi
echo "Released $DMG ($(du -h "$DMG" | cut -f1))"
8 changes: 6 additions & 2 deletions scripts/testbed/make_media.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# TTS speech clips, system sounds, and short videos cut into 6 s "moments".
# ./make_media.sh <out_dir>
set -euo pipefail
shopt -s nullglob # a missing sample-picture folder must not hand sips a literal glob
M="${1:?usage: make_media.sh <out_dir>}"; HERE="$(cd "$(dirname "$0")" && pwd)"
mkdir -p "$M/img" "$M/shots" "$M/audio" "$M/audio_trunc" "$M/video"

Expand All @@ -14,8 +15,11 @@ done

DEVELOPER_DIR="${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer}" swift "$HERE/make_screenshots.swift" "$M/shots"

# The default system voice renders silence from a script; Samantha works.
say_wav() { say -v Samantha -o "$M/audio/tmp.aiff" "$2"; ffmpeg -loglevel error -y -i "$M/audio/tmp.aiff" -ac 1 -ar 16000 "$M/audio/$1.wav"; rm "$M/audio/tmp.aiff"; }
# The default system voice renders silence from a script; prefer Samantha, else any en_US voice.
VOICE=$(say -v '?' | awk '/Samantha/ {print $1; exit}')
VOICE=${VOICE:-$(say -v '?' | awk '/en_US/ {print $1; exit}')}
[[ -n "$VOICE" ]] || { echo "no en_US speech voice available (say -v '?')" >&2; exit 1; }
say_wav() { say -v "$VOICE" -o "$M/audio/tmp.aiff" "$2"; ffmpeg -loglevel error -y -i "$M/audio/tmp.aiff" -ac 1 -ar 16000 "$M/audio/$1.wav"; rm "$M/audio/tmp.aiff"; }
say_wav speech_groceries "Don't forget to buy milk, eggs and bread on the way home tomorrow."
say_wav speech_revenue "Our quarterly revenue grew by twenty percent, driven by strong sales in Europe."
say_wav speech_flight_delay "Attention passengers. The flight to Tokyo has been delayed by two hours because of a storm."
Expand Down
31 changes: 25 additions & 6 deletions worker/digup_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,27 @@ def _vectors(self, output):
return np.array(output.text_embeds.astype(self.mx.float32))

def text(self, inputs):
rows = []
rows, errors = [], []
for i in range(0, len(inputs), BATCH["text"]):
batch = inputs[i : i + BATCH["text"]]
tokens = self.tokenizer(batch, padding=True, truncation=True, max_length=2048, return_tensors="np")
rows.append(self._vectors(self.model(**{k: self.mx.array(v) for k, v in tokens.items()})))
return np.concatenate(rows), [None] * len(inputs)
try:
rows.append(self._text_batch(batch))
errors += [None] * len(batch)
except Exception:
# One bad input must not sink its neighbours (the docstring promises per-item errors):
# retry the batch item by item, like media() does.
for text in batch:
try:
rows.append(self._text_batch([text]))
errors.append(None)
except Exception as item_error:
rows.append(np.zeros((1, DIM), np.float32))
errors.append(f"{type(item_error).__name__}: {item_error}"[:300])
return np.concatenate(rows), errors

def _text_batch(self, batch):
tokens = self.tokenizer(batch, padding=True, truncation=True, max_length=2048, return_tensors="np")
return self._vectors(self.model(**{k: self.mx.array(v) for k, v in tokens.items()}))

def media(self, kind, inputs, budget=None):
if self.processor is None:
Expand Down Expand Up @@ -146,8 +161,12 @@ def _media_batch(self, kind, paths):


def reply(message):
sys.stdout.write(json.dumps(message, separators=(",", ":")) + "\n")
sys.stdout.flush()
try:
sys.stdout.write(json.dumps(message, separators=(",", ":")) + "\n")
sys.stdout.flush()
except BrokenPipeError:
# The app died mid-session: leave quietly instead of with a traceback.
raise SystemExit(0)


def main():
Expand Down