From 19bc122e6d882a3e58cea4ed6d189579c7f60216 Mon Sep 17 00:00:00 2001 From: aseembits93 Date: Thu, 16 Jul 2026 15:08:05 -0700 Subject: [PATCH 01/11] test: add fresh File Provider prompt harness --- Makefile | 8 + scripts/install-macos-prompt-test-app.sh | 356 ++++++++++++++++++ scripts/install-macos-prompt-test-app.test.sh | 246 ++++++++++++ 3 files changed, 610 insertions(+) create mode 100755 scripts/install-macos-prompt-test-app.sh create mode 100755 scripts/install-macos-prompt-test-app.test.sh diff --git a/Makefile b/Makefile index d2209782..0b0ebab4 100644 --- a/Makefile +++ b/Makefile @@ -148,6 +148,10 @@ prepare-macos-file-provider: ## Stage the macOS File Provider extension for Taur install-macos-file-provider: ## Install/register the local macOS File Provider development bundle. platform/macos/LocalityFileProvider/scripts/install-dev-bundle.sh +.PHONY: install-macos-prompt-test-app +install-macos-prompt-test-app: build-tauri ## Build, reset, install, register, and launch a fresh macOS File Provider prompt test app. + scripts/install-macos-prompt-test-app.sh $(PROMPT_TEST_APP_ARGS) + .PHONY: prepare-desktop-dev-sidecars prepare-desktop-dev-sidecars: ## Build debug desktop sidecars used by Tauri dev. $(DESKTOP_NPM) run dev:prepare @@ -209,6 +213,10 @@ test-linux-publish-config: ## Validate Linux package publish configuration. test-macos-publish-config: ## Validate macOS package publish configuration. tests/macos_publish_config.sh +.PHONY: test-macos-prompt-test-app-installer +test-macos-prompt-test-app-installer: ## Validate the macOS prompt test app installer dry-run plan. + bash scripts/install-macos-prompt-test-app.test.sh + .PHONY: test-windows-publish-config test-windows-publish-config: ## Validate Windows package publish configuration. tests/windows_publish_config.sh diff --git a/scripts/install-macos-prompt-test-app.sh b/scripts/install-macos-prompt-test-app.sh new file mode 100755 index 00000000..5d1ccb60 --- /dev/null +++ b/scripts/install-macos-prompt-test-app.sh @@ -0,0 +1,356 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +DEFAULT_APP_PATH="/Applications/Locality Prompt Test.app" +DEFAULT_SIGNING_IDENTITY="Developer ID Application: CodeFlash Inc (C484HB7Q6S)" +DEFAULT_DISPLAY_NAME="Locality Prompt Test" +DEFAULT_DMG_DIR="${ROOT}/target/release/bundle/dmg" + +APP_PATH="${LOCALITY_PROMPT_TEST_APP_PATH:-${DEFAULT_APP_PATH}}" +SOURCE_APP="${LOCALITY_PROMPT_TEST_SOURCE_APP:-}" +DMG="${LOCALITY_PROMPT_TEST_DMG:-}" +DISPLAY_NAME="${LOCALITY_PROMPT_TEST_DISPLAY_NAME:-${DEFAULT_DISPLAY_NAME}}" +SIGNING_IDENTITY="${LOCALITY_PROMPT_TEST_SIGNING_IDENTITY:-${SIGNING_IDENTITY:-${DEFAULT_SIGNING_IDENTITY}}}" +BUNDLE_ID="${LOCALITY_PROMPT_TEST_BUNDLE_ID:-}" +DRY_RUN=0 +RESET_DOMAIN="${LOCALITY_PROMPT_TEST_RESET:-1}" +LAUNCH="${LOCALITY_PROMPT_TEST_LAUNCH:-1}" +FORCE_NON_TEST_APP_PATH="${LOCALITY_PROMPT_TEST_FORCE_NON_TEST_APP_PATH:-0}" + +HOST_ENTITLEMENTS="${ROOT}/platform/macos/LocalityFileProvider/App/LocalityDeveloperId.entitlements" +FILE_PROVIDER_ENTITLEMENTS="${ROOT}/platform/macos/LocalityFileProvider/App/LocalityFileProvider.entitlements" +MOUNT_POINT="" +TMPDIR="" + +usage() { + cat <<'USAGE' +Usage: scripts/install-macos-prompt-test-app.sh [options] + +Build target helper for manual macOS File Provider onboarding tests. Installs a +freshly identified "Locality Prompt Test.app" from a built Locality.app bundle +or DMG, resets any existing test app File Provider domain, re-signs the bundle, +registers its File Provider extension, and launches it. + +Options: + --app-path PATH Target app path. Defaults to /Applications/Locality Prompt Test.app. + --source-app PATH Source Locality.app bundle. Defaults to the built Tauri bundle or DMG. + --dmg PATH Source DMG when --source-app is not passed. Defaults to newest built Locality_*.dmg. + --bundle-id ID App bundle identifier. Defaults to ai.codeflash.locality.promptfresh. + --display-name NAME App and File Provider display name. Defaults to "Locality Prompt Test". + --signing-identity NAME Developer ID signing identity. + --reset-domain Reset the existing test app File Provider domain before reinstalling. Default. + --no-reset-domain Do not reset the existing test app File Provider domain. + --launch Launch the installed test app. Default. + --no-launch Do not launch the installed test app. + --force-non-test-app-path + Allow a target app path that does not look like a prompt-test app. + --dry-run Print the install plan without changing the system. + -h, --help Show this help. + +Environment aliases: + LOCALITY_PROMPT_TEST_APP_PATH + LOCALITY_PROMPT_TEST_SOURCE_APP + LOCALITY_PROMPT_TEST_DMG + LOCALITY_PROMPT_TEST_BUNDLE_ID + LOCALITY_PROMPT_TEST_TIMESTAMP + LOCALITY_PROMPT_TEST_DISPLAY_NAME + LOCALITY_PROMPT_TEST_SIGNING_IDENTITY + LOCALITY_PROMPT_TEST_RESET=0|1 + LOCALITY_PROMPT_TEST_LAUNCH=0|1 + LOCALITY_PROMPT_TEST_FORCE_NON_TEST_APP_PATH=0|1 +USAGE +} + +log() { + printf '%s\n' "$*" +} + +fail() { + printf 'install-macos-prompt-test-app: %s\n' "$*" >&2 + exit 1 +} + +print_cmd() { + printf '+' + local arg + for arg in "$@"; do + printf ' %q' "${arg}" + done + printf '\n' +} + +run() { + print_cmd "$@" + if [[ "${DRY_RUN}" -eq 0 ]]; then + "$@" + fi +} + +run_may_fail() { + print_cmd "$@" + if [[ "${DRY_RUN}" -eq 0 ]]; then + "$@" >/dev/null 2>&1 || true + fi +} + +prompt_test_timestamp() { + if [[ -n "${LOCALITY_PROMPT_TEST_TIMESTAMP:-}" ]]; then + printf '%s\n' "${LOCALITY_PROMPT_TEST_TIMESTAMP}" + return 0 + fi + date +%Y%m%d%H%M%S +} + +default_bundle_id() { + printf 'ai.codeflash.locality.promptfresh%s\n' "$(prompt_test_timestamp)" +} + +default_dmg() { + local candidate + local latest="" + while IFS= read -r -d '' candidate; do + if [[ -z "${latest}" || "${candidate}" -nt "${latest}" ]]; then + latest="${candidate}" + fi + done < <(find "${DEFAULT_DMG_DIR}" -maxdepth 1 -type f -name 'Locality_*.dmg' -print0 2>/dev/null) + + [[ -n "${latest}" ]] || return 1 + printf '%s\n' "${latest}" +} + +expand_tilde() { + local path="$1" + if [[ "${path}" == "~" ]]; then + printf '%s\n' "${HOME}" + elif [[ "${path}" == "~/"* ]]; then + printf '%s/%s\n' "${HOME}" "${path#"~/"}" + else + printf '%s\n' "${path}" + fi +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --app-path) + APP_PATH="${2:?--app-path requires a value}" + shift 2 + ;; + --source-app) + SOURCE_APP="${2:?--source-app requires a value}" + shift 2 + ;; + --dmg) + DMG="${2:?--dmg requires a value}" + shift 2 + ;; + --bundle-id) + BUNDLE_ID="${2:?--bundle-id requires a value}" + shift 2 + ;; + --display-name) + DISPLAY_NAME="${2:?--display-name requires a value}" + shift 2 + ;; + --signing-identity) + SIGNING_IDENTITY="${2:?--signing-identity requires a value}" + shift 2 + ;; + --reset-domain) + RESET_DOMAIN=1 + shift + ;; + --no-reset-domain) + RESET_DOMAIN=0 + shift + ;; + --launch) + LAUNCH=1 + shift + ;; + --no-launch) + LAUNCH=0 + shift + ;; + --force-non-test-app-path) + FORCE_NON_TEST_APP_PATH=1 + shift + ;; + --dry-run) + DRY_RUN=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + fail "unknown option: $1" + ;; + esac + done +} + +cleanup() { + if [[ -n "${MOUNT_POINT}" && -d "${MOUNT_POINT}" && "${DRY_RUN}" -eq 0 ]]; then + hdiutil detach "${MOUNT_POINT}" -quiet >/dev/null 2>&1 \ + || hdiutil detach "${MOUNT_POINT}" -force -quiet >/dev/null 2>&1 \ + || true + fi + if [[ -n "${TMPDIR}" && -d "${TMPDIR}" ]]; then + rm -rf "${TMPDIR}" >/dev/null 2>&1 || true + fi +} + +require_execute_environment() { + [[ "${DRY_RUN}" -eq 1 ]] && return 0 + [[ "$(uname -s)" == "Darwin" ]] || fail "this installer only runs on macOS" + command -v hdiutil >/dev/null 2>&1 || fail "hdiutil is required" + command -v codesign >/dev/null 2>&1 || fail "codesign is required" + command -v pluginkit >/dev/null 2>&1 || fail "pluginkit is required" + [[ -f "${HOST_ENTITLEMENTS}" ]] || fail "missing host entitlements: ${HOST_ENTITLEMENTS}" + [[ -f "${FILE_PROVIDER_ENTITLEMENTS}" ]] || fail "missing File Provider entitlements: ${FILE_PROVIDER_ENTITLEMENTS}" +} + +validate_app_path() { + [[ -n "${APP_PATH}" ]] || fail "--app-path must not be empty" + [[ "${APP_PATH}" == *.app ]] || fail "--app-path must point to a .app bundle: ${APP_PATH}" + [[ "${APP_PATH}" != "/" ]] || fail "--app-path cannot be /" + + if [[ "${FORCE_NON_TEST_APP_PATH}" != "1" ]]; then + local app_name + app_name="$(basename "${APP_PATH}")" + [[ "${app_name}" != "Locality.app" ]] \ + || fail "--app-path points at the production app. Use a prompt-test app path or pass --force-non-test-app-path." + [[ "${app_name}" == *Prompt* || "${app_name}" == *prompt* || "${app_name}" == *Test* || "${app_name}" == *test* ]] \ + || fail "--app-path must look like a prompt-test app path: ${APP_PATH}. Pass --force-non-test-app-path to override." + fi +} + +validate_source_and_target_paths() { + [[ "${SOURCE_APP}" != "${APP_PATH}" ]] \ + || fail "--source-app and --app-path must be different paths" + + if [[ -e "${SOURCE_APP}" && -e "${APP_PATH}" ]]; then + local source_real target_real + source_real="$(cd "$(dirname "${SOURCE_APP}")" && pwd -P)/$(basename "${SOURCE_APP}")" + target_real="$(cd "$(dirname "${APP_PATH}")" && pwd -P)/$(basename "${APP_PATH}")" + [[ "${source_real}" != "${target_real}" ]] \ + || fail "--source-app and --app-path resolve to the same path" + fi +} + +resolve_source_app() { + SOURCE_APP="$(expand_tilde "${SOURCE_APP}")" + DMG="$(expand_tilde "${DMG}")" + APP_PATH="$(expand_tilde "${APP_PATH}")" + validate_app_path + + if [[ -n "${SOURCE_APP}" ]]; then + [[ -d "${SOURCE_APP}" ]] || fail "source app does not exist: ${SOURCE_APP}" + validate_source_and_target_paths + return 0 + fi + + local bundle_app="${ROOT}/target/release/bundle/macos/Locality.app" + if [[ -d "${bundle_app}" ]]; then + SOURCE_APP="${bundle_app}" + validate_source_and_target_paths + return 0 + fi + + if [[ -z "${DMG}" ]]; then + DMG="$(default_dmg)" || fail "missing DMG under ${DEFAULT_DMG_DIR}. Run make build-tauri first or pass --dmg PATH." + DMG="$(expand_tilde "${DMG}")" + fi + [[ -f "${DMG}" ]] || fail "missing DMG: ${DMG}. Run make build-tauri first or pass --dmg PATH." + + TMPDIR="$(mktemp -d)" + MOUNT_POINT="${TMPDIR}/mount" + mkdir -p "${MOUNT_POINT}" + run hdiutil attach "${DMG}" -readonly -noverify -noautoopen -mountpoint "${MOUNT_POINT}" -quiet + SOURCE_APP="${MOUNT_POINT}/Locality.app" + if [[ "${DRY_RUN}" -eq 1 ]]; then + validate_source_and_target_paths + return 0 + fi + [[ -d "${SOURCE_APP}" ]] || fail "DMG did not contain Locality.app: ${DMG}" + validate_source_and_target_paths +} + +reset_existing_test_app() { + local old_helper="${APP_PATH}/Contents/MacOS/locality-file-providerctl" + local old_appex="${APP_PATH}/Contents/PlugIns/LocalityFileProvider.appex" + + run_may_fail osascript -e "tell application id \"${BUNDLE_ID}\" to quit" + run_may_fail pkill -f "${APP_PATH}/Contents" + + if [[ "${RESET_DOMAIN}" == "1" && -x "${old_helper}" ]]; then + run_may_fail "${old_helper}" reset --json + fi + if [[ -d "${old_appex}" ]]; then + run_may_fail pluginkit -r "${old_appex}" + fi +} + +install_bundle() { + local appex="${APP_PATH}/Contents/PlugIns/LocalityFileProvider.appex" + local extension_bundle_id="${BUNDLE_ID}.FileProvider" + + run mkdir -p "$(dirname "${APP_PATH}")" + run rm -rf "${APP_PATH}" + run ditto "${SOURCE_APP}" "${APP_PATH}" + + run /usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier ${BUNDLE_ID}" "${APP_PATH}/Contents/Info.plist" + run /usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName ${DISPLAY_NAME}" "${APP_PATH}/Contents/Info.plist" + run /usr/libexec/PlistBuddy -c "Set :CFBundleName ${DISPLAY_NAME}" "${APP_PATH}/Contents/Info.plist" + run /usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier ${extension_bundle_id}" "${appex}/Contents/Info.plist" + run /usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName ${DISPLAY_NAME}" "${appex}/Contents/Info.plist" + run /usr/libexec/PlistBuddy -c "Set :CFBundleName LocalityPromptTestFileProvider" "${appex}/Contents/Info.plist" + + run codesign --force --options runtime --timestamp=none --sign "${SIGNING_IDENTITY}" --entitlements "${HOST_ENTITLEMENTS}" --identifier "${BUNDLE_ID}.locality-desktop" "${APP_PATH}/Contents/MacOS/locality-desktop" + run codesign --force --options runtime --timestamp=none --sign "${SIGNING_IDENTITY}" --entitlements "${HOST_ENTITLEMENTS}" --identifier "${BUNDLE_ID}.loc" "${APP_PATH}/Contents/MacOS/loc" + run codesign --force --options runtime --timestamp=none --sign "${SIGNING_IDENTITY}" --entitlements "${HOST_ENTITLEMENTS}" --identifier "${BUNDLE_ID}.localityd" "${APP_PATH}/Contents/MacOS/localityd" + run codesign --force --options runtime --timestamp=none --sign "${SIGNING_IDENTITY}" --entitlements "${HOST_ENTITLEMENTS}" --identifier "${BUNDLE_ID}.file-providerctl" "${APP_PATH}/Contents/MacOS/locality-file-providerctl" + run codesign --force --options runtime --timestamp=none --sign "${SIGNING_IDENTITY}" --entitlements "${FILE_PROVIDER_ENTITLEMENTS}" --identifier "${extension_bundle_id}.binary" "${appex}/Contents/MacOS/LocalityFileProvider" + run codesign --force --options runtime --timestamp=none --sign "${SIGNING_IDENTITY}" --entitlements "${FILE_PROVIDER_ENTITLEMENTS}" --identifier "${extension_bundle_id}" "${appex}" + run codesign --force --options runtime --timestamp=none --sign "${SIGNING_IDENTITY}" --entitlements "${HOST_ENTITLEMENTS}" --identifier "${BUNDLE_ID}" "${APP_PATH}" + + run_may_fail xattr -dr com.apple.quarantine "${APP_PATH}" + run pluginkit -a "${appex}" + run codesign --verify --deep --strict --verbose=2 "${APP_PATH}" + run pluginkit -m -v -i "${extension_bundle_id}" + run_may_fail "${APP_PATH}/Contents/MacOS/locality-file-providerctl" --json list + + if [[ "${LAUNCH}" == "1" ]]; then + run open -a "${APP_PATH}" + fi +} + +main() { + parse_args "$@" + if [[ -z "${BUNDLE_ID}" ]]; then + BUNDLE_ID="$(default_bundle_id)" + fi + + require_execute_environment + trap cleanup EXIT + resolve_source_app + + log "source app: ${SOURCE_APP}" + log "target app: ${APP_PATH}" + log "bundle id: ${BUNDLE_ID}" + log "extension bundle id: ${BUNDLE_ID}.FileProvider" + log "display name: ${DISPLAY_NAME}" + log "signing identity: ${SIGNING_IDENTITY}" + log "reset existing domain: ${RESET_DOMAIN}" + log "launch after install: ${LAUNCH}" + log "state isolation: prompt-fresh only; this test app still uses the normal Locality state and app-group entitlements." + + reset_existing_test_app + install_bundle +} + +main "$@" diff --git a/scripts/install-macos-prompt-test-app.test.sh b/scripts/install-macos-prompt-test-app.test.sh new file mode 100755 index 00000000..a57de2b4 --- /dev/null +++ b/scripts/install-macos-prompt-test-app.test.sh @@ -0,0 +1,246 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SCRIPT="${ROOT}/scripts/install-macos-prompt-test-app.sh" + +fail() { + printf 'install-macos-prompt-test-app test failed: %s\n' "$*" >&2 + exit 1 +} + +assert_contains() { + local haystack="$1" + local needle="$2" + grep -Fq -- "${needle}" <<<"${haystack}" || fail "missing: ${needle}" +} + +assert_not_contains() { + local haystack="$1" + local needle="$2" + ! grep -Fq -- "${needle}" <<<"${haystack}" || fail "unexpected: ${needle}" +} + +make_source_app() { + local app="$1" + mkdir -p \ + "${app}/Contents/MacOS" \ + "${app}/Contents/PlugIns/LocalityFileProvider.appex/Contents/MacOS" + touch \ + "${app}/Contents/MacOS/locality-desktop" \ + "${app}/Contents/MacOS/loc" \ + "${app}/Contents/MacOS/localityd" \ + "${app}/Contents/MacOS/locality-file-providerctl" \ + "${app}/Contents/PlugIns/LocalityFileProvider.appex/Contents/MacOS/LocalityFileProvider" + chmod +x \ + "${app}/Contents/MacOS/locality-desktop" \ + "${app}/Contents/MacOS/loc" \ + "${app}/Contents/MacOS/localityd" \ + "${app}/Contents/MacOS/locality-file-providerctl" \ + "${app}/Contents/PlugIns/LocalityFileProvider.appex/Contents/MacOS/LocalityFileProvider" + cat >"${app}/Contents/Info.plist" <<'PLIST' + + +CFBundleExecutablelocality-desktop +PLIST + cat >"${app}/Contents/PlugIns/LocalityFileProvider.appex/Contents/Info.plist" <<'PLIST' + + +CFBundleExecutableLocalityFileProvider +PLIST +} + +test_dry_run_plans_fresh_prompt_test_app_installation() { + local tmp source app output + tmp="$(mktemp -d)" + trap '[[ -z "${tmp:-}" ]] || rm -rf "${tmp}"' RETURN + source="${tmp}/Source Locality.app" + app="${tmp}/Applications/Locality Prompt Test.app" + make_source_app "${source}" + + output="$( + LOCALITY_PROMPT_TEST_TIMESTAMP=20260714130000 \ + "${SCRIPT}" \ + --dry-run \ + --source-app "${source}" \ + --app-path "${app}" \ + --signing-identity "Developer ID Application: Test (TEAMID)" \ + --no-launch + )" + + assert_contains "${output}" "bundle id: ai.codeflash.locality.promptfresh20260714130000" + assert_contains "${output}" "extension bundle id: ai.codeflash.locality.promptfresh20260714130000.FileProvider" + assert_contains "${output}" "state isolation: prompt-fresh only" + assert_contains "${output}" "ditto" + assert_contains "${output}" "${source}" + assert_contains "${output}" "${app}" + assert_contains "${output}" "CFBundleIdentifier\\ ai.codeflash.locality.promptfresh20260714130000" + assert_contains "${output}" "CFBundleIdentifier\\ ai.codeflash.locality.promptfresh20260714130000.FileProvider" + assert_contains "${output}" "LocalityDeveloperId.entitlements" + assert_contains "${output}" "locality-file-providerctl" + assert_contains "${output}" "locality-desktop" + assert_contains "${output}" "LocalityFileProvider.entitlements" + assert_contains "${output}" "LocalityFileProvider.appex" + assert_contains "${output}" "pluginkit -a" + assert_not_contains "${output}" "open -a" +} + +test_dry_run_resets_existing_test_app_domain_by_default() { + local tmp source app output + tmp="$(mktemp -d)" + trap '[[ -z "${tmp:-}" ]] || rm -rf "${tmp}"' RETURN + source="${tmp}/Source Locality.app" + app="${tmp}/Applications/Locality Prompt Test.app" + make_source_app "${source}" + make_source_app "${app}" + + output="$( + LOCALITY_PROMPT_TEST_TIMESTAMP=20260714130001 \ + "${SCRIPT}" \ + --dry-run \ + --source-app "${source}" \ + --app-path "${app}" \ + --signing-identity "Developer ID Application: Test (TEAMID)" \ + --no-launch + )" + + assert_contains "${output}" "locality-file-providerctl reset --json" + assert_contains "${output}" "pluginkit -r" +} + +test_dry_run_can_skip_existing_test_app_domain_reset() { + local tmp source app output + tmp="$(mktemp -d)" + trap '[[ -z "${tmp:-}" ]] || rm -rf "${tmp}"' RETURN + source="${tmp}/Source Locality.app" + app="${tmp}/Applications/Locality Prompt Test.app" + make_source_app "${source}" + make_source_app "${app}" + + output="$( + LOCALITY_PROMPT_TEST_TIMESTAMP=20260714130002 \ + "${SCRIPT}" \ + --dry-run \ + --source-app "${source}" \ + --app-path "${app}" \ + --signing-identity "Developer ID Application: Test (TEAMID)" \ + --no-reset-domain \ + --no-launch + )" + + assert_not_contains "${output}" "locality-file-providerctl reset --json" + assert_contains "${output}" "pluginkit -r" +} + +test_dry_run_rejects_non_app_target_path() { + local tmp source app output status + tmp="$(mktemp -d)" + trap '[[ -z "${tmp:-}" ]] || rm -rf "${tmp}"' RETURN + source="${tmp}/Source Locality.app" + app="${tmp}/Applications/Locality Prompt Test" + make_source_app "${source}" + + set +e + output="$( + LOCALITY_PROMPT_TEST_TIMESTAMP=20260714130003 \ + "${SCRIPT}" \ + --dry-run \ + --source-app "${source}" \ + --app-path "${app}" \ + --signing-identity "Developer ID Application: Test (TEAMID)" \ + --no-launch 2>&1 + )" + status=$? + set -e + + [[ "${status}" -ne 0 ]] || fail "invalid app path unexpectedly succeeded" + assert_contains "${output}" "--app-path must point to a .app bundle" +} + +test_dry_run_rejects_production_target_path_by_default() { + local tmp source app output status + tmp="$(mktemp -d)" + trap '[[ -z "${tmp:-}" ]] || rm -rf "${tmp}"' RETURN + source="${tmp}/Source Locality.app" + app="${tmp}/Applications/Locality.app" + make_source_app "${source}" + + set +e + output="$( + LOCALITY_PROMPT_TEST_TIMESTAMP=20260714130004 \ + "${SCRIPT}" \ + --dry-run \ + --source-app "${source}" \ + --app-path "${app}" \ + --signing-identity "Developer ID Application: Test (TEAMID)" \ + --no-launch 2>&1 + )" + status=$? + set -e + + [[ "${status}" -ne 0 ]] || fail "production app path unexpectedly succeeded" + assert_contains "${output}" "--app-path points at the production app" + assert_not_contains "${output}" "rm -rf" +} + +test_dry_run_force_allows_non_test_target_path() { + local tmp source app output + tmp="$(mktemp -d)" + trap '[[ -z "${tmp:-}" ]] || rm -rf "${tmp}"' RETURN + source="${tmp}/Source Locality.app" + app="${tmp}/Applications/Locality.app" + make_source_app "${source}" + + output="$( + LOCALITY_PROMPT_TEST_TIMESTAMP=20260714130005 \ + "${SCRIPT}" \ + --dry-run \ + --source-app "${source}" \ + --app-path "${app}" \ + --force-non-test-app-path \ + --signing-identity "Developer ID Application: Test (TEAMID)" \ + --no-launch + )" + + assert_contains "${output}" "target app: ${app}" + assert_contains "${output}" "rm -rf" +} + +test_dry_run_rejects_source_app_as_target() { + local tmp source output status + tmp="$(mktemp -d)" + trap '[[ -z "${tmp:-}" ]] || rm -rf "${tmp}"' RETURN + source="${tmp}/Source Locality.app" + make_source_app "${source}" + + set +e + output="$( + LOCALITY_PROMPT_TEST_TIMESTAMP=20260714130006 \ + "${SCRIPT}" \ + --dry-run \ + --source-app "${source}" \ + --app-path "${source}" \ + --force-non-test-app-path \ + --signing-identity "Developer ID Application: Test (TEAMID)" \ + --no-launch 2>&1 + )" + status=$? + set -e + + [[ "${status}" -ne 0 ]] || fail "source app as target unexpectedly succeeded" + assert_contains "${output}" "--source-app and --app-path must be different paths" + assert_not_contains "${output}" "rm -rf" +} + +main() { + test_dry_run_plans_fresh_prompt_test_app_installation + test_dry_run_resets_existing_test_app_domain_by_default + test_dry_run_can_skip_existing_test_app_domain_reset + test_dry_run_rejects_non_app_target_path + test_dry_run_rejects_production_target_path_by_default + test_dry_run_force_allows_non_test_target_path + test_dry_run_rejects_source_app_as_target + printf 'install-macos-prompt-test-app helper tests passed\n' +} + +main "$@" From ff0f0b12494a741a9c5ab37a362369c11561397e Mon Sep 17 00:00:00 2001 From: aseembits93 Date: Thu, 16 Jul 2026 15:11:17 -0700 Subject: [PATCH 02/11] feat: expose File Provider enablement status --- apps/desktop/src-tauri/src/main.rs | 241 ++++++++++++++++++++++++++++- 1 file changed, 238 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 4eadc60d..bc526b97 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -348,6 +348,14 @@ struct WorkspaceMountOnboardingReport { launch_strategy: String, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct FileProviderEnablementReport { + state: String, + message: String, + path: Option, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum MacosWorkspaceMountOnboardingState { Created, @@ -360,7 +368,7 @@ impl MacosWorkspaceMountOnboardingState { fn as_str(self) -> &'static str { match self { Self::Created => "created", - Self::ApprovalRequired => "approval_required", + Self::ApprovalRequired => "needs_finder_enable", Self::WaitingForCloudStorageRoot => "waiting_for_cloudstorage_root", Self::Failed => "failed", } @@ -1920,6 +1928,33 @@ async fn run_workspace_mount_onboarding( report } +#[tauri::command] +async fn file_provider_enablement_status() -> FileProviderEnablementReport { + tauri::async_runtime::spawn_blocking(macos_file_provider_enablement_status_blocking) + .await + .unwrap_or_else(|error| FileProviderEnablementReport { + state: "unavailable".to_string(), + message: format!("File Provider status worker failed: {error}"), + path: None, + }) +} + +#[tauri::command] +async fn reveal_file_provider_enablement() -> ActionReport { + tauri::async_runtime::spawn_blocking(reveal_file_provider_enablement_blocking) + .await + .map_or_else( + |error| ActionReport { + ok: false, + message: format!("Finder worker failed: {error}"), + }, + |result| match result { + Ok(message) => ActionReport { ok: true, message }, + Err(message) => ActionReport { ok: false, message }, + }, + ) +} + async fn create_desktop_mount_command( app: AppHandle, request: CreateDesktopMountRequest, @@ -10059,7 +10094,7 @@ fn workspace_mount_onboarding_curated_message( ) -> Option<&'static str> { match state { MacosWorkspaceMountOnboardingState::ApprovalRequired => { - Some("Enable Locality in Finder, then return here and click Check again.") + Some("In Finder, click Enable for Locality. Locality will continue automatically.") } MacosWorkspaceMountOnboardingState::WaitingForCloudStorageRoot => { Some("Locality is still waiting for the CloudStorage folder to appear.") @@ -10151,6 +10186,128 @@ fn macos_workspace_mount_domain_user_enabled() -> Result { .unwrap_or(false)) } +fn classify_macos_file_provider_enablement( + user_enabled: Option, + fallback_path: Option, + resolved_root: Result, String>, +) -> FileProviderEnablementReport { + let path = |value: Option| value.map(|path| path.display().to_string()); + match user_enabled { + None => FileProviderEnablementReport { + state: "not_registered".to_string(), + message: "Locality is preparing the Finder location.".to_string(), + path: path(fallback_path), + }, + Some(false) => FileProviderEnablementReport { + state: "needs_finder_enable".to_string(), + message: "In Finder, click Enable for Locality.".to_string(), + path: path(fallback_path), + }, + Some(true) => match resolved_root { + Ok(Some(root)) => FileProviderEnablementReport { + state: "ready".to_string(), + message: "Locality is enabled in Finder.".to_string(), + path: path(Some(root)), + }, + Ok(None) => FileProviderEnablementReport { + state: "waiting_for_root".to_string(), + message: "Finishing the Locality folder setup.".to_string(), + path: path(fallback_path), + }, + Err(message) => FileProviderEnablementReport { + state: "unavailable".to_string(), + message, + path: path(fallback_path), + }, + }, + } +} + +#[cfg(target_os = "macos")] +fn macos_file_provider_enablement_status_blocking() -> FileProviderEnablementReport { + let provider_roots = macos_file_provider_cloud_storage_roots(); + let fallback_path = provider_roots.first().cloned(); + let report = match run_macos_file_provider_helper("list", Vec::new()) { + Ok(report) => report, + Err(error) => { + return FileProviderEnablementReport { + state: "unavailable".to_string(), + message: error.message(), + path: fallback_path.map(|path| path.display().to_string()), + }; + } + }; + let user_enabled = report + .helper_report + .get("domains") + .and_then(serde_json::Value::as_array) + .and_then(|domains| { + domains.iter().find(|domain| { + domain.get("identifier").and_then(serde_json::Value::as_str) + == Some(localityd::file_provider::MACOS_FILE_PROVIDER_DOMAIN_ID) + }) + }) + .and_then(|domain| domain.get("userEnabled")) + .and_then(serde_json::Value::as_bool); + + let resolved_root = if user_enabled == Some(true) { + match macos_file_provider_domain_url( + localityd::file_provider::MACOS_FILE_PROVIDER_DOMAIN_ID, + ) { + Ok(root) => Ok(Some(root)), + Err(error) if recoverable_macos_file_provider_activation_error(&error.message()) => { + Ok(None) + } + Err(error) => Err(error.message()), + } + } else { + Ok(None) + }; + classify_macos_file_provider_enablement(user_enabled, fallback_path, resolved_root) +} + +#[cfg(not(target_os = "macos"))] +fn macos_file_provider_enablement_status_blocking() -> FileProviderEnablementReport { + FileProviderEnablementReport { + state: "unavailable".to_string(), + message: "File Provider enablement is only available on macOS.".to_string(), + path: None, + } +} + +fn macos_file_provider_reveal_path( + domain_path: Option<&Path>, + candidates: &[PathBuf], +) -> Option { + domain_path + .into_iter() + .chain(candidates.iter().map(PathBuf::as_path)) + .find_map(|candidate| { + candidate + .ancestors() + .find(|ancestor| ancestor.is_dir()) + .map(Path::to_path_buf) + }) +} + +#[cfg(target_os = "macos")] +fn reveal_file_provider_enablement_blocking() -> Result { + let report = macos_file_provider_enablement_status_blocking(); + let domain_path = report.path.as_deref().map(Path::new); + let path = + macos_file_provider_reveal_path(domain_path, &macos_file_provider_cloud_storage_roots()) + .ok_or_else(|| { + "Could not find the Locality location or its CloudStorage parent.".to_string() + })?; + open_in_file_manager(&path)?; + Ok(format!("Opened {} in Finder.", path.display())) +} + +#[cfg(not(target_os = "macos"))] +fn reveal_file_provider_enablement_blocking() -> Result { + Err("File Provider enablement is only available on macOS.".to_string()) +} + #[cfg(not(target_os = "macos"))] fn macos_workspace_mount_domain_user_enabled() -> Result { Ok(false) @@ -13510,6 +13667,79 @@ mod tests { )); } + #[test] + fn file_provider_enablement_report_distinguishes_registration_and_approval() { + let missing = super::classify_macos_file_provider_enablement(None, None, Ok(None)); + assert_eq!(missing.state, "not_registered"); + + let disabled = super::classify_macos_file_provider_enablement( + Some(false), + Some(PathBuf::from("/Users/test/Library/CloudStorage/Locality")), + Ok(None), + ); + assert_eq!(disabled.state, "needs_finder_enable"); + assert_eq!( + disabled.path.as_deref(), + Some("/Users/test/Library/CloudStorage/Locality") + ); + } + + #[test] + fn file_provider_enablement_report_waits_for_enabled_root() { + let report = super::classify_macos_file_provider_enablement( + Some(true), + Some(PathBuf::from("/Users/test/Library/CloudStorage/Locality")), + Ok(None), + ); + + assert_eq!(report.state, "waiting_for_root"); + assert!(report.message.contains("Finishing")); + } + + #[test] + fn file_provider_enablement_report_is_ready_only_with_resolved_root() { + let root = PathBuf::from("/Users/test/Library/CloudStorage/Locality"); + let report = super::classify_macos_file_provider_enablement( + Some(true), + Some(root.clone()), + Ok(Some(root.clone())), + ); + + assert_eq!(report.state, "ready"); + assert_eq!(report.path.as_deref(), root.to_str()); + } + + #[test] + fn file_provider_enablement_report_exposes_unavailable_errors() { + let report = super::classify_macos_file_provider_enablement( + Some(true), + None, + Err("File Provider helper unavailable".to_string()), + ); + + assert_eq!(report.state, "unavailable"); + assert_eq!(report.message, "File Provider helper unavailable"); + } + + #[test] + fn file_provider_reveal_path_prefers_domain_then_existing_parent() { + let temp = TestTempDir::new("file-provider-reveal"); + let cloud_storage = temp.path().join("Library/CloudStorage"); + fs::create_dir_all(&cloud_storage).expect("create CloudStorage parent"); + let domain = cloud_storage.join("Locality"); + + assert_eq!( + super::macos_file_provider_reveal_path(Some(&domain), &[domain.clone()]), + Some(cloud_storage) + ); + + fs::create_dir_all(&domain).expect("create domain root"); + assert_eq!( + super::macos_file_provider_reveal_path(Some(&domain), &[domain.clone()]), + Some(domain) + ); + } + #[test] fn macos_file_provider_approval_surface_path_uses_first_existing_candidate() { let temp = TestTempDir::new("approval-surface"); @@ -13544,6 +13774,9 @@ mod tests { ), expected ); + if let Some(state) = expected { + assert_eq!(state.as_str(), "needs_finder_enable"); + } } #[test] @@ -13584,7 +13817,7 @@ mod tests { super::workspace_mount_onboarding_curated_message( super::MacosWorkspaceMountOnboardingState::ApprovalRequired ), - Some("Enable Locality in Finder, then return here and click Check again.") + Some("In Finder, click Enable for Locality. Locality will continue automatically.") ); assert_eq!( super::workspace_mount_onboarding_curated_message( @@ -16931,6 +17164,8 @@ fn main() { disconnect_source, export_source_backup, run_workspace_mount_onboarding, + file_provider_enablement_status, + reveal_file_provider_enablement, install_agent_guidance, locate_notion_page, search_notion_pages, From 0cb01e0db54e062b100f5a936b68f18dfb22a3db Mon Sep 17 00:00:00 2001 From: aseembits93 Date: Thu, 16 Jul 2026 15:19:44 -0700 Subject: [PATCH 03/11] feat: guide Finder enablement automatically --- apps/desktop/src/App.tsx | 342 +++++++++++++++++- .../src/file-provider-enablement-ui.test.js | 32 ++ .../src/file-provider-enablement.test.ts | 125 +++++++ apps/desktop/src/file-provider-enablement.ts | 140 +++++++ apps/desktop/src/onboarding-mount.test.ts | 18 +- apps/desktop/src/onboarding-mount.ts | 12 +- apps/desktop/src/styles.css | 214 +++++++++++ 7 files changed, 857 insertions(+), 26 deletions(-) create mode 100644 apps/desktop/src/file-provider-enablement-ui.test.js create mode 100644 apps/desktop/src/file-provider-enablement.test.ts create mode 100644 apps/desktop/src/file-provider-enablement.ts diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 4aaca439..fe08d1b6 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -60,6 +60,12 @@ import { connectionMissing, connectionReady } from "./connection-state"; import { copyLoginLinkDisabled, loginLinkFlowMode } from "./onboarding-connect"; import { mountRecoveryEnabled, shouldAutoCreateMount } from "./onboarding-flow"; import { classifyMountSetupError } from "./onboarding-errors"; +import { + createFileProviderEnablementPoller, + fileProviderEnablementHeadline, + fileProviderEnablementStatusLabel, + type FileProviderEnablementReport, +} from "./file-provider-enablement"; import { failedMountOnboardingReport, mountOnboardingHeadline, @@ -1656,9 +1662,13 @@ function Onboarding({ const [optionalGuideReturnStep, setOptionalGuideReturnStep] = useState(null); const [mountOnboarding, setMountOnboarding] = useState(null); const [mounting, setMounting] = useState(false); + const [fileProviderEnablement, setFileProviderEnablement] = useState(null); + const [finderHelpOpen, setFinderHelpOpen] = useState(false); + const [finderRevealError, setFinderRevealError] = useState(""); const [agentGuidanceReport, setAgentGuidanceReport] = useState(null); const [agentGuidanceState, setAgentGuidanceState] = useState<"idle" | "installing" | "ready" | "error">("idle"); const mountStartRequestedRef = useRef(false); + const finderRevealRequestedRef = useRef(false); const snapshotConnectionConnector = isOnboardingConnector(snapshot.connection.connector) ? snapshot.connection.connector : null; @@ -1804,6 +1814,67 @@ function Onboarding({ void runMountOnboarding("start"); }, [connectionReadyNow, mountOnboarding, mountPath, mounting, selectedOnboardingConnector, snapshot.mount.status, step]); + useEffect(() => { + const enablementActive = + step === 4 && + (mountOnboarding?.state === "needs_finder_enable" || + mountOnboarding?.state === "waiting_for_cloudstorage_root"); + if (!enablementActive) { + finderRevealRequestedRef.current = false; + setFileProviderEnablement(null); + setFinderHelpOpen(false); + setFinderRevealError(""); + return; + } + + let completionTimer: number | null = null; + const poller = createFileProviderEnablementPoller({ + probe: () => + callCommand( + "file_provider_enablement_status", + undefined, + { + state: "ready", + message: "Locality is enabled in Finder.", + path: mountPath, + }, + ), + onReport: (report) => { + setFileProviderEnablement(report); + if (report.state === "unavailable") { + setMountOnboarding(failedMountOnboardingReport(report.message)); + } + }, + onReady: (report) => { + setFileProviderEnablement(report); + completionTimer = window.setTimeout(() => { + void runMountOnboarding("start"); + }, 350); + }, + }); + const updateVisibility = () => { + poller.setVisible(document.visibilityState !== "hidden"); + }; + + if ( + mountOnboarding?.state === "needs_finder_enable" && + !finderRevealRequestedRef.current + ) { + finderRevealRequestedRef.current = true; + void revealFileProviderEnablement(); + } + document.addEventListener("visibilitychange", updateVisibility); + updateVisibility(); + poller.start(); + return () => { + poller.stop(); + document.removeEventListener("visibilitychange", updateVisibility); + if (completionTimer !== null) { + window.clearTimeout(completionTimer); + } + }; + }, [mountOnboarding?.state, step]); + useEffect(() => { if ( selectedOnboardingConnector !== "notion" || @@ -2131,6 +2202,22 @@ function Onboarding({ } } + async function revealFileProviderEnablement() { + setFinderRevealError(""); + try { + const report = await callCommand( + "reveal_file_provider_enablement", + undefined, + { ok: true, message: "Opened Locality in Finder." }, + ); + if (!report.ok) { + setFinderRevealError(report.message); + } + } catch (error) { + setFinderRevealError(errorMessage(error)); + } + } + async function ensureCliAvailable() { if (appStoreDistribution) { return true; @@ -2247,6 +2334,21 @@ function Onboarding({ ? classifyMountSetupError(mountOnboarding.message) : null; const showRecoveryChooser = mountRecoveryEnabled(mountSetupError); + const fileProviderGuideVisible = + mountOnboarding?.state === "needs_finder_enable" || + mountOnboarding?.state === "waiting_for_cloudstorage_root"; + const displayedFileProviderEnablement = fileProviderEnablement ?? + (mountOnboarding?.state === "waiting_for_cloudstorage_root" + ? { + state: "waiting_for_root" as const, + message: "Finishing the Locality folder setup.", + path: mountPath, + } + : { + state: "needs_finder_enable" as const, + message: "In Finder, click Enable for Locality.", + path: mountPath, + }); return (
@@ -2415,28 +2517,66 @@ function Onboarding({ >
Local folder
-

{mountOnboardingHeadline(mountOnboarding)}

+

+ {fileProviderGuideVisible + ? fileProviderEnablementHeadline(displayedFileProviderEnablement) + : mountOnboardingHeadline(mountOnboarding)} +

- {mountOnboarding?.message ?? - `Locality is creating your ${selectedSourceName} folder under the default CloudStorage root and verifying that files appear locally.`} + {fileProviderGuideVisible + ? displayedFileProviderEnablement.state === "needs_finder_enable" + ? "Finder is open to the Locality location. Click Enable there; this screen will continue automatically." + : displayedFileProviderEnablement.message + : mountOnboarding?.message ?? + `Locality is creating your ${selectedSourceName} folder under the default CloudStorage root and verifying that files appear locally.`}

-
+ {fileProviderGuideVisible && ( + + )} +
{mounting ? ( ) : mountOnboarding?.state === "failed" ? ( + ) : fileProviderGuideVisible ? ( + ) : ( )} - {mounting + {fileProviderGuideVisible + ? fileProviderEnablementStatusLabel(displayedFileProviderEnablement) + : mounting ? "Checking File Provider approval" : mountOnboarding?.message ?? `Creating folder and preparing ${selectedSourceName} files`}
{mountPath}
- {showRecoveryChooser ? ( + {fileProviderGuideVisible ? ( + <> +
+ void revealFileProviderEnablement()}> + Reopen Finder + + setFinderHelpOpen((open) => !open)}> + Having trouble? + +
+ {finderHelpOpen && ( +
+ Look under Locations in the Finder sidebar. + + Select Locality and click Enable. If Locality is missing, reopen Finder first, + then verify Locality under File Providers in System Settings. + +
+ )} + {finderRevealError &&

{finderRevealError}

} + + ) : showRecoveryChooser ? (
)} - {mountOnboardingNeedsInstructions(mountOnboarding) && ( + {!fileProviderGuideVisible && mountOnboardingNeedsInstructions(mountOnboarding) && (

{mountOnboardingInstructions(mountOnboarding)}

)} - {mountOnboardingSupplementaryNote(mountOnboarding) && ( + {!fileProviderGuideVisible && mountOnboardingSupplementaryNote(mountOnboarding) && (

{mountOnboardingSupplementaryNote(mountOnboarding)}

)}

@@ -3110,10 +3250,77 @@ function MountsView({ const [sourceDialogState, setSourceDialogState] = useState("idle"); const [sourceDialogConnector, setSourceDialogConnector] = useState(null); const [sourceDialogMessage, setSourceDialogMessage] = useState(""); + const [sourceFileProviderEnablement, setSourceFileProviderEnablement] = useState(null); + const [pendingMountRetry, setPendingMountRetry] = useState<{ + connector: SourceConnectorId; + googleDocsWorkspaceFolder?: string; + } | null>(null); + const sourceFinderRevealRequestedRef = useRef(false); const sourceSetupBusy = sourceSetupIsBusy(sourceDialogState); const readyToMountSources = connectedSourcesReadyToMount(snapshot); const hasVisibleSources = rows.length > 0 || readyToMountSources.length > 0; + useEffect(() => { + if (!pendingMountRetry) { + sourceFinderRevealRequestedRef.current = false; + return; + } + + let completionTimer: number | null = null; + const poller = createFileProviderEnablementPoller({ + probe: () => + callCommand( + "file_provider_enablement_status", + undefined, + { + state: "ready", + message: "Locality is enabled in Finder.", + path: sourceDefaultPath(snapshot, pendingMountRetry.connector), + }, + ), + onReport: (report) => { + setSourceFileProviderEnablement(report); + if (report.state === "unavailable") { + setSourceDialogMessage(report.message); + setSourceDialogState("error"); + } + }, + onReady: (report) => { + setSourceFileProviderEnablement(report); + completionTimer = window.setTimeout(async () => { + const mountReport = await createConnectorMount( + pendingMountRetry.connector, + pendingMountRetry.googleDocsWorkspaceFolder, + ); + if (mountReport.ok) { + setPendingMountRetry(null); + setSourceFileProviderEnablement(null); + setSourceDialogMessage(mountReport.message); + setSourceDialogState("success"); + } + }, 350); + }, + }); + const updateVisibility = () => { + poller.setVisible(document.visibilityState !== "hidden"); + }; + + if (!sourceFinderRevealRequestedRef.current) { + sourceFinderRevealRequestedRef.current = true; + void revealSourceFileProviderEnablement(); + } + document.addEventListener("visibilitychange", updateVisibility); + updateVisibility(); + poller.start(); + return () => { + poller.stop(); + document.removeEventListener("visibilitychange", updateVisibility); + if (completionTimer !== null) { + window.clearTimeout(completionTimer); + } + }; + }, [pendingMountRetry]); + function openAddSourceDialog() { setActionError(""); setActionMessage(""); @@ -3125,6 +3332,34 @@ function MountsView({ setSourceDialogOpen(true); } + function beginSourceFileProviderRecovery( + connector: SourceConnectorId, + googleDocsWorkspaceFolder: string | undefined, + ) { + setActionError(""); + setSourceDialogOpen(true); + setSourceDialogConnector(connector); + setSourceDialogState("creating"); + setSourceDialogMessage(""); + setSourceFileProviderEnablement({ + state: "needs_finder_enable", + message: "In Finder, click Enable for Locality.", + path: sourceDefaultPath(snapshot, connector), + }); + setPendingMountRetry({ connector, googleDocsWorkspaceFolder }); + } + + async function revealSourceFileProviderEnablement() { + const report = await callCommand( + "reveal_file_provider_enablement", + undefined, + { ok: true, message: "Opened Locality in Finder." }, + ).catch((error) => ({ ok: false, message: errorMessage(error) })); + if (!report.ok) { + setSourceDialogMessage(report.message); + } + } + async function createConnectorMount( connector: SourceConnectorId, googleDocsWorkspaceFolder?: string, @@ -3156,6 +3391,10 @@ function MountsView({ { ok: true, message: "Created demo mount." }, ); if (!report.ok) { + if (classifyMountSetupError(report.message).kind === "file-provider-disabled") { + beginSourceFileProviderRecovery(connector, googleDocsWorkspaceFolder); + return report; + } setActionError(report.message); return report; } @@ -3163,6 +3402,10 @@ function MountsView({ return report; } catch (error) { const message = errorMessage(error); + if (classifyMountSetupError(message).kind === "file-provider-disabled") { + beginSourceFileProviderRecovery(connector, googleDocsWorkspaceFolder); + return { ok: false, message }; + } setActionError(message); return { ok: false, message }; } finally { @@ -3253,6 +3496,9 @@ function MountsView({ try { if (connectorReady && !connectorHasMount) { const mountReport = await createConnectorMount(connector, options?.googleDocsWorkspaceFolder); + if (classifyMountSetupError(mountReport.message).kind === "file-provider-disabled") { + return; + } setSourceDialogMessage(mountReport.message); setSourceDialogState(mountReport.ok ? "success" : "error"); return; @@ -3271,6 +3517,9 @@ function MountsView({ setSourceDialogState("creating"); const mountReport = await createConnectorMount(connector, options?.googleDocsWorkspaceFolder); + if (classifyMountSetupError(mountReport.message).kind === "file-provider-disabled") { + return; + } const message = mountReport.ok ? `${report.message} ${mountReport.message}` : `${report.message} ${mountReport.message}`; @@ -3514,9 +3763,15 @@ function MountsView({ state={sourceDialogState} activeConnector={sourceDialogConnector} message={sourceDialogMessage} + fileProviderEnablement={sourceFileProviderEnablement} onAction={(connector, options) => void runSourceDialogAction(connector, options)} onGranolaAction={(apiKey) => void connectGranolaSource(apiKey)} - onClose={() => setSourceDialogOpen(false)} + onReopenFinder={() => void revealSourceFileProviderEnablement()} + onClose={() => { + setSourceDialogOpen(false); + setPendingMountRetry(null); + setSourceFileProviderEnablement(null); + }} /> )}

@@ -3528,16 +3783,20 @@ function AddSourceDialog({ state, activeConnector, message, + fileProviderEnablement, onAction, onGranolaAction, + onReopenFinder, onClose, }: { snapshot: DesktopSnapshot; state: SourceSetupState; activeConnector: SourceConnectorId | null; message: string; + fileProviderEnablement: FileProviderEnablementReport | null; onAction: (connector: SourceConnectorId, options?: { googleDocsWorkspaceFolder?: string }) => void; onGranolaAction: (apiKey: string) => void; + onReopenFinder: () => void; onClose: () => void; }) { const [query, setQuery] = useState(""); @@ -3603,6 +3862,30 @@ function AddSourceDialog({
+ {fileProviderEnablement ? ( +
+
+

Finder access

+

{fileProviderEnablementHeadline(fileProviderEnablement)}

+

+ {fileProviderEnablement.state === "needs_finder_enable" + ? "Click Enable in the Locality Finder window. Source setup will continue automatically." + : fileProviderEnablement.message} +

+
+ +
+ {fileProviderEnablement.state === "ready" ? : } + {fileProviderEnablementStatusLabel(fileProviderEnablement)} +
+ } onClick={onReopenFinder}> + Reopen Finder + +
+ ) : ( + <>
+ + )} {busy &&

Setup continues if you close this window.

} {message &&

{message}

} @@ -7356,6 +7641,45 @@ function SetupContent({ ); } +function FinderEnableGuide({ waitingForRoot }: { waitingForRoot: boolean }) { + if (waitingForRoot) { + return ( +
+ + + Finder access enabled + macOS is creating the Locality folder. + +
+ ); + } + + return ( +
+ +
+ ); +} + function BrandTile({ children, variant, diff --git a/apps/desktop/src/file-provider-enablement-ui.test.js b/apps/desktop/src/file-provider-enablement-ui.test.js new file mode 100644 index 00000000..180a8da0 --- /dev/null +++ b/apps/desktop/src/file-provider-enablement-ui.test.js @@ -0,0 +1,32 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const app = readFileSync(new URL("./App.tsx", import.meta.url), "utf8"); +const styles = readFileSync(new URL("./styles.css", import.meta.url), "utf8"); + +describe("Finder enablement checkpoint", () => { + it("renders the Finder cue and recovery actions", () => { + expect(app).toContain('className="finder-enable-guide"'); + expect(app).toContain('className="finder-enable-control"'); + expect(app).toContain("Reopen Finder"); + expect(app).toContain("Having trouble?"); + }); + + it("reuses the guided state for later source mount recovery", () => { + expect(app).toContain("sourceFileProviderEnablement"); + expect(app).toContain("pendingMountRetry"); + expect(app).toContain("fileProviderEnablement={sourceFileProviderEnablement}"); + }); + + it("keeps the Finder crop stable and theme-aware", () => { + expect(styles).toMatch(/\.finder-enable-illustration\s*\{[\s\S]*?aspect-ratio:\s*16 \/ 7;/s); + expect(styles).toMatch(/\.finder-enable-control\s*\{[\s\S]*?min-width:\s*62px;/s); + expect(styles).toContain('[data-theme="dark"] .finder-enable-illustration'); + }); + + it("disables the enable-control highlight for reduced motion", () => { + expect(styles).toMatch( + /@media \(prefers-reduced-motion: reduce\)[\s\S]*?\.finder-enable-control\s*\{[\s\S]*?animation:\s*none;/s, + ); + }); +}); diff --git a/apps/desktop/src/file-provider-enablement.test.ts b/apps/desktop/src/file-provider-enablement.test.ts new file mode 100644 index 00000000..51de691d --- /dev/null +++ b/apps/desktop/src/file-provider-enablement.test.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createFileProviderEnablementPoller, + fileProviderEnablementHeadline, + fileProviderEnablementStatusLabel, + type FileProviderEnablementReport, +} from "./file-provider-enablement"; + +function report( + state: FileProviderEnablementReport["state"], +): FileProviderEnablementReport { + return { state, message: state, path: null }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("File Provider enablement polling", () => { + it("polls immediately and completes exactly once when the domain becomes ready", async () => { + vi.useFakeTimers(); + const states = [report("needs_finder_enable"), report("ready")]; + const seen: string[] = []; + let completions = 0; + const poller = createFileProviderEnablementPoller({ + probe: async () => states.shift() ?? report("ready"), + onReport: (next) => seen.push(next.state), + onReady: () => { + completions += 1; + }, + }); + + poller.start(); + await vi.advanceTimersByTimeAsync(0); + expect(seen).toEqual(["needs_finder_enable"]); + + await vi.advanceTimersByTimeAsync(1_000); + await vi.advanceTimersByTimeAsync(5_000); + expect(seen).toEqual(["needs_finder_enable", "ready"]); + expect(completions).toBe(1); + }); + + it("does not overlap probes while a previous request is pending", async () => { + vi.useFakeTimers(); + let resolveProbe: ((value: FileProviderEnablementReport) => void) | undefined; + let calls = 0; + const poller = createFileProviderEnablementPoller({ + probe: () => { + calls += 1; + return new Promise((resolve) => { + resolveProbe = resolve; + }); + }, + onReport: () => undefined, + onReady: () => undefined, + }); + + poller.start(); + await vi.advanceTimersByTimeAsync(10_000); + expect(calls).toBe(1); + + resolveProbe?.(report("needs_finder_enable")); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(1_000); + expect(calls).toBe(2); + }); + + it("pauses while hidden and probes immediately when visible again", async () => { + vi.useFakeTimers(); + let calls = 0; + const poller = createFileProviderEnablementPoller({ + probe: async () => { + calls += 1; + return report("needs_finder_enable"); + }, + onReport: () => undefined, + onReady: () => undefined, + }); + + poller.start(); + await vi.advanceTimersByTimeAsync(0); + poller.setVisible(false); + await vi.advanceTimersByTimeAsync(10_000); + expect(calls).toBe(1); + + poller.setVisible(true); + await vi.advanceTimersByTimeAsync(0); + expect(calls).toBe(2); + }); + + it("backs transient failures off to five seconds", async () => { + vi.useFakeTimers(); + let calls = 0; + const poller = createFileProviderEnablementPoller({ + probe: async () => { + calls += 1; + throw new Error("temporary helper failure"); + }, + onReport: () => undefined, + onReady: () => undefined, + }); + + poller.start(); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(1_000); + await vi.advanceTimersByTimeAsync(2_000); + await vi.advanceTimersByTimeAsync(4_000); + await vi.advanceTimersByTimeAsync(5_000); + expect(calls).toBe(5); + }); +}); + +describe("File Provider enablement copy", () => { + it("keeps the Finder action and automatic waiting state explicit", () => { + expect(fileProviderEnablementHeadline(report("needs_finder_enable"))).toBe( + "Enable Locality in Finder", + ); + expect(fileProviderEnablementStatusLabel(report("needs_finder_enable"))).toBe( + "Waiting for macOS", + ); + expect(fileProviderEnablementHeadline(report("waiting_for_root"))).toBe( + "Finishing folder setup", + ); + }); +}); diff --git a/apps/desktop/src/file-provider-enablement.ts b/apps/desktop/src/file-provider-enablement.ts new file mode 100644 index 00000000..8665322e --- /dev/null +++ b/apps/desktop/src/file-provider-enablement.ts @@ -0,0 +1,140 @@ +export type FileProviderEnablementState = + | "not_registered" + | "needs_finder_enable" + | "waiting_for_root" + | "ready" + | "unavailable"; + +export type FileProviderEnablementReport = { + state: FileProviderEnablementState; + message: string; + path: string | null; +}; + +type PollerOptions = { + probe: () => Promise; + onReport: (report: FileProviderEnablementReport) => void; + onReady: (report: FileProviderEnablementReport) => void; +}; + +export type FileProviderEnablementPoller = { + start: () => void; + stop: () => void; + setVisible: (visible: boolean) => void; +}; + +export function createFileProviderEnablementPoller( + options: PollerOptions, +): FileProviderEnablementPoller { + let running = false; + let visible = true; + let inFlight = false; + let transientFailures = 0; + let timer: ReturnType | null = null; + + function clearTimer() { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + } + + function schedule(delay: number) { + clearTimer(); + if (!running || !visible || inFlight) { + return; + } + timer = setTimeout(() => { + timer = null; + void poll(); + }, delay); + } + + async function poll() { + if (!running || !visible || inFlight) { + return; + } + inFlight = true; + try { + const next = await options.probe(); + transientFailures = 0; + options.onReport(next); + if (next.state === "ready") { + running = false; + options.onReady(next); + return; + } + if (next.state === "unavailable") { + running = false; + return; + } + schedule(1_000); + } catch { + transientFailures += 1; + schedule(Math.min(1_000 * 2 ** (transientFailures - 1), 5_000)); + } finally { + inFlight = false; + if (running && visible && timer === null) { + const delay = transientFailures === 0 + ? 1_000 + : Math.min(1_000 * 2 ** (transientFailures - 1), 5_000); + schedule(delay); + } + } + } + + return { + start: () => { + if (running) { + return; + } + running = true; + schedule(0); + }, + stop: () => { + running = false; + clearTimer(); + }, + setVisible: (nextVisible) => { + if (visible === nextVisible) { + return; + } + visible = nextVisible; + if (!visible) { + clearTimer(); + } else if (running) { + schedule(0); + } + }, + }; +} + +export function fileProviderEnablementHeadline(report: FileProviderEnablementReport): string { + switch (report.state) { + case "needs_finder_enable": + return "Enable Locality in Finder"; + case "waiting_for_root": + return "Finishing folder setup"; + case "unavailable": + return "Folder setup needs attention"; + case "ready": + return "Locality is enabled"; + default: + return "Preparing Locality in Finder"; + } +} + +export function fileProviderEnablementStatusLabel(report: FileProviderEnablementReport): string { + switch (report.state) { + case "needs_finder_enable": + return "Waiting for macOS"; + case "waiting_for_root": + return "Finishing folder setup"; + case "unavailable": + return "Setup needs attention"; + case "ready": + return "Locality enabled"; + default: + return "Preparing Finder location"; + } +} diff --git a/apps/desktop/src/onboarding-mount.test.ts b/apps/desktop/src/onboarding-mount.test.ts index 26daea6d..5f8781bf 100644 --- a/apps/desktop/src/onboarding-mount.test.ts +++ b/apps/desktop/src/onboarding-mount.test.ts @@ -22,8 +22,8 @@ function report( overrides: Partial, ): WorkspaceMountOnboardingReport { return { - state: "approval_required", - message: "Enable Locality in Finder, then return here and click Check again.", + state: "needs_finder_enable", + message: "In Finder, click Enable for Locality. Locality will continue automatically.", primaryAction: "allow_in_macos", launchStrategy: "instructions_only", ...overrides, @@ -43,7 +43,7 @@ describe("mount onboarding helpers", () => { ); }); - it("switches to progress copy while the action is running", () => { + it("switches to Finder progress copy while the action is running", () => { expect(mountOnboardingPrimaryLabel(report({ primaryAction: "allow_in_macos" }), true)).toBe( "Opening Finder", ); @@ -70,19 +70,17 @@ describe("mount onboarding helpers", () => { ).toBe(false); }); - it("keeps the System Settings fallback in the approval instructions", () => { + it("explains automatic continuation without a confirmation click", () => { expect( mountOnboardingInstructions?.(report({ launchStrategy: "instructions_only" })) ?? null, ).toBe( - "Open Finder, choose Locality under Locations, enable the File Provider, then return here " + - "and click Check again. If Finder does not show Locality, open System Settings, go to " + - "Privacy & Security, then enable Locality under Extensions or File Providers.", + "Finder is open to Locality. Click Enable there; this screen will continue automatically.", ); }); it("maps backend states to the step 4 headline", () => { - expect(mountOnboardingHeadline(report({ state: "approval_required" }))).toBe( - "Allow Locality in Finder.", + expect(mountOnboardingHeadline(report({ state: "needs_finder_enable" }))).toBe( + "Enable Locality in Finder", ); expect(mountOnboardingHeadline(report({ state: "waiting_for_cloudstorage_root" }))).toBe( "Waiting for the Locality folder to appear.", @@ -97,7 +95,7 @@ describe("mount onboarding helpers", () => { expect( mountOnboardingSupplementaryNote(report({ state: "waiting_for_cloudstorage_root" })), ).toContain("CloudStorage"); - expect(mountOnboardingSupplementaryNote(report({ state: "approval_required" }))).toBeNull(); + expect(mountOnboardingSupplementaryNote(report({ state: "needs_finder_enable" }))).toBeNull(); expect(mountOnboardingSupplementaryNote(null)).toBeNull(); }); diff --git a/apps/desktop/src/onboarding-mount.ts b/apps/desktop/src/onboarding-mount.ts index 47727694..312885a2 100644 --- a/apps/desktop/src/onboarding-mount.ts +++ b/apps/desktop/src/onboarding-mount.ts @@ -1,6 +1,6 @@ export type WorkspaceMountOnboardingState = | "created" - | "approval_required" + | "needs_finder_enable" | "waiting_for_cloudstorage_root" | "failed"; @@ -47,8 +47,8 @@ export function mountOnboardingHeadline( report: WorkspaceMountOnboardingReport | null, ) { switch (report?.state) { - case "approval_required": - return "Allow Locality in Finder."; + case "needs_finder_enable": + return "Enable Locality in Finder"; case "waiting_for_cloudstorage_root": return "Waiting for the Locality folder to appear."; case "failed": @@ -67,13 +67,11 @@ export function mountOnboardingNeedsInstructions( export function mountOnboardingInstructions( report: WorkspaceMountOnboardingReport | null, ) { - if (report?.state !== "approval_required" || report.launchStrategy !== "instructions_only") { + if (report?.state !== "needs_finder_enable" || report.launchStrategy !== "instructions_only") { return null; } return ( - "Open Finder, choose Locality under Locations, enable the File Provider, then return here " + - "and click Check again. If Finder does not show Locality, open System Settings, go to " + - "Privacy & Security, then enable Locality under Extensions or File Providers." + "Finder is open to Locality. Click Enable there; this screen will continue automatically." ); } diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 9d166fcd..86ff550d 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -525,6 +525,220 @@ p { color: #0f766e; } +.finder-enable-guide { + width: min(560px, 100%); +} + +.finder-enable-illustration { + position: relative; + display: grid; + grid-template-columns: minmax(118px, 0.34fr) minmax(0, 1fr); + grid-template-rows: 34px minmax(0, 1fr); + width: 100%; + overflow: hidden; + aspect-ratio: 16 / 7; + border: 1px solid rgba(105, 123, 143, 0.25); + border-radius: 8px; + background: #f7f9fb; + box-shadow: 0 12px 30px rgba(20, 30, 43, 0.12); + color: #27384a; +} + +.finder-enable-toolbar { + display: flex; + grid-column: 1 / -1; + align-items: center; + gap: 6px; + padding: 0 11px; + border-bottom: 1px solid rgba(105, 123, 143, 0.18); + background: #e9edf1; +} + +.finder-enable-toolbar i { + width: 8px; + height: 8px; + border-radius: 50%; + background: #aeb8c2; +} + +.finder-enable-toolbar span { + margin-left: 6px; + font-size: 11px; + font-weight: 800; +} + +.finder-enable-sidebar { + display: grid; + align-content: start; + gap: 9px; + padding: 14px 12px; + border-right: 1px solid rgba(105, 123, 143, 0.16); + background: #edf1f4; +} + +.finder-enable-sidebar small { + color: #718093; + font-size: 9px; + font-weight: 850; + text-transform: uppercase; +} + +.finder-enable-location { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + font-size: 11px; +} + +.finder-enable-location svg { + width: 14px; + height: 14px; + color: #4c7cae; +} + +.finder-enable-content { + display: grid; + grid-template-columns: 34px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + padding: 18px 22px; +} + +.finder-enable-content > svg { + width: 30px; + height: 30px; + color: #4c7cae; +} + +.finder-enable-content > strong { + min-width: 0; + overflow: hidden; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.finder-enable-control { + display: inline-flex; + min-width: 62px; + min-height: 26px; + align-items: center; + justify-content: center; + border: 1px solid #7392b2; + border-radius: 6px; + background: #ffffff; + box-shadow: 0 0 0 4px rgba(39, 128, 216, 0.13); + color: #1769aa; + font-size: 11px; + font-weight: 850; + animation: finder-enable-pulse 1.7s ease-in-out infinite; +} + +.finder-enable-guide.complete { + display: flex; + align-items: center; + gap: 10px; + min-height: 58px; + padding: 10px 0; + color: var(--green); +} + +.finder-enable-guide.complete > svg { + width: 24px; + height: 24px; +} + +.finder-enable-guide.complete span { + display: grid; + gap: 2px; +} + +.finder-enable-guide.complete strong { + color: var(--ink); + font-size: 13px; +} + +.finder-enable-guide.complete small { + color: var(--muted); + font-size: 12px; +} + +.finder-enable-help { + display: grid; + gap: 4px; + max-width: 560px; + padding-left: 12px; + border-left: 3px solid var(--accent); + color: var(--muted); + font-size: 12px; + line-height: 1.5; +} + +.finder-enable-help strong { + color: var(--ink); +} + +.source-file-provider-recovery { + display: grid; + gap: 14px; + width: min(560px, 100%); + margin: 8px auto 0; + padding: 8px 0 18px; +} + +.source-file-provider-recovery h3 { + margin-top: 4px; + color: var(--ink); + font-size: 22px; +} + +.source-file-provider-recovery p:not(.label) { + margin-top: 6px; + color: var(--muted); + font-size: 13px; + line-height: 1.5; +} + +.source-file-provider-recovery .secondary-button { + justify-self: start; +} + +[data-theme="dark"] .finder-enable-illustration { + border-color: rgba(163, 183, 190, 0.24); + background: #253237; + box-shadow: 0 14px 32px rgba(0, 0, 0, 0.28); + color: #eef7f4; +} + +[data-theme="dark"] .finder-enable-toolbar, +[data-theme="dark"] .finder-enable-sidebar { + border-color: rgba(163, 183, 190, 0.18); + background: #303e43; +} + +[data-theme="dark"] .finder-enable-sidebar small { + color: #adbbb8; +} + +[data-theme="dark"] .finder-enable-control { + border-color: #65a7dc; + background: #1e2a2f; + color: #8dc8f2; +} + +@keyframes finder-enable-pulse { + 50% { + box-shadow: 0 0 0 7px rgba(39, 128, 216, 0.08); + } +} + +@media (prefers-reduced-motion: reduce) { + .finder-enable-control { + animation: none; + } +} + .sync-note { display: inline-flex; align-items: center; From 287231b3bd7654e420f4f9461c46c548bb472f73 Mon Sep 17 00:00:00 2001 From: aseembits93 Date: Thu, 16 Jul 2026 15:21:35 -0700 Subject: [PATCH 04/11] docs: describe Finder enablement onboarding --- docs/desktop-app.md | 14 ++++++++++---- docs/e2e-behavior-coverage.md | 10 ++++++---- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/docs/desktop-app.md b/docs/desktop-app.md index e84fe688..1d26b170 100644 --- a/docs/desktop-app.md +++ b/docs/desktop-app.md @@ -169,10 +169,16 @@ The app can track setup internally with human concepts: On macOS, this step is a blocked File Provider gate. If the Locality File Provider is registered but not yet enabled, the onboarding screen stays on step -4, offers an `Allow in macOS` action, and opens Finder at the Locality File -Provider root when possible. If macOS has accepted approval but has not yet -materialized `~/Library/CloudStorage/Locality`, the screen remains blocked with -`Check again` until the folder exists and the mount root passes verification. +4 and opens Finder at the Locality File Provider root when possible. The screen +shows the Finder `Enable` control, checks `NSFileProviderDomain.userEnabled` +automatically, and offers `Reopen Finder` without requiring a confirmation or +`Check again` action. Once enabled, Locality keeps checking until macOS +materializes `~/Library/CloudStorage/Locality`, then retries mount creation once +and continues automatically. + +The same readiness probe and automatic retry handle a disabled File Provider +encountered during later source setup. Slow approval remains a guided waiting +state; missing helpers or extensions remain explicit setup failures. The final ready screen must not appear until File Provider approval, the CloudStorage root, and the mount root are all verified successfully. diff --git a/docs/e2e-behavior-coverage.md b/docs/e2e-behavior-coverage.md index 6d90d9d7..ce04def5 100644 --- a/docs/e2e-behavior-coverage.md +++ b/docs/e2e-behavior-coverage.md @@ -312,7 +312,7 @@ Coverage labels: | Media download/upload | Covered for common file-like media | Live tests download image, video, file, PDF, and audio assets locally; upload edited image bytes; and append local video, PDF, audio, and HTML uploads while verifying local links after reconciliation. Local e2e verifies `.loc/media` manifest writing, missing-file repair, and stale-cache pruning after accepted remote projections remove media references. | | Mounted workflow | Good for core, partial for platform kernels | Live tests cover plain-file mounted workflow through library and real `loc` binary paths, virtual filesystem lazy paths, Linux FUSE against live Notion, desktop Live Mode bidirectional sync against live Notion, Windows Cloud Files registration, and a macOS File Provider conflict-marker cache/visible-replica path. Local e2e now verifies shared create/rename/delete status and diff semantics for macOS File Provider, Linux FUSE, and Windows Cloud Files projection modes, plus visible-replica conflict materialization for macOS File Provider and Windows Cloud Files below their OS adapters. Broader macOS File Provider create/rename/delete coverage remains open. | | Shared root mount points | Covered by Linux FUSE smoke, macOS File Provider domain children tests, Windows Cloud Files live test, and local e2e shared-root status coverage across macOS File Provider, Linux FUSE, and Windows Cloud Files modes | Verifies one Locality root exposes multiple mount-point folders mapped to distinct mount IDs/connectors, and that CLI status scopes correctly at the shared root and individual mount-point directories. | -| Desktop onboarding/tray/review UI | Partial live | Live Mode sync semantics are covered through the desktop tick loop against a live Notion scratch page and temp plain-file mount, and local e2e verifies daemon auto-save applies safe edits while blocking destructive plans before journal/apply; onboarding, tray, and review UI automation remain manual. | +| Desktop onboarding/tray/review UI | Partial live | File Provider enablement has typed Rust state tests, frontend polling/UI contract tests, and a guarded fresh-bundle-ID installer for manually exercising Finder's real `Enable` control. Live Mode sync semantics are covered through the desktop tick loop against a live Notion scratch page and temp plain-file mount, and local e2e verifies daemon auto-save applies safe edits while blocking destructive plans before journal/apply; signed Finder interaction and broader onboarding/tray/review UI automation remain manual. | | CLI diagnostics and local recovery | Covered live for plain-file mounted workflow and local virtual projection workflow | The stored-credential live binary workflow covers `info`, `doctor`, and `restore` against a scratch Notion-backed mount with token env removed. Local e2e coverage verifies virtual projection restore rewrites the daemon content cache and returns status clean, verifies plain-file `status`, `info`, `search`, `log`, `diff`, `restore`, and prepared-journal `undo` work from local state/shadow even when the stored credential is missing, verifies `connections`, `connection show`, and `disconnect` do not expose or recreate missing credential material, and verifies missing stored credentials make mount setup, `doctor`, `pull`, `push`, and `inspect` report reconnect guidance without leaking `secret_ref`, writing mount state, writing remote files locally, mutating inspected files, or creating a push journal. | | Agent guidance | Covered locally | Mount tests and local e2e coverage verify generated `AGENTS.md`/`CLAUDE.md` guidance is concise, includes Live Mode and filesystem workflow instructions, preserves custom guidance, and does not appear as synced dirty content after pull/status. | | Read-only mount policy | Covered live for plain-file mounted workflow and local shared virtual projections | The stored-credential live binary workflow verifies a read-only scratch mount blocks local push planning before journal/remote writes and leaves Notion unchanged. Local e2e verifies read-only macOS File Provider, Linux FUSE, and Windows Cloud Files virtual projection modes reject write/create/rename/delete attempts without dirtying local state. | @@ -331,9 +331,11 @@ locate coverage. create/rename/delete through signed macOS File Provider locally or on a dedicated Mac. Linux FUSE is covered by the live Notion workflow on runners where `/dev/fuse` and live Notion credentials are available. -2. **Desktop app smoke e2e**: run the Tauri app UI against a disposable state - dir and fake/live backend, verify onboarding reset, pending changes refresh, - non-blocking push, success confirmation, tray state, and low idle CPU. +2. **Desktop app smoke e2e**: use `make install-macos-prompt-test-app` for the + signed fresh File Provider identity, then verify Finder enablement advances + automatically. Extend that path with disposable app state to cover onboarding + reset, pending changes refresh, non-blocking push, success confirmation, tray + state, and low idle CPU. 3. **OAuth broker smoke**: use a test Notion integration and broker deployment to verify the local client receives/stores only the broker refresh handle. 4. **Google Docs live e2e**: use a test Google OAuth integration and scratch From 625cf856e1cfda7e1f817751eb484bc57454a7e8 Mon Sep 17 00:00:00 2001 From: aseembits93 Date: Thu, 16 Jul 2026 15:33:34 -0700 Subject: [PATCH 05/11] fix: isolate fresh Finder enablement tests --- apps/desktop/src/App.tsx | 10 +++- .../src/file-provider-enablement-ui.test.js | 1 + apps/desktop/src/styles.css | 48 +++++++++++++------ scripts/install-macos-prompt-test-app.sh | 1 + scripts/install-macos-prompt-test-app.test.sh | 1 + 5 files changed, 45 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index fe08d1b6..6c11f6c8 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -7671,9 +7671,15 @@ function FinderEnableGuide({ waitingForRoot }: { waitingForRoot: boolean }) {
- - Locality +

+ "Locality" is not enabled. To access Locality, click Enable. +

Enable +
+ + + +
diff --git a/apps/desktop/src/file-provider-enablement-ui.test.js b/apps/desktop/src/file-provider-enablement-ui.test.js index 180a8da0..745ecc6f 100644 --- a/apps/desktop/src/file-provider-enablement-ui.test.js +++ b/apps/desktop/src/file-provider-enablement-ui.test.js @@ -8,6 +8,7 @@ describe("Finder enablement checkpoint", () => { it("renders the Finder cue and recovery actions", () => { expect(app).toContain('className="finder-enable-guide"'); expect(app).toContain('className="finder-enable-control"'); + expect(app).toContain('"Locality" is not enabled.'); expect(app).toContain("Reopen Finder"); expect(app).toContain("Having trouble?"); }); diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 86ff550d..c0d11e1d 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -599,24 +599,22 @@ p { .finder-enable-content { display: grid; - grid-template-columns: 34px minmax(0, 1fr) auto; - align-items: center; - gap: 10px; - padding: 18px 22px; + grid-template-columns: minmax(0, 1fr) auto; + align-content: start; + align-items: start; + gap: 14px; + padding: 16px; } -.finder-enable-content > svg { - width: 30px; - height: 30px; - color: #4c7cae; +.finder-enable-content > p { + min-width: 0; + color: #5e6c7b; + font-size: 10px; + line-height: 1.45; } -.finder-enable-content > strong { - min-width: 0; - overflow: hidden; - font-size: 13px; - text-overflow: ellipsis; - white-space: nowrap; +.finder-enable-content > p strong { + color: #384858; } .finder-enable-control { @@ -635,6 +633,20 @@ p { animation: finder-enable-pulse 1.7s ease-in-out infinite; } +.finder-enable-placeholders { + display: grid; + grid-column: 1 / -1; + gap: 8px; +} + +.finder-enable-placeholders i { + display: block; + width: 100%; + height: 10px; + border-radius: 4px; + background: rgba(105, 123, 143, 0.1); +} + .finder-enable-guide.complete { display: flex; align-items: center; @@ -721,6 +733,14 @@ p { color: #adbbb8; } +[data-theme="dark"] .finder-enable-content > p { + color: #b8c6c3; +} + +[data-theme="dark"] .finder-enable-content > p strong { + color: #eef7f4; +} + [data-theme="dark"] .finder-enable-control { border-color: #65a7dc; background: #1e2a2f; diff --git a/scripts/install-macos-prompt-test-app.sh b/scripts/install-macos-prompt-test-app.sh index 5d1ccb60..6ed63c0e 100755 --- a/scripts/install-macos-prompt-test-app.sh +++ b/scripts/install-macos-prompt-test-app.sh @@ -322,6 +322,7 @@ install_bundle() { run pluginkit -a "${appex}" run codesign --verify --deep --strict --verbose=2 "${APP_PATH}" run pluginkit -m -v -i "${extension_bundle_id}" + run "${APP_PATH}/Contents/MacOS/locality-file-providerctl" register --mount-id loc --display-name "${DISPLAY_NAME}" --json run_may_fail "${APP_PATH}/Contents/MacOS/locality-file-providerctl" --json list if [[ "${LAUNCH}" == "1" ]]; then diff --git a/scripts/install-macos-prompt-test-app.test.sh b/scripts/install-macos-prompt-test-app.test.sh index a57de2b4..0a06e044 100755 --- a/scripts/install-macos-prompt-test-app.test.sh +++ b/scripts/install-macos-prompt-test-app.test.sh @@ -82,6 +82,7 @@ test_dry_run_plans_fresh_prompt_test_app_installation() { assert_contains "${output}" "LocalityFileProvider.entitlements" assert_contains "${output}" "LocalityFileProvider.appex" assert_contains "${output}" "pluginkit -a" + assert_contains "${output}" "locality-file-providerctl register --mount-id loc --display-name Locality\\ Prompt\\ Test --json" assert_not_contains "${output}" "open -a" } From 6653fbdb1ae244418098b5cc8fa8c12cec54f6df Mon Sep 17 00:00:00 2001 From: aseembits93 Date: Thu, 16 Jul 2026 15:35:08 -0700 Subject: [PATCH 06/11] fix: report disabled File Provider root --- apps/desktop/src-tauri/src/main.rs | 63 ++++++++++++++----- .../LocalityFileProviderCtl/main.swift | 7 +++ 2 files changed, 56 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index bc526b97..0a79c6b8 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -10223,32 +10223,46 @@ fn classify_macos_file_provider_enablement( } } +fn macos_file_provider_domain_status( + report: &serde_json::Value, +) -> (Option, Option) { + let domain = report + .get("domains") + .and_then(serde_json::Value::as_array) + .and_then(|domains| { + domains.iter().find(|domain| { + domain.get("identifier").and_then(serde_json::Value::as_str) + == Some(localityd::file_provider::MACOS_FILE_PROVIDER_DOMAIN_ID) + }) + }); + let user_enabled = domain + .and_then(|domain| domain.get("userEnabled")) + .and_then(serde_json::Value::as_bool); + let path = domain + .and_then(|domain| domain.get("url")) + .and_then(serde_json::Value::as_str) + .filter(|url| !url.is_empty()) + .map(PathBuf::from); + (user_enabled, path) +} + #[cfg(target_os = "macos")] fn macos_file_provider_enablement_status_blocking() -> FileProviderEnablementReport { let provider_roots = macos_file_provider_cloud_storage_roots(); - let fallback_path = provider_roots.first().cloned(); let report = match run_macos_file_provider_helper("list", Vec::new()) { Ok(report) => report, Err(error) => { return FileProviderEnablementReport { state: "unavailable".to_string(), message: error.message(), - path: fallback_path.map(|path| path.display().to_string()), + path: provider_roots + .first() + .map(|path| path.display().to_string()), }; } }; - let user_enabled = report - .helper_report - .get("domains") - .and_then(serde_json::Value::as_array) - .and_then(|domains| { - domains.iter().find(|domain| { - domain.get("identifier").and_then(serde_json::Value::as_str) - == Some(localityd::file_provider::MACOS_FILE_PROVIDER_DOMAIN_ID) - }) - }) - .and_then(|domain| domain.get("userEnabled")) - .and_then(serde_json::Value::as_bool); + let (user_enabled, reported_path) = macos_file_provider_domain_status(&report.helper_report); + let fallback_path = reported_path.or_else(|| provider_roots.first().cloned()); let resolved_root = if user_enabled == Some(true) { match macos_file_provider_domain_url( @@ -13684,6 +13698,27 @@ mod tests { ); } + #[test] + fn file_provider_domain_status_reads_helper_url_while_disabled() { + let helper_report = serde_json::json!({ + "domains": [{ + "identifier": "loc", + "userEnabled": false, + "url": "/Users/test/Library/CloudStorage/LocalityPromptTest" + }] + }); + + let (user_enabled, path) = super::macos_file_provider_domain_status(&helper_report); + + assert_eq!(user_enabled, Some(false)); + assert_eq!( + path, + Some(PathBuf::from( + "/Users/test/Library/CloudStorage/LocalityPromptTest" + )) + ); + } + #[test] fn file_provider_enablement_report_waits_for_enabled_root() { let report = super::classify_macos_file_provider_enablement( diff --git a/platform/macos/LocalityFileProvider/Sources/LocalityFileProviderCtl/main.swift b/platform/macos/LocalityFileProvider/Sources/LocalityFileProviderCtl/main.swift index 5d49def3..ccc7186f 100644 --- a/platform/macos/LocalityFileProvider/Sources/LocalityFileProviderCtl/main.swift +++ b/platform/macos/LocalityFileProvider/Sources/LocalityFileProviderCtl/main.swift @@ -273,6 +273,7 @@ private struct DomainReport: Encodable { let userEnabled: Bool let disconnected: Bool let hidden: Bool + let url: String? init(_ domain: NSFileProviderDomain) { self.identifier = domain.identifier.rawValue @@ -280,6 +281,12 @@ private struct DomainReport: Encodable { self.userEnabled = domain.userEnabled self.disconnected = domain.isDisconnected self.hidden = domain.isHidden + let managerURL = try? userVisibleDomainURLFromManager(for: domain) + let fallbackURL = realHomeDirectoryURL() + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("CloudStorage", isDirectory: true) + .appendingPathComponent(fileProviderDirectoryName(for: domain.displayName), isDirectory: true) + self.url = (managerURL ?? fallbackURL).path } } From 4e9256718268348e4c7bb884579be5a801dcc0e2 Mon Sep 17 00:00:00 2001 From: aseembits93 Date: Thu, 16 Jul 2026 17:05:09 -0700 Subject: [PATCH 07/11] docs: design Finder recovery review fixes --- ...der-source-recovery-review-fixes-design.md | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-16-finder-source-recovery-review-fixes-design.md diff --git a/docs/superpowers/specs/2026-07-16-finder-source-recovery-review-fixes-design.md b/docs/superpowers/specs/2026-07-16-finder-source-recovery-review-fixes-design.md new file mode 100644 index 00000000..c2de5f88 --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-finder-source-recovery-review-fixes-design.md @@ -0,0 +1,90 @@ +# Finder Source Recovery Review Fixes Design + +## Goal + +Make later-source File Provider recovery deterministic across dialog close, +automatic mount retry, and React effect cleanup, while ensuring the macOS prompt +test installer honors an explicitly selected DMG. + +## Recovery Ownership + +`MountsView` owns File Provider recovery. Dialog visibility is presentation +state and does not control the lifetime of a pending recovery operation. + +When the user closes the Add Source dialog during recovery, Locality hides the +dialog but retains the pending mount request, File Provider readiness report, +and `creating` setup state. The readiness poll continues in the background, as +promised by the existing “Setup continues if you close this window” copy. If the +dialog is reopened while recovery is pending, it shows the same guided recovery +state. Recovery ends only in a terminal mount result or when `MountsView` +unmounts. + +## Automatic Mount Retry + +When File Provider readiness reaches `ready`, Locality performs the existing +single delayed mount retry. + +- A successful mount clears the pending retry and readiness report, then shows + the mount success message. +- A mount that still reports the disabled-provider condition re-enters the + readiness flow through the existing recovery entry point. +- Any other failed mount clears the pending retry and readiness report, changes + the source dialog to `error`, and displays the mount report message in the + dialog. This returns source actions to an enabled state instead of leaving the + dialog indefinitely busy. + +The implementation will keep this result handling in one focused helper or +branch so success, repeated provider disablement, and terminal errors remain +mutually exclusive. + +## Poller Cancellation + +Each active poller run has a generation identifier. `stop()` invalidates the +active generation in addition to clearing its timer. After `probe()` settles, +the poller verifies that the captured generation is still active before calling +`onReport`, calling `onReady`, changing failure backoff, or scheduling work for +that result. + +This generation check handles both cleanup without restart and a stop/start +sequence in which an older probe resolves after a newer run has begun. A newer +run may wait for the already in-flight probe to settle, but it never receives +the old run’s report or ready callback. + +## Installer Source Precedence + +The prompt-test installer resolves sources in this order: + +1. Explicit `--source-app` or `LOCALITY_PROMPT_TEST_SOURCE_APP`. +2. Explicit `--dmg` or `LOCALITY_PROMPT_TEST_DMG`. +3. The normal built Tauri app bundle. +4. The newest default `Locality_*.dmg`. + +An explicit DMG is therefore mounted even when +`target/release/bundle/macos/Locality.app` exists. Existing path validation, +dry-run behavior, target safety checks, and source-app priority remain +unchanged. + +## Testing + +Follow red-green TDD for each behavior: + +- Add deferred-probe tests proving `stop()` drops a late non-ready report and a + late ready report, including stop/start generation invalidation. +- Add source recovery contract coverage proving close preserves the pending + flow and a non-provider mount failure becomes a visible terminal dialog + error. Use extracted behavior where practical; any source-level UI contract + assertion must match the complete relevant handler rather than isolated + substrings. +- Add an installer dry-run test using an isolated temporary repository layout + containing both a built app directory and an explicit fake DMG. Assert that + the DMG is attached and the built app is not selected. +- Run the focused Vitest and shell tests after each fix, then the complete + desktop frontend test suite, installer helper suite, TypeScript production + build, and formatting or lint checks relevant to the changed files. + +## Scope + +This change does not alter onboarding recovery, add retry limits, change File +Provider readiness states, redesign the Add Source dialog, or modify installer +signing and registration behavior. It only corrects the four reviewed lifecycle +and source-selection regressions. From f8baa5abd0498ee933f33f81e3c58049b74edae5 Mon Sep 17 00:00:00 2001 From: aseembits93 Date: Thu, 16 Jul 2026 17:12:13 -0700 Subject: [PATCH 08/11] docs: plan Finder recovery review fixes --- ...-16-finder-source-recovery-review-fixes.md | 489 ++++++++++++++++++ 1 file changed, 489 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-16-finder-source-recovery-review-fixes.md diff --git a/docs/superpowers/plans/2026-07-16-finder-source-recovery-review-fixes.md b/docs/superpowers/plans/2026-07-16-finder-source-recovery-review-fixes.md new file mode 100644 index 00000000..1d80bf0d --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-finder-source-recovery-review-fixes.md @@ -0,0 +1,489 @@ +# Finder Source Recovery Review Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Eliminate the four reviewed Finder recovery and prompt-test installer regressions without changing the intended onboarding experience. + +**Architecture:** Keep source recovery owned by `MountsView` independently of dialog visibility, express automatic retry outcomes with a small pure source-setup helper, and invalidate stopped poller generations before they can publish asynchronous results. Resolve installer inputs with explicit source options ahead of discovered build artifacts. + +**Tech Stack:** React 18, TypeScript, Vitest, Bash, macOS File Provider test harness + +--- + +## File Structure + +- `apps/desktop/src/file-provider-enablement.ts`: generation-safe readiness poller. +- `apps/desktop/src/file-provider-enablement.test.ts`: asynchronous stop and restart regressions. +- `apps/desktop/src/source-setup.ts`: pure classification of automatic mount retry outcomes. +- `apps/desktop/src/source-setup.test.ts`: success, repeated-disablement, and terminal-error outcome tests. +- `apps/desktop/src/App.tsx`: dialog visibility and recovery lifecycle integration. +- `apps/desktop/src/file-provider-enablement-ui.test.js`: exact Add Source close-handler contract. +- `scripts/install-macos-prompt-test-app.sh`: explicit input and discovered artifact precedence. +- `scripts/install-macos-prompt-test-app.test.sh`: isolated dry-run source-selection regression. + +### Task 1: Drop Results From Stopped Poller Generations + +**Files:** +- Modify: `apps/desktop/src/file-provider-enablement.test.ts` +- Modify: `apps/desktop/src/file-provider-enablement.ts` + +- [ ] **Step 1: Add the failing late-result tests** + +Add these tests inside `describe("File Provider enablement polling", ...)`: + +```ts + it("drops a non-ready report that resolves after the poller stops", async () => { + vi.useFakeTimers(); + let resolveProbe: ((value: FileProviderEnablementReport) => void) | undefined; + const seen: string[] = []; + const poller = createFileProviderEnablementPoller({ + probe: () => new Promise((resolve) => { + resolveProbe = resolve; + }), + onReport: (next) => seen.push(next.state), + onReady: () => undefined, + }); + + poller.start(); + await vi.advanceTimersByTimeAsync(0); + poller.stop(); + resolveProbe?.(report("needs_finder_enable")); + await vi.advanceTimersByTimeAsync(0); + + expect(seen).toEqual([]); + }); + + it("drops a ready result from an older run after stop and restart", async () => { + vi.useFakeTimers(); + let resolveFirstProbe: ((value: FileProviderEnablementReport) => void) | undefined; + let calls = 0; + const seen: string[] = []; + let completions = 0; + const poller = createFileProviderEnablementPoller({ + probe: () => { + calls += 1; + if (calls === 1) { + return new Promise((resolve) => { + resolveFirstProbe = resolve; + }); + } + return Promise.resolve(report("needs_finder_enable")); + }, + onReport: (next) => seen.push(next.state), + onReady: () => { + completions += 1; + }, + }); + + poller.start(); + await vi.advanceTimersByTimeAsync(0); + poller.stop(); + poller.start(); + resolveFirstProbe?.(report("ready")); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(1_000); + + expect(seen).toEqual(["needs_finder_enable"]); + expect(completions).toBe(0); + expect(calls).toBe(2); + }); +``` + +- [ ] **Step 2: Run the focused tests and verify RED** + +Run: + +```bash +cd apps/desktop +npm test -- --run src/file-provider-enablement.test.ts +``` + +Expected: the new tests fail because the first stopped run still calls +`onReport`, and its ready result still calls `onReady` after restart. + +- [ ] **Step 3: Invalidate and check poller generations** + +In `createFileProviderEnablementPoller`, add `let generation = 0;` beside the +other lifecycle fields. Capture it before awaiting the probe and reject stale +results in both resolution paths: + +```ts + async function poll() { + if (!running || !visible || inFlight) { + return; + } + const pollGeneration = generation; + inFlight = true; + try { + const next = await options.probe(); + if (!running || generation !== pollGeneration) { + return; + } + transientFailures = 0; + options.onReport(next); + if (next.state === "ready") { + running = false; + options.onReady(next); + return; + } + if (next.state === "unavailable") { + running = false; + return; + } + schedule(1_000); + } catch { + if (!running || generation !== pollGeneration) { + return; + } + transientFailures += 1; + schedule(Math.min(1_000 * 2 ** (transientFailures - 1), 5_000)); + } finally { + inFlight = false; + if (running && visible && timer === null) { + const delay = transientFailures === 0 + ? 1_000 + : Math.min(1_000 * 2 ** (transientFailures - 1), 5_000); + schedule(delay); + } + } + } +``` + +Advance the generation for each new run and every stop: + +```ts + start: () => { + if (running) { + return; + } + generation += 1; + running = true; + schedule(0); + }, + stop: () => { + generation += 1; + running = false; + clearTimer(); + }, +``` + +- [ ] **Step 4: Run the focused tests and verify GREEN** + +Run: + +```bash +cd apps/desktop +npm test -- --run src/file-provider-enablement.test.ts +``` + +Expected: all File Provider enablement tests pass, including both late-result +regressions. + +- [ ] **Step 5: Commit the poller fix** + +```bash +git add apps/desktop/src/file-provider-enablement.ts apps/desktop/src/file-provider-enablement.test.ts +git commit -m "fix: discard stopped File Provider probes" +``` + +### Task 2: Make Source Recovery Close And Retry Outcomes Terminally Consistent + +**Files:** +- Modify: `apps/desktop/src/source-setup.test.ts` +- Modify: `apps/desktop/src/source-setup.ts` +- Modify: `apps/desktop/src/file-provider-enablement-ui.test.js` +- Modify: `apps/desktop/src/App.tsx` + +- [ ] **Step 1: Add failing automatic-retry outcome tests** + +Import `sourceMountRetryOutcome` in `source-setup.test.ts`, then add: + +```ts +describe("source File Provider mount retry", () => { + it("completes a successful automatic mount retry", () => { + expect(sourceMountRetryOutcome({ ok: true, message: "Mounted Notion." })).toEqual({ + kind: "success", + message: "Mounted Notion.", + }); + }); + + it("continues recovery when File Provider is still disabled", () => { + expect(sourceMountRetryOutcome({ + ok: false, + message: "The Locality File Provider is registered but not enabled.", + })).toEqual({ kind: "retry" }); + }); + + it("turns another automatic mount failure into a visible dialog error", () => { + expect(sourceMountRetryOutcome({ + ok: false, + message: "Could not load the top-level Notion folder.", + })).toEqual({ + kind: "error", + message: "Could not load the top-level Notion folder.", + }); + }); +}); +``` + +- [ ] **Step 2: Tighten the Add Source close-handler contract** + +Replace the broad later-source recovery assertion in +`file-provider-enablement-ui.test.js` with a full handler assertion: + +```js + it("keeps later source recovery running when its dialog closes", () => { + const addSourceDialog = app.match( + / \{([\s\S]*?)\}\}\s*\/>/, + ); + + expect(addSourceDialog?.[1].trim()).toBe("setSourceDialogOpen(false);"); + }); +``` + +Keep a separate integration-contract assertion that the component receives the +recovery report: + +```js + it("passes later source recovery state into the guided dialog", () => { + expect(app).toContain("fileProviderEnablement={sourceFileProviderEnablement}"); + }); +``` + +- [ ] **Step 3: Run the focused tests and verify RED** + +Run: + +```bash +cd apps/desktop +npm test -- --run src/source-setup.test.ts src/file-provider-enablement-ui.test.js +``` + +Expected: `sourceMountRetryOutcome` is missing, and the close-handler assertion +reports the existing pending-retry and readiness-state cancellation calls. + +- [ ] **Step 4: Implement the pure retry outcome** + +Add this import and helper to `source-setup.ts`: + +```ts +import { classifyMountSetupError } from "./onboarding-errors"; + +export type SourceMountRetryOutcome = + | { kind: "retry" } + | { kind: "success" | "error"; message: string }; + +export function sourceMountRetryOutcome( + report: { ok: boolean; message: string }, +): SourceMountRetryOutcome { + if (report.ok) { + return { kind: "success", message: report.message }; + } + if (classifyMountSetupError(report.message).kind === "file-provider-disabled") { + return { kind: "retry" }; + } + return { kind: "error", message: report.message }; +} +``` + +- [ ] **Step 5: Apply the retry outcome in `MountsView`** + +Import `sourceMountRetryOutcome` from `./source-setup`. Replace the success-only +completion branch inside the delayed automatic retry with: + +```ts + const outcome = sourceMountRetryOutcome(mountReport); + if (outcome.kind === "retry") { + return; + } + setPendingMountRetry(null); + setSourceFileProviderEnablement(null); + setSourceDialogMessage(outcome.message); + setSourceDialogState(outcome.kind); +``` + +This relies on `createConnectorMount` to have already called +`beginSourceFileProviderRecovery` for the repeated disabled-provider result. +For success and terminal error, it clears recovery and makes the result visible. + +- [ ] **Step 6: Make dialog close visibility-only** + +Replace the Add Source dialog close callback with: + +```tsx + onClose={() => { + setSourceDialogOpen(false); + }} +``` + +Do not clear `pendingMountRetry`, `sourceFileProviderEnablement`, +`sourceDialogState`, or the active connector in this callback. + +- [ ] **Step 7: Run the focused tests and verify GREEN** + +Run: + +```bash +cd apps/desktop +npm test -- --run src/source-setup.test.ts src/file-provider-enablement-ui.test.js +``` + +Expected: all source retry outcome and exact close-handler tests pass. + +- [ ] **Step 8: Commit the source recovery fix** + +```bash +git add apps/desktop/src/App.tsx apps/desktop/src/source-setup.ts apps/desktop/src/source-setup.test.ts apps/desktop/src/file-provider-enablement-ui.test.js +git commit -m "fix: keep Finder source recovery consistent" +``` + +### Task 3: Honor An Explicit Prompt-Test DMG + +**Files:** +- Modify: `scripts/install-macos-prompt-test-app.test.sh` +- Modify: `scripts/install-macos-prompt-test-app.sh` + +- [ ] **Step 1: Add an isolated source-precedence regression test** + +Add this test before `main()` in `install-macos-prompt-test-app.test.sh`: + +```bash +test_dry_run_prefers_explicit_dmg_over_built_app() { + local tmp isolated_root isolated_script built_app dmg app output + tmp="$(mktemp -d)" + trap '[[ -z "${tmp:-}" ]] || rm -rf "${tmp}"' RETURN + isolated_root="${tmp}/repo" + isolated_script="${isolated_root}/scripts/install-macos-prompt-test-app.sh" + built_app="${isolated_root}/target/release/bundle/macos/Locality.app" + dmg="${tmp}/explicit.dmg" + app="${tmp}/Applications/Locality Prompt Test.app" + mkdir -p "$(dirname "${isolated_script}")" "${built_app}" + cp "${SCRIPT}" "${isolated_script}" + touch "${dmg}" + + output="$( + LOCALITY_PROMPT_TEST_TIMESTAMP=20260714130007 \ + "${isolated_script}" \ + --dry-run \ + --dmg "${dmg}" \ + --app-path "${app}" \ + --signing-identity "Developer ID Application: Test (TEAMID)" \ + --no-launch + )" + + assert_contains "${output}" "+ hdiutil attach ${dmg}" + assert_not_contains "${output}" "source app: ${built_app}" +} +``` + +Add `test_dry_run_prefers_explicit_dmg_over_built_app` to `main()`. + +- [ ] **Step 2: Run the installer helper test and verify RED** + +Run: + +```bash +bash scripts/install-macos-prompt-test-app.test.sh +``` + +Expected: the new test fails with a missing `hdiutil attach` line because the +isolated built app is selected before the explicit DMG. + +- [ ] **Step 3: Put explicit DMG selection before built-app discovery** + +Restructure `resolve_source_app()` after the explicit `SOURCE_APP` branch: + +```bash + if [[ -z "${DMG}" ]]; then + local bundle_app="${ROOT}/target/release/bundle/macos/Locality.app" + if [[ -d "${bundle_app}" ]]; then + SOURCE_APP="${bundle_app}" + validate_source_and_target_paths + return 0 + fi + + DMG="$(default_dmg)" || fail "missing DMG under ${DEFAULT_DMG_DIR}. Run make build-tauri first or pass --dmg PATH." + DMG="$(expand_tilde "${DMG}")" + fi + [[ -f "${DMG}" ]] || fail "missing DMG: ${DMG}. Run make build-tauri first or pass --dmg PATH." +``` + +Keep the existing DMG mount and `Locality.app` validation code unchanged after +this block. Explicit `SOURCE_APP` remains the first branch, while a non-empty +explicit `DMG` now bypasses built-app discovery. + +- [ ] **Step 4: Run the installer helper test and verify GREEN** + +Run: + +```bash +bash scripts/install-macos-prompt-test-app.test.sh +``` + +Expected: `install-macos-prompt-test-app helper tests passed`. + +- [ ] **Step 5: Commit the installer fix** + +```bash +git add scripts/install-macos-prompt-test-app.sh scripts/install-macos-prompt-test-app.test.sh +git commit -m "fix: honor explicit prompt-test DMG" +``` + +### Task 4: Verify The Review Fixes Together + +**Files:** +- Verify: `apps/desktop/src/App.tsx` +- Verify: `apps/desktop/src/file-provider-enablement.ts` +- Verify: `apps/desktop/src/source-setup.ts` +- Verify: `scripts/install-macos-prompt-test-app.sh` + +- [ ] **Step 1: Run all desktop frontend tests** + +```bash +cd apps/desktop +npm test -- --run +``` + +Expected: the complete Vitest suite passes with zero failures. + +- [ ] **Step 2: Run the production frontend build** + +```bash +cd apps/desktop +npm run build +``` + +Expected: TypeScript compilation and the Vite production build exit zero. + +- [ ] **Step 3: Run the installer helper through its Make target** + +```bash +make test-macos-prompt-test-app-installer +``` + +Expected: `install-macos-prompt-test-app helper tests passed`. + +- [ ] **Step 4: Check formatting and the final patch** + +```bash +cargo fmt --all -- --check +git diff --check HEAD~3..HEAD +git status --short +``` + +Expected: formatting and diff checks exit zero. The worktree has no uncommitted +changes, and the three implementation commits contain only the planned files. + +- [ ] **Step 5: Re-read the four review requirements** + +Confirm directly from the final code and tests: + +1. Closing the source dialog no longer cancels recovery or leaves a canceled + `creating` state. +2. Every automatic mount report reaches success, renewed provider recovery, or + a visible terminal error. +3. A stopped or superseded poll generation cannot call `onReport` or `onReady`. +4. Explicit `--dmg` selection bypasses an existing default built app. + +No additional commit is needed unless verification uncovers a defect; any such +defect must restart the relevant red-green task before completion is claimed. From 6aee0346ea9dd32ef8a41dbfaadb0ebe2127b761 Mon Sep 17 00:00:00 2001 From: aseembits93 Date: Thu, 16 Jul 2026 17:17:16 -0700 Subject: [PATCH 09/11] fix: discard stopped File Provider probes --- .../src/file-provider-enablement.test.ts | 56 +++++++++++++++++++ apps/desktop/src/file-provider-enablement.ts | 10 ++++ 2 files changed, 66 insertions(+) diff --git a/apps/desktop/src/file-provider-enablement.test.ts b/apps/desktop/src/file-provider-enablement.test.ts index 51de691d..a97ac339 100644 --- a/apps/desktop/src/file-provider-enablement.test.ts +++ b/apps/desktop/src/file-provider-enablement.test.ts @@ -65,6 +65,62 @@ describe("File Provider enablement polling", () => { expect(calls).toBe(2); }); + it("drops a non-ready report that resolves after the poller stops", async () => { + vi.useFakeTimers(); + let resolveProbe: ((value: FileProviderEnablementReport) => void) | undefined; + const seen: string[] = []; + const poller = createFileProviderEnablementPoller({ + probe: () => new Promise((resolve) => { + resolveProbe = resolve; + }), + onReport: (next) => seen.push(next.state), + onReady: () => undefined, + }); + + poller.start(); + await vi.advanceTimersByTimeAsync(0); + poller.stop(); + resolveProbe?.(report("needs_finder_enable")); + await vi.advanceTimersByTimeAsync(0); + + expect(seen).toEqual([]); + }); + + it("drops a ready result from an older run after stop and restart", async () => { + vi.useFakeTimers(); + let resolveFirstProbe: ((value: FileProviderEnablementReport) => void) | undefined; + let calls = 0; + const seen: string[] = []; + let completions = 0; + const poller = createFileProviderEnablementPoller({ + probe: () => { + calls += 1; + if (calls === 1) { + return new Promise((resolve) => { + resolveFirstProbe = resolve; + }); + } + return Promise.resolve(report("needs_finder_enable")); + }, + onReport: (next) => seen.push(next.state), + onReady: () => { + completions += 1; + }, + }); + + poller.start(); + await vi.advanceTimersByTimeAsync(0); + poller.stop(); + poller.start(); + resolveFirstProbe?.(report("ready")); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(1_000); + + expect(seen).toEqual(["needs_finder_enable"]); + expect(completions).toBe(0); + expect(calls).toBe(2); + }); + it("pauses while hidden and probes immediately when visible again", async () => { vi.useFakeTimers(); let calls = 0; diff --git a/apps/desktop/src/file-provider-enablement.ts b/apps/desktop/src/file-provider-enablement.ts index 8665322e..c6150b9e 100644 --- a/apps/desktop/src/file-provider-enablement.ts +++ b/apps/desktop/src/file-provider-enablement.ts @@ -30,6 +30,7 @@ export function createFileProviderEnablementPoller( let visible = true; let inFlight = false; let transientFailures = 0; + let generation = 0; let timer: ReturnType | null = null; function clearTimer() { @@ -54,9 +55,13 @@ export function createFileProviderEnablementPoller( if (!running || !visible || inFlight) { return; } + const pollGeneration = generation; inFlight = true; try { const next = await options.probe(); + if (!running || generation !== pollGeneration) { + return; + } transientFailures = 0; options.onReport(next); if (next.state === "ready") { @@ -70,6 +75,9 @@ export function createFileProviderEnablementPoller( } schedule(1_000); } catch { + if (!running || generation !== pollGeneration) { + return; + } transientFailures += 1; schedule(Math.min(1_000 * 2 ** (transientFailures - 1), 5_000)); } finally { @@ -88,10 +96,12 @@ export function createFileProviderEnablementPoller( if (running) { return; } + generation += 1; running = true; schedule(0); }, stop: () => { + generation += 1; running = false; clearTimer(); }, From e87fdb08baf285bb3a231a6bc268680e71abe2dc Mon Sep 17 00:00:00 2001 From: aseembits93 Date: Thu, 16 Jul 2026 17:17:52 -0700 Subject: [PATCH 10/11] fix: keep Finder source recovery consistent --- apps/desktop/src/App.tsx | 15 ++++++----- .../src/file-provider-enablement-ui.test.js | 12 ++++++--- apps/desktop/src/source-setup.test.ts | 27 +++++++++++++++++++ apps/desktop/src/source-setup.ts | 17 ++++++++++++ 4 files changed, 61 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 1995199c..7c0fca8a 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -99,6 +99,7 @@ import { connectedSourcesReadyToMount, isSourceConnectorId, sourceConnectionReady, + sourceMountRetryOutcome, sourceMounted, sourceSetupIsActiveConnector, sourceSetupIsBusy, @@ -3299,12 +3300,14 @@ function MountsView({ pendingMountRetry.connector, pendingMountRetry.googleDocsWorkspaceFolder, ); - if (mountReport.ok) { - setPendingMountRetry(null); - setSourceFileProviderEnablement(null); - setSourceDialogMessage(mountReport.message); - setSourceDialogState("success"); + const outcome = sourceMountRetryOutcome(mountReport); + if (outcome.kind === "retry") { + return; } + setPendingMountRetry(null); + setSourceFileProviderEnablement(null); + setSourceDialogMessage(outcome.message); + setSourceDialogState(outcome.kind); }, 350); }, }); @@ -3776,8 +3779,6 @@ function MountsView({ onReopenFinder={() => void revealSourceFileProviderEnablement()} onClose={() => { setSourceDialogOpen(false); - setPendingMountRetry(null); - setSourceFileProviderEnablement(null); }} /> )} diff --git a/apps/desktop/src/file-provider-enablement-ui.test.js b/apps/desktop/src/file-provider-enablement-ui.test.js index 745ecc6f..ba4cffea 100644 --- a/apps/desktop/src/file-provider-enablement-ui.test.js +++ b/apps/desktop/src/file-provider-enablement-ui.test.js @@ -13,9 +13,15 @@ describe("Finder enablement checkpoint", () => { expect(app).toContain("Having trouble?"); }); - it("reuses the guided state for later source mount recovery", () => { - expect(app).toContain("sourceFileProviderEnablement"); - expect(app).toContain("pendingMountRetry"); + it("keeps later source recovery running when its dialog closes", () => { + const addSourceDialog = app.match( + / \{([\s\S]*?)\}\}\s*\/>/, + ); + + expect(addSourceDialog?.[1].trim()).toBe("setSourceDialogOpen(false);"); + }); + + it("passes later source recovery state into the guided dialog", () => { expect(app).toContain("fileProviderEnablement={sourceFileProviderEnablement}"); }); diff --git a/apps/desktop/src/source-setup.test.ts b/apps/desktop/src/source-setup.test.ts index d482d005..adce9607 100644 --- a/apps/desktop/src/source-setup.test.ts +++ b/apps/desktop/src/source-setup.test.ts @@ -3,6 +3,7 @@ import { connectedSourcesReadyToMount, sourceSetupIsActiveConnector, sourceSetupIsBusy, + sourceMountRetryOutcome, sourceSetupProgressLabel, } from "./source-setup"; @@ -43,3 +44,29 @@ describe("source setup progress", () => { ).toEqual(["notion"]); }); }); + +describe("source File Provider mount retry", () => { + it("completes a successful automatic mount retry", () => { + expect(sourceMountRetryOutcome({ ok: true, message: "Mounted Notion." })).toEqual({ + kind: "success", + message: "Mounted Notion.", + }); + }); + + it("continues recovery when File Provider is still disabled", () => { + expect(sourceMountRetryOutcome({ + ok: false, + message: "The Locality File Provider is registered but not enabled.", + })).toEqual({ kind: "retry" }); + }); + + it("turns another automatic mount failure into a visible dialog error", () => { + expect(sourceMountRetryOutcome({ + ok: false, + message: "Could not load the top-level Notion folder.", + })).toEqual({ + kind: "error", + message: "Could not load the top-level Notion folder.", + }); + }); +}); diff --git a/apps/desktop/src/source-setup.ts b/apps/desktop/src/source-setup.ts index 81d51212..2579e528 100644 --- a/apps/desktop/src/source-setup.ts +++ b/apps/desktop/src/source-setup.ts @@ -1,5 +1,10 @@ +import { classifyMountSetupError } from "./onboarding-errors"; + export type SourceSetupState = "idle" | "connecting" | "creating" | "changing" | "success" | "error"; export type SourceConnectorId = "notion" | "google-docs" | "gmail" | "granola"; +export type SourceMountRetryOutcome = + | { kind: "retry" } + | { kind: "success" | "error"; message: string }; type SourceConnectionLike = { connector: string; @@ -20,6 +25,18 @@ type SourceSnapshotLike = { const SOURCE_CONNECTORS: SourceConnectorId[] = ["notion", "google-docs", "gmail", "granola"]; +export function sourceMountRetryOutcome( + report: { ok: boolean; message: string }, +): SourceMountRetryOutcome { + if (report.ok) { + return { kind: "success", message: report.message }; + } + if (classifyMountSetupError(report.message).kind === "file-provider-disabled") { + return { kind: "retry" }; + } + return { kind: "error", message: report.message }; +} + export function sourceSetupIsBusy(state: SourceSetupState): boolean { return state === "connecting" || state === "creating" || state === "changing"; } From 20a97fb2028a49314e596785d961bfe6ec6a5c04 Mon Sep 17 00:00:00 2001 From: aseembits93 Date: Thu, 16 Jul 2026 17:18:32 -0700 Subject: [PATCH 11/11] fix: honor explicit prompt-test DMG --- scripts/install-macos-prompt-test-app.sh | 14 +++++----- scripts/install-macos-prompt-test-app.test.sh | 28 +++++++++++++++++++ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/scripts/install-macos-prompt-test-app.sh b/scripts/install-macos-prompt-test-app.sh index 6ed63c0e..de5d8f18 100755 --- a/scripts/install-macos-prompt-test-app.sh +++ b/scripts/install-macos-prompt-test-app.sh @@ -254,14 +254,14 @@ resolve_source_app() { return 0 fi - local bundle_app="${ROOT}/target/release/bundle/macos/Locality.app" - if [[ -d "${bundle_app}" ]]; then - SOURCE_APP="${bundle_app}" - validate_source_and_target_paths - return 0 - fi - if [[ -z "${DMG}" ]]; then + local bundle_app="${ROOT}/target/release/bundle/macos/Locality.app" + if [[ -d "${bundle_app}" ]]; then + SOURCE_APP="${bundle_app}" + validate_source_and_target_paths + return 0 + fi + DMG="$(default_dmg)" || fail "missing DMG under ${DEFAULT_DMG_DIR}. Run make build-tauri first or pass --dmg PATH." DMG="$(expand_tilde "${DMG}")" fi diff --git a/scripts/install-macos-prompt-test-app.test.sh b/scripts/install-macos-prompt-test-app.test.sh index 0a06e044..d0c22822 100755 --- a/scripts/install-macos-prompt-test-app.test.sh +++ b/scripts/install-macos-prompt-test-app.test.sh @@ -233,6 +233,33 @@ test_dry_run_rejects_source_app_as_target() { assert_not_contains "${output}" "rm -rf" } +test_dry_run_prefers_explicit_dmg_over_built_app() { + local tmp isolated_root isolated_script built_app dmg app output + tmp="$(mktemp -d)" + trap '[[ -z "${tmp:-}" ]] || rm -rf "${tmp}"' RETURN + isolated_root="${tmp}/repo" + isolated_script="${isolated_root}/scripts/install-macos-prompt-test-app.sh" + built_app="${isolated_root}/target/release/bundle/macos/Locality.app" + dmg="${tmp}/explicit.dmg" + app="${tmp}/Applications/Locality Prompt Test.app" + mkdir -p "$(dirname "${isolated_script}")" "${built_app}" + cp "${SCRIPT}" "${isolated_script}" + touch "${dmg}" + + output="$( + LOCALITY_PROMPT_TEST_TIMESTAMP=20260714130007 \ + "${isolated_script}" \ + --dry-run \ + --dmg "${dmg}" \ + --app-path "${app}" \ + --signing-identity "Developer ID Application: Test (TEAMID)" \ + --no-launch + )" + + assert_contains "${output}" "+ hdiutil attach ${dmg}" + assert_not_contains "${output}" "source app: ${built_app}" +} + main() { test_dry_run_plans_fresh_prompt_test_app_installation test_dry_run_resets_existing_test_app_domain_by_default @@ -241,6 +268,7 @@ main() { test_dry_run_rejects_production_target_path_by_default test_dry_run_force_allows_non_test_target_path test_dry_run_rejects_source_app_as_target + test_dry_run_prefers_explicit_dmg_over_built_app printf 'install-macos-prompt-test-app helper tests passed\n' }