6 Commits
init ... v1.0.1

Author SHA1 Message Date
Kyle Bolen
11a1faa395 Release v1.0.1 2026-08-02 18:32:00 +00:00
Kyle Bolen
d041298c5c Fix adb path resolution: lazy eval instead of default param
ExternalTools.resolve() called as default constructor param was
evaluated at class load time, before the filesystem was checked.
Use lazy delegation instead so adb path is resolved on first use.
2026-08-02 18:29:35 +00:00
Kyle Bolen
b416e528b0 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.
2026-08-02 18:12:53 +00:00
Kyle Bolen
207f55d263 Add comprehensive JVM module list for packaged distribution
Include all modules commonly needed by logback, coroutines, Swing,
Kotlin serialization, and networking to prevent NoClassDefFoundError
when running as a packaged .app bundle.
2026-08-02 18:04:27 +00:00
Kyle Bolen
4b6a5fccf6 Add missing JVM modules to packaged distribution
javax.naming.NamingException (used by logback's JNDI lookup) was not
included in the bundled JVM image. Explicitly include:
- java.naming (javax.naming)
- java.management (JMX, used by logback)
- java.instrument (used by logback)
- jdk.unsupported (reflection support)
- java.security.jgss (networking)
2026-08-02 18:02:49 +00:00
Kyle Bolen
29595d5a7f Release v1.0.0 2026-08-02 03:53:04 +00:00
8 changed files with 161 additions and 6 deletions

View File

@@ -40,10 +40,39 @@ compose.desktop {
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "PhotoPhetch"
packageVersion = "1.0.0"
packageVersion = "1.0.1"
description = "Import photos from phones into PhotoPhile"
vendor = "bolenpad"
copyright = "© 2026 Kyle Bolen"
modules(
// Networking and naming — needed by logback JNDI
"java.naming",
// JMX — needed by logback JMX appender
"java.management",
// Instrumentation — needed by logback/class loading
"java.instrument",
// Reflection support for Unsafe — needed by coroutines, serialization
"jdk.unsupported",
// XML processing — needed by logback XML config parser
"java.xml",
// Logging — java.util.logging bridge
"java.logging",
// Security
"java.security.jgss",
"java.security.sasl",
// Desktop/AWT (for Swing integration)
"java.desktop",
// Compiler API — sometimes needed by Kotlin reflection
"java.compiler",
// SQL — needed by some Kotlin stdlib internals
"java.sql",
// Network — needed by HTTP calls (Gitea API, pymobiledevice3)
"java.net.http",
// Preferences — used by some desktop apps
"java.prefs",
)
macOS {
bundleID = "com.bolenpad.photophetch"
// Uncomment and add icon.icns to src/main/resources/ for a custom icon:

View File

@@ -39,9 +39,16 @@ fi
NOTES=$(cat "$NOTES_FILE")
echo "→ Release notes: $NOTES_FILE"
# ── Gitea token ───────────────────────────────────────────────────────────────
# ── Gitea token — from macOS Keychain, env var, or prompt ────────────────────
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
fi

46
releases/v1.0.0.md Normal file
View File

@@ -0,0 +1,46 @@
## PhotoPhetch v1.0.0 — First Release
PhotoPhetch is a macOS desktop app for importing photos from Android and iPhone phones into a PhotoPhile staging directory.
### Features
**Android (Samsung, Pixel, and others)**
- Connects via ADB — scans all DCIM and Pictures subfolders automatically
- Smart filtering: excludes Screenshots, Screen Recordings, WhatsApp, Telegram, and other app folders
- Folder structure preserved on import (Camera/, Screenshots/, etc.)
- Thumbnails loaded and cached locally
**iPhone**
- Connects via pymobiledevice3 (AFC protocol) — works on iOS 17+, no gphoto2 needed
- Photos classified by filename pattern: Camera, Edited, Videos, Screenshots
- Live Photo detection: companion MOV files hidden from grid, HEIC stills show LIVE badge
- Atomic import/delete: HEIC and its MOV companion treated as a unit
- Thumbnails batch pre-fetched newest-first and cached permanently
**Import flow**
- Browse photos in a two-panel layout: folder jump list on left, dense photo grid on right
- Date markers on first photo of each day; +/- buttons to adjust grid density
- Tap + on any photo to preview full-size (HEIC converted to JPEG via sips)
- Select individual photos, entire days, or entire folders
- SHA-256 copy verification; size-based verification when remote hash unavailable
- EXIF Make verification via exiftool: flags photos with absent Make as unverified
**Verify screen**
- Per-file status: green (verified), yellow (origin unknown), red (failed)
- Filter by: All / OK / Unverified / Failed
- Thumbnail chip on each row — click to preview
- Soft-remove any file with undo before committing
- Single Done button commits all actions: delete from phone + clean staging
**Settings**
- Staging path with native macOS folder picker
- Folder name template with {date} and {device} tokens
- Auto-increments folder name if it already exists (e.g. 2026-07-28_iPhone15Pro_2)
- Persists HEIC conversion, folder structure, and other preferences
### Requirements
- macOS (Intel or Apple Silicon)
- Java 17+
- ADB for Android: brew install android-platform-tools
- pymobiledevice3 for iPhone: brew install pipx && pipx install pymobiledevice3
- exiftool for EXIF verification (optional): brew install exiftool

9
releases/v1.0.1.md Normal file
View File

@@ -0,0 +1,9 @@
## PhotoPhetch v1.0.1 — Packaging Fixes
### Bug fixes
- **Fixed crash on launch** when installed as a .app bundle: bundled JVM was missing required modules (java.naming, java.xml, java.desktop, and others needed by logback and Kotlin coroutines)
- **Fixed 'exiftool not installed' false warning** when launched as .app: the bundled app does not inherit the shell PATH, so Homebrew tools in /usr/local/bin and /opt/homebrew/bin were not found. ExternalTools now searches common Homebrew paths directly
- **Fixed adb not found** when launched as .app: same PATH issue, now resolved lazily using Homebrew path search
- **Fixed EXIF verification false warnings** on Android: Samsung and other Android manufacturers were incorrectly flagged as 'Origin unknown'. Any non-blank EXIF Make is now considered a verified camera original; only absent Make triggers the warning
- **Preserve folder structure now sticky**: the toggle in the Import Configuration screen now saves your preference and remembers it between imports

View File

@@ -24,6 +24,12 @@ class AndroidConnector(
private val log = LoggerFactory.getLogger(AndroidConnector::class.java)
// Resolve adb path lazily — ExternalTools searches Homebrew paths
// so the packaged .app finds adb even without shell PATH
private val resolvedAdb: String by lazy {
com.bolenpad.photophetch.util.ExternalTools.resolve(adbBin)
}
// Directories on Android that contain camera-roll originals.
// Used as fallback if dynamic DCIM discovery finds nothing.
private val fallbackCameraDirs = listOf(
@@ -213,7 +219,7 @@ class AndroidConnector(
private fun runAdb(serial: String?, vararg args: String): String {
val cmd = buildList {
add(adbBin)
add(resolvedAdb)
if (serial != null) {
add("-s")
add(serial)

View File

@@ -247,7 +247,7 @@ class Copier(
*/
private fun readExifMake(file: Path): String? {
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
.start()
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.unit.dp
import com.bolenpad.photophetch.model.DeviceInfo
import com.bolenpad.photophetch.util.ExternalTools
import com.bolenpad.photophetch.model.DeviceType
@Composable
@@ -49,7 +50,8 @@ fun ConnectScreen(state: AppState) {
// exiftool warning — checked once per session
val exiftoolMissing = remember {
try {
val p = ProcessBuilder("exiftool", "-ver").redirectErrorStream(true).start()
val p = ProcessBuilder(ExternalTools.resolve("exiftool"), "-ver")
.redirectErrorStream(true).start()
p.inputStream.readBytes()
p.waitFor() != 0
} 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
}
}
}