Fix tool resolution for packaged .app bundle

.app bundles launch with minimal PATH (/usr/bin:/bin only), missing
Homebrew paths. ExternalTools.resolve() searches:
- /usr/local/bin (Homebrew Intel)
- /opt/homebrew/bin (Homebrew Apple Silicon)
- ~/.local/bin (pipx installs)

Updated: exiftool (ConnectScreen + Copier), adb (AndroidConnector).
This fixes 'exiftool not installed' warning when launching as .app.
This commit is contained in:
Kyle Bolen
2026-08-02 18:12:53 +00:00
parent 207f55d263
commit b416e528b0
5 changed files with 70 additions and 5 deletions

View File

@@ -39,9 +39,16 @@ fi
NOTES=$(cat "$NOTES_FILE") NOTES=$(cat "$NOTES_FILE")
echo "→ Release notes: $NOTES_FILE" echo "→ Release notes: $NOTES_FILE"
# ── Gitea token ─────────────────────────────────────────────────────────────── # ── Gitea token — from macOS Keychain, env var, or prompt ────────────────────
if [ -z "${GITEA_TOKEN:-}" ]; then if [ -z "${GITEA_TOKEN:-}" ]; then
read -rsp "GITEA_TOKEN not set. Enter token: " GITEA_TOKEN # Try macOS Keychain first
GITEA_TOKEN=$(security find-generic-password -a kbolen -s gitea-photophetch -w 2>/dev/null || echo "")
fi
if [ -z "${GITEA_TOKEN:-}" ]; then
echo "No token found in Keychain or environment."
echo "Store it once with:"
echo " security add-generic-password -a kbolen -s gitea-photophetch -w YOUR_TOKEN"
read -rsp "Or enter token now: " GITEA_TOKEN
echo echo
fi fi

View File

@@ -19,7 +19,7 @@ import kotlin.io.path.absolutePathString
* Install via: brew install android-platform-tools * Install via: brew install android-platform-tools
*/ */
class AndroidConnector( class AndroidConnector(
private val adbBin: String = "adb", private val adbBin: String = com.bolenpad.photophetch.util.ExternalTools.resolve("adb"),
) : DeviceConnector { ) : DeviceConnector {
private val log = LoggerFactory.getLogger(AndroidConnector::class.java) private val log = LoggerFactory.getLogger(AndroidConnector::class.java)

View File

@@ -247,7 +247,7 @@ class Copier(
*/ */
private fun readExifMake(file: Path): String? { private fun readExifMake(file: Path): String? {
return try { return try {
val process = ProcessBuilder("exiftool", "-Make", "-s3", file.absolutePathString()) val process = ProcessBuilder(com.bolenpad.photophetch.util.ExternalTools.resolve("exiftool"), "-Make", "-s3", file.absolutePathString())
.redirectErrorStream(false) // keep stderr separate .redirectErrorStream(false) // keep stderr separate
.start() .start()
val stdout = process.inputStream.bufferedReader().readText() val stdout = process.inputStream.bufferedReader().readText()

View File

@@ -13,6 +13,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.bolenpad.photophetch.model.DeviceInfo import com.bolenpad.photophetch.model.DeviceInfo
import com.bolenpad.photophetch.util.ExternalTools
import com.bolenpad.photophetch.model.DeviceType import com.bolenpad.photophetch.model.DeviceType
@Composable @Composable
@@ -49,7 +50,8 @@ fun ConnectScreen(state: AppState) {
// exiftool warning — checked once per session // exiftool warning — checked once per session
val exiftoolMissing = remember { val exiftoolMissing = remember {
try { try {
val p = ProcessBuilder("exiftool", "-ver").redirectErrorStream(true).start() val p = ProcessBuilder(ExternalTools.resolve("exiftool"), "-ver")
.redirectErrorStream(true).start()
p.inputStream.readBytes() p.inputStream.readBytes()
p.waitFor() != 0 p.waitFor() != 0
} catch (_: Exception) { true } } catch (_: Exception) { true }

View File

@@ -0,0 +1,56 @@
package com.bolenpad.photophetch.util
import java.io.File
/**
* Resolves paths for external command-line tools (exiftool, adb, python3, etc.)
*
* macOS .app bundles launch with a minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin)
* that doesn't include Homebrew. This utility searches common Homebrew install
* locations so tools work whether launched from the command line or as a .app.
*/
object ExternalTools {
/** Common binary locations to search, in priority order */
private val searchPaths = listOf(
"/usr/local/bin", // Homebrew on Intel Mac
"/opt/homebrew/bin", // Homebrew on Apple Silicon
"/usr/bin", // System tools
"/bin",
"${System.getProperty("user.home")}/.local/bin", // pipx and user installs
"/usr/local/opt/exiftool/bin", // Homebrew formula-specific
)
/**
* Find the full path to a tool binary.
* Returns the full path if found in a known location, or just the bare
* name (relying on PATH) if not found — which works for command-line launches.
*/
fun resolve(toolName: String): String {
for (dir in searchPaths) {
val file = File(dir, toolName)
if (file.exists() && file.canExecute()) {
return file.absolutePath
}
}
return toolName // fall back to PATH lookup
}
/**
* Check if a tool is available (either in known paths or on PATH).
*/
fun isAvailable(toolName: String): Boolean {
val path = resolve(toolName)
if (path != toolName) return true // found at explicit path
// Try running it to check PATH
return try {
val p = ProcessBuilder(toolName, "--version")
.redirectErrorStream(true).start()
p.inputStream.readBytes()
p.waitFor() == 0 || true // any exit = it exists
} catch (_: Exception) {
false
}
}
}