From 2cf5d98f3feb44dc3e4e9c61b9281ab2a7e97aa1 Mon Sep 17 00:00:00 2001 From: crschnick Date: Tue, 25 Mar 2025 22:04:12 +0000 Subject: [PATCH] Terminal rework --- .../java/io/xpipe/app/prefs/AppPrefs.java | 31 +++++++ .../app/terminal/TerminalLaunchRequest.java | 6 +- .../xpipe/app/terminal/TerminalLauncher.java | 39 ++++++++- .../app/terminal/TerminalMultiplexer.java | 26 ++++++ .../terminal/TerminalMultiplexerManager.java | 22 +++++ .../app/terminal/TerminalProxyManager.java | 81 +++++++++++++++++++ .../xpipe/app/terminal/WarpTerminalType.java | 6 +- .../terminal/ZellijTerminalMultiplexer.java | 64 +++++++++++++++ .../io/xpipe/app/util/AppJacksonModule.java | 6 +- .../io/xpipe/core/process/ShellScript.java | 12 +++ 10 files changed, 284 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/io/xpipe/app/terminal/TerminalMultiplexer.java create mode 100644 app/src/main/java/io/xpipe/app/terminal/TerminalMultiplexerManager.java create mode 100644 app/src/main/java/io/xpipe/app/terminal/TerminalProxyManager.java create mode 100644 app/src/main/java/io/xpipe/app/terminal/ZellijTerminalMultiplexer.java diff --git a/app/src/main/java/io/xpipe/app/prefs/AppPrefs.java b/app/src/main/java/io/xpipe/app/prefs/AppPrefs.java index f1a040982..a4d79c834 100644 --- a/app/src/main/java/io/xpipe/app/prefs/AppPrefs.java +++ b/app/src/main/java/io/xpipe/app/prefs/AppPrefs.java @@ -5,13 +5,17 @@ import io.xpipe.app.core.*; import io.xpipe.app.core.mode.OperationMode; import io.xpipe.app.ext.PrefsHandler; import io.xpipe.app.ext.PrefsProvider; +import io.xpipe.app.ext.ShellStore; import io.xpipe.app.icon.SystemIconSource; import io.xpipe.app.issue.ErrorEvent; import io.xpipe.app.password.NoPasswordManager; import io.xpipe.app.password.PasswordManager; import io.xpipe.app.password.PasswordManagerCommand; import io.xpipe.app.storage.DataStorage; +import io.xpipe.app.storage.DataStoreEntryRef; import io.xpipe.app.terminal.ExternalTerminalType; +import io.xpipe.app.terminal.TerminalMultiplexer; +import io.xpipe.app.terminal.ZellijTerminalMultiplexer; import io.xpipe.app.util.PlatformState; import io.xpipe.app.util.PlatformThread; import io.xpipe.core.process.ShellScript; @@ -107,6 +111,24 @@ public class AppPrefs { "passwordManager", PasswordManager.class, false); + final Property terminalInitScript = mapLocal( + new SimpleObjectProperty<>(null), + "terminalInitScript", + ShellScript.class, + false); + final Property terminalProxy = mapLocal( + new SimpleObjectProperty<>(), + "terminalProxy", + UUID.class, + false); + final Property terminalMultiplexer = mapLocal( + new SimpleObjectProperty<>(ZellijTerminalMultiplexer.builder().build()), + "terminalMultiplexer", + TerminalMultiplexer.class, + false); + public ObservableValue terminalProxy() { + return terminalProxy; + } public final StringProperty passwordManagerCommand = mapLocal(new SimpleStringProperty(null), "passwordManagerCommand", String.class, false); final ObjectProperty startupBehaviour = mapLocal( @@ -291,6 +313,7 @@ public class AppPrefs { DataStorage.get().forceRewrite(); } }); + INSTANCE.terminalProxy.setValue(UUID.fromString("08438e45-1d9f-4ce6-bbd7-cf47514d15f1")); } public static void setLocalDefaultsIfNeeded() { @@ -317,6 +340,14 @@ public class AppPrefs { return passwordManager; } + public ObservableValue terminalMultiplexer() { + return terminalMultiplexer; + } + + public ObservableValue terminalInitScript() { + return terminalInitScript; + } + public ObservableValue language() { return language; } diff --git a/app/src/main/java/io/xpipe/app/terminal/TerminalLaunchRequest.java b/app/src/main/java/io/xpipe/app/terminal/TerminalLaunchRequest.java index 2df3a9616..240322622 100644 --- a/app/src/main/java/io/xpipe/app/terminal/TerminalLaunchRequest.java +++ b/app/src/main/java/io/xpipe/app/terminal/TerminalLaunchRequest.java @@ -1,5 +1,6 @@ package io.xpipe.app.terminal; +import io.xpipe.app.prefs.AppPrefs; import io.xpipe.app.util.ScriptHelper; import io.xpipe.app.util.ThreadHelper; import io.xpipe.beacon.BeaconServerException; @@ -94,8 +95,9 @@ public class TerminalLaunchRequest { }; try { - var file = ScriptHelper.createLocalExecScript(processControl.prepareTerminalOpen(config, wd)); - setResult(new TerminalLaunchResult.ResultSuccess(Path.of(file.toString()))); + var command = TerminalLauncher.launchMultiplexer(processControl, config, wd); + var file = ScriptHelper.createLocalExecScript(command); + setResult(new TerminalLaunchResult.ResultSuccess(file.asLocalPath())); } catch (Exception e) { setResult(new TerminalLaunchResult.ResultFailure(e)); } diff --git a/app/src/main/java/io/xpipe/app/terminal/TerminalLauncher.java b/app/src/main/java/io/xpipe/app/terminal/TerminalLauncher.java index 88789df4a..5d4dda89f 100644 --- a/app/src/main/java/io/xpipe/app/terminal/TerminalLauncher.java +++ b/app/src/main/java/io/xpipe/app/terminal/TerminalLauncher.java @@ -128,7 +128,9 @@ public class TerminalLauncher { var config = TerminalLaunchConfiguration.create(request, entry, cleanTitle, adjustedTitle, preferTabs); var latch = TerminalLauncherManager.submitAsync(request, cc, terminalConfig, directory); try { - type.launch(config); + if (!checkMultiplexerLaunch(request, config)) { + type.launch(config); + } latch.await(); } catch (Exception ex) { var modMsg = ex.getMessage() != null && ex.getMessage().contains("Unable to find application named") @@ -138,4 +140,39 @@ public class TerminalLauncher { "Unable to launch terminal " + type.toTranslatedString().getValue() + ": " + modMsg, ex)); } } + + private static boolean checkMultiplexerLaunch(UUID request, TerminalLaunchConfiguration config) throws Exception { + if (!TerminalMultiplexerManager.requiresNewTerminalSession(request)) { + var control = TerminalProxyManager.getProxy(); + if (control.isPresent()) { + var type = AppPrefs.get().terminalType().getValue(); + var title = type.useColoredTitle() ? config.getColoredTitle() : config.getCleanTitle(); + var openCommand = control.get().prepareTerminalOpen(TerminalInitScriptConfig.ofName(title), WorkingDirectoryFunction.none()); + var multiplexer = AppPrefs.get().terminalMultiplexer().getValue(); + var fullCommand = multiplexer.launchScriptExternal(openCommand).toString(); + control.get().command(fullCommand).execute(); + return true; + } + } + return false; + } + + public static String launchMultiplexer(ProcessControl processControl, TerminalInitScriptConfig config, WorkingDirectoryFunction wd) throws Exception { + var initScript = AppPrefs.get().terminalInitScript().getValue(); + var initialCommand = initScript != null ? initScript.toString() : ""; + var openCommand = processControl.prepareTerminalOpen(config, wd); + var proxy = TerminalProxyManager.getProxy(); + var multiplexer = AppPrefs.get().terminalMultiplexer().getValue(); + var fullCommand = initialCommand + "\n" + (multiplexer != null ? multiplexer.launchScriptSession(openCommand).toString() : openCommand); + if (proxy.isPresent()) { + var proxyOpenCommand = fullCommand; + var proxyLaunchCommand = proxy.get().prepareIntermediateTerminalOpen( + TerminalInitFunction.fixed(proxyOpenCommand), + TerminalInitScriptConfig.ofName("XPipe"), + WorkingDirectoryFunction.none()); + return proxyLaunchCommand; + } else { + return fullCommand; + } + } } diff --git a/app/src/main/java/io/xpipe/app/terminal/TerminalMultiplexer.java b/app/src/main/java/io/xpipe/app/terminal/TerminalMultiplexer.java new file mode 100644 index 000000000..1c07ef8b2 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/terminal/TerminalMultiplexer.java @@ -0,0 +1,26 @@ +package io.xpipe.app.terminal; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.xpipe.core.process.ShellScript; +import io.xpipe.core.util.ValidationException; + +import java.util.ArrayList; +import java.util.List; + +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") +public interface TerminalMultiplexer { + + static List> getClasses() { + var l = new ArrayList>(); + l.add(ZellijTerminalMultiplexer.class); + return l; + } + + default void checkComplete() throws ValidationException {} + + String getDocsLink(); + + ShellScript launchScriptExternal(String command) throws Exception; + + ShellScript launchScriptSession(String command) throws Exception; +} diff --git a/app/src/main/java/io/xpipe/app/terminal/TerminalMultiplexerManager.java b/app/src/main/java/io/xpipe/app/terminal/TerminalMultiplexerManager.java new file mode 100644 index 000000000..a32866bf7 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/terminal/TerminalMultiplexerManager.java @@ -0,0 +1,22 @@ +package io.xpipe.app.terminal; + +import io.xpipe.app.prefs.AppPrefs; + +import java.util.*; + +public class TerminalMultiplexerManager { + + private static final Set connectionHubRequests = new HashSet<>(); + + public static boolean requiresNewTerminalSession(UUID requestUuid) { + if (AppPrefs.get().terminalMultiplexer().getValue() == null) { + connectionHubRequests.add(requestUuid); + return true; + } + + var hasTerminal = TerminalView.get().getSessions().stream().anyMatch(shellSession -> + shellSession.getTerminal().isRunning() && connectionHubRequests.contains(shellSession.getRequest())); + connectionHubRequests.add(requestUuid); + return !hasTerminal; + } +} diff --git a/app/src/main/java/io/xpipe/app/terminal/TerminalProxyManager.java b/app/src/main/java/io/xpipe/app/terminal/TerminalProxyManager.java new file mode 100644 index 000000000..1433b2bac --- /dev/null +++ b/app/src/main/java/io/xpipe/app/terminal/TerminalProxyManager.java @@ -0,0 +1,81 @@ +package io.xpipe.app.terminal; + +import io.xpipe.app.ext.ShellStore; +import io.xpipe.app.issue.ErrorEvent; +import io.xpipe.app.prefs.AppPrefs; +import io.xpipe.app.storage.DataStorage; +import io.xpipe.app.storage.DataStoreEntryRef; +import io.xpipe.app.util.ScriptHelper; +import io.xpipe.core.process.ShellControl; +import io.xpipe.core.process.TerminalInitFunction; +import io.xpipe.core.process.TerminalInitScriptConfig; +import io.xpipe.core.process.WorkingDirectoryFunction; +import io.xpipe.core.store.DataStore; +import lombok.Value; + +import java.util.Optional; +import java.util.UUID; + +public class TerminalProxyManager { + + @Value + private static class ActiveSession { + UUID uuid; + ShellControl control; + } + + private static ActiveSession activeSession; + + public static Optional getProxy() { + var uuid = AppPrefs.get().terminalProxy().getValue(); + var hasCustomTerminalShell = uuid != null && + !DataStorage.get().local().getUuid().equals(uuid); + if (!hasCustomTerminalShell) { + return Optional.empty(); + } + + var matchingSession = activeSession != null && activeSession.uuid.equals(uuid) ? activeSession : null; + if (matchingSession != null) { + // Probably incompatible + if (matchingSession.control == null) { + return Optional.empty(); + } + + try { + matchingSession.getControl().start(); + return Optional.of(matchingSession.getControl()); + } catch (Exception ex) { + ErrorEvent.fromThrowable(ex).handle(); + activeSession = new ActiveSession(uuid, null); + return Optional.empty(); + } + } + + DataStoreEntryRef ref = DataStorage.get().getStoreEntry(uuid).ref(); + try { + var control = createControl(ref); + if (control.isPresent()) { + control.get().start(); + activeSession = new ActiveSession(uuid, control.get()); + return control; + } + } catch (Exception ex) { + ErrorEvent.fromThrowable(ex).handle(); + } + activeSession = new ActiveSession(uuid, null); + return Optional.empty(); + } + + private static Optional createControl(DataStoreEntryRef ref) throws Exception { + if (ref == null || !ref.get().getValidity().isUsable() || !(ref.getStore() instanceof ShellStore ss)) { + return Optional.empty(); + } + + var store = ss; + var control = store.standaloneControl(); + if (!control.getLocalSystemAccess().supportsExecutables() || !control.getLocalSystemAccess().supportsFileSystemAccess()) { + return Optional.empty(); + } + return Optional.of(control); + } +} diff --git a/app/src/main/java/io/xpipe/app/terminal/WarpTerminalType.java b/app/src/main/java/io/xpipe/app/terminal/WarpTerminalType.java index 281344faa..70c451540 100644 --- a/app/src/main/java/io/xpipe/app/terminal/WarpTerminalType.java +++ b/app/src/main/java/io/xpipe/app/terminal/WarpTerminalType.java @@ -25,9 +25,9 @@ public interface WarpTerminalType extends ExternalTerminalType, TrackableTermina @Override public void launch(TerminalLaunchConfiguration configuration) throws Exception { try (var sc = LocalShell.getShell().start()) { - var command = sc.getShellDialect().getSetEnvironmentVariableCommand("PSModulePath", "") + "\n" + - sc.getShellDialect().runScriptCommand(sc, configuration.getScriptFile().toString()); - var script = ScriptHelper.createExecScript(sc, command); + var command = configuration.getScriptDialect().getSetEnvironmentVariableCommand("PSModulePath", "") + "\n" + + configuration.getScriptDialect().runScriptCommand(sc, configuration.getScriptFile().toString()); + var script = ScriptHelper.createExecScript(configuration.getScriptDialect(), sc, command); if (!configuration.isPreferTabs()) { DesktopHelper.openUrl("warp://action/new_window?path=" + script); } else { diff --git a/app/src/main/java/io/xpipe/app/terminal/ZellijTerminalMultiplexer.java b/app/src/main/java/io/xpipe/app/terminal/ZellijTerminalMultiplexer.java new file mode 100644 index 000000000..427d53a63 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/terminal/ZellijTerminalMultiplexer.java @@ -0,0 +1,64 @@ +package io.xpipe.app.terminal; + +import com.fasterxml.jackson.annotation.JsonTypeName; +import io.xpipe.app.comp.base.ButtonComp; +import io.xpipe.app.core.AppI18n; +import io.xpipe.app.password.KeePassXcAssociationKey; +import io.xpipe.app.password.KeePassXcManager; +import io.xpipe.app.password.KeePassXcProxyClient; +import io.xpipe.app.password.PasswordManager; +import io.xpipe.app.util.LocalShell; +import io.xpipe.app.util.OptionsBuilder; +import io.xpipe.app.util.ThreadHelper; +import io.xpipe.core.process.CommandBuilder; +import io.xpipe.core.process.ShellControl; +import io.xpipe.core.process.ShellScript; +import io.xpipe.core.store.FilePath; +import javafx.beans.property.Property; +import javafx.beans.property.SimpleObjectProperty; +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; +import lombok.extern.jackson.Jacksonized; + +@Getter +@Builder(toBuilder = true) +@ToString +@Jacksonized +@JsonTypeName("zellij") +public class ZellijTerminalMultiplexer implements TerminalMultiplexer { + + private final String wslDistribution; + private final FilePath config; + + public static OptionsBuilder createOptions(Property p) { + var config = new SimpleObjectProperty(p.getValue() != null ? p.getValue().getConfig() : null); + return new OptionsBuilder() + .addProperty(config) + .bind(() -> { + return null; //new ZellijTerminalMultiplexer(config.getValue()); + }, p); + } + + @Override + public String getDocsLink() { + return ""; + } + + @Override + public ShellScript launchScriptExternal(String command) throws Exception { + return ShellScript.lines( + "zellij attach --create-background xpipe", + "zellij run --close-on-exit -- " + command + ); + } + + @Override + public ShellScript launchScriptSession(String command) throws Exception { + return ShellScript.lines( + "zellij attach --create-background xpipe", + "zellij run --close-on-exit -- " + command, + "zellij attach xpipe" + ); + } +} diff --git a/app/src/main/java/io/xpipe/app/util/AppJacksonModule.java b/app/src/main/java/io/xpipe/app/util/AppJacksonModule.java index 23a6d3991..945056308 100644 --- a/app/src/main/java/io/xpipe/app/util/AppJacksonModule.java +++ b/app/src/main/java/io/xpipe/app/util/AppJacksonModule.java @@ -4,6 +4,7 @@ import io.xpipe.app.ext.LocalStore; import io.xpipe.app.password.PasswordManager; import io.xpipe.app.storage.*; import io.xpipe.app.terminal.ExternalTerminalType; +import io.xpipe.app.terminal.TerminalMultiplexer; import io.xpipe.core.util.InPlaceSecretValue; import io.xpipe.core.util.JacksonMapper; @@ -42,9 +43,8 @@ public class AppJacksonModule extends SimpleModule { addSerializer(EncryptedValue.VaultKey.class, new EncryptedValueSerializer()); addDeserializer(EncryptedValue.VaultKey.class, new EncryptedValueDeserializer<>()); - for (Class c : PasswordManager.getClasses()) { - context.registerSubtypes(c); - } + context.registerSubtypes(PasswordManager.getClasses()); + context.registerSubtypes(TerminalMultiplexer.getClasses()); context.addSerializers(_serializers); context.addDeserializers(_deserializers); diff --git a/core/src/main/java/io/xpipe/core/process/ShellScript.java b/core/src/main/java/io/xpipe/core/process/ShellScript.java index 87bb2371a..122a0b0de 100644 --- a/core/src/main/java/io/xpipe/core/process/ShellScript.java +++ b/core/src/main/java/io/xpipe/core/process/ShellScript.java @@ -2,8 +2,20 @@ package io.xpipe.core.process; import lombok.Value; +import java.util.Arrays; +import java.util.stream.Collectors; + @Value public class ShellScript { + public static ShellScript lines(String... lines) { + return new ShellScript(Arrays.stream(lines).collect(Collectors.joining("\n"))); + } + String value; + + @Override + public String toString() { + return value; + } }