#!/bin/bash # # Duenote macOS installer. # # Since 0.7.5 the builds are signed with a Developer ID certificate and # notarized, with the ticket stapled to the bundle, so Gatekeeper accepts them # from any source -- a browser download works, and this script is a # convenience rather than the only way in. # # It still matters for the releases before that. Those shipped with an ad-hoc # signature only, and a browser download of one is dead on arrival: the # browser stamps com.apple.quarantine on the zip, the attribute propagates to # the extracted bundle, and Gatekeeper refuses to launch it (since macOS 15 # the right-click "Open" bypass is gone too). curl does not declare # LSFileQuarantineEnabled, so it never sets the attribute in the first place, # and `ditto -xk` has nothing to propagate. DUENOTE_VERSION can still pin one # of those builds, which is why the checks below warn rather than refuse when # a bundle turns out to be unsigned. # # Usage: # curl -fsSL https://download.duenote.app/install.sh | bash # DUENOTE_VERSION=0.6.1 ... | bash # pin a version # DUENOTE_DEST=~/Applications ... | bash set -euo pipefail # Public base URL of the R2 bucket the Forge S3 publisher uploads to. # Either the bucket's r2.dev public URL or a custom domain bound to it. BASE_URL="${DUENOTE_BASE_URL:-https://download.duenote.app}" APP_NAME="Duenote.app" DEST="${DUENOTE_DEST:-/Applications}" info() { printf '\033[1;34m==>\033[0m %s\n' "$1"; } warn() { printf '\033[1;33mwarning:\033[0m %s\n' "$1" >&2; } die() { printf '\033[1;31merror:\033[0m %s\n' "$1" >&2; exit 1; } [ "$(uname -s)" = "Darwin" ] || die "This installer is for macOS only." case "$BASE_URL" in *REPLACE-WITH-YOUR-R2-PUBLIC-URL*) die "BASE_URL is unset. Edit this script or pass DUENOTE_BASE_URL=https://..." ;; esac # Forge's macos-latest runner builds arm64 only. Fail loudly on Intel rather # than 404ing, and catch Rosetta shells reporting the wrong arch. raw_arch="$(uname -m)" if [ "$raw_arch" = "x86_64" ] && [ "$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)" = "1" ]; then raw_arch="arm64" fi case "$raw_arch" in arm64) ARCH="arm64" ;; x86_64) die "No Intel (x64) build is published. Only Apple Silicon is available." ;; *) die "Unsupported architecture: $raw_arch" ;; esac # Resolve the version from a marker file in the bucket. The GitHub releases # API is not an option here -- the source repo is private, so an anonymous # API call 404s. The release workflow writes latest.txt only after every # platform has built, so it never points at a half-published release. VERSION="${DUENOTE_VERSION:-}" if [ -z "$VERSION" ]; then info "Resolving latest release..." VERSION="$(curl -fsSL "${BASE_URL%/}/latest.txt" 2>/dev/null | tr -d '[:space:]')" [ -n "$VERSION" ] || die "Could not resolve the latest version. Set DUENOTE_VERSION=x.y.z and retry." fi # The version lands in a URL path, so refuse anything that is not a plain # version string rather than interpolating whatever the marker file held. case "$VERSION" in *[!0-9.]*|''|.*|*.) die "Unexpected version string: $VERSION" ;; esac ZIP="Duenote-darwin-${ARCH}-${VERSION}.zip" URL="${BASE_URL%/}/Duenote/darwin/${ARCH}/${ZIP}" if pgrep -x "Duenote" >/dev/null 2>&1; then die "Duenote is running. Quit it and re-run this installer." fi TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT info "Downloading Duenote ${VERSION} (${ARCH})..." curl -fL --progress-bar "$URL" -o "$TMP/$ZIP" \ || die "Download failed: $URL" info "Extracting..." # ditto is the correct extractor for a .app: it preserves symlinks, resource # forks and the code signature that `unzip` can mangle inside Electron's # Frameworks directory. ditto -xk "$TMP/$ZIP" "$TMP/extracted" || die "Extraction failed." SRC="$TMP/extracted/$APP_NAME" [ -d "$SRC" ] || die "Archive did not contain $APP_NAME." # Sanity-check the transfer. A full bundle verify is meaningful now that Forge # signs *after* packager has merged extendInfo into Info.plist -- on the old # ad-hoc builds the plist was left unsealed ("Info.plist=not bound") and this # would have failed on a perfectly working build. # # --deep is what catches a mangled Electron framework, which is the failure # `unzip` used to produce and the reason this script extracts with ditto. BIN="$SRC/Contents/MacOS/Duenote" [ -x "$BIN" ] || die "Extracted bundle is missing its executable." [ -f "$SRC/Contents/Info.plist" ] || die "Extracted bundle is missing Info.plist." if codesign --verify --deep --strict "$SRC" >/dev/null 2>&1; then # spctl is the assessment Gatekeeper itself runs: signature plus a # notarization ticket. The ticket is stapled into the bundle, so this # answers without a network round trip. if spctl -a -t exec -vv "$SRC" >/dev/null 2>&1; then info "Signature and notarization verified." else warn "Signed, but Gatekeeper did not accept it -- notarization may be missing." fi elif codesign -dv "$BIN" >/dev/null 2>&1; then # An older release, or one built without the certificate. The kernel only # checks the code pages, which are intact, so it will still run -- that is # exactly what the curl-not-a-browser trick above buys. warn "This build is not signed with a Developer ID (ad-hoc only)." else warn "No code signature found; the app will not launch on Apple Silicon." fi if [ ! -w "$DEST" ]; then die "$DEST is not writable. Re-run with DUENOTE_DEST=\"\$HOME/Applications\"." fi # Replace any existing install. Verified as a real Duenote bundle first so a # mistyped DUENOTE_DEST can never delete something else. TARGET="$DEST/$APP_NAME" if [ -e "$TARGET" ]; then if [ -f "$TARGET/Contents/Info.plist" ]; then info "Removing previous install at $TARGET" rm -rf "$TARGET" else die "$TARGET exists but is not an app bundle. Remove it manually." fi fi info "Installing to $TARGET" ditto "$SRC" "$TARGET" || die "Install failed." # Belt and braces: strip quarantine if anything upstream introduced it. xattr -dr com.apple.quarantine "$TARGET" 2>/dev/null || true if xattr -p com.apple.quarantine "$TARGET" >/dev/null 2>&1; then warn "Quarantine attribute is still present on $TARGET." else info "Installed clean -- no quarantine attribute." fi info "Done. Launch with: open -a Duenote"