diff --git a/core/src/main/java/io/xpipe/core/dialog/Choice.java b/core/src/main/java/io/xpipe/core/dialog/Choice.java index 797c3f487..e6d6bec27 100644 --- a/core/src/main/java/io/xpipe/core/dialog/Choice.java +++ b/core/src/main/java/io/xpipe/core/dialog/Choice.java @@ -10,8 +10,21 @@ import lombok.extern.jackson.Jacksonized; @Jacksonized @AllArgsConstructor public class Choice { + + /** + * The optional character which can be used to enter a choice. + */ Character character; + + /** + * The shown description of this choice. + */ String description; + + /** + * A Boolean indicating whether this choice is disabled or not. + * Disabled choices are still shown but can't be selected. + */ boolean disabled; public Choice(String description) { diff --git a/core/src/main/java/io/xpipe/core/dialog/Dialog.java b/core/src/main/java/io/xpipe/core/dialog/Dialog.java index dd851a28e..4eb49453d 100644 --- a/core/src/main/java/io/xpipe/core/dialog/Dialog.java +++ b/core/src/main/java/io/xpipe/core/dialog/Dialog.java @@ -8,8 +8,25 @@ import java.util.function.Function; import java.util.function.Predicate; import java.util.function.Supplier; +/** + * A Dialog is a sequence of questions and answers. + * + * The dialogue API is only used for the command line interface. + * Therefore, the actual implementation is handled by the command line component. + * This API provides a way of creating server-side dialogues which makes + * it possible to create extensions that provide a commandline configuration component. + * + * When a Dialog is completed, it can also be optionally evaluated to a value, which can be queried by calling {@link #getResult()}. + * The evaluation function can be set with {@link #evaluateTo(Supplier)}. + * Alternatively, a dialogue can also copy the evaluation function of another dialogue with {@link #evaluateTo(Dialog)}. + * An evaluation result can also be mapped to another type with {@link #map(Function)}. + * It is also possible to listen for the completion of this dialogue with {@link #onCompletion(Consumer)}. + */ public abstract class Dialog { + /** + * Creates an empty dialogue. This dialogue completes immediately and does not handle any questions or answers. + */ public static Dialog empty() { return new Dialog() { @Override @@ -53,12 +70,29 @@ public abstract class Dialog { } } + /** + * Creates a choice dialogue. + * + * @param description the shown question description + * @param elements the available elements to choose from + * @param required signals whether a choice is required or can be left empty + * @param selected the selected element index + */ public static Dialog.Choice choice(String description, List elements, boolean required, int selected) { Dialog.Choice c = new Dialog.Choice(description, elements, required, selected); c.evaluateTo(c::getSelected); return c; } + /** + * Creates a choice dialogue from a set of objects. + * + * @param description the shown question description + * @param toString a function that maps the objects to a string + * @param required signals whether choices required or can be left empty + * @param def the element which is selected by default + * @param vals the range of possible elements + */ @SafeVarargs public static Dialog.Choice choice(String description, Function toString, boolean required, T def, T... vals) { var elements = Arrays.stream(vals).map(v -> new io.xpipe.core.dialog.Choice(null, toString.apply(v))).toList(); @@ -85,11 +119,6 @@ public abstract class Dialog { this.element = new QueryElement(description, newLine, required, quiet, value, converter, hidden); } - @Override - public Optional> toValue() { - return Optional.of(new AbstractMap.SimpleEntry<>(element.getDescription(), element.getValue())); - } - @Override public DialogElement start() throws Exception { return element; @@ -109,17 +138,38 @@ public abstract class Dialog { } } + /** + * Creates a simple query dialogue. + * + * @param description the shown question description + * @param newLine signals whether the query should be done on a new line or not + * @param required signals whether the query can be left empty or not + * @param quiet signals whether the user should be explicitly queried for the value. + * In case the user is not queried, a value can still be set using the command line arguments + * that allow to set the specific value for a configuration query parameter + * @param value the default value + * @param converter the converter + */ public static Dialog.Query query(String description, boolean newLine, boolean required, boolean quiet, T value, QueryConverter converter) { var q = new Dialog.Query(description, newLine, required, quiet, value, converter, false); q.evaluateTo(q::getConvertedValue); return q; } + + /** + * A special wrapper for secret values of {@link #query(String, boolean, boolean, boolean, Object, QueryConverter)}. + */ public static Dialog.Query querySecret(String description, boolean newLine, boolean required, Secret value) { var q = new Dialog.Query(description, newLine, required, false, value, QueryConverter.SECRET, true); q.evaluateTo(q::getConvertedValue); return q; } + /** + * Chains multiple dialogues together. + * + * @param ds the dialogues + */ public static Dialog chain(Dialog... ds) { return new Dialog() { @@ -156,6 +206,9 @@ public abstract class Dialog { }.evaluateTo(ds[ds.length - 1]); } + /** + * Creates a dialogue that starts from the beginning if the repeating condition is true. + */ public static Dialog repeatIf(Dialog d, Predicate shouldRepeat) { return new Dialog() { @@ -180,10 +233,16 @@ public abstract class Dialog { }.evaluateTo(d).onCompletion(d.completion); } + /** + * Create a simple dialogue that will print a message. + */ public static Dialog header(String msg) { return of(new HeaderElement(msg)).evaluateTo(() -> msg); } + /** + * Create a simple dialogue that will print a message. + */ public static Dialog header(Supplier msg) { final String[] msgEval = {null}; return new Dialog() { @@ -200,7 +259,9 @@ public abstract class Dialog { }.evaluateTo(() -> msgEval[0]); } - + /** + * Creates a dialogue that will show a loading icon until the next dialogue question is sent. + */ public static Dialog busy() { return of(new BusyElement()); } @@ -210,6 +271,10 @@ public abstract class Dialog { T get() throws Exception; } + /** + * Creates a dialogue that will only evaluate when needed. + * This allows a dialogue to incorporate completion information about a previous dialogue. + */ public static Dialog lazy(FailableSupplier d) { return new Dialog() { @@ -231,7 +296,7 @@ public abstract class Dialog { }; } - public static Dialog of(DialogElement e) { + private static Dialog of(DialogElement e) { return new Dialog() { @@ -253,6 +318,9 @@ public abstract class Dialog { } + /** + * Creates a dialogue that will not be executed if the condition is true. + */ public static Dialog skipIf(Dialog d, Supplier check) { return new Dialog() { @@ -271,6 +339,9 @@ public abstract class Dialog { }.evaluateTo(d).onCompletion(d.completion); } + /** + * Creates a dialogue that will repeat with an error message if the condition is met. + */ public static Dialog retryIf(Dialog d, Function msg) { return new Dialog() { @@ -303,6 +374,15 @@ public abstract class Dialog { }.evaluateTo(d.evaluation).onCompletion(d.completion); } + /** + * Creates a dialogue that will fork the control flow. + * + * @param description the shown question description + * @param elements the available elements to choose from + * @param required signals whether a choice is required or not + * @param selected the index of the element that is selected by default + * @param c the dialogue index mapping function + */ public static Dialog fork(String description, List elements, boolean required, int selected, Function c) { var choice = new ChoiceElement(description, elements, required, selected); return new Dialog() { @@ -343,10 +423,6 @@ public abstract class Dialog { public abstract DialogElement start() throws Exception; - public Optional> toValue() { - return Optional.empty(); - } - public Dialog evaluateTo(Dialog d) { evaluation = () -> d.evaluation.get(); return this; diff --git a/core/src/main/java/io/xpipe/core/dialog/DialogCancelException.java b/core/src/main/java/io/xpipe/core/dialog/DialogCancelException.java index 00270b6f2..ba3436dfe 100644 --- a/core/src/main/java/io/xpipe/core/dialog/DialogCancelException.java +++ b/core/src/main/java/io/xpipe/core/dialog/DialogCancelException.java @@ -1,5 +1,8 @@ package io.xpipe.core.dialog; +/** + * An exception indicating that the user aborted the dialogue. + */ public class DialogCancelException extends Exception { public DialogCancelException() { diff --git a/core/src/main/java/io/xpipe/core/dialog/DialogReference.java b/core/src/main/java/io/xpipe/core/dialog/DialogReference.java index f376968cd..a63ab0495 100644 --- a/core/src/main/java/io/xpipe/core/dialog/DialogReference.java +++ b/core/src/main/java/io/xpipe/core/dialog/DialogReference.java @@ -6,6 +6,9 @@ import lombok.Value; import java.util.UUID; +/** + * A reference to a dialogue instance that will be exchanged whenever a dialogue is started. + */ @Value public class DialogReference { diff --git a/core/src/main/java/io/xpipe/core/store/DataStore.java b/core/src/main/java/io/xpipe/core/store/DataStore.java index 9d73a409c..0e02dd2de 100644 --- a/core/src/main/java/io/xpipe/core/store/DataStore.java +++ b/core/src/main/java/io/xpipe/core/store/DataStore.java @@ -16,10 +16,22 @@ import java.util.Optional; @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") public interface DataStore { - default boolean isComplete() { - return true; - } - + /** + * Performs a validation of this data store. + * + * This validation can include one of multiple things: + * - Sanity checks of individual properties + * - Existence checks + * - Connection checks + * + * All in all, a successful execution of this method should almost guarantee + * that the data store can be successfully accessed in the near future. + * + * Note that some checks may take a long time, for example if a connection has to be validated. + * The caller should therefore expect a runtime of multiple seconds when calling this method. + * + * @throws Exception if any part of the validation went wrong + */ default void validate() throws Exception { } @@ -27,6 +39,10 @@ public interface DataStore { return false; } + /** + * Creates a display string of this store. + * This can be a multiline string. + */ default String toDisplay() { return null; } diff --git a/core/src/main/java/io/xpipe/core/store/FileStore.java b/core/src/main/java/io/xpipe/core/store/FileStore.java index 0d710d14c..12d71eb30 100644 --- a/core/src/main/java/io/xpipe/core/store/FileStore.java +++ b/core/src/main/java/io/xpipe/core/store/FileStore.java @@ -8,6 +8,9 @@ import java.io.InputStream; import java.io.OutputStream; import java.nio.file.Path; +/** + * Represents a file located on a certain machine. + */ @Value @JsonTypeName("file") public class FileStore implements StreamDataStore, FilenameStore { @@ -16,6 +19,9 @@ public class FileStore implements StreamDataStore, FilenameStore { return new FileStore(MachineFileStore.local(), p.toString()); } + /** + * Creates a file store for a file that is local to the callers machine. + */ public static FileStore local(String p) { return new FileStore(MachineFileStore.local(), p); } diff --git a/core/src/main/java/io/xpipe/core/store/FilenameStore.java b/core/src/main/java/io/xpipe/core/store/FilenameStore.java index 0dd486cdb..c14375953 100644 --- a/core/src/main/java/io/xpipe/core/store/FilenameStore.java +++ b/core/src/main/java/io/xpipe/core/store/FilenameStore.java @@ -2,6 +2,10 @@ package io.xpipe.core.store; import java.util.Optional; +/** + * Represents a store that has a filename. + * Note that this does not only apply to file stores but any other store as well that has some kind of file name. + */ public interface FilenameStore extends DataStore { @Override diff --git a/core/src/main/java/io/xpipe/core/store/StringStore.java b/core/src/main/java/io/xpipe/core/store/InMemoryStore.java similarity index 63% rename from core/src/main/java/io/xpipe/core/store/StringStore.java rename to core/src/main/java/io/xpipe/core/store/InMemoryStore.java index 8124ea276..6430c312d 100644 --- a/core/src/main/java/io/xpipe/core/store/StringStore.java +++ b/core/src/main/java/io/xpipe/core/store/InMemoryStore.java @@ -8,21 +8,29 @@ import java.io.ByteArrayInputStream; import java.io.InputStream; import java.nio.charset.StandardCharsets; +/** + * A store whose contents are stored in memory. + */ @Value -@JsonTypeName("string") -public class StringStore implements StreamDataStore { +@JsonTypeName("inMemory") +public class InMemoryStore implements StreamDataStore { byte[] value; @JsonCreator - public StringStore(byte[] value) { + public InMemoryStore(byte[] value) { this.value = value; } - public StringStore(String s) { + public InMemoryStore(String s) { value = s.getBytes(StandardCharsets.UTF_8); } + @Override + public boolean isLocalToApplication() { + return true; + } + @Override public InputStream openInput() throws Exception { return new ByteArrayInputStream(value); @@ -30,6 +38,6 @@ public class StringStore implements StreamDataStore { @Override public String toDisplay() { - return "string"; + return "inMemory"; } } diff --git a/core/src/main/java/io/xpipe/core/store/InputStreamDataStore.java b/core/src/main/java/io/xpipe/core/store/InputStreamDataStore.java index 7db905e6d..5edea6773 100644 --- a/core/src/main/java/io/xpipe/core/store/InputStreamDataStore.java +++ b/core/src/main/java/io/xpipe/core/store/InputStreamDataStore.java @@ -1,21 +1,14 @@ package io.xpipe.core.store; -import java.io.BufferedInputStream; -import java.io.IOException; import java.io.InputStream; -import java.io.OutputStream; /** * A data store that is only represented by an InputStream. - * One common use case of this class are piped inputs. - * - * As the data in a pipe can only be read once, this implementation - * internally uses a BufferedInputStream to support mark/rest. + * This can be useful for development. */ public class InputStreamDataStore implements StreamDataStore { private final InputStream in; - private BufferedInputStream bufferedInputStream; public InputStreamDataStore(InputStream in) { this.in = in; @@ -23,89 +16,7 @@ public class InputStreamDataStore implements StreamDataStore { @Override public InputStream openInput() throws Exception { - if (bufferedInputStream != null) { - bufferedInputStream.reset(); - return bufferedInputStream; - } - - bufferedInputStream = new BufferedInputStream(in); - bufferedInputStream.mark(Integer.MAX_VALUE); - return new InputStream() { - @Override - public int read() throws IOException { - return bufferedInputStream.read(); - } - - @Override - public int read(byte[] b) throws IOException { - return bufferedInputStream.read(b); - } - - @Override - public int read(byte[] b, int off, int len) throws IOException { - return bufferedInputStream.read(b, off, len); - } - - @Override - public byte[] readAllBytes() throws IOException { - return bufferedInputStream.readAllBytes(); - } - - @Override - public byte[] readNBytes(int len) throws IOException { - return bufferedInputStream.readNBytes(len); - } - - @Override - public int readNBytes(byte[] b, int off, int len) throws IOException { - return bufferedInputStream.readNBytes(b, off, len); - } - - @Override - public long skip(long n) throws IOException { - return bufferedInputStream.skip(n); - } - - @Override - public void skipNBytes(long n) throws IOException { - bufferedInputStream.skipNBytes(n); - } - - @Override - public int available() throws IOException { - return bufferedInputStream.available(); - } - - @Override - public void close() throws IOException { - reset(); - } - - @Override - public synchronized void mark(int readlimit) { - bufferedInputStream.mark(readlimit); - } - - @Override - public synchronized void reset() throws IOException { - bufferedInputStream.reset(); - } - - @Override - public boolean markSupported() { - return bufferedInputStream.markSupported(); - } - - @Override - public long transferTo(OutputStream out) throws IOException { - return bufferedInputStream.transferTo(out); - } - }; - } - - @Override - public OutputStream openOutput() throws Exception { - throw new UnsupportedOperationException("No output available"); + return in; } @Override diff --git a/core/src/main/java/io/xpipe/core/store/LocalStore.java b/core/src/main/java/io/xpipe/core/store/LocalStore.java index d60491aff..ffbadb9be 100644 --- a/core/src/main/java/io/xpipe/core/store/LocalStore.java +++ b/core/src/main/java/io/xpipe/core/store/LocalStore.java @@ -3,16 +3,74 @@ package io.xpipe.core.store; import com.fasterxml.jackson.annotation.JsonTypeName; import lombok.Value; +import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.io.UncheckedIOException; +import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; @JsonTypeName("local") @Value -public class LocalStore implements ShellStore { +public class LocalStore implements ShellProcessStore { + + + static class LocalProcessControl extends ProcessControl { + + private final InputStream input; + private final ProcessBuilder builder; + + private Process process; + + LocalProcessControl(InputStream input, List cmd) { + this.input = input; + var l = new ArrayList(); + l.add("cmd"); + l.add("/c"); + l.addAll(cmd); + builder = new ProcessBuilder(l); + } + + @Override + public void start() throws IOException { + process = builder.start(); + + var t = new Thread(() -> { + try { + input.transferTo(process.getOutputStream()); + process.getOutputStream().close(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + t.setDaemon(true); + t.start(); + } + + @Override + public int waitFor() throws Exception { + return process.waitFor(); + } + + @Override + public InputStream getStdout() { + return process.getInputStream(); + } + + @Override + public InputStream getStderr() { + return process.getErrorStream(); + } + + @Override + public Charset getCharset() { + return StandardCharsets.UTF_8; + } + } @Override public boolean exists(String file) { @@ -37,17 +95,17 @@ public class LocalStore implements ShellStore { } @Override - public String executeAndRead(List cmd) throws Exception { - var p = prepare(cmd).redirectErrorStream(true); - var proc = p.start(); - var b = proc.getInputStream().readAllBytes(); - proc.waitFor(); - //TODO - return new String(b, StandardCharsets.UTF_16LE); + public ProcessControl prepareCommand(InputStream input, List cmd) { + return new LocalProcessControl(input, cmd); } @Override - public List createCommand(List cmd) { - return cmd; + public ProcessControl preparePrivilegedCommand(InputStream input, List cmd) throws Exception { + return new LocalProcessControl(input, cmd); + } + + @Override + public ShellType determineType() { + return ShellTypes.CMD; } } diff --git a/core/src/main/java/io/xpipe/core/store/NamedStore.java b/core/src/main/java/io/xpipe/core/store/NamedStore.java index d64e438b7..3e968e83d 100644 --- a/core/src/main/java/io/xpipe/core/store/NamedStore.java +++ b/core/src/main/java/io/xpipe/core/store/NamedStore.java @@ -7,6 +7,10 @@ import lombok.Getter; import java.time.Instant; import java.util.Optional; +/** + * A store that refers to another store in the X-Pipe storage. + * The referenced store has to be resolved by the caller manually, as this class does not act as a resolver. + */ @JsonTypeName("named") public final class NamedStore implements DataStore { diff --git a/core/src/main/java/io/xpipe/core/store/ProcessControl.java b/core/src/main/java/io/xpipe/core/store/ProcessControl.java new file mode 100644 index 000000000..d936feda1 --- /dev/null +++ b/core/src/main/java/io/xpipe/core/store/ProcessControl.java @@ -0,0 +1,79 @@ +package io.xpipe.core.store; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UncheckedIOException; +import java.nio.charset.Charset; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + +public abstract class ProcessControl { + + public String readOutOnly() throws Exception { + start(); + var errT = discardErr(); + var string = new String(getStdout().readAllBytes(), getCharset()); + errT.join(); + waitFor(); + return string; + } + + public Optional readErrOnly() throws Exception { + start(); + var outT = discardOut(); + + AtomicReference read = new AtomicReference<>(); + var t = new Thread(() -> { + try { + read.set(new String(getStderr().readAllBytes(), getCharset())); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + t.setDaemon(true); + t.start(); + + outT.join(); + t.join(); + + var ec = waitFor(); + return ec != 0 ? Optional.of(read.get()) : Optional.empty(); + } + + public Thread discardOut() { + var t = new Thread(() -> { + try { + getStdout().transferTo(OutputStream.nullOutputStream()); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + t.setDaemon(true); + t.start(); + return t; + } + + public Thread discardErr() { + var t = new Thread(() -> { + try { + getStderr().transferTo(OutputStream.nullOutputStream()); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + t.setDaemon(true); + t.start(); + return t; + } + + public abstract void start() throws Exception; + + public abstract int waitFor() throws Exception; + + public abstract InputStream getStdout(); + + public abstract InputStream getStderr(); + + public abstract Charset getCharset(); +} diff --git a/core/src/main/java/io/xpipe/core/store/ProcessHandler.java b/core/src/main/java/io/xpipe/core/store/ProcessHandler.java new file mode 100644 index 000000000..b587eeed6 --- /dev/null +++ b/core/src/main/java/io/xpipe/core/store/ProcessHandler.java @@ -0,0 +1,8 @@ +package io.xpipe.core.store; + +import java.io.OutputStream; + +public interface ProcessHandler { + + void handle(OutputStream out, OutputStream err); +} diff --git a/core/src/main/java/io/xpipe/core/store/ShellProcessStore.java b/core/src/main/java/io/xpipe/core/store/ShellProcessStore.java new file mode 100644 index 000000000..ae22ca1ff --- /dev/null +++ b/core/src/main/java/io/xpipe/core/store/ShellProcessStore.java @@ -0,0 +1,37 @@ +package io.xpipe.core.store; + +import java.io.InputStream; +import java.io.OutputStream; + +public interface ShellProcessStore extends StandardShellStore { + + ShellType determineType() throws Exception; + + @Override + default InputStream openInput(String file) throws Exception { + var type = determineType(); + var cmd = type.createFileReadCommand(file); + var p = prepareCommand(InputStream.nullInputStream(), cmd); + p.start(); + return p.getStdout(); + } + + @Override + default OutputStream openOutput(String file) throws Exception { + return null; +// var type = determineType(); +// var cmd = type.createFileWriteCommand(file); +// var p = prepare(cmd).redirectErrorStream(true); +// var proc = p.start(); +// return proc.getOutputStream(); + } + + @Override + default boolean exists(String file) throws Exception { + var type = determineType(); + var cmd = type.createFileExistsCommand(file); + var p = prepareCommand(InputStream.nullInputStream(), cmd); + p.start(); + return p.waitFor() == 0; + } +} diff --git a/core/src/main/java/io/xpipe/core/store/ShellStore.java b/core/src/main/java/io/xpipe/core/store/ShellStore.java index afb5f8c5a..af0a64bca 100644 --- a/core/src/main/java/io/xpipe/core/store/ShellStore.java +++ b/core/src/main/java/io/xpipe/core/store/ShellStore.java @@ -1,19 +1,83 @@ package io.xpipe.core.store; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; public interface ShellStore extends MachineFileStore { - static ShellStore local() { + static StandardShellStore local() { return new LocalStore(); } - default ProcessBuilder prepare(List cmd) throws Exception { - var toExec = createCommand(cmd); - return new ProcessBuilder(toExec); + default String executeAndRead(List cmd) throws Exception { + var pc = prepareCommand(InputStream.nullInputStream(), cmd); + pc.start(); + pc.discardErr(); + var string = new String(pc.getStdout().readAllBytes(), pc.getCharset()); + return string; } - String executeAndRead(List cmd) throws Exception; + default Optional executeAndCheckOut(InputStream in, List cmd) throws Exception { + var pc = prepareCommand(in, cmd); + pc.start(); + var outT = pc.discardErr(); - List createCommand(List cmd); + AtomicReference read = new AtomicReference<>(); + var t = new Thread(() -> { + try { + read.set(new String(pc.getStdout().readAllBytes(), pc.getCharset())); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + t.setDaemon(true); + t.start(); + + outT.join(); + t.join(); + + var ec = pc.waitFor(); + return ec == 0 ? Optional.of(read.get()) : Optional.empty(); + } + + default Optional executeAndCheckErr(InputStream in, List cmd) throws Exception { + var pc = prepareCommand(in, cmd); + pc.start(); + var outT = pc.discardOut(); + + AtomicReference read = new AtomicReference<>(); + var t = new Thread(() -> { + try { + read.set(new String(pc.getStderr().readAllBytes(), pc.getCharset())); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + t.setDaemon(true); + t.start(); + + outT.join(); + t.join(); + + var ec = pc.waitFor(); + return ec != 0 ? Optional.of(read.get()) : Optional.empty(); + } + + default ProcessControl prepareCommand(List cmd) throws Exception { + return prepareCommand(InputStream.nullInputStream(), cmd); + } + + ProcessControl prepareCommand(InputStream input, List cmd) throws Exception; + + default ProcessControl preparePrivilegedCommand(List cmd) throws Exception { + return preparePrivilegedCommand(InputStream.nullInputStream(), cmd); + } + + default ProcessControl preparePrivilegedCommand(InputStream input, List cmd) throws Exception { + throw new UnsupportedOperationException(); + } } diff --git a/core/src/main/java/io/xpipe/core/store/ShellTypes.java b/core/src/main/java/io/xpipe/core/store/ShellTypes.java new file mode 100644 index 000000000..5753e0ae6 --- /dev/null +++ b/core/src/main/java/io/xpipe/core/store/ShellTypes.java @@ -0,0 +1,195 @@ +package io.xpipe.core.store; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +public class ShellTypes { + + public static StandardShellStore.ShellType determine(ShellStore store) throws Exception { + var o = store.executeAndCheckOut(InputStream.nullInputStream(), List.of("echo", "$0")); + if (o.isPresent() && !o.get().equals("$0")) { + return SH; + } else { + o = store.executeAndCheckOut(InputStream.nullInputStream(), List.of("(dir 2>&1 *`|echo CMD);&<# rem #>echo PowerShell")); + if (o.isPresent() && o.get().equals("PowerShell")) { + return POWERSHELL; + } else { + return CMD; + } + } + } + + public static StandardShellStore.ShellType[] getAvailable(ShellStore store) throws Exception { + var o = store.executeAndCheckOut(InputStream.nullInputStream(), List.of("echo", "$0")); + if (o.isPresent() && !o.get().trim().equals("$0")) { + return getLinuxShells(); + } else { + return getWindowsShells(); + } + } + + public static final StandardShellStore.ShellType POWERSHELL = new StandardShellStore.ShellType() { + + @Override + public List switchTo(List cmd) { + var l = new ArrayList<>(cmd); + l.add(0, "powershell.exe"); + return l; + } + + @Override + public List createFileReadCommand(String file) { + return List.of("Get-Content", file); + } + + @Override + public List createFileWriteCommand(String file) { + return List.of("Out-File", "-FilePath", file); + } + + @Override + public List createFileExistsCommand(String file) { + return List.of("Test-Path", "-path", file); + } + + @Override + public Charset getCharset() { + return StandardCharsets.UTF_16LE; + } + + @Override + public String getName() { + return "powershell"; + } + + @Override + public String getDisplayName() { + return "PowerShell"; + } + }; + + public static final StandardShellStore.ShellType CMD = new StandardShellStore.ShellType() { + + @Override + public List switchTo(List cmd) { + var l = new ArrayList<>(cmd); + l.add(0, "cmd.exe"); + l.add(1, "/c"); + return l; + } + + @Override + public ProcessControl prepareElevatedCommand(ShellStore st, InputStream in, List cmd, String pw) throws Exception { + var l = List.of("net", "session", ";", "if", "%errorLevel%", "!=", "0"); + return st.prepareCommand(InputStream.nullInputStream(), l); + } + + @Override + public List createFileReadCommand(String file) { + return List.of("type", file); + } + + @Override + public List createFileWriteCommand(String file) { + return List.of("Out-File", "-FilePath", file); + } + + @Override + public List createFileExistsCommand(String file) { + return List.of("if", "exist", file, "echo", "hi"); + } + + @Override + public Charset getCharset() { + return StandardCharsets.UTF_16LE; + } + + @Override + public String getName() { + return "cmd"; + } + + @Override + public String getDisplayName() { + return "cmd.exe"; + } + }; + + public static final StandardShellStore.ShellType SH = new StandardShellStore.ShellType() { + + @Override + public List switchTo(List cmd) { + return cmd; + } + + @Override + public ProcessControl prepareElevatedCommand(ShellStore st, InputStream in, List cmd, String pw) throws Exception { + var l = new ArrayList<>(cmd); + l.add(0, "sudo"); + l.add(1, "-S"); + var pws = new ByteArrayInputStream(pw.getBytes(getCharset())); + return st.prepareCommand(pws, l); + } + + @Override + public List createFileReadCommand(String file) { + return List.of("cat", file); + } + + @Override + public List createFileWriteCommand(String file) { + return List.of(file); + } + + @Override + public List createFileExistsCommand(String file) { + return List.of("test", "-f", file, "||", "test", "-d", file); + } + + @Override + public Charset getCharset() { + return StandardCharsets.UTF_8; + } + + @Override + public String getName() { + return "sh"; + } + + @Override + public String getDisplayName() { + return "/bin/sh"; + } + }; + + public static StandardShellStore.ShellType getDefault() { + if (System.getProperty("os.name").startsWith("Windows")) { + return CMD; + } else { + return SH; + } + } + + + public static StandardShellStore.ShellType[] getWindowsShells() { + return new StandardShellStore.ShellType[]{CMD, POWERSHELL}; + } + + public static StandardShellStore.ShellType[] getLinuxShells() { + return new StandardShellStore.ShellType[]{SH}; + } + + private final String name; + + ShellTypes(String name) { + this.name = name; + } + + public String getName() { + return name; + } +} diff --git a/core/src/main/java/io/xpipe/core/store/StandardShellStore.java b/core/src/main/java/io/xpipe/core/store/StandardShellStore.java index 614326d59..6b8321ba9 100644 --- a/core/src/main/java/io/xpipe/core/store/StandardShellStore.java +++ b/core/src/main/java/io/xpipe/core/store/StandardShellStore.java @@ -1,7 +1,6 @@ package io.xpipe.core.store; import java.io.InputStream; -import java.io.OutputStream; import java.nio.charset.Charset; import java.util.List; @@ -9,6 +8,12 @@ public interface StandardShellStore extends ShellStore { static interface ShellType { + List switchTo(List cmd); + + default ProcessControl prepareElevatedCommand(ShellStore st, InputStream in, List cmd, String pw) throws Exception { + return st.prepareCommand(in, cmd); + } + List createFileReadCommand(String file); List createFileWriteCommand(String file); @@ -18,44 +23,9 @@ public interface StandardShellStore extends ShellStore { Charset getCharset(); String getName(); + + String getDisplayName(); } - default String executeAndRead(List cmd) throws Exception { - var type = determineType(); - var p = prepare(cmd).redirectErrorStream(true); - var proc = p.start(); - var s = new String(proc.getInputStream().readAllBytes(), type.getCharset()); - return s; - } - - List createCommand(List cmd); - - ShellType determineType(); - - @Override - default InputStream openInput(String file) throws Exception { - var type = determineType(); - var cmd = type.createFileReadCommand(file); - var p = prepare(cmd).redirectErrorStream(true); - var proc = p.start(); - return proc.getInputStream(); - } - - @Override - default OutputStream openOutput(String file) throws Exception { - var type = determineType(); - var cmd = type.createFileWriteCommand(file); - var p = prepare(cmd).redirectErrorStream(true); - var proc = p.start(); - return proc.getOutputStream(); - } - - @Override - default boolean exists(String file) throws Exception { - var type = determineType(); - var cmd = type.createFileExistsCommand(file); - var p = prepare(cmd).redirectErrorStream(true); - var proc = p.start(); - return proc.waitFor() == 0; - } + ShellType determineType() throws Exception; } diff --git a/core/src/main/java/io/xpipe/core/store/StdinDataStore.java b/core/src/main/java/io/xpipe/core/store/StdinDataStore.java index dcb279bdd..d92971fee 100644 --- a/core/src/main/java/io/xpipe/core/store/StdinDataStore.java +++ b/core/src/main/java/io/xpipe/core/store/StdinDataStore.java @@ -16,6 +16,7 @@ public class StdinDataStore implements StreamDataStore { @Override public InputStream openInput() throws Exception { var in = System.in; + // Prevent closing the standard in when the returned input stream is closed return new InputStream() { @Override public int read() throws IOException { @@ -87,9 +88,4 @@ public class StdinDataStore implements StreamDataStore { } }; } - - @Override - public boolean canOpen() { - return false; - } } diff --git a/core/src/main/java/io/xpipe/core/store/StdoutDataStore.java b/core/src/main/java/io/xpipe/core/store/StdoutDataStore.java index 8388f7130..3c12eb99a 100644 --- a/core/src/main/java/io/xpipe/core/store/StdoutDataStore.java +++ b/core/src/main/java/io/xpipe/core/store/StdoutDataStore.java @@ -14,6 +14,7 @@ public class StdoutDataStore implements StreamDataStore { @Override public OutputStream openOutput() throws Exception { + // Create an output stream that will write to standard out but will not close it return new OutputStream() { @Override public void write(int b) throws IOException { @@ -40,9 +41,4 @@ public class StdoutDataStore implements StreamDataStore { } }; } - - @Override - public boolean canOpen() { - return false; - } } diff --git a/core/src/main/java/io/xpipe/core/store/StreamDataStore.java b/core/src/main/java/io/xpipe/core/store/StreamDataStore.java index 0dae70b02..cd91b3c2a 100644 --- a/core/src/main/java/io/xpipe/core/store/StreamDataStore.java +++ b/core/src/main/java/io/xpipe/core/store/StreamDataStore.java @@ -6,21 +6,30 @@ import java.io.OutputStream; /** * A data store that can be accessed using InputStreams and/or OutputStreams. - * These streams must support mark/reset. */ public interface StreamDataStore extends DataStore { - default boolean isLocalOnly() { + /** + * Indicates whether this data store can only be accessed by the current running application. + * One example are standard in and standard out stores. + * + * @see StdinDataStore + * @see StdoutDataStore + */ + default boolean isLocalToApplication() { return true; } /** - * Opens an input stream. This input stream does not necessarily have to be a new instance. + * Opens an input stream that can be used to read its data. */ default InputStream openInput() throws Exception { throw new UnsupportedOperationException("Can't open store input"); } + /** + * Opens an input stream that is guaranteed to be buffered. + */ default InputStream openBufferedInput() throws Exception { var in = openInput(); if (in.markSupported()) { @@ -31,16 +40,24 @@ public interface StreamDataStore extends DataStore { } /** - * Opens an output stream. This output stream does not necessarily have to be a new instance. + * Opens an output stream that can be used to write data. */ default OutputStream openOutput() throws Exception { throw new UnsupportedOperationException("Can't open store output"); } + /** + * Checks whether this store can be opened. + * This can be not the case for example if the underlying store does not exist. + */ default boolean canOpen() throws Exception { return true; } + /** + * Indicates whether this store is persistent, i.e. whether the stored data can be read again or not. + * The caller has to adapt accordingly based on the persistence property. + */ default boolean persistent() { return false; } diff --git a/core/src/main/java/io/xpipe/core/util/CoreJacksonModule.java b/core/src/main/java/io/xpipe/core/util/CoreJacksonModule.java index c37b3f3ae..06cba56e5 100644 --- a/core/src/main/java/io/xpipe/core/util/CoreJacksonModule.java +++ b/core/src/main/java/io/xpipe/core/util/CoreJacksonModule.java @@ -37,7 +37,7 @@ public class CoreJacksonModule extends SimpleModule { new NamedType(StdoutDataStore.class), new NamedType(LocalDirectoryDataStore.class), new NamedType(CollectionEntryDataStore.class), - new NamedType(StringStore.class), + new NamedType(InMemoryStore.class), new NamedType(LocalStore.class), new NamedType(NamedStore.class), @@ -141,7 +141,7 @@ public class CoreJacksonModule extends SimpleModule { @Override public Secret deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { - return Secret.create(p.getValueAsString()); + return new Secret(p.getValueAsString()); } } diff --git a/core/src/main/java/io/xpipe/core/util/Secret.java b/core/src/main/java/io/xpipe/core/util/Secret.java index 5d62349f7..9c69f01fd 100644 --- a/core/src/main/java/io/xpipe/core/util/Secret.java +++ b/core/src/main/java/io/xpipe/core/util/Secret.java @@ -10,7 +10,7 @@ import java.util.Base64; @EqualsAndHashCode public class Secret { - public static Secret create(String s) { + public static Secret createForSecretValue(String s) { if (s == null) { return null; } diff --git a/core/src/main/java/io/xpipe/core/util/StreamHelper.java b/core/src/main/java/io/xpipe/core/util/StreamHelper.java new file mode 100644 index 000000000..f142be3c4 --- /dev/null +++ b/core/src/main/java/io/xpipe/core/util/StreamHelper.java @@ -0,0 +1,23 @@ +package io.xpipe.core.util; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Objects; + +public class StreamHelper { + + private static final int DEFAULT_BUFFER_SIZE = 8192; + + public static long transferTo(InputStream in, OutputStream out) throws IOException { + Objects.requireNonNull(out, "out"); + long transferred = 0; + byte[] buffer = new byte[DEFAULT_BUFFER_SIZE]; + int read; + while (in.available() > 0 && (read = in.read(buffer, 0, DEFAULT_BUFFER_SIZE)) >= 0) { + out.write(buffer, 0, read); + transferred += read; + } + return transferred; + } +} diff --git a/extension/src/main/java/io/xpipe/extension/DataStoreProvider.java b/extension/src/main/java/io/xpipe/extension/DataStoreProvider.java index 7531fd58d..4ee6e0471 100644 --- a/extension/src/main/java/io/xpipe/extension/DataStoreProvider.java +++ b/extension/src/main/java/io/xpipe/extension/DataStoreProvider.java @@ -38,6 +38,10 @@ public interface DataStoreProvider { default void init() throws Exception { } + default boolean isHidden() { + return false; + } + default String i18n(String key) { return I18n.get(getId() + "." + key); } diff --git a/extension/src/main/java/io/xpipe/extension/I18n.java b/extension/src/main/java/io/xpipe/extension/I18n.java index d3fe9e2b5..9115806a6 100644 --- a/extension/src/main/java/io/xpipe/extension/I18n.java +++ b/extension/src/main/java/io/xpipe/extension/I18n.java @@ -15,6 +15,10 @@ public interface I18n { } public static ObservableValue observable(String s, Object... vars) { + if (s == null) { + return null; + } + return Bindings.createStringBinding(() -> { return get(s, vars); }); diff --git a/extension/src/main/java/io/xpipe/extension/comp/DynamicOptionsBuilder.java b/extension/src/main/java/io/xpipe/extension/comp/DynamicOptionsBuilder.java index 77343ee5c..a30badda8 100644 --- a/extension/src/main/java/io/xpipe/extension/comp/DynamicOptionsBuilder.java +++ b/extension/src/main/java/io/xpipe/extension/comp/DynamicOptionsBuilder.java @@ -93,6 +93,13 @@ public class DynamicOptionsBuilder { return this; } + public DynamicOptionsBuilder addStringArea(String nameKey, Property prop) { + var comp = new TextAreaComp(prop); + entries.add(new DynamicOptionsComp.Entry(I18n.observable(nameKey), comp)); + props.add(prop); + return this; + } + public DynamicOptionsBuilder addString(String nameKey, Property prop) { var comp = new TextFieldComp(prop); entries.add(new DynamicOptionsComp.Entry(I18n.observable(nameKey), comp)); @@ -107,8 +114,9 @@ public class DynamicOptionsBuilder { return this; } - public DynamicOptionsBuilder addComp(ObservableValue name, Comp comp) { + public DynamicOptionsBuilder addComp(ObservableValue name, Comp comp, Property prop) { entries.add(new DynamicOptionsComp.Entry(name, comp)); + props.add(prop); return this; } diff --git a/extension/src/main/java/io/xpipe/extension/comp/SecretFieldComp.java b/extension/src/main/java/io/xpipe/extension/comp/SecretFieldComp.java index e725d358d..3ab7d80b3 100644 --- a/extension/src/main/java/io/xpipe/extension/comp/SecretFieldComp.java +++ b/extension/src/main/java/io/xpipe/extension/comp/SecretFieldComp.java @@ -22,7 +22,7 @@ public class SecretFieldComp extends Comp> { var text = new PasswordField(); text.setText(value.getValue() != null ? value.getValue().getSecretValue() : null); text.textProperty().addListener((c, o, n) -> { - value.setValue(n.length() > 0 ? Secret.create(n) : null); + value.setValue(n.length() > 0 ? Secret.createForSecretValue(n) : null); }); value.addListener((c, o, n) -> { PlatformThread.runLaterIfNeeded(() -> { diff --git a/extension/src/main/java/io/xpipe/extension/comp/TextAreaComp.java b/extension/src/main/java/io/xpipe/extension/comp/TextAreaComp.java new file mode 100644 index 000000000..124efb603 --- /dev/null +++ b/extension/src/main/java/io/xpipe/extension/comp/TextAreaComp.java @@ -0,0 +1,31 @@ +package io.xpipe.extension.comp; + +import io.xpipe.fxcomps.Comp; +import io.xpipe.fxcomps.CompStructure; +import io.xpipe.fxcomps.SimpleCompStructure; +import io.xpipe.fxcomps.util.PlatformThread; +import javafx.beans.property.Property; +import javafx.scene.control.TextArea; + +public class TextAreaComp extends Comp> { + + private final Property value; + + public TextAreaComp(Property value) { + this.value = value; + } + + @Override + public CompStructure