diff --git a/bundles/com.espressif.idf.core/META-INF/MANIFEST.MF b/bundles/com.espressif.idf.core/META-INF/MANIFEST.MF index ab53426c8..7cceb1532 100644 --- a/bundles/com.espressif.idf.core/META-INF/MANIFEST.MF +++ b/bundles/com.espressif.idf.core/META-INF/MANIFEST.MF @@ -38,6 +38,7 @@ Export-Package: com.espressif.idf.core, com.espressif.idf.core.toolchain, com.espressif.idf.core.tools, com.espressif.idf.core.tools.exceptions, + com.espressif.idf.core.tools.launch, com.espressif.idf.core.tools.util, com.espressif.idf.core.tools.vo, com.espressif.idf.core.tools.watcher, diff --git a/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/EimLoader.java b/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/EimLoader.java index 4db1005c2..88ea9a67e 100644 --- a/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/EimLoader.java +++ b/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/EimLoader.java @@ -13,20 +13,19 @@ import java.io.InputStreamReader; import java.io.OutputStream; import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URISyntaxException; import java.net.URL; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; -import java.util.List; import java.util.Optional; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import org.eclipse.core.runtime.IProgressMonitor; import org.eclipse.core.runtime.IStatus; -import org.eclipse.core.runtime.Platform; -import org.eclipse.core.runtime.Status; import org.eclipse.core.runtime.jobs.IJobChangeEvent; import org.eclipse.core.runtime.jobs.Job; import org.eclipse.core.runtime.jobs.JobChangeAdapter; @@ -35,6 +34,8 @@ import com.espressif.idf.core.IDFEnvironmentVariables; import com.espressif.idf.core.logging.Logger; +import com.espressif.idf.core.tools.launch.EimLaunchService; +import com.espressif.idf.core.tools.launch.LaunchResult; import com.espressif.idf.core.tools.watcher.EimJsonWatchService; import com.espressif.idf.core.util.StringUtil; import com.google.gson.JsonArray; @@ -42,8 +43,9 @@ import com.google.gson.JsonParser; /** - * This class is responsible for downloading and launching the EIM. - * The clients using this must take care of UI refreshes and pausing any listeners. + * This class is responsible for downloading and launching the EIM. The clients using this must take care of UI + * refreshes and pausing any listeners. + * * @author Ali Azam Rana * */ @@ -52,27 +54,33 @@ public class EimLoader private static final String URL_JSON = "https://dl.espressif.com/dl/eim/eim_unified_release.json"; //$NON-NLS-1$ private static final Path DOWNLOAD_DIR = Paths.get(System.getProperty("java.io.tmpdir"), "eim_gui"); //$NON-NLS-1$ //$NON-NLS-2$ - private String os; - private String arch; - private DownloadListener listener; - private MessageConsoleStream standardConsoleStream; - private MessageConsoleStream errorConsoleStream; - private Display display; - private long windowsPid; + private final String os; + private final String arch; + private final DownloadListener listener; + private final MessageConsoleStream standardConsoleStream; + private final MessageConsoleStream errorConsoleStream; + private final Display display; - public EimLoader(DownloadListener listener, MessageConsoleStream standardConsoleStream, MessageConsoleStream errorConsoleStream, Display display) + private final EimLaunchService launchService; + + private volatile LaunchResult lastLaunchResult; + + public EimLoader(DownloadListener listener, MessageConsoleStream standardConsoleStream, + MessageConsoleStream errorConsoleStream, Display display) { - os = Platform.getOS(); - arch = Platform.getOSArch(); + this.os = org.eclipse.core.runtime.Platform.getOS(); + this.arch = org.eclipse.core.runtime.Platform.getOSArch(); this.listener = listener; this.standardConsoleStream = standardConsoleStream; this.errorConsoleStream = errorConsoleStream; this.display = display; + + this.launchService = new EimLaunchService(display, standardConsoleStream, errorConsoleStream); } - + private void logMessage(String message) { - display.asyncExec(()->{ + display.asyncExec(() -> { try { standardConsoleStream.write(message); @@ -83,13 +91,13 @@ private void logMessage(String message) logError(e.getMessage()); } }); - + Logger.log(message); } - + private void logError(String message) { - display.asyncExec(()->{ + display.asyncExec(() -> { try { errorConsoleStream.write(message); @@ -99,80 +107,66 @@ private void logError(String message) Logger.log(e); } }); - + Logger.log(message); } - - public Process launchEim(String eimPath) throws IOException + + /** + * Launches the eim and return {@link com.espressif.idf.core.tools.launch.LaunchResult}. + */ + public LaunchResult launchEimWithResult(String eimPath) throws IOException { - if (!Files.exists(Paths.get(eimPath))) - throw new FileNotFoundException("EIM path not found: " + eimPath); //$NON-NLS-1$ + LaunchResult result = launchService.launch(eimPath); + this.lastLaunchResult = result; - String os = Platform.getOS(); - List command; + logMessage("Launched EIM application: " + eimPath + " (pid=" + result.pid().orElse(-1) + ")\n"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + return result; + } - if (os.equals(Platform.OS_WIN32)) - { - String escapedPathForPowershell = eimPath.replace("'", "''"); //$NON-NLS-1$ //$NON-NLS-2$ - String powershellCmd = String.format( - "Start-Process -FilePath '%s' -PassThru | " //$NON-NLS-1$ - + "Select-Object -ExpandProperty Id", //$NON-NLS-1$ - escapedPathForPowershell); - - command = List.of("powershell.exe", //$NON-NLS-1$ - "-Command", powershellCmd); //$NON-NLS-1$ - } - else if (os.equals(Platform.OS_MACOSX)) - { - command = List.of("open", "-W", "-a", eimPath); //$NON-NLS-1$//$NON-NLS-2$ //$NON-NLS-3$ - } - else if (os.equals(Platform.OS_LINUX)) - { - command = List.of("bash", "-c", "\"" + eimPath + "\""); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ - } - else - { - throw new UnsupportedOperationException("Unsupported OS: " + os); //$NON-NLS-1$ - } - - Process process = new ProcessBuilder(command).redirectErrorStream(true).start(); - if (os.equals(Platform.OS_WIN32)) + public void waitForEimClosure(LaunchResult launchResult, Runnable callback) + { + Job waitJob = new Job("Wait for EIM Closure") //$NON-NLS-1$ { - // store the PID returned by powershell query to a variable - storePid(process); - } - - logMessage("Launched EIM application: " + eimPath + "\n"); //$NON-NLS-1$ //$NON-NLS-2$ + @Override + protected IStatus run(IProgressMonitor monitor) + { + return launchService.waitForExit(launchResult, monitor); + } + }; + waitJob.setSystem(true); - return process; - } - - private void storePid(Process powershellProcess) - { - try (BufferedReader reader = new BufferedReader(new InputStreamReader(powershellProcess.getInputStream()))) + waitJob.addJobChangeListener(new JobChangeAdapter() { - String line; - while ((line = reader.readLine()) != null) + @Override + public void aboutToRun(IJobChangeEvent event) { - line = line.trim(); - if (!line.isEmpty()) - { + EimJsonWatchService.getInstance().pauseListeners(); + } + + @Override + public void done(IJobChangeEvent event) + { + Display.getDefault().asyncExec(() -> { try { - windowsPid = Long.parseLong(line); - + standardConsoleStream.write("EIM has been closed.\n"); //$NON-NLS-1$ } - catch (NumberFormatException ignored) + catch (IOException e) { - // skipping invalid lines + Logger.log(e); } + }); + + if (callback != null) + { + callback.run(); } + + EimJsonWatchService.getInstance().unpauseListeners(); } - } - catch (IOException e) - { - Logger.log(e); - } + }); + + waitJob.schedule(); } public String installAndLaunchDmg(Path dmgPath) throws IOException, InterruptedException @@ -219,35 +213,6 @@ public String installAndLaunchDmg(Path dmgPath) throws IOException, InterruptedE new IDFEnvironmentVariables().addEnvVariable(IDFEnvironmentVariables.EIM_PATH, eimPath); return eimPath; } - - private String readProcessOutput(Process p) throws IOException - { - try (BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream()))) - { - StringBuilder sb = new StringBuilder(); - String line; - while ((line = br.readLine()) != null) sb.append(line).append(System.lineSeparator()); - return sb.toString(); - } - } - - private String parseVolumePath(InputStream mountOut) throws IOException - { - try (BufferedReader reader = new BufferedReader(new InputStreamReader(mountOut))) - { - String line; - while ((line = reader.readLine()) != null) - { - if (line.contains("/Volumes/")) //$NON-NLS-1$ - { - for (String part : line.split("\t")) //$NON-NLS-1$ - if (part.startsWith("/Volumes/")) return part.trim(); //$NON-NLS-1$ - } - } - } - return null; - } - public void downloadEim(IProgressMonitor monitor) { @@ -273,9 +238,10 @@ public void downloadEim(IProgressMonitor monitor) Path downloadPath = DOWNLOAD_DIR.resolve(name); downloadFile(downloadUrl, downloadPath, listener, monitor); + Path eimPath = Paths.get(EimConstants.USER_EIM_DIR); Files.createDirectories(eimPath); - + if (name.endsWith(".zip")) //$NON-NLS-1$ { Path extracted = unzip(downloadPath, eimPath); @@ -283,139 +249,28 @@ public void downloadEim(IProgressMonitor monitor) } else if (name.endsWith(".exe")) //$NON-NLS-1$ { - eimPath = Paths.get(eimPath.toString(), name); - Files.copy(downloadPath, eimPath, StandardCopyOption.REPLACE_EXISTING); - listener.onCompleted(eimPath.toString()); + Path exePath = Paths.get(eimPath.toString(), name); + Files.copy(downloadPath, exePath, StandardCopyOption.REPLACE_EXISTING); + listener.onCompleted(exePath.toString()); } - else + else { listener.onCompleted(downloadPath.toAbsolutePath().toString()); } } - catch (IOException e) + catch (IOException | URISyntaxException e) { listener.onError("Download failed", e); //$NON-NLS-1$ - } finally - { - monitor.done(); - } - - } - - private IStatus waitForProcessWindows() - { - while (isWindowsProcessAlive(windowsPid)) - { - try - { - Thread.sleep(1000); - } - catch (InterruptedException e) - { - Logger.log(e); - } } - - return Status.OK_STATUS; - } - - private boolean isWindowsProcessAlive(long pid) - { - try + finally { - Process check = new ProcessBuilder("cmd.exe", //$NON-NLS-1$ - "/c", //$NON-NLS-1$ - "tasklist", //$NON-NLS-1$ - "/FI", //$NON-NLS-1$ - "\"PID eq " + pid + "\"").redirectErrorStream(true).start(); //$NON-NLS-1$ //$NON-NLS-2$ - try (BufferedReader reader = new BufferedReader(new InputStreamReader(check.getInputStream()))) - { - String line; - while ((line = reader.readLine()) != null) - { - if (line.contains(String.valueOf(windowsPid))) - { - return true; - } - } - } - } - catch (IOException e) - { - Logger.log(e); - } - - return false; - } - - private IStatus waitForProcess(Process process) - { - try - { - process.waitFor(); - return Status.OK_STATUS; - } - catch (InterruptedException e) - { - return Status.CANCEL_STATUS; - } - catch (Exception e) - { - Logger.log(e); - return Status.error(e.getMessage()); + monitor.done(); } } - - public void waitForEimClosure(Process process, Runnable callback) - { - Job waitJob = new Job("Wait for EIM Closure") //$NON-NLS-1$ - { - @Override - protected IStatus run(IProgressMonitor monitor) - { - return os.equals(Platform.OS_WIN32) ? waitForProcessWindows() : waitForProcess(process); - } - }; - waitJob.setSystem(true); - - - waitJob.addJobChangeListener(new JobChangeAdapter() - { - @Override - public void aboutToRun(IJobChangeEvent event) - { - EimJsonWatchService.getInstance().pauseListeners(); - } - - @Override - public void done(IJobChangeEvent event) - { - Display.getDefault().asyncExec(() -> { - try - { - standardConsoleStream.write("EIM has been closed.\n"); //$NON-NLS-1$ - } - catch (IOException e) - { - Logger.log(e); - } - }); - - if (callback != null) - { - callback.run(); - } - - EimJsonWatchService.getInstance().unpauseListeners(); - } - }); - - waitJob.schedule(); - } - private JsonObject fetchJson() throws IOException + private JsonObject fetchJson() throws IOException, URISyntaxException { - URL url = new URL(URL_JSON); + URL url = new URI(URL_JSON).toURL(); HttpURLConnection connection = (HttpURLConnection) url.openConnection(); connection.setRequestProperty("accept", "application/json"); //$NON-NLS-1$//$NON-NLS-2$ connection.setConnectTimeout(10000); @@ -427,43 +282,20 @@ private JsonObject fetchJson() throws IOException } } - private void cleanupDownloadDirectory() - { - try - { - Files.list(DOWNLOAD_DIR) - .filter(Files::isRegularFile) - .forEach(path -> { - try - { - Files.deleteIfExists(path); - } - catch (IOException e) - { - Logger.log("Failed to delete old download: " + path); //$NON-NLS-1$ - } - }); - } - catch (IOException e) - { - Logger.log("Failed to clean up download directory: " + e.getMessage()); //$NON-NLS-1$ - } - } - private Optional findMatchingAsset(JsonArray assets) { String osToken = switch (os) { - case Platform.OS_WIN32 -> "windows"; //$NON-NLS-1$ - case Platform.OS_MACOSX -> "macos"; //$NON-NLS-1$ - case Platform.OS_LINUX -> "linux"; //$NON-NLS-1$ + case org.eclipse.core.runtime.Platform.OS_WIN32 -> "windows"; //$NON-NLS-1$ + case org.eclipse.core.runtime.Platform.OS_MACOSX -> "macos"; //$NON-NLS-1$ + case org.eclipse.core.runtime.Platform.OS_LINUX -> "linux"; //$NON-NLS-1$ default -> StringUtil.EMPTY; }; String archToken = switch (arch) { - case Platform.ARCH_X86_64 -> "x64"; //$NON-NLS-1$ - case Platform.ARCH_AARCH64, "arm64" -> "aarch64"; //$NON-NLS-1$ //$NON-NLS-2$ + case org.eclipse.core.runtime.Platform.ARCH_X86_64 -> "x64"; //$NON-NLS-1$ + case org.eclipse.core.runtime.Platform.ARCH_AARCH64, "arm64" -> "aarch64"; //$NON-NLS-1$ //$NON-NLS-2$ default -> StringUtil.EMPTY; }; @@ -483,9 +315,9 @@ private Optional findMatchingAsset(JsonArray assets) } private void downloadFile(String fileURL, Path targetPath, DownloadListener listener, IProgressMonitor monitor) - throws IOException + throws IOException, URISyntaxException { - URL url = new URL(fileURL); + URL url = new URI(fileURL).toURL(); HttpURLConnection connection = (HttpURLConnection) url.openConnection(); connection.setConnectTimeout(10000); connection.setReadTimeout(10000); @@ -495,7 +327,6 @@ private void downloadFile(String fileURL, Path targetPath, DownloadListener list try (InputStream in = connection.getInputStream(); OutputStream out = Files.newOutputStream(targetPath)) { - byte[] buffer = new byte[8192]; int bytesRead; long totalRead = 0; @@ -519,6 +350,27 @@ private void downloadFile(String fileURL, Path targetPath, DownloadListener list } } + private void cleanupDownloadDirectory() + { + try + { + Files.list(DOWNLOAD_DIR).filter(Files::isRegularFile).forEach(path -> { + try + { + Files.deleteIfExists(path); + } + catch (IOException e) + { + Logger.log("Failed to delete old download: " + path); //$NON-NLS-1$ + } + }); + } + catch (IOException e) + { + Logger.log("Failed to clean up download directory: " + e.getMessage()); //$NON-NLS-1$ + } + } + private Path unzip(Path zipPath, Path destDir) throws IOException { Files.createDirectories(destDir); @@ -549,4 +401,33 @@ private Path unzip(Path zipPath, Path destDir) throws IOException return firstExecutable != null ? firstExecutable : destDir; } + private String readProcessOutput(Process p) throws IOException + { + try (BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream()))) + { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = br.readLine()) != null) + sb.append(line).append(System.lineSeparator()); + return sb.toString(); + } + } + + private String parseVolumePath(InputStream mountOut) throws IOException + { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(mountOut))) + { + String line; + while ((line = reader.readLine()) != null) + { + if (line.contains("/Volumes/")) //$NON-NLS-1$ + { + for (String part : line.split("\t")) //$NON-NLS-1$ + if (part.startsWith("/Volumes/")) //$NON-NLS-1$ + return part.trim(); + } + } + } + return null; + } } diff --git a/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/launch/EimLaunchService.java b/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/launch/EimLaunchService.java new file mode 100644 index 000000000..8d8bf114f --- /dev/null +++ b/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/launch/EimLaunchService.java @@ -0,0 +1,48 @@ +/******************************************************************************* + * Copyright 2025 Espressif Systems (Shanghai) PTE LTD. All rights reserved. + * Use is subject to license terms. + *******************************************************************************/ +package com.espressif.idf.core.tools.launch; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; + +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.IStatus; +import org.eclipse.swt.widgets.Display; +import org.eclipse.ui.console.MessageConsoleStream; + +import com.espressif.idf.core.tools.launch.strategies.EimLauncherStrategy; + +/** + * Service for launching EIM using appropriate strategy + * @author Ali Azam Rana + * + */ +public final class EimLaunchService +{ + private final EimLauncherStrategy strategy; + + public EimLaunchService(Display display, MessageConsoleStream standardConsoleStream, + MessageConsoleStream errorConsoleStream) + { + this.strategy = EimLauncherFactory.create(display, standardConsoleStream, errorConsoleStream); + } + + public LaunchResult launch(String eimPath) throws IOException + { + if (eimPath == null || eimPath.isBlank()) + throw new IOException("EIM path is null/blank"); //$NON-NLS-1$ + + if (!Files.exists(Paths.get(eimPath))) + throw new IOException("EIM path not found: " + eimPath); //$NON-NLS-1$ + + return strategy.launch(eimPath); + } + + public IStatus waitForExit(LaunchResult launchResult, IProgressMonitor monitor) + { + return strategy.waitForExit(launchResult, monitor); + } +} diff --git a/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/launch/EimLauncherFactory.java b/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/launch/EimLauncherFactory.java new file mode 100644 index 000000000..7074ffec6 --- /dev/null +++ b/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/launch/EimLauncherFactory.java @@ -0,0 +1,47 @@ +/******************************************************************************* + * Copyright 2025 Espressif Systems (Shanghai) PTE LTD. All rights reserved. + * Use is subject to license terms. + *******************************************************************************/ +package com.espressif.idf.core.tools.launch; + +import org.eclipse.core.runtime.Platform; +import org.eclipse.swt.widgets.Display; +import org.eclipse.ui.console.MessageConsoleStream; + +import com.espressif.idf.core.tools.launch.strategies.EimLauncherStrategy; +import com.espressif.idf.core.tools.launch.strategies.LinuxEimLauncherStrategy; +import com.espressif.idf.core.tools.launch.strategies.MacOsEimLauncherStrategy; +import com.espressif.idf.core.tools.launch.strategies.WindowsEimLauncherStrategy; + +/** + * Factory for creating EIM launcher strategies based on OS + * @author Ali Azam Rana + */ +public final class EimLauncherFactory +{ + private EimLauncherFactory() + { + // utility + } + + public static EimLauncherStrategy create(Display display, MessageConsoleStream standardConsoleStream, + MessageConsoleStream errorConsoleStream) + { + String os = Platform.getOS(); + + if (Platform.OS_MACOSX.equals(os)) + { + return new MacOsEimLauncherStrategy(display, standardConsoleStream, errorConsoleStream); + } + if (Platform.OS_WIN32.equals(os)) + { + return new WindowsEimLauncherStrategy(display, standardConsoleStream, errorConsoleStream); + } + if (Platform.OS_LINUX.equals(os)) + { + return new LinuxEimLauncherStrategy(display, standardConsoleStream, errorConsoleStream); + } + + throw new UnsupportedOperationException("Unsupported OS: " + os); //$NON-NLS-1$ + } +} diff --git a/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/launch/LaunchResult.java b/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/launch/LaunchResult.java new file mode 100644 index 000000000..0d7423b82 --- /dev/null +++ b/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/launch/LaunchResult.java @@ -0,0 +1,59 @@ +/******************************************************************************* + * Copyright 2025 Espressif Systems (Shanghai) PTE LTD. All rights reserved. + * Use is subject to license terms. + *******************************************************************************/ +package com.espressif.idf.core.tools.launch; + +import java.util.OptionalLong; + +/** + * Launch result abstraction so the rest of the code doesn't care how the process + * is identified (PID on Win/Linux, PID or execPath polling on macOS, etc). + * + * @author Ali Azam Rana + */ +public final class LaunchResult +{ + private final OptionalLong pid; + private final String execPath; + private final String details; + + private LaunchResult(OptionalLong pid, String execPath, String details) + { + this.pid = pid; + this.execPath = execPath; + this.details = details; + } + + public static LaunchResult ofPid(long pid, String execPath, String details) + { + return new LaunchResult(OptionalLong.of(pid), execPath, details); + } + + public static LaunchResult ofNoPid(String execPath, String details) + { + return new LaunchResult(OptionalLong.empty(), execPath, details); + } + + public OptionalLong pid() + { + return pid; + } + + /** + * Platform-specific executable path (e.g. .../Contents/MacOS/eim on macOS) or + * the launched binary path on Linux/Windows. + */ + public String execPath() + { + return execPath; + } + + /** + * Debug info: launcher output, command used, etc. + */ + public String details() + { + return details; + } +} diff --git a/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/launch/ProcessUtils.java b/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/launch/ProcessUtils.java new file mode 100644 index 000000000..130265a47 --- /dev/null +++ b/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/launch/ProcessUtils.java @@ -0,0 +1,59 @@ +/******************************************************************************* + * Copyright 2025 Espressif Systems (Shanghai) PTE LTD. All rights reserved. + * Use is subject to license terms. + *******************************************************************************/ +package com.espressif.idf.core.tools.launch; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; + +/** + * Utility class for process-related operations + * + * @author Ali Azam Rana + */ +public final class ProcessUtils +{ + private ProcessUtils() + { + // utility + } + + public static String readAll(InputStream in) throws IOException + { + try (BufferedReader br = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) + { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = br.readLine()) != null) + { + sb.append(line).append('\n'); + } + return sb.toString().trim(); + } + } + + public static Long parseFirstLongLine(String output) + { + if (output == null || output.isEmpty()) + return null; + + for (String line : output.split("\n")) //$NON-NLS-1$ + { + String t = line.trim(); + if (t.matches("\\d+")) //$NON-NLS-1$ + { + return Long.valueOf(t); + } + } + return null; + } + + public static String bashSingleQuote(String input) + { + return "'" + input.replace("'", "'\"'\"'") + "'"; //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ + } +} diff --git a/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/launch/ProcessWaiter.java b/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/launch/ProcessWaiter.java new file mode 100644 index 000000000..88203cba9 --- /dev/null +++ b/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/launch/ProcessWaiter.java @@ -0,0 +1,104 @@ +/******************************************************************************* + * Copyright 2025 Espressif Systems (Shanghai) PTE LTD. All rights reserved. + * Use is subject to license terms. + *******************************************************************************/ +package com.espressif.idf.core.tools.launch; + +import java.io.IOException; +import java.util.OptionalLong; + +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.IStatus; +import org.eclipse.core.runtime.Status; + +import com.espressif.idf.core.logging.Logger; + +/** + * Utility class for waiting for process exit + * @author Ali Azam Rana + */ +public final class ProcessWaiter +{ + private ProcessWaiter() + { + // utility + } + + public static IStatus waitForExitByPid(OptionalLong pidOpt, IProgressMonitor monitor) + { + if (pidOpt == null || pidOpt.isEmpty()) + return Status.OK_STATUS; + + long pid = pidOpt.getAsLong(); + if (pid <= 0) + return Status.error("Invalid PID: " + pid); //$NON-NLS-1$ + + try + { + while (!monitor.isCanceled() && ProcessHandle.of(pid).map(ProcessHandle::isAlive).orElse(false)) + { + Thread.sleep(1000); + } + return monitor.isCanceled() ? Status.CANCEL_STATUS : Status.OK_STATUS; + } + catch (InterruptedException e) + { + Thread.currentThread().interrupt(); + return Status.CANCEL_STATUS; + } + catch (Exception e) + { + Logger.log(e); + return Status.error(e.getMessage()); + } + } + + /** + * Fallback waiter (macOS): poll for a process matching execPath using pgrep. + * This avoids relying on System Events / Accessibility permissions for PID lookup. + */ + public static IStatus waitForExitByExecPath(String execPath, IProgressMonitor monitor) + { + if (execPath == null || execPath.isBlank()) + return Status.error("execPath is null/blank"); //$NON-NLS-1$ + + try + { + while (!monitor.isCanceled() && isAnyProcessMatchingExecPath(execPath)) + { + Thread.sleep(1000); + } + return monitor.isCanceled() ? Status.CANCEL_STATUS : Status.OK_STATUS; + } + catch (InterruptedException e) + { + Thread.currentThread().interrupt(); + return Status.CANCEL_STATUS; + } + catch (Exception e) + { + Logger.log(e); + return Status.error(e.getMessage()); + } + } + + private static boolean isAnyProcessMatchingExecPath(String execPath) throws IOException, InterruptedException + { + // pgrep -f searches the full command line; quote it defensively for sh -lc + String q = ProcessUtils.bashSingleQuote(execPath); + String cmd = "pgrep -f " + q + " >/dev/null 2>&1; echo $?"; //$NON-NLS-1$ //$NON-NLS-2$ + + Process p = new ProcessBuilder("bash", "-lc", cmd) //$NON-NLS-1$ //$NON-NLS-2$ + .redirectErrorStream(true).start(); + + int exit = p.waitFor(); + String out = ProcessUtils.readAll(p.getInputStream()); + + // If bash failed, treat as "not running" to avoid infinite waits. + if (exit != 0 && (out == null || out.isBlank())) + return false; + + // echo $? prints 0 if pgrep found something + return out.trim().endsWith("0"); //$NON-NLS-1$ + } +} diff --git a/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/launch/strategies/AbstractLoggingLauncherStrategy.java b/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/launch/strategies/AbstractLoggingLauncherStrategy.java new file mode 100644 index 000000000..9ffc05d88 --- /dev/null +++ b/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/launch/strategies/AbstractLoggingLauncherStrategy.java @@ -0,0 +1,65 @@ +/******************************************************************************* + * Copyright 2025 Espressif Systems (Shanghai) PTE LTD. All rights reserved. + * Use is subject to license terms. + *******************************************************************************/ +package com.espressif.idf.core.tools.launch.strategies; + +import java.io.IOException; + +import org.eclipse.swt.widgets.Display; +import org.eclipse.ui.console.MessageConsoleStream; + +import com.espressif.idf.core.logging.Logger; + +/** + * Abstract class for logging used by strategies launching EIM + * @author Ali Azam Rana + * + */ +public abstract class AbstractLoggingLauncherStrategy implements EimLauncherStrategy +{ + protected final Display display; + protected final MessageConsoleStream standardConsoleStream; + protected final MessageConsoleStream errorConsoleStream; + + protected AbstractLoggingLauncherStrategy(Display display, MessageConsoleStream standardConsoleStream, + MessageConsoleStream errorConsoleStream) + { + this.display = display; + this.standardConsoleStream = standardConsoleStream; + this.errorConsoleStream = errorConsoleStream; + } + + protected void logMessage(String message) + { + display.asyncExec(() -> { + try + { + standardConsoleStream.write(message); + } + catch (IOException e) + { + Logger.log(e); + logError(e.getMessage()); + } + }); + + Logger.log(message); + } + + protected void logError(String message) + { + display.asyncExec(() -> { + try + { + errorConsoleStream.write(message); + } + catch (IOException e) + { + Logger.log(e); + } + }); + + Logger.log(message); + } +} diff --git a/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/launch/strategies/EimLauncherStrategy.java b/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/launch/strategies/EimLauncherStrategy.java new file mode 100644 index 000000000..402f03f69 --- /dev/null +++ b/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/launch/strategies/EimLauncherStrategy.java @@ -0,0 +1,24 @@ +/******************************************************************************* + * Copyright 2025 Espressif Systems (Shanghai) PTE LTD. All rights reserved. + * Use is subject to license terms. + *******************************************************************************/ +package com.espressif.idf.core.tools.launch.strategies; + +import java.io.IOException; + +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.IStatus; + +import com.espressif.idf.core.tools.launch.LaunchResult; + +/** + * Strategy interface for launching EIM + * @author Ali Azam Rana + * * + */ +public interface EimLauncherStrategy +{ + LaunchResult launch(String eimPath) throws IOException; + + IStatus waitForExit(LaunchResult launchResult, IProgressMonitor monitor); +} diff --git a/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/launch/strategies/LinuxEimLauncherStrategy.java b/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/launch/strategies/LinuxEimLauncherStrategy.java new file mode 100644 index 000000000..299aca87a --- /dev/null +++ b/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/launch/strategies/LinuxEimLauncherStrategy.java @@ -0,0 +1,63 @@ +/******************************************************************************* + * Copyright 2025 Espressif Systems (Shanghai) PTE LTD. All rights reserved. + * Use is subject to license terms. + *******************************************************************************/ +package com.espressif.idf.core.tools.launch.strategies; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.List; + +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.IStatus; +import org.eclipse.swt.widgets.Display; +import org.eclipse.ui.console.MessageConsoleStream; +import com.espressif.idf.core.tools.launch.LaunchResult; +import com.espressif.idf.core.tools.launch.ProcessUtils; +import com.espressif.idf.core.tools.launch.ProcessWaiter; + +import com.espressif.idf.core.logging.Logger; + +/** + * Linux EIM launcher strategy + * @author Ali Azam Rana + */ +public final class LinuxEimLauncherStrategy extends AbstractLoggingLauncherStrategy +{ + public LinuxEimLauncherStrategy(Display display, MessageConsoleStream standardConsoleStream, + MessageConsoleStream errorConsoleStream) + { + super(display, standardConsoleStream, errorConsoleStream); + } + + @Override + public LaunchResult launch(String eimPath) throws IOException + { + if (!Files.exists(Paths.get(eimPath))) + throw new IOException("EIM path not found: " + eimPath); //$NON-NLS-1$ + + String quotedPath = ProcessUtils.bashSingleQuote(eimPath); + String bashCmd = "nohup " + quotedPath + " > /dev/null 2>&1 & echo $!"; //$NON-NLS-1$ //$NON-NLS-2$ + + List command = List.of("bash", "-lc", bashCmd); //$NON-NLS-1$ //$NON-NLS-2$ + Process launcher = new ProcessBuilder(command).redirectErrorStream(true).start(); + + String out = ProcessUtils.readAll(launcher.getInputStream()); + Long pid = ProcessUtils.parseFirstLongLine(out); + + if (pid == null) + { + Logger.log("Linux launcher output was:\n" + out); //$NON-NLS-1$ + throw new IOException("No PID found in launcher output. Output was:\n" + out); //$NON-NLS-1$ + } + + return LaunchResult.ofPid(pid.longValue(), eimPath, out); + } + + @Override + public IStatus waitForExit(LaunchResult launchResult, IProgressMonitor monitor) + { + return ProcessWaiter.waitForExitByPid(launchResult.pid(), monitor); + } +} diff --git a/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/launch/strategies/MacOsEimLauncherStrategy.java b/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/launch/strategies/MacOsEimLauncherStrategy.java new file mode 100644 index 000000000..9632e34b0 --- /dev/null +++ b/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/launch/strategies/MacOsEimLauncherStrategy.java @@ -0,0 +1,179 @@ +/******************************************************************************* + * Copyright 2025 Espressif Systems (Shanghai) PTE LTD. All rights reserved. + * Use is subject to license terms. + *******************************************************************************/ +package com.espressif.idf.core.tools.launch.strategies; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Path; +import java.nio.file.Paths; + +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.IStatus; +import org.eclipse.swt.widgets.Display; +import org.eclipse.ui.console.MessageConsoleStream; + +import com.espressif.idf.core.logging.Logger; +import com.espressif.idf.core.tools.launch.LaunchResult; +import com.espressif.idf.core.tools.launch.ProcessUtils; +import com.espressif.idf.core.tools.launch.ProcessWaiter; + +/** + * macOS EIM launcher strategy using AppleScript and execPath polling fallback + * @author Ali Azam Rana + */ +public final class MacOsEimLauncherStrategy extends AbstractLoggingLauncherStrategy +{ + /** + * AppleScript tries to: + * - open the app bundle + * - poll System Events for a process with the same bundle id + * - return unix id (PID) + * + * If PID is not returned (System Events permission, slow launch, etc), + * caller will fall back to execPath polling. + */ + private static final String MACOS_LAUNCH_AND_PID_APPLESCRIPT = """ + set appPath to system attribute "APP_PATH" + set bundleId to system attribute "BUNDLE_ID" + + do shell script "open -a " & quoted form of appPath + + tell application "System Events" + repeat 300 times + set matches to (application processes whose bundle identifier is bundleId) + if (count of matches) > 0 then + return unix id of (item 1 of matches) + end if + delay 0.1 + end repeat + end tell + + error "PID not found (app may not have launched)" + """; //$NON-NLS-1$ + + public MacOsEimLauncherStrategy(Display display, MessageConsoleStream standardConsoleStream, + MessageConsoleStream errorConsoleStream) + { + super(display, standardConsoleStream, errorConsoleStream); + } + + @Override + public LaunchResult launch(String eimPath) throws IOException + { + String appBundlePath = deriveAppBundlePath(eimPath); + String execPath = deriveExecPath(eimPath, appBundlePath); + String bundleId = readBundleId(appBundlePath); + + ProcessBuilder pb = new ProcessBuilder("osascript", "-"); //$NON-NLS-1$ //$NON-NLS-2$ + pb.redirectErrorStream(true); + pb.environment().put("APP_PATH", appBundlePath); //$NON-NLS-1$ + pb.environment().put("BUNDLE_ID", bundleId); //$NON-NLS-1$ + + Process p = pb.start(); + try (OutputStream stdin = p.getOutputStream()) + { + stdin.write(MACOS_LAUNCH_AND_PID_APPLESCRIPT.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + + String out; + int exit; + try + { + exit = p.waitFor(); + out = ProcessUtils.readAll(p.getInputStream()); + } + catch (InterruptedException e) + { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while waiting for osascript", e); //$NON-NLS-1$ + } + + Logger.log("APP_PATH=" + appBundlePath); //$NON-NLS-1$ + Logger.log("BUNDLE_ID=" + bundleId); //$NON-NLS-1$ + Logger.log("Launcher output was:\n" + out); //$NON-NLS-1$ + + // If osascript failed or returned no pid, we fall back to execPath polling. + Long pid = ProcessUtils.parseFirstLongLine(out); + if (exit == 0 && pid != null) + { + return LaunchResult.ofPid(pid.longValue(), execPath, out); + } + + // Fallback (still "successfully launched" from user perspective): + // - we already attempted "open -a" + // - we can wait for closure using pgrep -f execPath + return LaunchResult.ofNoPid(execPath, + "osascript exit=" + exit + "\n" + out); //$NON-NLS-1$ //$NON-NLS-2$ + } + + @Override + public IStatus waitForExit(LaunchResult launchResult, IProgressMonitor monitor) + { + if (launchResult.pid().isPresent()) + { + return ProcessWaiter.waitForExitByPid(launchResult.pid(), monitor); + } + + // Fallback wait (pgrep -f execPath) + return ProcessWaiter.waitForExitByExecPath(launchResult.execPath(), monitor); + } + + private String deriveAppBundlePath(String eimPath) + { + Path p = Paths.get(eimPath).toAbsolutePath().normalize(); + + while (p != null) + { + String name = p.getFileName() != null ? p.getFileName().toString() : ""; //$NON-NLS-1$ + if (name.endsWith(".app")) //$NON-NLS-1$ + { + return p.toString(); + } + p = p.getParent(); + } + + throw new IllegalArgumentException("Cannot derive .app bundle path from: " + eimPath); //$NON-NLS-1$ + } + + private String deriveExecPath(String eimPath, String appBundlePath) + { + // If caller passed the internal binary path, keep it. + if (eimPath != null && eimPath.contains(".app/Contents/MacOS/")) //$NON-NLS-1$ + { + return eimPath; + } + + // Otherwise derive default binary location for waiting. + return Paths.get(appBundlePath, "Contents", "MacOS", "eim").toString(); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + } + + private String readBundleId(String appBundlePath) throws IOException + { + // Prefer PlistBuddy because it reads Info.plist directly (no LaunchServices dependency). + String infoPlist = Paths.get(appBundlePath, "Contents", "Info.plist").toString(); //$NON-NLS-1$ //$NON-NLS-2$ + Process p = new ProcessBuilder("/usr/libexec/PlistBuddy", "-c", "Print :CFBundleIdentifier", infoPlist) //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + .redirectErrorStream(true).start(); + + String out; + int exit; + try + { + exit = p.waitFor(); + out = ProcessUtils.readAll(p.getInputStream()); + } + catch (InterruptedException e) + { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while reading Info.plist", e); //$NON-NLS-1$ + } + + if (exit != 0 || out == null || out.isBlank()) + { + throw new IOException("Failed to read CFBundleIdentifier. Output was:\n" + out); //$NON-NLS-1$ + } + + return out.trim(); + } +} diff --git a/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/launch/strategies/WindowsEimLauncherStrategy.java b/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/launch/strategies/WindowsEimLauncherStrategy.java new file mode 100644 index 000000000..8680aef7d --- /dev/null +++ b/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/launch/strategies/WindowsEimLauncherStrategy.java @@ -0,0 +1,65 @@ +/******************************************************************************* + * Copyright 2025 Espressif Systems (Shanghai) PTE LTD. All rights reserved. + * Use is subject to license terms. + *******************************************************************************/ +package com.espressif.idf.core.tools.launch.strategies; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.List; + +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.IStatus; +import org.eclipse.swt.widgets.Display; +import org.eclipse.ui.console.MessageConsoleStream; + +import com.espressif.idf.core.logging.Logger; +import com.espressif.idf.core.tools.launch.LaunchResult; +import com.espressif.idf.core.tools.launch.ProcessUtils; +import com.espressif.idf.core.tools.launch.ProcessWaiter; + +/** + * Linux EIM launcher strategy + * @author Ali Azam Rana + */ +public final class WindowsEimLauncherStrategy extends AbstractLoggingLauncherStrategy +{ + public WindowsEimLauncherStrategy(Display display, MessageConsoleStream standardConsoleStream, + MessageConsoleStream errorConsoleStream) + { + super(display, standardConsoleStream, errorConsoleStream); + } + + @Override + public LaunchResult launch(String eimPath) throws IOException + { + if (!Files.exists(Paths.get(eimPath))) + throw new IOException("EIM path not found: " + eimPath); //$NON-NLS-1$ + + String escapedPathForPowershell = eimPath.replace("'", "''"); //$NON-NLS-1$ //$NON-NLS-2$ + String powershellCmd = String.format( + "Start-Process -FilePath '%s' -PassThru | Select-Object -ExpandProperty Id", //$NON-NLS-1$ + escapedPathForPowershell); + + List command = List.of("powershell.exe", "-Command", powershellCmd); //$NON-NLS-1$ //$NON-NLS-2$ + Process launcher = new ProcessBuilder(command).redirectErrorStream(true).start(); + + String out = ProcessUtils.readAll(launcher.getInputStream()); + Long pid = ProcessUtils.parseFirstLongLine(out); + + if (pid == null) + { + Logger.log("Windows launcher output was:\n" + out); //$NON-NLS-1$ + throw new IOException("No PID found in launcher output. Output was:\n" + out); //$NON-NLS-1$ + } + + return LaunchResult.ofPid(pid.longValue(), eimPath, out); + } + + @Override + public IStatus waitForExit(LaunchResult launchResult, IProgressMonitor monitor) + { + return ProcessWaiter.waitForExitByPid(launchResult.pid(), monitor); + } +} diff --git a/bundles/com.espressif.idf.ui/META-INF/MANIFEST.MF b/bundles/com.espressif.idf.ui/META-INF/MANIFEST.MF index 2bc59e837..af5959f68 100644 --- a/bundles/com.espressif.idf.ui/META-INF/MANIFEST.MF +++ b/bundles/com.espressif.idf.ui/META-INF/MANIFEST.MF @@ -29,7 +29,8 @@ Require-Bundle: org.eclipse.core.runtime, org.eclipse.ltk.ui.refactoring, org.eclipse.ltk.core.refactoring, org.eclipse.tools.templates.core, - org.eclipse.tools.templates.ui + org.eclipse.tools.templates.ui, + org.eclipse.ui.intro Automatic-Module-Name: com.espressif.idf.ui Bundle-ActivationPolicy: lazy Bundle-RequiredExecutionEnvironment: JavaSE-17 diff --git a/bundles/com.espressif.idf.ui/src/com/espressif/idf/ui/tools/EimButtonLaunchListener.java b/bundles/com.espressif.idf.ui/src/com/espressif/idf/ui/tools/EimButtonLaunchListener.java index eb2eafef5..3baa44356 100644 --- a/bundles/com.espressif.idf.ui/src/com/espressif/idf/ui/tools/EimButtonLaunchListener.java +++ b/bundles/com.espressif.idf.ui/src/com/espressif/idf/ui/tools/EimButtonLaunchListener.java @@ -28,6 +28,7 @@ import com.espressif.idf.core.tools.EimLoader; import com.espressif.idf.core.tools.ToolInitializer; import com.espressif.idf.core.tools.exceptions.EimVersionMismatchException; +import com.espressif.idf.core.tools.launch.LaunchResult; import com.espressif.idf.core.tools.vo.EimJson; import com.espressif.idf.ui.UIPlugin; import com.espressif.idf.ui.handlers.EclipseHandler; @@ -87,8 +88,8 @@ protected IStatus run(IProgressMonitor monitor) { try { - Process process = eimLoader.launchEim(idfEnvironmentVariables.getEnvValue(IDFEnvironmentVariables.EIM_PATH)); - eimLoader.waitForEimClosure(process, EimButtonLaunchListener.this::refreshAfterEimClose); + var launchResult = eimLoader.launchEimWithResult(idfEnvironmentVariables.getEnvValue(IDFEnvironmentVariables.EIM_PATH)); + eimLoader.waitForEimClosure(launchResult, EimButtonLaunchListener.this::refreshAfterEimClose); } catch (IOException e) { @@ -194,12 +195,12 @@ public void onCompleted(String filePath) } } - Process process; + try { idfEnvironmentVariables.addEnvVariable(IDFEnvironmentVariables.EIM_PATH, appToLaunch); - process = eimLoader.launchEim(appToLaunch); - eimLoader.waitForEimClosure(process, EimButtonLaunchListener.this::refreshAfterEimClose); + LaunchResult launchResult = eimLoader.launchEimWithResult(appToLaunch); + eimLoader.waitForEimClosure(launchResult, EimButtonLaunchListener.this::refreshAfterEimClose); } catch (IOException e) { diff --git a/bundles/com.espressif.idf.ui/src/com/espressif/idf/ui/tools/EspressifToolStartup.java b/bundles/com.espressif.idf.ui/src/com/espressif/idf/ui/tools/EspressifToolStartup.java index 5f53a295a..9874bb8d9 100644 --- a/bundles/com.espressif.idf.ui/src/com/espressif/idf/ui/tools/EspressifToolStartup.java +++ b/bundles/com.espressif.idf.ui/src/com/espressif/idf/ui/tools/EspressifToolStartup.java @@ -49,6 +49,8 @@ import com.espressif.idf.ui.tools.manager.EimEditorInput; import com.espressif.idf.ui.tools.watcher.EimJsonUiChangeHandler; +import com.espressif.idf.core.tools.launch.LaunchResult; + /** * Startup class to handle the tools * @@ -376,7 +378,7 @@ public void onCompleted(String filePath) } }); - Process process = null; + LaunchResult launchResult = null; String appToLaunch = filePath; try { @@ -386,7 +388,7 @@ public void onCompleted(String filePath) } idfEnvironmentVariables.addEnvVariable(IDFEnvironmentVariables.EIM_PATH, appToLaunch); - process = eimLoader.launchEim(appToLaunch); + launchResult = eimLoader.launchEimWithResult(appToLaunch); } catch ( IOException @@ -395,7 +397,7 @@ public void onCompleted(String filePath) Logger.log(e); } - eimLoader.waitForEimClosure(process, () -> { + eimLoader.waitForEimClosure(launchResult, () -> { if (toolInitializer.isOldEspIdfConfigPresent() && !toolInitializer.isOldConfigExported()) { Logger.log("Old configuration found and not converted");