mirror of
https://github.com/xpipe-io/xpipe.git
synced 2026-04-22 07:29:05 -04:00
Fix font size
This commit is contained in:
@@ -36,12 +36,6 @@ public class AppBeaconCache {
|
||||
control.setNonInteractive();
|
||||
control.start();
|
||||
|
||||
var d = control.getShellDialect().getDumbMode();
|
||||
if (!d.supportsAnyPossibleInteraction()) {
|
||||
control.close();
|
||||
d.throwIfUnsupported();
|
||||
}
|
||||
|
||||
if (existing.isEmpty()) {
|
||||
AppBeaconServer.get().getCache().getShellSessions().add(new BeaconShellSession(ref.get(), control));
|
||||
}
|
||||
|
||||
@@ -31,9 +31,6 @@ public class AppBeaconServer {
|
||||
@Getter
|
||||
private final int port;
|
||||
|
||||
@Getter
|
||||
private final boolean propertyPort;
|
||||
|
||||
@Getter
|
||||
private final Set<BeaconSession> sessions = new HashSet<>();
|
||||
|
||||
@@ -47,22 +44,13 @@ public class AppBeaconServer {
|
||||
@Getter
|
||||
private String localAuthSecret;
|
||||
|
||||
private AppBeaconServer(int port, boolean propertyPort) {
|
||||
private AppBeaconServer(int port) {
|
||||
this.port = port;
|
||||
this.propertyPort = propertyPort;
|
||||
}
|
||||
|
||||
public static void setupPort() {
|
||||
int port;
|
||||
boolean propertyPort;
|
||||
if (System.getProperty(BeaconConfig.BEACON_PORT_PROP) != null) {
|
||||
port = BeaconConfig.getUsedPort();
|
||||
propertyPort = true;
|
||||
} else {
|
||||
port = BeaconConfig.getDefaultBeaconPort();
|
||||
propertyPort = false;
|
||||
}
|
||||
INSTANCE = new AppBeaconServer(port, propertyPort);
|
||||
int port = BeaconConfig.getUsedPort();
|
||||
INSTANCE = new AppBeaconServer(port);
|
||||
}
|
||||
|
||||
public static void init() {
|
||||
|
||||
@@ -31,8 +31,7 @@ public class FsReadExchangeImpl extends FsReadExchange {
|
||||
var file = BlobManager.get().newBlobFile();
|
||||
try (var in = fs.openInput(msg.getPath())) {
|
||||
var fixedIn = new FixedSizeInputStream(new BufferedInputStream(in), size);
|
||||
try (var fileOut =
|
||||
Files.newOutputStream(file.resolve(msg.getPath().getFileName()))) {
|
||||
try (var fileOut = Files.newOutputStream(file)) {
|
||||
fixedIn.transferTo(fileOut);
|
||||
}
|
||||
in.transferTo(OutputStream.nullOutputStream());
|
||||
|
||||
@@ -49,11 +49,11 @@ public class AppMcpServer {
|
||||
McpSyncServer syncServer = io.modelcontextprotocol.server.McpServer.sync(transportProvider)
|
||||
.serverInfo(AppNames.ofCurrent().getName(), AppProperties.get().getVersion())
|
||||
.capabilities(McpSchema.ServerCapabilities.builder()
|
||||
.resources(true, true)
|
||||
.resources(false, false)
|
||||
.tools(true)
|
||||
.prompts(false)
|
||||
.completions()
|
||||
.build())
|
||||
.instructions(AppPrefs.get().mcpAdditionalContext().getValue())
|
||||
.build();
|
||||
|
||||
var readOnlyTools = new ArrayList<McpServerFeatures.SyncToolSpecification>();
|
||||
@@ -65,13 +65,13 @@ public class AppMcpServer {
|
||||
readOnlyTools.add(McpTools.getFileInfo());
|
||||
|
||||
var mutationTools = new ArrayList<McpServerFeatures.SyncToolSpecification>();
|
||||
mutationTools.add(McpTools.openTerminal());
|
||||
mutationTools.add(McpTools.openTerminalInline());
|
||||
mutationTools.add(McpTools.createFile());
|
||||
mutationTools.add(McpTools.writeFile());
|
||||
mutationTools.add(McpTools.createDirectory());
|
||||
mutationTools.add(McpTools.runCommand());
|
||||
mutationTools.add(McpTools.runScript());
|
||||
mutationTools.add(McpTools.openTerminal());
|
||||
mutationTools.add(McpTools.openTerminalInline());
|
||||
mutationTools.add(McpTools.toggleState());
|
||||
|
||||
for (McpServerFeatures.SyncToolSpecification readOnlyTool : readOnlyTools) {
|
||||
|
||||
@@ -37,7 +37,7 @@ public interface McpToolHandler
|
||||
.isError(true)
|
||||
.build();
|
||||
} catch (Throwable e) {
|
||||
ErrorEventFactory.fromThrowable(e).handle();
|
||||
ErrorEventFactory.fromThrowable(e).omit().handle();
|
||||
return McpSchema.CallToolResult.builder()
|
||||
.addTextContent(e.getMessage())
|
||||
.isError(true)
|
||||
@@ -137,7 +137,7 @@ public interface McpToolHandler
|
||||
return e.ref();
|
||||
}
|
||||
|
||||
public DataStoreEntryRef<ShellStore> getShellStoreRef(String name) throws BeaconClientException {
|
||||
public DataStoreEntryRef<ShellStore> getShellStoreRef(String name, boolean mutation) throws BeaconClientException {
|
||||
var ref = getDataStoreRef(name);
|
||||
var isShell = ref.getStore() instanceof ShellStore;
|
||||
if (!isShell) {
|
||||
@@ -145,6 +145,12 @@ public interface McpToolHandler
|
||||
+ DataStorage.get().getStorePath(ref.get()).toString() + " is not a shell connection");
|
||||
}
|
||||
|
||||
var disableMutation = DataStorage.get().getEffectiveCategoryConfig(ref.get()).getDontAllowScripts();
|
||||
if (mutation && disableMutation != null && disableMutation) {
|
||||
throw new BeaconClientException("Modifications to connection "
|
||||
+ DataStorage.get().getStorePath(ref.get()).toString() + " is disabled by the category setting");
|
||||
}
|
||||
|
||||
return ref.asNeeded();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,8 @@ package io.xpipe.app.beacon.mcp;
|
||||
import io.xpipe.app.beacon.AppBeaconServer;
|
||||
import io.xpipe.app.core.AppExtensionManager;
|
||||
import io.xpipe.app.core.AppNames;
|
||||
import io.xpipe.app.ext.ConnectionFileSystem;
|
||||
import io.xpipe.app.ext.FileEntry;
|
||||
import io.xpipe.app.ext.FileInfo;
|
||||
import io.xpipe.app.ext.SingletonSessionStore;
|
||||
import io.xpipe.app.ext.*;
|
||||
import io.xpipe.app.hub.comp.StoreViewState;
|
||||
import io.xpipe.app.process.ScriptHelper;
|
||||
import io.xpipe.app.process.ShellControl;
|
||||
import io.xpipe.app.process.TerminalInitScriptConfig;
|
||||
@@ -85,6 +83,10 @@ public final class McpTools {
|
||||
|
||||
@NonNull
|
||||
String path;
|
||||
|
||||
String information;
|
||||
|
||||
String notes;
|
||||
}
|
||||
|
||||
public static McpServerFeatures.SyncToolSpecification listSystems() throws IOException {
|
||||
@@ -107,9 +109,14 @@ public final class McpTools {
|
||||
continue;
|
||||
}
|
||||
|
||||
var section = StoreViewState.get().getSectionForWrapper(StoreViewState.get().getEntryWrapper(e));
|
||||
var info = section.isPresent() ? e.getProvider().informationString(section.get()).getValue() : null;
|
||||
|
||||
var r = ConnectionResource.builder()
|
||||
.name(e.getName())
|
||||
.path(DataStorage.get().getStorePath(e).toString())
|
||||
.information(info)
|
||||
.notes(e.getNotes())
|
||||
.build();
|
||||
list.add(r);
|
||||
}
|
||||
@@ -138,7 +145,7 @@ public final class McpTools {
|
||||
.callHandler(McpToolHandler.of((req) -> {
|
||||
var path = req.getFilePath("path");
|
||||
var system = req.getStringArgument("system");
|
||||
var shellStore = req.getShellStoreRef(system);
|
||||
var shellStore = req.getShellStoreRef(system, false);
|
||||
var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore);
|
||||
var fs = new ConnectionFileSystem(shellSession.getControl());
|
||||
|
||||
@@ -165,7 +172,7 @@ public final class McpTools {
|
||||
var path = req.getFilePath("path");
|
||||
var system = req.getStringArgument("system");
|
||||
var recursive = req.getOptionalBooleanArgument("recursive").orElse(false);
|
||||
var shellStore = req.getShellStoreRef(system);
|
||||
var shellStore = req.getShellStoreRef(system, false);
|
||||
var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore);
|
||||
var fs = new ConnectionFileSystem(shellSession.getControl());
|
||||
|
||||
@@ -194,7 +201,7 @@ public final class McpTools {
|
||||
var system = req.getStringArgument("system");
|
||||
var recursive = req.getOptionalBooleanArgument("recursive").orElse(false);
|
||||
var pattern = req.getStringArgument("name");
|
||||
var shellStore = req.getShellStoreRef(system);
|
||||
var shellStore = req.getShellStoreRef(system, false);
|
||||
var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore);
|
||||
var fs = new ConnectionFileSystem(shellSession.getControl());
|
||||
|
||||
@@ -226,7 +233,7 @@ public final class McpTools {
|
||||
.callHandler(McpToolHandler.of((req) -> {
|
||||
var path = req.getFilePath("path");
|
||||
var system = req.getStringArgument("system");
|
||||
var shellStore = req.getShellStoreRef(system);
|
||||
var shellStore = req.getShellStoreRef(system, false);
|
||||
var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore);
|
||||
var fs = new ConnectionFileSystem(shellSession.getControl());
|
||||
|
||||
@@ -267,7 +274,7 @@ public final class McpTools {
|
||||
.callHandler(McpToolHandler.of((req) -> {
|
||||
var path = req.getFilePath("path");
|
||||
var system = req.getStringArgument("system");
|
||||
var shellStore = req.getShellStoreRef(system);
|
||||
var shellStore = req.getShellStoreRef(system, true);
|
||||
var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore);
|
||||
var fs = new ConnectionFileSystem(shellSession.getControl());
|
||||
|
||||
@@ -300,7 +307,7 @@ public final class McpTools {
|
||||
var path = req.getFilePath("path");
|
||||
var system = req.getStringArgument("system");
|
||||
var content = req.getStringArgument("content");
|
||||
var shellStore = req.getShellStoreRef(system);
|
||||
var shellStore = req.getShellStoreRef(system, true);
|
||||
var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore);
|
||||
var fs = new ConnectionFileSystem(shellSession.getControl());
|
||||
|
||||
@@ -323,7 +330,7 @@ public final class McpTools {
|
||||
.callHandler(McpToolHandler.of((req) -> {
|
||||
var path = req.getFilePath("path");
|
||||
var system = req.getStringArgument("system");
|
||||
var shellStore = req.getShellStoreRef(system);
|
||||
var shellStore = req.getShellStoreRef(system, true);
|
||||
var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore);
|
||||
var fs = new ConnectionFileSystem(shellSession.getControl());
|
||||
|
||||
@@ -347,10 +354,10 @@ public final class McpTools {
|
||||
.callHandler(McpToolHandler.of((req) -> {
|
||||
var command = req.getStringArgument("command");
|
||||
var system = req.getStringArgument("system");
|
||||
var shellStore = req.getShellStoreRef(system);
|
||||
var shellStore = req.getShellStoreRef(system, true);
|
||||
var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore);
|
||||
|
||||
var out = shellSession.getControl().command(command).readStdoutOrThrow();
|
||||
var out = ProcessControlProvider.get().executeMcpCommand(shellSession.getControl(), command);
|
||||
var formatted = CommandDialog.formatOutput(out);
|
||||
|
||||
return McpSchema.CallToolResult.builder()
|
||||
@@ -370,7 +377,7 @@ public final class McpTools {
|
||||
var directory = req.getFilePath("directory");
|
||||
var arguments = req.getStringArgument("arguments");
|
||||
|
||||
var shellStore = req.getShellStoreRef(system);
|
||||
var shellStore = req.getShellStoreRef(system, true);
|
||||
var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore);
|
||||
|
||||
var clazz = Class.forName(
|
||||
@@ -407,7 +414,7 @@ public final class McpTools {
|
||||
.callHandler(McpToolHandler.of((req) -> {
|
||||
var system = req.getStringArgument("system");
|
||||
var directory = req.getOptionalStringArgument("directory");
|
||||
var shellStore = req.getShellStoreRef(system);
|
||||
var shellStore = req.getShellStoreRef(system, true);
|
||||
var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore);
|
||||
|
||||
TerminalLaunch.builder()
|
||||
@@ -430,7 +437,7 @@ public final class McpTools {
|
||||
.callHandler(McpToolHandler.of((req) -> {
|
||||
var system = req.getStringArgument("system");
|
||||
var directory = req.getOptionalStringArgument("directory");
|
||||
var shellStore = req.getShellStoreRef(system);
|
||||
var shellStore = req.getShellStoreRef(system, true);
|
||||
var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore);
|
||||
|
||||
var script = shellSession
|
||||
|
||||
@@ -14,6 +14,7 @@ import io.xpipe.app.terminal.TerminalDockBrowserComp;
|
||||
import io.xpipe.app.terminal.TerminalDockView;
|
||||
import io.xpipe.app.terminal.TerminalView;
|
||||
import io.xpipe.app.terminal.WindowsTerminalType;
|
||||
import io.xpipe.app.util.GlobalTimer;
|
||||
import io.xpipe.app.util.ThreadHelper;
|
||||
|
||||
import javafx.application.Platform;
|
||||
@@ -25,6 +26,7 @@ import javafx.beans.value.ObservableValue;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.collections.ObservableList;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.UUID;
|
||||
import java.util.function.UnaryOperator;
|
||||
|
||||
@@ -36,6 +38,7 @@ public final class BrowserTerminalDockTabModel extends BrowserSessionTab {
|
||||
private final BooleanProperty opened = new SimpleBooleanProperty();
|
||||
private TerminalView.Listener listener;
|
||||
private ObservableBooleanValue viewActive;
|
||||
private boolean closed;
|
||||
|
||||
public BrowserTerminalDockTabModel(
|
||||
BrowserAbstractSessionModel<?> browserModel,
|
||||
@@ -147,6 +150,14 @@ public final class BrowserTerminalDockTabModel extends BrowserSessionTab {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
GlobalTimer.scheduleUntil(Duration.ofMillis(300), false, () -> {
|
||||
if (viewActive.get()) {
|
||||
dockModel.clearDeadTerminals();
|
||||
dockModel.updateCustomBounds();
|
||||
}
|
||||
return closed;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -155,6 +166,7 @@ public final class BrowserTerminalDockTabModel extends BrowserSessionTab {
|
||||
TerminalView.get().removeListener(listener);
|
||||
}
|
||||
dockModel.onClose();
|
||||
closed = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -19,7 +19,7 @@ public class BrowserIconManager {
|
||||
}
|
||||
|
||||
public static void loadIfNecessary(String s) {
|
||||
var res = AppDisplayScale.hasDefaultDisplayScale() ? "24" : "40";
|
||||
var res = AppDisplayScale.hasOnlyDefaultDisplayScale() ? "24" : "40";
|
||||
var key = "browser/" + FilenameUtils.getBaseName(s) + "-" + res + ".png";
|
||||
if (AppImages.hasImage(key)) {
|
||||
return;
|
||||
|
||||
@@ -4,11 +4,14 @@ import io.xpipe.app.comp.BaseRegionBuilder;
|
||||
import io.xpipe.app.comp.RegionStructure;
|
||||
import io.xpipe.app.comp.RegionStructureBuilder;
|
||||
import io.xpipe.app.core.AppLayoutModel;
|
||||
import io.xpipe.app.core.AppRestart;
|
||||
import io.xpipe.app.core.window.AppDialog;
|
||||
import io.xpipe.app.hub.comp.StoreViewState;
|
||||
import io.xpipe.app.platform.PlatformThread;
|
||||
import io.xpipe.app.prefs.AppPrefs;
|
||||
import io.xpipe.app.storage.DataStorage;
|
||||
|
||||
import io.xpipe.app.util.GlobalTimer;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.scene.Node;
|
||||
@@ -19,7 +22,9 @@ import javafx.scene.input.KeyEvent;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import org.bouncycastle.math.raw.Mod;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
@@ -61,6 +66,15 @@ public class AppLayoutComp extends RegionStructureBuilder<BorderPane, AppLayoutC
|
||||
if (storage != null) {
|
||||
storage.saveAsync();
|
||||
}
|
||||
|
||||
if (AppPrefs.get() != null && AppPrefs.get().getRequiresRestart().get()) {
|
||||
GlobalTimer.delay(() -> {
|
||||
var modal = ModalOverlay.of("prefsRestartTitle", AppDialog.dialogTextKey("prefsRestartContent"));
|
||||
modal.addButton(ModalButton.cancel());
|
||||
modal.addButton(new ModalButton("restart", () -> AppRestart.restart(), true, true));
|
||||
modal.show();
|
||||
}, Duration.ofSeconds(1));
|
||||
}
|
||||
}
|
||||
|
||||
if (o != null && o.equals(model.getEntries().get(0))) {
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
package io.xpipe.app.comp.base;
|
||||
|
||||
import io.xpipe.app.comp.RegionBuilder;
|
||||
import io.xpipe.app.core.AppI18n;
|
||||
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.ScrollPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.layout.VBox;
|
||||
|
||||
import atlantafx.base.theme.Styles;
|
||||
import org.int4.fx.builders.common.AbstractRegionBuilder;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public abstract class DialogComp extends RegionBuilder<Region> {
|
||||
|
||||
protected Region createNavigation() {
|
||||
HBox buttons = new HBox();
|
||||
buttons.setFillHeight(true);
|
||||
var customButton = bottom();
|
||||
if (customButton != null) {
|
||||
var c = customButton.build();
|
||||
buttons.getChildren().add(c);
|
||||
HBox.setHgrow(c, Priority.ALWAYS);
|
||||
}
|
||||
var spacer = new Region();
|
||||
HBox.setHgrow(spacer, Priority.SOMETIMES);
|
||||
buttons.getChildren().add(spacer);
|
||||
buttons.getStyleClass().add("buttons");
|
||||
buttons.setSpacing(5);
|
||||
buttons.setAlignment(Pos.CENTER_RIGHT);
|
||||
|
||||
buttons.getChildren()
|
||||
.addAll(customButtons().stream()
|
||||
.map(buttonComp -> buttonComp.build())
|
||||
.toList());
|
||||
var nextButton = finishButton();
|
||||
buttons.getChildren().add(nextButton.build());
|
||||
return buttons;
|
||||
}
|
||||
|
||||
protected AbstractRegionBuilder<?, ?> finishButton() {
|
||||
return new ButtonComp(AppI18n.observable(finishKey()), this::finish)
|
||||
.style(Styles.ACCENT)
|
||||
.style("next");
|
||||
}
|
||||
|
||||
protected String finishKey() {
|
||||
return "finishStep";
|
||||
}
|
||||
|
||||
protected List<AbstractRegionBuilder<?, ?>> customButtons() {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Region createSimple() {
|
||||
var sp = pane(content()).style("dialog-content").build();
|
||||
VBox vbox = new VBox();
|
||||
vbox.getChildren().addAll(sp, createNavigation());
|
||||
vbox.getStyleClass().add("dialog-comp");
|
||||
vbox.setFillWidth(true);
|
||||
VBox.setVgrow(sp, Priority.ALWAYS);
|
||||
return vbox;
|
||||
}
|
||||
|
||||
protected abstract void finish();
|
||||
|
||||
public abstract AbstractRegionBuilder<?, ?> content();
|
||||
|
||||
protected AbstractRegionBuilder<?, ?> pane(AbstractRegionBuilder<?, ?> content) {
|
||||
var entry = content;
|
||||
return RegionBuilder.of(() -> {
|
||||
var entryR = entry.build();
|
||||
var sp = new ScrollPane(entryR);
|
||||
sp.setFitToWidth(true);
|
||||
entryR.minHeightProperty().bind(sp.heightProperty());
|
||||
return sp;
|
||||
});
|
||||
}
|
||||
|
||||
public AbstractRegionBuilder<?, ?> bottom() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -89,13 +89,19 @@ public class IntegratedTextAreaComp extends RegionStructureBuilder<AnchorPane, I
|
||||
.bind(Bindings.createIntegerBinding(
|
||||
() -> {
|
||||
var val = value.getValue() != null ? value.getValue() : "";
|
||||
var count = (int) val.lines().count() + (val.endsWith("\n") ? 1 : 0);
|
||||
var valCount = (int) val.lines().count() + (val.endsWith("\n") ? 1 : 0);
|
||||
|
||||
var promptVal = struc.getTextArea().getPromptText() != null ? struc.getTextArea().getPromptText() : "";
|
||||
var promptValCount = (int) promptVal.lines().count() + (promptVal.endsWith("\n") ? 1 : 0);
|
||||
|
||||
var count = Math.max(valCount, promptValCount);
|
||||
// Somehow the handling of trailing newlines is weird
|
||||
// This makes the handling better for JavaFX text areas
|
||||
count++;
|
||||
return Math.max(1, count);
|
||||
},
|
||||
value));
|
||||
value,
|
||||
struc.getTextArea().promptTextProperty()));
|
||||
});
|
||||
var textAreaStruc = textArea.buildStructure();
|
||||
var copyButton = createOpenButton();
|
||||
|
||||
@@ -62,13 +62,13 @@ public class ListBoxViewComp<T> extends RegionBuilder<ScrollPane> {
|
||||
vbox.setFocusTraversable(false);
|
||||
var scroll = new ScrollPane(vbox);
|
||||
|
||||
refresh(scroll, vbox, shown, all, cache, false);
|
||||
refresh(vbox, shown, all, cache);
|
||||
|
||||
var hadScene = new AtomicBoolean(false);
|
||||
scroll.sceneProperty().subscribe(scene -> {
|
||||
if (scene != null) {
|
||||
hadScene.set(true);
|
||||
refresh(scroll, vbox, shown, all, cache, true);
|
||||
refresh(vbox, shown, all, cache);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -78,7 +78,7 @@ public class ListBoxViewComp<T> extends RegionBuilder<ScrollPane> {
|
||||
return;
|
||||
}
|
||||
|
||||
refresh(scroll, vbox, c.getList(), all, cache, true);
|
||||
refresh(vbox, c.getList(), all, cache);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -118,12 +118,21 @@ public class ListBoxViewComp<T> extends RegionBuilder<ScrollPane> {
|
||||
|
||||
var dirty = new SimpleBooleanProperty();
|
||||
var animationTimer = new AnimationTimer() {
|
||||
|
||||
private long delayThresholdCrossed;
|
||||
|
||||
@Override
|
||||
public void handle(long now) {
|
||||
if (!dirty.get()) {
|
||||
return;
|
||||
}
|
||||
|
||||
var ms = now / 1_000_000;
|
||||
if (ms < delayThresholdCrossed + (super.hashCode() % 100)) {
|
||||
return;
|
||||
}
|
||||
delayThresholdCrossed = ms;
|
||||
|
||||
updateVisibilities(scroll, vbox);
|
||||
dirty.set(false);
|
||||
}
|
||||
@@ -146,6 +155,9 @@ public class ListBoxViewComp<T> extends RegionBuilder<ScrollPane> {
|
||||
vbox.heightProperty().addListener((observable, oldValue, newValue) -> {
|
||||
dirty.set(true);
|
||||
});
|
||||
vbox.getChildren().addListener((ListChangeListener<? super Node>) (change) -> {
|
||||
dirty.set(true);
|
||||
});
|
||||
|
||||
// We can't directly listen to any parent element changing visibility, so this is a compromise
|
||||
if (AppLayoutModel.get() != null) {
|
||||
@@ -297,12 +309,10 @@ public class ListBoxViewComp<T> extends RegionBuilder<ScrollPane> {
|
||||
}
|
||||
|
||||
private void refresh(
|
||||
ScrollPane scroll,
|
||||
VBox listView,
|
||||
List<? extends T> shown,
|
||||
List<? extends T> all,
|
||||
Map<T, Region> cache,
|
||||
boolean refreshVisibilities) {
|
||||
Map<T, Region> cache) {
|
||||
Runnable update = () -> {
|
||||
if (!Platform.isFxApplicationThread()) {
|
||||
throw new IllegalStateException("Not in FxApplication thread");
|
||||
@@ -358,9 +368,6 @@ public class ListBoxViewComp<T> extends RegionBuilder<ScrollPane> {
|
||||
|
||||
var d = DerivedObservableList.wrap(listView.getChildren(), true);
|
||||
d.setContent(newShown);
|
||||
if (refreshVisibilities) {
|
||||
updateVisibilities(scroll, listView);
|
||||
}
|
||||
};
|
||||
update.run();
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ public class MarkdownComp extends RegionBuilder<StackPane> {
|
||||
var url = AppResources.getResourceURL(AppResources.MAIN_MODULE, theme).orElseThrow();
|
||||
wv.getEngine().setUserStyleSheetLocation(url.toString());
|
||||
|
||||
PlatformThread.sync(markdown).subscribe(val -> {
|
||||
markdown.subscribe(val -> {
|
||||
PlatformThread.runLaterIfNeeded(() -> {
|
||||
var file = getHtmlFile(val);
|
||||
if (file != null) {
|
||||
|
||||
@@ -49,10 +49,11 @@ public class SideMenuBarComp extends RegionBuilder<VBox> {
|
||||
|
||||
if (e.action() != null) {
|
||||
e.action().run();
|
||||
return;
|
||||
}
|
||||
|
||||
value.setValue(e);
|
||||
if (e.comp() != null) {
|
||||
value.setValue(e);
|
||||
}
|
||||
});
|
||||
b.describe(d -> d.name(e.name()));
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ public class ToggleSwitchComp extends RegionBuilder<ToggleSwitch> {
|
||||
s.setGraphic(value.createGraphicNode());
|
||||
});
|
||||
});
|
||||
s.setAlignment(Pos.CENTER);
|
||||
s.pseudoClassStateChanged(PseudoClass.getPseudoClass("has-graphic"), true);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import io.xpipe.app.comp.base.ScrollComp;
|
||||
import io.xpipe.app.core.window.AppDialog;
|
||||
import io.xpipe.app.platform.OptionsBuilder;
|
||||
import io.xpipe.app.prefs.EditorCategory;
|
||||
import io.xpipe.app.prefs.PasswordManagerCategory;
|
||||
import io.xpipe.app.prefs.PersonalizationCategory;
|
||||
import io.xpipe.app.prefs.TerminalCategory;
|
||||
import io.xpipe.app.util.DocumentationLink;
|
||||
@@ -24,6 +25,7 @@ public class AppConfigurationDialog {
|
||||
.sub(PersonalizationCategory.themeChoice())
|
||||
.sub(TerminalCategory.terminalChoice(false))
|
||||
.sub(EditorCategory.editorChoice())
|
||||
.sub(PasswordManagerCategory.passwordManagerChoice())
|
||||
.buildComp();
|
||||
options.style("initial-setup");
|
||||
options.style("prefs-container");
|
||||
|
||||
@@ -8,6 +8,7 @@ import javafx.stage.Screen;
|
||||
public class AppDisplayScale {
|
||||
|
||||
private static Double screenOutputScale;
|
||||
private static Boolean defaultDisplayScale;
|
||||
|
||||
public static void init() {
|
||||
try {
|
||||
@@ -15,13 +16,23 @@ public class AppDisplayScale {
|
||||
if (primary != null) {
|
||||
screenOutputScale = primary.getOutputScaleX();
|
||||
}
|
||||
|
||||
var s = AppPrefs.get().uiScale().getValue();
|
||||
if (s != null && s == 100) {
|
||||
defaultDisplayScale = true;
|
||||
}
|
||||
|
||||
var allScreensDefault = Screen.getScreens().stream().allMatch(screen -> screen.getOutputScaleX() == 1.0);
|
||||
if (allScreensDefault) {
|
||||
defaultDisplayScale = true;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
ErrorEventFactory.fromThrowable(e).omit().expected().handle();
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean hasDefaultDisplayScale() {
|
||||
return getEffectiveDisplayScale() == 1.0;
|
||||
public static boolean hasOnlyDefaultDisplayScale() {
|
||||
return defaultDisplayScale != null ? defaultDisplayScale : false;
|
||||
}
|
||||
|
||||
public static double getEffectiveDisplayScale() {
|
||||
|
||||
@@ -8,6 +8,7 @@ import javafx.scene.Node;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Value;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.function.Function;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@@ -75,16 +76,21 @@ public class AppFontSizes {
|
||||
return;
|
||||
}
|
||||
|
||||
var ref = new WeakReference<>(node);
|
||||
AppPrefs.get().theme().subscribe((newValue) -> {
|
||||
var effective = newValue != null ? newValue.getFontSizes().get() : getDefault();
|
||||
setFont(node, function.apply(effective));
|
||||
var refNode = ref.get();
|
||||
if (refNode != null) {
|
||||
var effective = newValue != null ? newValue.getFontSizes().get() : getDefault();
|
||||
setFont(refNode, function.apply(effective));
|
||||
}
|
||||
});
|
||||
|
||||
AppPrefs.get().useSystemFont().addListener((ignored, ignored2, newValue) -> {
|
||||
var effective = AppPrefs.get().theme().getValue() != null
|
||||
? AppPrefs.get().theme().getValue().getFontSizes().get()
|
||||
: getDefault();
|
||||
setFont(node, function.apply(effective));
|
||||
var refNode = ref.get();
|
||||
if (refNode != null) {
|
||||
var effective = AppPrefs.get().theme().getValue() != null ? AppPrefs.get().theme().getValue().getFontSizes().get() : getDefault();
|
||||
setFont(refNode, function.apply(effective));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ public class AppImages {
|
||||
return;
|
||||
}
|
||||
|
||||
var skipLarge = AppDisplayScale.hasDefaultDisplayScale();
|
||||
var skipLarge = AppDisplayScale.hasOnlyDefaultDisplayScale();
|
||||
Files.walkFileTree(basePath, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
|
||||
@@ -63,7 +63,7 @@ public class AppImages {
|
||||
|
||||
private static void loadOsIcons() {
|
||||
AppResources.with(AppResources.MAIN_MODULE, "os", basePath -> {
|
||||
var skipLarge = AppDisplayScale.hasDefaultDisplayScale();
|
||||
var skipLarge = AppDisplayScale.hasOnlyDefaultDisplayScale();
|
||||
Files.walkFileTree(basePath, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
|
||||
@@ -81,7 +81,7 @@ public class AppImages {
|
||||
|
||||
private static void loadWelcomeImages() {
|
||||
AppResources.with(AppResources.MAIN_MODULE, "welcome", basePath -> {
|
||||
var skipLarge = AppDisplayScale.hasDefaultDisplayScale();
|
||||
var skipLarge = AppDisplayScale.hasOnlyDefaultDisplayScale();
|
||||
Files.walkFileTree(basePath, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import io.xpipe.app.hub.comp.StoreLayoutComp;
|
||||
import io.xpipe.app.platform.LabelGraphic;
|
||||
import io.xpipe.app.platform.PlatformThread;
|
||||
import io.xpipe.app.prefs.AppPrefsComp;
|
||||
import io.xpipe.app.terminal.TerminalDockHubManager;
|
||||
import io.xpipe.app.update.AppDistributionType;
|
||||
import io.xpipe.app.util.*;
|
||||
|
||||
@@ -117,7 +118,9 @@ public class AppLayoutModel {
|
||||
AppI18n.observable("connections"),
|
||||
new LabelGraphic.IconGraphic("mdi2c-connection"),
|
||||
new StoreLayoutComp(),
|
||||
null,
|
||||
() -> {
|
||||
TerminalDockHubManager.get().hideDock();
|
||||
},
|
||||
new KeyCodeCombination(KeyCode.DIGIT1, KeyCombination.SHORTCUT_DOWN)),
|
||||
new Entry(
|
||||
AppI18n.observable("browser"),
|
||||
|
||||
@@ -260,6 +260,7 @@ public abstract class AppSystemInfo {
|
||||
|
||||
private Path downloads;
|
||||
private Path desktop;
|
||||
private Path config;
|
||||
private Boolean vm;
|
||||
|
||||
public boolean isDebianBased() {
|
||||
@@ -356,6 +357,20 @@ public abstract class AppSystemInfo {
|
||||
return (desktop = fallback);
|
||||
}
|
||||
|
||||
public Path getConfigDir() {
|
||||
if (config != null) {
|
||||
return config;
|
||||
}
|
||||
|
||||
if (System.getenv("XDG_CONFIG_HOME") != null) {
|
||||
return (config = Path.of(System.getenv("XDG_CONFIG_HOME")));
|
||||
} else {
|
||||
return (config = AppSystemInfo.ofLinux()
|
||||
.getUserHome()
|
||||
.resolve(".config"));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Path getTemp() {
|
||||
return Path.of(System.getProperty("java.io.tmpdir"));
|
||||
|
||||
@@ -251,41 +251,8 @@ public class AppTheme {
|
||||
|
||||
TrackEvent.debug("Setting theme " + newTheme.getId() + " for scene");
|
||||
|
||||
// Don't animate transition in performance mode
|
||||
if (AppPrefs.get() == null || AppPrefs.get().performanceMode().get()) {
|
||||
newTheme.apply();
|
||||
return;
|
||||
}
|
||||
|
||||
var stage = window.getStage();
|
||||
var scene = stage.getScene();
|
||||
Pane root = (Pane) scene.getRoot();
|
||||
Image snapshot = null;
|
||||
try {
|
||||
scene.snapshot(null);
|
||||
} catch (Exception ex) {
|
||||
// This can fail if there is no window / screen I guess?
|
||||
ErrorEventFactory.fromThrowable(ex).expected().omit().handle();
|
||||
return;
|
||||
}
|
||||
ImageView imageView = new ImageView(snapshot);
|
||||
root.getChildren().add(imageView);
|
||||
// Don't animate anything for performance reasons
|
||||
newTheme.apply();
|
||||
|
||||
Platform.runLater(() -> {
|
||||
// Animate!
|
||||
var transition = new Timeline(
|
||||
new KeyFrame(
|
||||
Duration.millis(0),
|
||||
new KeyValue(imageView.opacityProperty(), 1, Interpolator.EASE_OUT)),
|
||||
new KeyFrame(
|
||||
Duration.millis(600),
|
||||
new KeyValue(imageView.opacityProperty(), 0, Interpolator.EASE_OUT)));
|
||||
transition.setOnFinished(e -> {
|
||||
root.getChildren().remove(imageView);
|
||||
});
|
||||
transition.play();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import io.xpipe.app.core.window.AppWindowTitle;
|
||||
import io.xpipe.app.ext.DataStoreProviders;
|
||||
import io.xpipe.app.ext.ProcessControlProvider;
|
||||
import io.xpipe.app.ext.StartOnInitStore;
|
||||
import io.xpipe.app.hub.comp.StoreQuickConnect;
|
||||
import io.xpipe.app.hub.comp.StoreViewState;
|
||||
import io.xpipe.app.icon.SystemIconManager;
|
||||
import io.xpipe.app.issue.TrackEvent;
|
||||
@@ -130,6 +131,7 @@ public class AppBaseMode extends AppOperationMode {
|
||||
AppMcpServer.init();
|
||||
iconsInit.await();
|
||||
StoreViewState.init();
|
||||
StoreQuickConnect.init();
|
||||
AppMainWindow.loadingText("loadingSettings");
|
||||
TrackEvent.info("Connection storage initialization thread completed");
|
||||
},
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
package io.xpipe.ext.base.identity.ssh;
|
||||
package io.xpipe.app.cred;
|
||||
|
||||
import io.xpipe.app.comp.base.ButtonComp;
|
||||
import io.xpipe.app.comp.base.HorizontalComp;
|
||||
import io.xpipe.app.comp.base.TextFieldComp;
|
||||
import io.xpipe.app.core.AppI18n;
|
||||
import io.xpipe.app.platform.BindingsHelper;
|
||||
import io.xpipe.app.platform.OptionsBuilder;
|
||||
import io.xpipe.app.platform.Validator;
|
||||
import io.xpipe.app.prefs.AppPrefs;
|
||||
import io.xpipe.app.process.CommandBuilder;
|
||||
import io.xpipe.app.process.ShellControl;
|
||||
import io.xpipe.core.FilePath;
|
||||
import io.xpipe.core.KeyValue;
|
||||
import io.xpipe.core.OsType;
|
||||
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
@@ -31,7 +32,7 @@ import java.util.List;
|
||||
@Value
|
||||
@Jacksonized
|
||||
@Builder
|
||||
public class CustomAgentStrategy implements SshIdentityStrategy {
|
||||
public class CustomAgentStrategy implements SshIdentityAgentStrategy {
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public static OptionsBuilder createOptions(
|
||||
@@ -41,10 +42,13 @@ public class CustomAgentStrategy implements SshIdentityStrategy {
|
||||
var publicKey =
|
||||
new SimpleStringProperty(p.getValue() != null ? p.getValue().getPublicKey() : null);
|
||||
|
||||
var socket = AppPrefs.get().sshAgentSocket();
|
||||
var socketBinding = BindingsHelper.map(socket, s -> {
|
||||
return s != null ? s.toString() : AppI18n.get("agentSocketNotConfigured");
|
||||
});
|
||||
var socketBinding = Bindings.createObjectBinding(() -> {
|
||||
var agent = AppPrefs.get().sshAgentSocket().getValue();
|
||||
if (agent == null) {
|
||||
agent = AppPrefs.get().defaultSshAgentSocket().getValue();
|
||||
}
|
||||
return agent != null ? agent.toString() : AppI18n.get("agentSocketNotConfigured");
|
||||
}, AppPrefs.get().defaultSshAgentSocket(), AppPrefs.get().sshAgentSocket());
|
||||
var socketProp = new SimpleStringProperty();
|
||||
socketProp.bind(socketBinding);
|
||||
var socketDisplay = new HorizontalComp(List.of(
|
||||
@@ -62,21 +66,22 @@ public class CustomAgentStrategy implements SshIdentityStrategy {
|
||||
.addComp(socketDisplay)
|
||||
.check(val -> Validator.create(
|
||||
val,
|
||||
AppI18n.observable("agentSocketNotConfigured"),
|
||||
AppPrefs.get().sshAgentSocket(),
|
||||
AppI18n.observable("agentSocketNotConfigured"), Bindings.createObjectBinding(() -> {
|
||||
var agent = AppPrefs.get().sshAgentSocket().getValue();
|
||||
if (agent == null) {
|
||||
agent = AppPrefs.get().defaultSshAgentSocket().getValue();
|
||||
}
|
||||
return agent;
|
||||
}, AppPrefs.get().sshAgentSocket(), AppPrefs.get().defaultSshAgentSocket()),
|
||||
i -> {
|
||||
return i != null;
|
||||
}))
|
||||
.nameAndDescription("publicKey")
|
||||
.addComp(new SshAgentKeyListComp(config.getFileSystem(), p, publicKey, false), publicKey)
|
||||
.nameAndDescription("forwardAgent")
|
||||
.addToggle(forward)
|
||||
.nonNull()
|
||||
.hide(!config.isAllowAgentForward())
|
||||
.nameAndDescription("publicKey")
|
||||
.addComp(
|
||||
new TextFieldComp(publicKey)
|
||||
.apply(struc -> struc.setPromptText(
|
||||
"ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIBmhLUTJiP...== Your Comment")),
|
||||
publicKey)
|
||||
.bind(
|
||||
() -> {
|
||||
return new CustomAgentStrategy(forward.get(), publicKey.get());
|
||||
@@ -90,29 +95,37 @@ public class CustomAgentStrategy implements SshIdentityStrategy {
|
||||
@Override
|
||||
public void prepareParent(ShellControl parent) throws Exception {
|
||||
if (parent.isLocal()) {
|
||||
var agent = AppPrefs.get().sshAgentSocket().getValue();
|
||||
if (agent == null) {
|
||||
agent = AppPrefs.get().defaultSshAgentSocket().getValue();
|
||||
}
|
||||
SshIdentityStateManager.prepareLocalCustomAgent(
|
||||
parent, AppPrefs.get().sshAgentSocket().getValue());
|
||||
parent, agent);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void buildCommand(CommandBuilder builder) {}
|
||||
|
||||
private String getIdentityAgent(ShellControl sc) throws Exception {
|
||||
public FilePath determinetAgentSocketLocation(ShellControl sc) throws Exception {
|
||||
if (!sc.isLocal() || sc.getOsType() == OsType.WINDOWS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (AppPrefs.get() != null) {
|
||||
var socket = AppPrefs.get().sshAgentSocket().getValue();
|
||||
if (socket != null) {
|
||||
return socket.resolveTildeHome(sc.view().userHome()).toString();
|
||||
var agent = AppPrefs.get().sshAgentSocket().getValue();
|
||||
if (agent == null) {
|
||||
agent = AppPrefs.get().defaultSshAgentSocket().getValue();
|
||||
}
|
||||
if (agent != null) {
|
||||
return agent.resolveTildeHome(sc.view().userHome());
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void buildCommand(CommandBuilder builder) {}
|
||||
|
||||
@Override
|
||||
public List<KeyValue> configOptions(ShellControl sc) throws Exception {
|
||||
var file = SshIdentityStrategy.getPublicKeyPath(sc, publicKey);
|
||||
@@ -122,11 +135,15 @@ public class CustomAgentStrategy implements SshIdentityStrategy {
|
||||
new KeyValue("IdentityFile", file.isPresent() ? file.get().toString() : "none"),
|
||||
new KeyValue("PKCS11Provider", "none")));
|
||||
|
||||
var agent = getIdentityAgent(sc);
|
||||
var agent = determinetAgentSocketLocation(sc);
|
||||
if (agent != null) {
|
||||
l.add(new KeyValue("IdentityAgent", "\"" + agent + "\""));
|
||||
}
|
||||
|
||||
return l;
|
||||
}
|
||||
|
||||
public PublicKeyStrategy getPublicKeyStrategy() {
|
||||
return PublicKeyStrategy.Fixed.of(publicKey);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package io.xpipe.ext.base.identity.ssh;
|
||||
package io.xpipe.app.cred;
|
||||
|
||||
import io.xpipe.app.comp.base.ContextualFileReferenceChoiceComp;
|
||||
import io.xpipe.app.ext.ValidationException;
|
||||
@@ -116,8 +116,7 @@ public class CustomPkcs11LibraryStrategy implements SshIdentityStrategy {
|
||||
new KeyValue("IdentityAgent", "none"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPublicKey() {
|
||||
public PublicKeyStrategy getPublicKeyStrategy() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package io.xpipe.ext.base.identity.ssh;
|
||||
package io.xpipe.app.cred;
|
||||
|
||||
import io.xpipe.app.comp.base.TextFieldComp;
|
||||
import io.xpipe.app.core.AppSystemInfo;
|
||||
@@ -7,6 +7,7 @@ import io.xpipe.app.process.CommandBuilder;
|
||||
import io.xpipe.app.process.LocalShell;
|
||||
import io.xpipe.app.process.ShellControl;
|
||||
import io.xpipe.app.util.LicenseProvider;
|
||||
import io.xpipe.core.FilePath;
|
||||
import io.xpipe.core.KeyValue;
|
||||
import io.xpipe.core.OsType;
|
||||
|
||||
@@ -27,7 +28,7 @@ import java.util.List;
|
||||
@Jacksonized
|
||||
@Builder
|
||||
@JsonTypeName("gpgAgent")
|
||||
public class GpgAgentStrategy implements SshIdentityStrategy {
|
||||
public class GpgAgentStrategy implements SshIdentityAgentStrategy {
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public static OptionsBuilder createOptions(Property<GpgAgentStrategy> p, SshIdentityStrategyChoiceConfig config) {
|
||||
@@ -36,11 +37,11 @@ public class GpgAgentStrategy implements SshIdentityStrategy {
|
||||
var publicKey =
|
||||
new SimpleStringProperty(p.getValue() != null ? p.getValue().getPublicKey() : null);
|
||||
return new OptionsBuilder()
|
||||
.nameAndDescription("publicKey")
|
||||
.addComp(new SshAgentKeyListComp(config.getFileSystem(), p, publicKey, false), publicKey)
|
||||
.nameAndDescription("forwardAgent")
|
||||
.addToggle(forward)
|
||||
.nonNull()
|
||||
.hide(!config.isAllowAgentForward())
|
||||
.nameAndDescription("publicKey")
|
||||
.addComp(
|
||||
new TextFieldComp(publicKey)
|
||||
.apply(struc -> struc.setPromptText(
|
||||
@@ -60,18 +61,6 @@ public class GpgAgentStrategy implements SshIdentityStrategy {
|
||||
return supported;
|
||||
}
|
||||
|
||||
try {
|
||||
var found = LocalShell.getShell()
|
||||
.view()
|
||||
.findProgram("gpg-connect-agent")
|
||||
.isPresent();
|
||||
if (!found) {
|
||||
return (supported = false);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
return (supported = false);
|
||||
}
|
||||
|
||||
if (OsType.ofLocal() == OsType.WINDOWS) {
|
||||
var file = AppSystemInfo.ofWindows().getRoamingAppData().resolve("gnupg", "gpg-agent.conf");
|
||||
return (supported = Files.exists(file));
|
||||
@@ -93,9 +82,7 @@ public class GpgAgentStrategy implements SshIdentityStrategy {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void buildCommand(CommandBuilder builder) {}
|
||||
|
||||
private String getIdentityAgent(ShellControl sc) throws Exception {
|
||||
public FilePath determinetAgentSocketLocation(ShellControl sc) throws Exception {
|
||||
if (sc.getOsType() == OsType.WINDOWS) {
|
||||
return null;
|
||||
}
|
||||
@@ -105,9 +92,12 @@ public class GpgAgentStrategy implements SshIdentityStrategy {
|
||||
return null;
|
||||
}
|
||||
|
||||
return r;
|
||||
return FilePath.of(r);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void buildCommand(CommandBuilder builder) {}
|
||||
|
||||
@Override
|
||||
public List<KeyValue> configOptions(ShellControl sc) throws Exception {
|
||||
var file = SshIdentityStrategy.getPublicKeyPath(sc, publicKey);
|
||||
@@ -117,11 +107,15 @@ public class GpgAgentStrategy implements SshIdentityStrategy {
|
||||
new KeyValue("IdentityFile", file.isPresent() ? file.get().toString() : "none"),
|
||||
new KeyValue("PKCS11Provider", "none")));
|
||||
|
||||
var agent = getIdentityAgent(sc);
|
||||
var agent = determinetAgentSocketLocation(sc);
|
||||
if (agent != null) {
|
||||
l.add(new KeyValue("IdentityAgent", "\"" + agent + "\""));
|
||||
}
|
||||
|
||||
return l;
|
||||
}
|
||||
|
||||
public PublicKeyStrategy getPublicKeyStrategy() {
|
||||
return PublicKeyStrategy.Fixed.of(publicKey);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package io.xpipe.ext.base.identity.ssh;
|
||||
package io.xpipe.app.cred;
|
||||
|
||||
import io.xpipe.app.comp.base.ButtonComp;
|
||||
import io.xpipe.app.comp.base.InputGroupComp;
|
||||
@@ -184,4 +184,8 @@ public class InPlaceKeyStrategy implements SshIdentityStrategy {
|
||||
+ Math.abs(Objects.hash(this, AppSystemInfo.ofCurrent().getUser())) + ".key");
|
||||
return temp;
|
||||
}
|
||||
|
||||
public PublicKeyStrategy getPublicKeyStrategy() {
|
||||
return PublicKeyStrategy.Fixed.of(publicKey);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package io.xpipe.ext.base.identity.ssh;
|
||||
package io.xpipe.app.cred;
|
||||
|
||||
import io.xpipe.app.comp.base.*;
|
||||
import io.xpipe.app.core.AppI18n;
|
||||
@@ -268,4 +268,8 @@ public class KeyFileStrategy implements SshIdentityStrategy {
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
public PublicKeyStrategy getPublicKeyStrategy() {
|
||||
return PublicKeyStrategy.Fixed.of(publicKey);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package io.xpipe.ext.base.identity.ssh;
|
||||
package io.xpipe.app.cred;
|
||||
|
||||
import io.xpipe.app.process.CommandBuilder;
|
||||
import io.xpipe.app.process.ShellControl;
|
||||
@@ -30,7 +30,7 @@ public class NoIdentityStrategy implements SshIdentityStrategy {
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPublicKey() {
|
||||
public PublicKeyStrategy getPublicKeyStrategy() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package io.xpipe.ext.base.identity.ssh;
|
||||
package io.xpipe.app.cred;
|
||||
|
||||
import io.xpipe.app.comp.base.TextFieldComp;
|
||||
import io.xpipe.app.core.AppI18n;
|
||||
import io.xpipe.app.platform.OptionsBuilder;
|
||||
import io.xpipe.app.prefs.AppPrefs;
|
||||
import io.xpipe.app.process.CommandBuilder;
|
||||
import io.xpipe.app.process.ShellControl;
|
||||
import io.xpipe.core.FilePath;
|
||||
import io.xpipe.core.KeyValue;
|
||||
import io.xpipe.core.OsType;
|
||||
|
||||
@@ -25,7 +25,7 @@ import java.util.List;
|
||||
@Value
|
||||
@Jacksonized
|
||||
@Builder
|
||||
public class OpenSshAgentStrategy implements SshIdentityStrategy {
|
||||
public class OpenSshAgentStrategy implements SshIdentityAgentStrategy {
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public static OptionsBuilder createOptions(
|
||||
@@ -39,16 +39,12 @@ public class OpenSshAgentStrategy implements SshIdentityStrategy {
|
||||
.nameAndDescription("agentSocket")
|
||||
.addStaticString(socket != null ? socket : AppI18n.get("agentSocketNotFound"))
|
||||
.hide(OsType.ofLocal() == OsType.WINDOWS)
|
||||
.nameAndDescription("publicKey")
|
||||
.addComp(new SshAgentKeyListComp(config.getFileSystem(), p, publicKey, false), publicKey)
|
||||
.nameAndDescription("forwardAgent")
|
||||
.addToggle(forward)
|
||||
.nonNull()
|
||||
.hide(!config.isAllowAgentForward())
|
||||
.nameAndDescription("publicKey")
|
||||
.addComp(
|
||||
new TextFieldComp(publicKey)
|
||||
.apply(struc -> struc.setPromptText(
|
||||
"ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIBmhLUTJiP...== Your Comment")),
|
||||
publicKey)
|
||||
.bind(
|
||||
() -> {
|
||||
return new OpenSshAgentStrategy(forward.get(), publicKey.get());
|
||||
@@ -68,9 +64,7 @@ public class OpenSshAgentStrategy implements SshIdentityStrategy {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void buildCommand(CommandBuilder builder) {}
|
||||
|
||||
private String getIdentityAgent(ShellControl sc) throws Exception {
|
||||
public FilePath determinetAgentSocketLocation(ShellControl sc) throws Exception {
|
||||
if (sc.getOsType() == OsType.WINDOWS) {
|
||||
return null;
|
||||
}
|
||||
@@ -78,13 +72,16 @@ public class OpenSshAgentStrategy implements SshIdentityStrategy {
|
||||
if (AppPrefs.get() != null) {
|
||||
var socket = AppPrefs.get().defaultSshAgentSocket().getValue();
|
||||
if (socket != null) {
|
||||
return socket.resolveTildeHome(sc.view().userHome()).toString();
|
||||
return socket.resolveTildeHome(sc.view().userHome());
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void buildCommand(CommandBuilder builder) {}
|
||||
|
||||
@Override
|
||||
public List<KeyValue> configOptions(ShellControl sc) throws Exception {
|
||||
var file = SshIdentityStrategy.getPublicKeyPath(sc, publicKey);
|
||||
@@ -94,11 +91,15 @@ public class OpenSshAgentStrategy implements SshIdentityStrategy {
|
||||
new KeyValue("IdentityFile", file.isPresent() ? file.get().toString() : "none"),
|
||||
new KeyValue("PKCS11Provider", "none")));
|
||||
|
||||
var agent = getIdentityAgent(sc);
|
||||
var agent = determinetAgentSocketLocation(sc);
|
||||
if (agent != null) {
|
||||
l.add(new KeyValue("IdentityAgent", "\"" + agent + "\""));
|
||||
}
|
||||
|
||||
return l;
|
||||
}
|
||||
|
||||
public PublicKeyStrategy getPublicKeyStrategy() {
|
||||
return PublicKeyStrategy.Fixed.of(publicKey);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
package io.xpipe.ext.base.identity.ssh;
|
||||
package io.xpipe.app.cred;
|
||||
|
||||
import io.xpipe.app.comp.base.TextFieldComp;
|
||||
import io.xpipe.app.platform.OptionsBuilder;
|
||||
import io.xpipe.app.process.CommandBuilder;
|
||||
import io.xpipe.app.process.ShellControl;
|
||||
import io.xpipe.core.FilePath;
|
||||
import io.xpipe.core.KeyValue;
|
||||
|
||||
import javafx.beans.property.Property;
|
||||
@@ -21,7 +21,7 @@ import java.util.List;
|
||||
@Value
|
||||
@Jacksonized
|
||||
@Builder
|
||||
public class OtherExternalAgentStrategy implements SshIdentityStrategy {
|
||||
public class OtherExternalAgentStrategy implements SshIdentityAgentStrategy {
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public static OptionsBuilder createOptions(
|
||||
@@ -31,16 +31,13 @@ public class OtherExternalAgentStrategy implements SshIdentityStrategy {
|
||||
var publicKey =
|
||||
new SimpleStringProperty(p.getValue() != null ? p.getValue().getPublicKey() : null);
|
||||
return new OptionsBuilder()
|
||||
.nameAndDescription("publicKey")
|
||||
.addComp(new SshAgentKeyListComp(config.getFileSystem(), p, publicKey, false), publicKey)
|
||||
.nameAndDescription("forwardAgent")
|
||||
.addToggle(forward)
|
||||
.nonNull()
|
||||
.hide(!config.isAllowAgentForward())
|
||||
.nameAndDescription("publicKey")
|
||||
.addComp(
|
||||
new TextFieldComp(publicKey)
|
||||
.apply(struc -> struc.setPromptText(
|
||||
"ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIBmhLUTJiP...== Your Comment")),
|
||||
publicKey)
|
||||
.bind(
|
||||
() -> {
|
||||
return new OtherExternalAgentStrategy(forward.get(), publicKey.get());
|
||||
@@ -54,10 +51,15 @@ public class OtherExternalAgentStrategy implements SshIdentityStrategy {
|
||||
@Override
|
||||
public void prepareParent(ShellControl parent) throws Exception {
|
||||
if (parent.isLocal()) {
|
||||
SshIdentityStateManager.prepareLocalExternalAgent();
|
||||
SshIdentityStateManager.prepareLocalExternalAgent(null);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public FilePath determinetAgentSocketLocation(ShellControl parent) throws Exception {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void buildCommand(CommandBuilder builder) {}
|
||||
|
||||
@@ -70,4 +72,8 @@ public class OtherExternalAgentStrategy implements SshIdentityStrategy {
|
||||
new KeyValue("IdentityFile", file.isPresent() ? file.get().toString() : "none"),
|
||||
new KeyValue("PKCS11Provider", "none"));
|
||||
}
|
||||
|
||||
public PublicKeyStrategy getPublicKeyStrategy() {
|
||||
return PublicKeyStrategy.Fixed.of(publicKey);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package io.xpipe.ext.base.identity.ssh;
|
||||
package io.xpipe.app.cred;
|
||||
|
||||
import io.xpipe.app.comp.base.TextFieldComp;
|
||||
import io.xpipe.app.core.AppSystemInfo;
|
||||
import io.xpipe.app.issue.ErrorEventFactory;
|
||||
import io.xpipe.app.platform.OptionsBuilder;
|
||||
@@ -8,6 +7,8 @@ import io.xpipe.app.prefs.AppPrefs;
|
||||
import io.xpipe.app.process.CommandBuilder;
|
||||
import io.xpipe.app.process.LocalShell;
|
||||
import io.xpipe.app.process.ShellControl;
|
||||
import io.xpipe.app.util.LocalExec;
|
||||
import io.xpipe.core.FilePath;
|
||||
import io.xpipe.core.KeyValue;
|
||||
import io.xpipe.core.OsType;
|
||||
|
||||
@@ -30,7 +31,7 @@ import java.util.List;
|
||||
@Value
|
||||
@Jacksonized
|
||||
@Builder
|
||||
public class PageantStrategy implements SshIdentityStrategy {
|
||||
public class PageantStrategy implements SshIdentityAgentStrategy {
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public static OptionsBuilder createOptions(Property<PageantStrategy> p, SshIdentityStrategyChoiceConfig config) {
|
||||
@@ -39,16 +40,12 @@ public class PageantStrategy implements SshIdentityStrategy {
|
||||
var publicKey =
|
||||
new SimpleStringProperty(p.getValue() != null ? p.getValue().getPublicKey() : null);
|
||||
return new OptionsBuilder()
|
||||
.nameAndDescription("publicKey")
|
||||
.addComp(new SshAgentKeyListComp(config.getFileSystem(), p, publicKey, false), publicKey)
|
||||
.nameAndDescription("forwardAgent")
|
||||
.addToggle(forward)
|
||||
.nonNull()
|
||||
.hide(!config.isAllowAgentForward())
|
||||
.nameAndDescription("publicKey")
|
||||
.addComp(
|
||||
new TextFieldComp(publicKey)
|
||||
.apply(struc -> struc.setPromptText(
|
||||
"ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIBmhLUTJiP...== Your Comment")),
|
||||
publicKey)
|
||||
.bind(
|
||||
() -> {
|
||||
return new PageantStrategy(forward.get(), publicKey.get());
|
||||
@@ -67,7 +64,7 @@ public class PageantStrategy implements SshIdentityStrategy {
|
||||
return true;
|
||||
} else {
|
||||
try {
|
||||
var found = LocalShell.getShell().view().findProgram("pageant").isPresent();
|
||||
var found = LocalExec.readStdoutIfPossible("which", "pageant").isPresent();
|
||||
return (supported = found);
|
||||
} catch (Exception ex) {
|
||||
return (supported = false);
|
||||
@@ -92,20 +89,24 @@ public class PageantStrategy implements SshIdentityStrategy {
|
||||
throw ErrorEventFactory.expected(new IllegalStateException(
|
||||
"Pageant is not running as the primary agent via the $SSH_AUTH_SOCK variable."));
|
||||
}
|
||||
} else if (parent.isLocal()) {
|
||||
// Check if it exists
|
||||
getPageantWindowsPipe();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void buildCommand(CommandBuilder builder) {}
|
||||
|
||||
private String getIdentityAgent(ShellControl sc) {
|
||||
public FilePath determinetAgentSocketLocation(ShellControl sc) throws Exception {
|
||||
if (sc.isLocal() && sc.getOsType() == OsType.WINDOWS) {
|
||||
return getPageantWindowsPipe();
|
||||
return FilePath.of(getPageantWindowsPipe());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void buildCommand(CommandBuilder builder) {}
|
||||
|
||||
@Override
|
||||
public List<KeyValue> configOptions(ShellControl sc) throws Exception {
|
||||
var file = SshIdentityStrategy.getPublicKeyPath(sc, publicKey);
|
||||
@@ -115,7 +116,7 @@ public class PageantStrategy implements SshIdentityStrategy {
|
||||
new KeyValue("IdentityFile", file.isPresent() ? file.get().toString() : "none"),
|
||||
new KeyValue("PKCS11Provider", "none")));
|
||||
|
||||
var agent = getIdentityAgent(sc);
|
||||
var agent = determinetAgentSocketLocation(sc);
|
||||
if (agent != null) {
|
||||
l.add(new KeyValue("IdentityAgent", "\"" + agent + "\""));
|
||||
}
|
||||
@@ -136,4 +137,8 @@ public class PageantStrategy implements SshIdentityStrategy {
|
||||
var file = "\\\\.\\pipe\\" + fd.getFileName();
|
||||
return file;
|
||||
}
|
||||
|
||||
public PublicKeyStrategy getPublicKeyStrategy() {
|
||||
return PublicKeyStrategy.Fixed.of(publicKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package io.xpipe.app.cred;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonTypeName;
|
||||
import io.xpipe.app.comp.base.ButtonComp;
|
||||
import io.xpipe.app.comp.base.HorizontalComp;
|
||||
import io.xpipe.app.comp.base.LabelComp;
|
||||
import io.xpipe.app.core.AppI18n;
|
||||
import io.xpipe.app.ext.ValidationException;
|
||||
import io.xpipe.app.platform.OptionsBuilder;
|
||||
import io.xpipe.app.platform.Validator;
|
||||
import io.xpipe.app.prefs.AppPrefs;
|
||||
import io.xpipe.app.process.CommandBuilder;
|
||||
import io.xpipe.app.process.ShellControl;
|
||||
import io.xpipe.app.pwman.PasswordManagerKeyConfiguration;
|
||||
import io.xpipe.app.storage.DataStorage;
|
||||
import io.xpipe.app.util.Validators;
|
||||
import io.xpipe.core.FilePath;
|
||||
import io.xpipe.core.KeyValue;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.beans.property.ReadOnlyBooleanWrapper;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
import lombok.extern.jackson.Jacksonized;
|
||||
import org.kordamp.ikonli.javafx.FontIcon;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@JsonTypeName("passwordManagerAgent")
|
||||
@Value
|
||||
@Jacksonized
|
||||
@Builder
|
||||
public class PasswordManagerAgentStrategy implements SshIdentityAgentStrategy {
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public static OptionsBuilder createOptions(
|
||||
Property<PasswordManagerAgentStrategy> p, SshIdentityStrategyChoiceConfig config) {
|
||||
var identifier =
|
||||
new SimpleStringProperty(p.getValue() != null ? p.getValue().getIdentifier() : null);
|
||||
|
||||
var pwmanBinding = Bindings.createObjectBinding(() -> {
|
||||
var pwman = AppPrefs.get().passwordManager().getValue();
|
||||
if (pwman == null) {
|
||||
return AppI18n.get("passwordManagerEmpty");
|
||||
}
|
||||
|
||||
if (!pwman.getKeyConfiguration().useAgent()) {
|
||||
return AppI18n.get("passwordManagerNoAgentSupport");
|
||||
}
|
||||
|
||||
return null;
|
||||
}, AppPrefs.get().passwordManager(), AppI18n.activeLanguage());
|
||||
var pwmanProp = new SimpleStringProperty();
|
||||
pwmanProp.bind(pwmanBinding);
|
||||
var pwmanDisplay = new HorizontalComp(List.of(
|
||||
new LabelComp(pwmanProp)
|
||||
.maxWidth(10000)
|
||||
.apply(label -> label.setAlignment(Pos.CENTER_LEFT))
|
||||
.hgrow(),
|
||||
new ButtonComp(null, new FontIcon("mdomz-settings"), () -> {
|
||||
AppPrefs.get().selectCategory("passwordManager");
|
||||
})
|
||||
.padding(new Insets(7))))
|
||||
.spacing(9);
|
||||
|
||||
return new OptionsBuilder()
|
||||
.nameAndDescription("passwordManagerSshKeyConfig")
|
||||
.addComp(pwmanDisplay)
|
||||
.hide(pwmanProp.isNull())
|
||||
.nameAndDescription(useKeyName() ? "agentKeyName" : "publicKey")
|
||||
.addComp(new SshAgentKeyListComp(config.getFileSystem(), p, identifier, useKeyName()), identifier)
|
||||
.nonNull()
|
||||
.hide(!config.isAllowAgentForward())
|
||||
.bind(
|
||||
() -> {
|
||||
return new PasswordManagerAgentStrategy(identifier.get());
|
||||
},
|
||||
p);
|
||||
}
|
||||
|
||||
String identifier;
|
||||
|
||||
private static PasswordManagerKeyConfiguration getConfig() {
|
||||
var pwman = AppPrefs.get().passwordManager().getValue();
|
||||
return pwman != null && pwman.getKeyConfiguration() != null && pwman.getKeyConfiguration().useAgent() ? pwman.getKeyConfiguration() : null;
|
||||
}
|
||||
|
||||
private static boolean useKeyName() {
|
||||
var config = getConfig();
|
||||
return config != null && config.supportsAgentKeyNames();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void checkComplete() throws ValidationException {
|
||||
Validators.nonNull(identifier);
|
||||
var config = getConfig();
|
||||
if (config == null) {
|
||||
throw new ValidationException(AppI18n.get("passwordManagerSshKeysNotSupported"));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void prepareParent(ShellControl parent) throws Exception {
|
||||
var config = getConfig();
|
||||
if (config != null) {
|
||||
var strat = config.getSshIdentityStrategy(null, false);
|
||||
strat.prepareParent(parent);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public FilePath determinetAgentSocketLocation(ShellControl parent) throws Exception {
|
||||
var config = getConfig();
|
||||
return config != null ? FilePath.of(config.getDefaultSocketLocation()) : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void buildCommand(CommandBuilder builder) {
|
||||
var config = getConfig();
|
||||
if (config != null) {
|
||||
var strat = config.getSshIdentityStrategy(null, false);
|
||||
strat.buildCommand(builder);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<KeyValue> configOptions(ShellControl sc) throws Exception {
|
||||
var config = getConfig();
|
||||
if (config != null) {
|
||||
var strat = config.getSshIdentityStrategy(getPublicKeyStrategy().retrievePublicKey(), false);
|
||||
return strat.configOptions(sc);
|
||||
} else {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public PublicKeyStrategy getPublicKeyStrategy() {
|
||||
if (identifier == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!useKeyName()) {
|
||||
return PublicKeyStrategy.Fixed.of(identifier);
|
||||
}
|
||||
|
||||
return new PublicKeyStrategy.Dynamic(() -> {
|
||||
return SshAgentKeyList.findAgentIdentity(
|
||||
DataStorage.get().local().ref(), this, identifier).toString();
|
||||
});
|
||||
}
|
||||
}
|
||||
78
app/src/main/java/io/xpipe/app/cred/PublicKeyStrategy.java
Normal file
78
app/src/main/java/io/xpipe/app/cred/PublicKeyStrategy.java
Normal file
@@ -0,0 +1,78 @@
|
||||
package io.xpipe.app.cred;
|
||||
|
||||
import io.xpipe.core.FailableSupplier;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.ToString;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface PublicKeyStrategy {
|
||||
|
||||
Optional<String> getFixedPublicKey();
|
||||
|
||||
String retrievePublicKey() throws Exception;
|
||||
|
||||
@EqualsAndHashCode
|
||||
@ToString
|
||||
final class Fixed implements PublicKeyStrategy {
|
||||
|
||||
public static Fixed of(String publicKey) {
|
||||
return publicKey != null ? new Fixed(publicKey) : null;
|
||||
}
|
||||
|
||||
private final String publicKey;
|
||||
|
||||
public Fixed(String publicKey) {
|
||||
this.publicKey = publicKey;
|
||||
}
|
||||
|
||||
public String get() {
|
||||
return publicKey;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> getFixedPublicKey() {
|
||||
return Optional.ofNullable(publicKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String retrievePublicKey() {
|
||||
return getFixedPublicKey().orElseThrow();
|
||||
}
|
||||
}
|
||||
|
||||
final class Dynamic implements PublicKeyStrategy {
|
||||
|
||||
private final FailableSupplier<String> publicKey;
|
||||
|
||||
public Dynamic(FailableSupplier<String> publicKey) {
|
||||
this.publicKey = publicKey;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return getClass().hashCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
return obj instanceof Dynamic;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "<dynamic>";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> getFixedPublicKey() {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String retrievePublicKey() throws Exception {
|
||||
var r = publicKey.get();
|
||||
return r;
|
||||
}
|
||||
}
|
||||
}
|
||||
73
app/src/main/java/io/xpipe/app/cred/SshAgentKeyList.java
Normal file
73
app/src/main/java/io/xpipe/app/cred/SshAgentKeyList.java
Normal file
@@ -0,0 +1,73 @@
|
||||
package io.xpipe.app.cred;
|
||||
|
||||
import io.xpipe.app.ext.ShellStore;
|
||||
import io.xpipe.app.issue.ErrorEventFactory;
|
||||
import io.xpipe.app.process.CommandBuilder;
|
||||
import io.xpipe.app.storage.DataStorage;
|
||||
import io.xpipe.app.storage.DataStoreEntryRef;
|
||||
import lombok.Value;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class SshAgentKeyList {
|
||||
|
||||
@Value
|
||||
public static class Entry {
|
||||
|
||||
String type;
|
||||
String publicKey;
|
||||
String name;
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return type + " " + publicKey + (name != null ? " " + name : "");
|
||||
}
|
||||
}
|
||||
|
||||
public static Entry findAgentIdentity(DataStoreEntryRef<ShellStore> ref, SshIdentityAgentStrategy strategy, String identifier) throws Exception {
|
||||
var list = listAgentIdentities(ref, strategy).stream().filter(entry -> {
|
||||
return (entry.getName() != null && entry.getName().equalsIgnoreCase(identifier)) || entry.getPublicKey().equalsIgnoreCase(identifier);
|
||||
}).toList();
|
||||
|
||||
if (list.isEmpty()) {
|
||||
throw ErrorEventFactory.expected(new IllegalArgumentException("No such agent identity: " + identifier));
|
||||
}
|
||||
|
||||
if (list.size() > 1) {
|
||||
throw ErrorEventFactory.expected(new IllegalArgumentException("Ambiguous agent identities: " + list.stream()
|
||||
.map(entry -> entry.getName() != null ? entry.getName() : entry.getPublicKey())
|
||||
.collect(Collectors.joining(", "))));
|
||||
}
|
||||
|
||||
return list.getFirst();
|
||||
}
|
||||
|
||||
public static List<Entry> listAgentIdentities(DataStoreEntryRef<ShellStore> ref, SshIdentityAgentStrategy strategy) throws Exception {
|
||||
var session = ref.getStore().getOrStartSession();
|
||||
strategy.prepareParent(session);
|
||||
|
||||
var socket = strategy.determinetAgentSocketLocation(session);
|
||||
var out = session.command(CommandBuilder.of().add("ssh-add", "-L").fixedEnvironment("SSH_AUTH_SOCK", socket != null ? socket.toString() : null)).readStdoutOrThrow();
|
||||
var pattern = Pattern.compile("([^ ]+) ([^ ]+)(?: (.+))?");
|
||||
var lines = out.lines().toList();
|
||||
var list = new ArrayList<Entry>();
|
||||
for (String line : lines) {
|
||||
var matcher = pattern.matcher(line);
|
||||
if (!matcher.matches()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
var type = matcher.group(1);
|
||||
var publicKey = matcher.group(2);
|
||||
var name = matcher.groupCount() > 3 ? matcher.group(3) : null;
|
||||
list.add(new Entry(type, publicKey, name));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
}
|
||||
99
app/src/main/java/io/xpipe/app/cred/SshAgentKeyListComp.java
Normal file
99
app/src/main/java/io/xpipe/app/cred/SshAgentKeyListComp.java
Normal file
@@ -0,0 +1,99 @@
|
||||
package io.xpipe.app.cred;
|
||||
|
||||
import atlantafx.base.controls.Popover;
|
||||
import atlantafx.base.theme.Styles;
|
||||
import io.xpipe.app.comp.SimpleRegionBuilder;
|
||||
import io.xpipe.app.comp.base.ButtonComp;
|
||||
import io.xpipe.app.comp.base.IconButtonComp;
|
||||
import io.xpipe.app.comp.base.InputGroupComp;
|
||||
import io.xpipe.app.comp.base.TextFieldComp;
|
||||
import io.xpipe.app.core.AppI18n;
|
||||
import io.xpipe.app.ext.ShellStore;
|
||||
import io.xpipe.app.platform.LabelGraphic;
|
||||
import io.xpipe.app.storage.DataStorage;
|
||||
import io.xpipe.app.storage.DataStoreEntryRef;
|
||||
import io.xpipe.app.util.ThreadHelper;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.layout.VBox;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class SshAgentKeyListComp extends SimpleRegionBuilder {
|
||||
|
||||
private final ObservableValue<DataStoreEntryRef<ShellStore>> ref;
|
||||
private final ObservableValue<? extends SshIdentityAgentStrategy> sshIdentityStrategy;
|
||||
private final StringProperty value;
|
||||
private final boolean useKeyNames;
|
||||
|
||||
public SshAgentKeyListComp(ObservableValue<DataStoreEntryRef<ShellStore>> ref, ObservableValue<? extends SshIdentityAgentStrategy> sshIdentityStrategy, StringProperty value,
|
||||
boolean useKeyNames
|
||||
) {
|
||||
this.ref = ref;
|
||||
this.sshIdentityStrategy = sshIdentityStrategy;
|
||||
this.value = value;
|
||||
this.useKeyNames = useKeyNames;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Region createSimple() {
|
||||
var field = new TextFieldComp(value);
|
||||
field.apply(struc -> struc.setPromptText(
|
||||
useKeyNames ? "<name>" : "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIBmhLUTJiP...== <key comment>"));
|
||||
var button = new ButtonComp(null, new LabelGraphic.IconGraphic("mdi2m-magnify-scan"), null);
|
||||
button.apply(struc -> {
|
||||
struc.setOnAction(event -> {
|
||||
DataStoreEntryRef<ShellStore> refToUse = ref.getValue() != null ? ref.getValue() : DataStorage.get().local().ref();
|
||||
ThreadHelper.runFailableAsync(() -> {
|
||||
var list = SshAgentKeyList.listAgentIdentities(refToUse, sshIdentityStrategy.getValue());
|
||||
Platform.runLater(() -> {
|
||||
var popover = new Popover();
|
||||
popover.setArrowLocation(Popover.ArrowLocation.TOP_CENTER);
|
||||
|
||||
if (list.size() > 0) {
|
||||
var content = new VBox();
|
||||
content.setPadding(new Insets(10));
|
||||
content.setFillWidth(true);
|
||||
var header = new Label(AppI18n.get("sshAgentHasKeys"));
|
||||
header.setPadding(new Insets(0, 0, 8, 8));
|
||||
content.getChildren().add(header);
|
||||
for (SshAgentKeyList.Entry entry : list) {
|
||||
var buttonName = entry.getType() + " " + (entry.getName() != null ? entry.getName() : entry.getPublicKey());
|
||||
var entryButton = new Button(buttonName);
|
||||
entryButton.setMaxWidth(400);
|
||||
entryButton.getStyleClass().add(Styles.FLAT);
|
||||
entryButton.setOnAction(e -> {
|
||||
value.setValue(useKeyNames && entry.getName() != null ? entry.getName() : entry.toString());
|
||||
popover.hide();
|
||||
e.consume();
|
||||
});
|
||||
entryButton.setMinWidth(400);
|
||||
entryButton.setAlignment(Pos.CENTER_LEFT);
|
||||
content.getChildren().add(entryButton);
|
||||
}
|
||||
popover.setContentNode(content);
|
||||
} else {
|
||||
var content = new Label(AppI18n.get("sshAgentNoKeys"));
|
||||
content.setPadding(new Insets(10));
|
||||
popover.setContentNode(content);
|
||||
}
|
||||
|
||||
var target = struc.getParent().getChildrenUnmodifiable().getFirst();
|
||||
popover.show(target);
|
||||
});
|
||||
});
|
||||
event.consume();
|
||||
});
|
||||
});
|
||||
var inputGroup = new InputGroupComp(List.of(field, button));
|
||||
inputGroup.setMainReference(field);
|
||||
return inputGroup.build();
|
||||
}
|
||||
}
|
||||
79
app/src/main/java/io/xpipe/app/cred/SshAgentTestComp.java
Normal file
79
app/src/main/java/io/xpipe/app/cred/SshAgentTestComp.java
Normal file
@@ -0,0 +1,79 @@
|
||||
package io.xpipe.app.cred;
|
||||
|
||||
import atlantafx.base.controls.Popover;
|
||||
import atlantafx.base.theme.Styles;
|
||||
import io.xpipe.app.comp.SimpleRegionBuilder;
|
||||
import io.xpipe.app.comp.base.ButtonComp;
|
||||
import io.xpipe.app.comp.base.InputGroupComp;
|
||||
import io.xpipe.app.comp.base.TextFieldComp;
|
||||
import io.xpipe.app.core.AppI18n;
|
||||
import io.xpipe.app.ext.ShellStore;
|
||||
import io.xpipe.app.platform.LabelGraphic;
|
||||
import io.xpipe.app.storage.DataStorage;
|
||||
import io.xpipe.app.storage.DataStoreEntryRef;
|
||||
import io.xpipe.app.util.ThreadHelper;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.kordamp.ikonli.javafx.FontIcon;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class SshAgentTestComp extends SimpleRegionBuilder {
|
||||
|
||||
private final ObservableValue<? extends SshIdentityAgentStrategy> sshIdentityStrategy;
|
||||
|
||||
public SshAgentTestComp(ObservableValue<? extends SshIdentityAgentStrategy> sshIdentityStrategy) {this.sshIdentityStrategy = sshIdentityStrategy;}
|
||||
|
||||
@Override
|
||||
protected Region createSimple() {
|
||||
var button = new ButtonComp(AppI18n.observable("test"), new LabelGraphic.IconGraphic("mdi2p-play"), null);
|
||||
button.padding(new Insets(6, 9, 6, 9));
|
||||
button.apply(struc -> {
|
||||
struc.setOnAction(event -> {
|
||||
DataStoreEntryRef<ShellStore> refToUse = DataStorage.get().local().ref();
|
||||
ThreadHelper.runFailableAsync(() -> {
|
||||
var list = SshAgentKeyList.listAgentIdentities(refToUse, sshIdentityStrategy.getValue());
|
||||
Platform.runLater(() -> {
|
||||
var popover = new Popover();
|
||||
popover.setArrowLocation(Popover.ArrowLocation.LEFT_CENTER);
|
||||
|
||||
if (list.size() > 0) {
|
||||
var content = new VBox();
|
||||
content.setPadding(new Insets(10));
|
||||
content.setFillWidth(true);
|
||||
var header = new Label(AppI18n.get("sshAgentHasKeys"));
|
||||
header.setPadding(new Insets(0, 0, 8, 8));
|
||||
content.getChildren().add(header);
|
||||
for (SshAgentKeyList.Entry entry : list) {
|
||||
var buttonName = entry.getType() + " " + (entry.getName() != null ? entry.getName() : entry.getPublicKey());
|
||||
var entryButton = new Button(buttonName);
|
||||
entryButton.setMaxWidth(400);
|
||||
entryButton.getStyleClass().add(Styles.FLAT);
|
||||
entryButton.setMinWidth(400);
|
||||
entryButton.setAlignment(Pos.CENTER_LEFT);
|
||||
content.getChildren().add(entryButton);
|
||||
}
|
||||
popover.setContentNode(content);
|
||||
} else {
|
||||
var content = new Label(AppI18n.get("sshAgentNoKeys"));
|
||||
content.setPadding(new Insets(10));
|
||||
popover.setContentNode(content);
|
||||
}
|
||||
|
||||
var target = struc;
|
||||
popover.show(target);
|
||||
});
|
||||
});
|
||||
event.consume();
|
||||
});
|
||||
});
|
||||
return button.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package io.xpipe.app.cred;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||
import io.xpipe.app.ext.ValidationException;
|
||||
import io.xpipe.app.issue.ErrorEventFactory;
|
||||
import io.xpipe.app.process.CommandBuilder;
|
||||
import io.xpipe.app.process.OsFileSystem;
|
||||
import io.xpipe.app.process.ShellControl;
|
||||
import io.xpipe.app.secret.SecretNoneStrategy;
|
||||
import io.xpipe.app.secret.SecretRetrievalStrategy;
|
||||
import io.xpipe.core.FilePath;
|
||||
import io.xpipe.core.KeyValue;
|
||||
import io.xpipe.core.OsType;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface SshIdentityAgentStrategy extends SshIdentityStrategy {
|
||||
|
||||
void prepareParent(ShellControl parent) throws Exception;
|
||||
|
||||
FilePath determinetAgentSocketLocation(ShellControl parent) throws Exception;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package io.xpipe.ext.base.identity.ssh;
|
||||
package io.xpipe.app.cred;
|
||||
|
||||
import io.xpipe.app.issue.ErrorAction;
|
||||
import io.xpipe.app.issue.ErrorEvent;
|
||||
@@ -136,7 +136,7 @@ public class SshIdentityStateManager {
|
||||
}
|
||||
}
|
||||
|
||||
public static synchronized void prepareLocalExternalAgent() throws Exception {
|
||||
public static synchronized void prepareLocalExternalAgent(FilePath socket) throws Exception {
|
||||
if (runningAgent == RunningAgent.EXTERNAL_AGENT) {
|
||||
return;
|
||||
}
|
||||
@@ -149,12 +149,12 @@ public class SshIdentityStateManager {
|
||||
if (!pipeExists) {
|
||||
// No agent is running
|
||||
throw ErrorEventFactory.expected(new IllegalStateException(
|
||||
"An external password manager agent is set for this connection, but no external SSH agent is running. Make sure that the "
|
||||
+ "agent is started in your password manager"));
|
||||
"An external agent is configured, but no external SSH agent is running. Make sure that the external "
|
||||
+ "agent is started"));
|
||||
}
|
||||
}
|
||||
|
||||
checkLocalAgentIdentities(null);
|
||||
checkLocalAgentIdentities(socket != null ? socket.toString() : null);
|
||||
|
||||
runningAgent = RunningAgent.EXTERNAL_AGENT;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package io.xpipe.ext.base.identity.ssh;
|
||||
package io.xpipe.app.cred;
|
||||
|
||||
import io.xpipe.app.ext.ValidationException;
|
||||
import io.xpipe.app.issue.ErrorEventFactory;
|
||||
@@ -11,7 +11,6 @@ import io.xpipe.core.FilePath;
|
||||
import io.xpipe.core.KeyValue;
|
||||
import io.xpipe.core.OsType;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonSubTypes;
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@@ -19,26 +18,15 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
|
||||
@JsonSubTypes({
|
||||
@JsonSubTypes.Type(value = NoIdentityStrategy.class),
|
||||
@JsonSubTypes.Type(value = KeyFileStrategy.class),
|
||||
@JsonSubTypes.Type(value = InPlaceKeyStrategy.class),
|
||||
@JsonSubTypes.Type(value = OpenSshAgentStrategy.class),
|
||||
@JsonSubTypes.Type(value = PageantStrategy.class),
|
||||
@JsonSubTypes.Type(value = CustomAgentStrategy.class),
|
||||
@JsonSubTypes.Type(value = GpgAgentStrategy.class),
|
||||
@JsonSubTypes.Type(value = YubikeyPivStrategy.class),
|
||||
@JsonSubTypes.Type(value = CustomPkcs11LibraryStrategy.class),
|
||||
@JsonSubTypes.Type(value = OtherExternalAgentStrategy.class)
|
||||
})
|
||||
public interface SshIdentityStrategy {
|
||||
|
||||
static List<Class<?>> getSubclasses() {
|
||||
static List<Class<?>> getClasses() {
|
||||
var l = new ArrayList<Class<?>>();
|
||||
l.add(NoIdentityStrategy.class);
|
||||
l.add(InPlaceKeyStrategy.class);
|
||||
l.add(KeyFileStrategy.class);
|
||||
l.add(OpenSshAgentStrategy.class);
|
||||
l.add(PasswordManagerAgentStrategy.class);
|
||||
if (OsType.ofLocal() != OsType.WINDOWS) {
|
||||
l.add(CustomAgentStrategy.class);
|
||||
}
|
||||
@@ -97,5 +85,5 @@ public interface SshIdentityStrategy {
|
||||
return new SecretNoneStrategy();
|
||||
}
|
||||
|
||||
String getPublicKey();
|
||||
PublicKeyStrategy getPublicKeyStrategy();
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package io.xpipe.ext.base.identity.ssh;
|
||||
package io.xpipe.app.cred;
|
||||
|
||||
import io.xpipe.app.ext.ShellStore;
|
||||
import io.xpipe.app.storage.DataStoreEntryRef;
|
||||
@@ -1,4 +1,4 @@
|
||||
package io.xpipe.ext.base.identity;
|
||||
package io.xpipe.app.cred;
|
||||
|
||||
import io.xpipe.core.FailableSupplier;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package io.xpipe.ext.base.identity.ssh;
|
||||
package io.xpipe.app.cred;
|
||||
|
||||
import io.xpipe.app.issue.ErrorEventFactory;
|
||||
import io.xpipe.app.process.CommandBuilder;
|
||||
@@ -81,7 +81,7 @@ public class YubikeyPivStrategy implements SshIdentityStrategy {
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPublicKey() {
|
||||
public PublicKeyStrategy getPublicKeyStrategy() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,10 @@ import java.util.UUID;
|
||||
|
||||
public interface DataStoreProvider {
|
||||
|
||||
default boolean allowCreation() {
|
||||
return true;
|
||||
}
|
||||
|
||||
default boolean showIncompleteInfo() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package io.xpipe.app.ext;
|
||||
|
||||
import io.xpipe.app.process.ShellStoreState;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
import lombok.experimental.SuperBuilder;
|
||||
import lombok.extern.jackson.Jacksonized;
|
||||
|
||||
@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE)
|
||||
@Getter
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@SuperBuilder(toBuilder = true)
|
||||
@Jacksonized
|
||||
public class NetworkContainerStoreState extends ContainerStoreState {
|
||||
|
||||
String ipv4;
|
||||
String ipv6;
|
||||
|
||||
@Override
|
||||
public DataStoreState mergeCopy(DataStoreState newer) {
|
||||
var n = (NetworkContainerStoreState) newer;
|
||||
var b = toBuilder();
|
||||
mergeBuilder(n, b);
|
||||
return b.build();
|
||||
}
|
||||
|
||||
protected void mergeBuilder(NetworkContainerStoreState css, NetworkContainerStoreState.NetworkContainerStoreStateBuilder<?, ?> b) {
|
||||
super.mergeBuilder(css, b);
|
||||
b.ipv4 = css.ipv4;
|
||||
b.ipv6 = css.ipv6;
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,8 @@ public abstract class ProcessControlProvider {
|
||||
|
||||
public abstract ShellDialect getEffectiveLocalDialect();
|
||||
|
||||
public abstract String executeMcpCommand(ShellControl sc, String command) throws Exception;
|
||||
|
||||
public ShellDialect getNextFallbackDialect() {
|
||||
var av = getAvailableLocalDialects();
|
||||
var index = av.indexOf(getEffectiveLocalDialect());
|
||||
@@ -76,4 +78,7 @@ public abstract class ProcessControlProvider {
|
||||
public abstract void cloneRepository(String url, Path target) throws Exception;
|
||||
|
||||
public abstract void pullRepository(Path target) throws Exception;
|
||||
|
||||
public abstract DataStore quickConnectStore(String user, String host, Integer port, DataStore existing);
|
||||
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ public class StoreCategoryWrapper {
|
||||
private final IntegerProperty allContainedEntriesCount = new SimpleIntegerProperty();
|
||||
private final BooleanProperty expanded = new SimpleBooleanProperty();
|
||||
private final Property<DataStoreColor> color = new SimpleObjectProperty<>();
|
||||
private final BooleanProperty largeCategoryOptimizations = new SimpleBooleanProperty();
|
||||
private final Trigger<Void> renameTrigger = Trigger.of();
|
||||
private StoreCategoryWrapper cachedParent;
|
||||
|
||||
@@ -191,12 +190,6 @@ public class StoreCategoryWrapper {
|
||||
.sum();
|
||||
allContainedEntriesCount.setValue(direct + sub);
|
||||
|
||||
var performanceCount =
|
||||
AppPrefs.get().showChildCategoriesInParentCategory().get() ? allContainedEntriesCount.get() : direct;
|
||||
if (performanceCount > 500) {
|
||||
largeCategoryOptimizations.setValue(true);
|
||||
}
|
||||
|
||||
var directFiltered = directContainedEntries.getList().stream()
|
||||
.filter(storeEntryWrapper -> {
|
||||
var filter = StoreViewState.get().getFilterString().getValue();
|
||||
|
||||
@@ -44,7 +44,6 @@ public class StoreComboChoiceComp<T extends DataStore> extends SimpleRegionBuild
|
||||
private final Property<ComboValue<T>> selected;
|
||||
private final Function<T, String> stringConverter;
|
||||
private final StoreChoicePopover<T> popover;
|
||||
private final boolean requireComplete;
|
||||
|
||||
public StoreComboChoiceComp(
|
||||
Function<T, String> stringConverter,
|
||||
@@ -56,7 +55,6 @@ public class StoreComboChoiceComp<T extends DataStore> extends SimpleRegionBuild
|
||||
boolean requireComplete) {
|
||||
this.stringConverter = stringConverter;
|
||||
this.selected = selected;
|
||||
this.requireComplete = requireComplete;
|
||||
|
||||
var popoverProp = new SimpleObjectProperty<>(
|
||||
selected.getValue() != null ? selected.getValue().getRef() : null);
|
||||
|
||||
@@ -58,8 +58,8 @@ public class StoreCreationComp extends ModalOverlayContentComp {
|
||||
var provider = model.getProvider().getValue() != null
|
||||
? model.getProvider().getValue()
|
||||
: providerChoice.getProviders().getFirst();
|
||||
var showProviders = (!model.isStaticDisplay() && provider.showProviderChoice())
|
||||
|| (model.isStaticDisplay() && provider.showProviderChoice());
|
||||
var showProviders = !model.isQuickConnect() && ((!model.isStaticDisplay() && provider.showProviderChoice())
|
||||
|| (model.isStaticDisplay() && provider.showProviderChoice()));
|
||||
if (model.isStaticDisplay()) {
|
||||
providerChoice.apply(struc -> struc.setDisable(true));
|
||||
}
|
||||
@@ -88,7 +88,6 @@ public class StoreCreationComp extends ModalOverlayContentComp {
|
||||
});
|
||||
}
|
||||
|
||||
var propOptions = createStoreProperties();
|
||||
model.getInitialStore().setValue(model.getStore().getValue());
|
||||
|
||||
var valSp = new GraphicDecorationStackPane();
|
||||
@@ -103,7 +102,11 @@ public class StoreCreationComp extends ModalOverlayContentComp {
|
||||
}
|
||||
|
||||
full.sub(d.getOptions());
|
||||
full.sub(propOptions);
|
||||
|
||||
if (!model.isQuickConnect()) {
|
||||
var propOptions = createStoreProperties();
|
||||
full.sub(propOptions);
|
||||
}
|
||||
|
||||
var comp = full.buildComp();
|
||||
var region = comp.style("store-creator-options").build();
|
||||
|
||||
@@ -26,17 +26,23 @@ import java.util.function.Predicate;
|
||||
|
||||
public class StoreCreationDialog {
|
||||
|
||||
public static void showEdit(DataStoreEntry e) {
|
||||
showEdit(e, dataStoreEntry -> {});
|
||||
public static StoreCreationModel showEdit(DataStoreEntry e) {
|
||||
return showEdit(e, dataStoreEntry -> {});
|
||||
}
|
||||
|
||||
public static void showEdit(DataStoreEntry e, Consumer<DataStoreEntry> c) {
|
||||
showEdit(e, e.getStore(), c);
|
||||
public static StoreCreationModel showEdit(DataStoreEntry e, Consumer<DataStoreEntry> c) {
|
||||
return showEdit(e, e.getStore(), true, c);
|
||||
}
|
||||
|
||||
public static void showEdit(DataStoreEntry e, DataStore base, Consumer<DataStoreEntry> c) {
|
||||
public static StoreCreationModel showEdit(DataStoreEntry e, DataStore base, boolean addToStorage, Consumer<DataStoreEntry> c) {
|
||||
StoreCreationConsumer consumer = (newE, validated) -> {
|
||||
ThreadHelper.runAsync(() -> {
|
||||
if (!addToStorage) {
|
||||
DataStorage.get().updateEntry(e, newE);
|
||||
c.accept(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!DataStorage.get().getStoreEntries().contains(e)
|
||||
|| DataStorage.get().getEffectiveReadOnlyState(e)) {
|
||||
DataStorage.get().addStoreEntryIfNotPresent(newE);
|
||||
@@ -48,14 +54,12 @@ public class StoreCreationDialog {
|
||||
var madeValid = !e.getValidity().isUsable()
|
||||
&& newE.getValidity().isUsable();
|
||||
DataStorage.get().updateEntry(e, newE);
|
||||
if (madeValid) {
|
||||
if (validated
|
||||
&& e.getProvider().shouldShowScan()
|
||||
&& AppPrefs.get()
|
||||
.openConnectionSearchWindowOnConnectionCreation()
|
||||
.get()) {
|
||||
ScanDialog.showSingleAsync(e);
|
||||
}
|
||||
if (madeValid && validated
|
||||
&& e.getProvider().shouldShowScan()
|
||||
&& AppPrefs.get()
|
||||
.openConnectionSearchWindowOnConnectionCreation()
|
||||
.get()) {
|
||||
ScanDialog.showSingleAsync(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,11 +76,11 @@ public class StoreCreationDialog {
|
||||
c.accept(e);
|
||||
});
|
||||
};
|
||||
show(e.getName(), DataStoreProviders.byStore(base), base, v -> true, consumer, true, e);
|
||||
return show(e.getName(), DataStoreProviders.byStore(base), base, v -> true, consumer, true, e);
|
||||
}
|
||||
|
||||
public static void showCreation(DataStoreProvider selected, DataStoreCreationCategory category) {
|
||||
showCreation(
|
||||
public static StoreCreationModel showCreation(DataStoreProvider selected, DataStoreCreationCategory category) {
|
||||
return showCreation(
|
||||
null,
|
||||
selected != null ? selected.defaultStore(DataStorage.get().getSelectedCategory()) : null,
|
||||
category,
|
||||
@@ -84,7 +88,7 @@ public class StoreCreationDialog {
|
||||
true);
|
||||
}
|
||||
|
||||
public static void showCreation(
|
||||
public static StoreCreationModel showCreation(
|
||||
String name,
|
||||
DataStore base,
|
||||
DataStoreCreationCategory category,
|
||||
@@ -118,18 +122,19 @@ public class StoreCreationDialog {
|
||||
ErrorEventFactory.fromThrowable(ex).handle();
|
||||
}
|
||||
};
|
||||
show(
|
||||
return show(
|
||||
name,
|
||||
prov,
|
||||
base,
|
||||
dataStoreProvider -> (category != null && category.equals(dataStoreProvider.getCreationCategory()))
|
||||
dataStoreProvider -> (category != null && dataStoreProvider.allowCreation() &&
|
||||
category.equals(dataStoreProvider.getCreationCategory()))
|
||||
|| dataStoreProvider.equals(prov),
|
||||
consumer,
|
||||
false,
|
||||
null);
|
||||
}
|
||||
|
||||
private static void show(
|
||||
private static StoreCreationModel show(
|
||||
String initialName,
|
||||
DataStoreProvider provider,
|
||||
DataStore s,
|
||||
@@ -140,7 +145,7 @@ public class StoreCreationDialog {
|
||||
var ex = StoreCreationQueueEntry.findExisting(existingEntry);
|
||||
if (ex.isPresent()) {
|
||||
ex.get().execute();
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
var prop = new SimpleObjectProperty<>(provider);
|
||||
@@ -148,12 +153,13 @@ public class StoreCreationDialog {
|
||||
var model = new StoreCreationModel(prop, store, filter, initialName, existingEntry, staticDisplay, con);
|
||||
var modal = createModalOverlay(model);
|
||||
modal.show();
|
||||
return model;
|
||||
}
|
||||
|
||||
private static ModalOverlay createModalOverlay(StoreCreationModel model) {
|
||||
var comp = new StoreCreationComp(model);
|
||||
comp.prefWidth(650);
|
||||
var nameKey = model.storeTypeNameKey() + "Add";
|
||||
var nameKey = model.isQuickConnect() ? "quickConnect" : model.storeTypeNameKey() + "Add";
|
||||
var modal = ModalOverlay.of(nameKey, comp);
|
||||
var queueEntry = StoreCreationQueueEntry.of(model, modal);
|
||||
comp.apply(struc -> {
|
||||
@@ -205,21 +211,18 @@ public class StoreCreationDialog {
|
||||
.augment(button -> {
|
||||
button.visibleProperty().bind(Bindings.not(model.canConnect()));
|
||||
}));
|
||||
modal.addButton(new ModalButton(
|
||||
"skip",
|
||||
() -> {
|
||||
model.commit(false);
|
||||
modal.close();
|
||||
},
|
||||
false,
|
||||
false))
|
||||
.augment(button -> {
|
||||
button.visibleProperty().bind(model.getSkippable());
|
||||
button.disableProperty().bind(model.getBusy());
|
||||
});
|
||||
if (!model.isQuickConnect()) {
|
||||
modal.addButton(new ModalButton("skip", () -> {
|
||||
model.commit(false);
|
||||
modal.close();
|
||||
}, false, false)).augment(button -> {
|
||||
button.visibleProperty().bind(model.getSkippable());
|
||||
button.disableProperty().bind(model.getBusy());
|
||||
});
|
||||
}
|
||||
|
||||
modal.addButton(new ModalButton(
|
||||
"finish",
|
||||
model.isQuickConnect() ? "connect" : "finish",
|
||||
() -> {
|
||||
model.finish();
|
||||
},
|
||||
@@ -239,7 +242,7 @@ public class StoreCreationDialog {
|
||||
button.textProperty()
|
||||
.bind(Bindings.createStringBinding(
|
||||
() -> {
|
||||
return !model.getBusy().get() ? AppI18n.get("finish") : null;
|
||||
return !model.getBusy().get() ? AppI18n.get(model.isQuickConnect() ? "connect" : "finish") : null;
|
||||
},
|
||||
PlatformThread.sync(model.getBusy()),
|
||||
AppI18n.activeLanguage()));
|
||||
|
||||
@@ -153,7 +153,7 @@ public class StoreCreationMenu {
|
||||
});
|
||||
|
||||
int lastOrder = providers.getFirst().getOrderPriority();
|
||||
for (io.xpipe.app.ext.DataStoreProvider dataStoreProvider : providers) {
|
||||
for (var dataStoreProvider : providers) {
|
||||
if (dataStoreProvider.getOrderPriority() != lastOrder) {
|
||||
menu.getItems().add(new SeparatorMenuItem());
|
||||
lastOrder = dataStoreProvider.getOrderPriority();
|
||||
@@ -167,6 +167,7 @@ public class StoreCreationMenu {
|
||||
StoreCreationDialog.showCreation(dataStoreProvider, category);
|
||||
event.consume();
|
||||
});
|
||||
item.setDisable(!dataStoreProvider.allowCreation());
|
||||
menu.getItems().add(item);
|
||||
}
|
||||
return menu;
|
||||
|
||||
@@ -167,6 +167,10 @@ public class StoreCreationModel {
|
||||
store));
|
||||
}
|
||||
|
||||
boolean isQuickConnect() {
|
||||
return existingEntry != null && existingEntry.getUuid().equals(StoreQuickConnect.STORE_ID);
|
||||
}
|
||||
|
||||
void connect() {
|
||||
var temp = entry.getValue() != null ? entry.getValue() : DataStoreEntry.createTempWrapper(store.getValue());
|
||||
var action = OpenHubMenuLeafProvider.Action.builder().ref(temp.ref()).build();
|
||||
@@ -211,7 +215,7 @@ public class StoreCreationModel {
|
||||
}
|
||||
|
||||
// We didn't change anything
|
||||
if (store.getValue().isComplete() && !wasChanged()) {
|
||||
if (store.getValue().isComplete() && !wasChanged() && !isQuickConnect()) {
|
||||
commit(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package io.xpipe.app.hub.comp;
|
||||
|
||||
import io.xpipe.app.comp.SimpleRegionBuilder;
|
||||
import io.xpipe.app.storage.DataStoreEntry;
|
||||
import io.xpipe.app.util.BooleanScope;
|
||||
|
||||
import javafx.application.Platform;
|
||||
@@ -23,6 +24,7 @@ public class StoreEntryBatchSelectComp extends SimpleRegionBuilder {
|
||||
protected Region createSimple() {
|
||||
var selfUpdate = new SimpleBooleanProperty(false);
|
||||
var cb = new CheckBox();
|
||||
externalUpdate(cb);
|
||||
cb.setAllowIndeterminate(true);
|
||||
cb.selectedProperty().addListener((observable, oldValue, newValue) -> {
|
||||
BooleanScope.executeExclusive(selfUpdate, () -> {
|
||||
@@ -64,6 +66,13 @@ public class StoreEntryBatchSelectComp extends SimpleRegionBuilder {
|
||||
}
|
||||
|
||||
private void externalUpdate(CheckBox checkBox) {
|
||||
if (section.getWrapper() != null && section.getWrapper().getEntry().getValidity() == DataStoreEntry.Validity.LOAD_FAILED) {
|
||||
checkBox.setSelected(false);
|
||||
checkBox.setIndeterminate(false);
|
||||
checkBox.setDisable(true);
|
||||
return;
|
||||
}
|
||||
|
||||
var isSelected = section.getWrapper() == null
|
||||
? checkBox.isSelected()
|
||||
: StoreViewState.get().isBatchModeSelected(section.getWrapper());
|
||||
|
||||
@@ -459,10 +459,10 @@ public abstract class StoreEntryComp extends SimpleRegionBuilder {
|
||||
|
||||
var notes = new MenuItem(AppI18n.get("addNotes"), new FontIcon("mdi2c-comment-text-outline"));
|
||||
notes.setOnAction(event -> {
|
||||
getWrapper().getNotes().setValue(new StoreNotes(null, getDefaultNotes()));
|
||||
StoreNotesComp.showDialog(getWrapper(), getDefaultNotes());
|
||||
event.consume();
|
||||
});
|
||||
notes.visibleProperty().bind(BindingsHelper.map(getWrapper().getNotes(), s -> s.getCommited() == null));
|
||||
notes.visibleProperty().bind(BindingsHelper.map(getWrapper().getNotes(), s -> s == null));
|
||||
items.add(items.size(), notes);
|
||||
|
||||
var freeze = new MenuItem();
|
||||
|
||||
@@ -4,7 +4,6 @@ import io.xpipe.app.comp.BaseRegionBuilder;
|
||||
import io.xpipe.app.comp.RegionBuilder;
|
||||
import io.xpipe.app.comp.SimpleRegionBuilder;
|
||||
import io.xpipe.app.comp.base.CountComp;
|
||||
import io.xpipe.app.comp.base.FilterComp;
|
||||
import io.xpipe.app.comp.base.IconButtonComp;
|
||||
import io.xpipe.app.core.AppFontSizes;
|
||||
import io.xpipe.app.core.AppI18n;
|
||||
@@ -14,7 +13,9 @@ import io.xpipe.app.platform.MenuHelper;
|
||||
import io.xpipe.app.util.ObservableSubscriber;
|
||||
import io.xpipe.core.OsType;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.css.PseudoClass;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Orientation;
|
||||
@@ -40,7 +41,7 @@ public class StoreEntryListOverviewComp extends SimpleRegionBuilder {
|
||||
this.filterTrigger = filterTrigger;
|
||||
}
|
||||
|
||||
private Region createGroupListHeader() {
|
||||
private Region createHeaderBar() {
|
||||
var label = new Label();
|
||||
var name = BindingsHelper.flatMap(
|
||||
StoreViewState.get().getActiveCategory(),
|
||||
@@ -94,17 +95,10 @@ public class StoreEntryListOverviewComp extends SimpleRegionBuilder {
|
||||
return topBar;
|
||||
}
|
||||
|
||||
private Region createGroupListFilter() {
|
||||
var filter = new FilterComp(StoreViewState.get().getFilterString()).build();
|
||||
filterTrigger.subscribe(() -> {
|
||||
filter.requestFocus();
|
||||
});
|
||||
private Region createAddBar() {
|
||||
var add = createAddButton();
|
||||
var batchMode = createBatchModeButton().build();
|
||||
var hbox = new HBox(add, filter, batchMode);
|
||||
filter.minHeightProperty().bind(add.heightProperty());
|
||||
filter.prefHeightProperty().bind(add.heightProperty());
|
||||
filter.maxHeightProperty().bind(add.heightProperty());
|
||||
var hbox = new HBox(add, batchMode);
|
||||
batchMode.minHeightProperty().bind(add.heightProperty());
|
||||
batchMode.prefHeightProperty().bind(add.heightProperty());
|
||||
batchMode.maxHeightProperty().bind(add.heightProperty());
|
||||
@@ -112,9 +106,25 @@ public class StoreEntryListOverviewComp extends SimpleRegionBuilder {
|
||||
batchMode.prefWidthProperty().bind(add.heightProperty());
|
||||
batchMode.maxWidthProperty().bind(add.heightProperty());
|
||||
hbox.setSpacing(8);
|
||||
hbox.setAlignment(Pos.CENTER_LEFT);
|
||||
return hbox;
|
||||
}
|
||||
|
||||
|
||||
private Region createFilterBar(Region addBar) {
|
||||
var filter = new StoreFilterComp().build();
|
||||
filterTrigger.subscribe(() -> {
|
||||
filter.requestFocus();
|
||||
});
|
||||
|
||||
filter.minHeightProperty().bind(addBar.heightProperty());
|
||||
filter.prefHeightProperty().bind(addBar.heightProperty());
|
||||
filter.maxHeightProperty().bind(addBar.heightProperty());
|
||||
|
||||
var hbox = new HBox(filter);
|
||||
hbox.setSpacing(8);
|
||||
hbox.setAlignment(Pos.CENTER);
|
||||
HBox.setHgrow(filter, Priority.ALWAYS);
|
||||
|
||||
filter.getStyleClass().add("filter-bar");
|
||||
return hbox;
|
||||
}
|
||||
@@ -267,7 +277,8 @@ public class StoreEntryListOverviewComp extends SimpleRegionBuilder {
|
||||
|
||||
@Override
|
||||
public Region createSimple() {
|
||||
var bar = new VBox(createGroupListHeader(), createGroupListFilter());
|
||||
var addBar = createAddBar();
|
||||
var bar = new VBox(createHeaderBar(), addBar, createFilterBar(addBar));
|
||||
bar.setFillWidth(true);
|
||||
bar.getStyleClass().add("bar");
|
||||
bar.getStyleClass().add("store-header-bar");
|
||||
|
||||
@@ -58,7 +58,7 @@ public class StoreEntryWrapper {
|
||||
private final Property<DataStoreColor> color = new SimpleObjectProperty<>();
|
||||
private final Property<StoreCategoryWrapper> category = new SimpleObjectProperty<>();
|
||||
private final Property<String> summary = new SimpleObjectProperty<>();
|
||||
private final Property<StoreNotes> notes;
|
||||
private final ObjectProperty<String> notes;
|
||||
private final Property<String> customIcon = new SimpleObjectProperty<>();
|
||||
private final Property<String> iconFile = new SimpleObjectProperty<>();
|
||||
private final BooleanProperty sessionActive = new SimpleBooleanProperty();
|
||||
@@ -69,7 +69,6 @@ public class StoreEntryWrapper {
|
||||
private final ObservableValue<String> shownSummary;
|
||||
private final ObservableValue<String> shownDescription;
|
||||
private final Property<String> shownInformation;
|
||||
private final BooleanProperty largeCategoryOptimizations = new SimpleBooleanProperty();
|
||||
private final BooleanProperty readOnly = new SimpleBooleanProperty();
|
||||
private final BooleanProperty renaming = new SimpleBooleanProperty();
|
||||
private final BooleanProperty pinToTop = new SimpleBooleanProperty();
|
||||
@@ -117,7 +116,7 @@ public class StoreEntryWrapper {
|
||||
shownSummary,
|
||||
AppI18n.activeLanguage());
|
||||
this.shownInformation = new SimpleObjectProperty<>();
|
||||
this.notes = new SimpleObjectProperty<>(new StoreNotes(entry.getNotes(), entry.getNotes()));
|
||||
this.notes = new SimpleObjectProperty<>(entry.getNotes());
|
||||
|
||||
setupListeners();
|
||||
}
|
||||
@@ -158,12 +157,6 @@ public class StoreEntryWrapper {
|
||||
entry.addListener(() -> PlatformThread.runLaterIfNeeded(() -> {
|
||||
update();
|
||||
}));
|
||||
|
||||
notes.addListener((observable, oldValue, newValue) -> {
|
||||
if (newValue.isCommited()) {
|
||||
entry.setNotes(newValue.getCurrent());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void stopSession() {
|
||||
@@ -211,7 +204,7 @@ public class StoreEntryWrapper {
|
||||
}
|
||||
orderIndex.setValue(entry.getOrderIndex());
|
||||
color.setValue(entry.getColor());
|
||||
notes.setValue(new StoreNotes(entry.getNotes(), entry.getNotes()));
|
||||
notes.setValue(entry.getNotes());
|
||||
customIcon.setValue(entry.getIcon());
|
||||
readOnly.setValue(entry.isFreeze());
|
||||
iconFile.setValue(entry.getEffectiveIconFile());
|
||||
@@ -226,8 +219,6 @@ public class StoreEntryWrapper {
|
||||
storeCategoryWrapper.getCategory().getUuid().equals(entry.getCategoryUuid()))
|
||||
.findFirst()
|
||||
.orElse(StoreViewState.get().getAllConnectionsCategory()));
|
||||
largeCategoryOptimizations.setValue(
|
||||
category.getValue().getLargeCategoryOptimizations().getValue());
|
||||
perUser.setValue(
|
||||
!category.getValue().getRoot().equals(StoreViewState.get().getAllIdentitiesCategory())
|
||||
&& entry.isPerUserStore());
|
||||
|
||||
92
app/src/main/java/io/xpipe/app/hub/comp/StoreFilterComp.java
Normal file
92
app/src/main/java/io/xpipe/app/hub/comp/StoreFilterComp.java
Normal file
@@ -0,0 +1,92 @@
|
||||
package io.xpipe.app.hub.comp;
|
||||
|
||||
import atlantafx.base.controls.CustomTextField;
|
||||
import io.xpipe.app.comp.RegionBuilder;
|
||||
import io.xpipe.app.comp.RegionDescriptor;
|
||||
import io.xpipe.app.core.AppI18n;
|
||||
import io.xpipe.app.core.AppOpenArguments;
|
||||
import io.xpipe.app.platform.PlatformThread;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.scene.input.KeyCode;
|
||||
import javafx.scene.input.KeyCodeCombination;
|
||||
import javafx.scene.input.KeyEvent;
|
||||
import org.kordamp.ikonli.javafx.FontIcon;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public class StoreFilterComp extends RegionBuilder<CustomTextField> {
|
||||
|
||||
private final Property<String> rawText = new SimpleStringProperty();
|
||||
|
||||
private boolean isQuickConnect() {
|
||||
var v = rawText.getValue();
|
||||
return v != null && (v.startsWith("ssh") || "ssh".startsWith(v));
|
||||
}
|
||||
|
||||
private boolean isSearch() {
|
||||
return rawText.getValue() != null && rawText.getValue().length() > 1 && !isQuickConnect();
|
||||
}
|
||||
|
||||
@Override
|
||||
public CustomTextField createSimple() {
|
||||
var searchIcon = new FontIcon("mdi2m-magnify");
|
||||
var launchIcon = new FontIcon("mdi2p-play");
|
||||
var filter = new CustomTextField();
|
||||
filter.setMinHeight(0);
|
||||
filter.setMaxHeight(20000);
|
||||
filter.getStyleClass().add("filter-comp");
|
||||
filter.promptTextProperty().bind(AppI18n.observable("storeFilterPrompt"));
|
||||
filter.rightProperty()
|
||||
.bind(Bindings.createObjectBinding(
|
||||
() -> {
|
||||
return filter.isFocused() ? (isQuickConnect() ? launchIcon : isSearch() ? searchIcon : null) : null;
|
||||
},
|
||||
filter.focusedProperty(), rawText));
|
||||
RegionDescriptor.builder().nameKey("search").build().apply(filter);
|
||||
|
||||
filter.addEventFilter(KeyEvent.KEY_PRESSED, event -> {
|
||||
if (new KeyCodeCombination(KeyCode.ESCAPE).match(event)) {
|
||||
filter.clear();
|
||||
event.consume();
|
||||
} else if (isQuickConnect() && new KeyCodeCombination(KeyCode.ENTER).match(event)) {
|
||||
if (StoreQuickConnect.launchQuickConnect(filter.getText())) {
|
||||
filter.clear();
|
||||
event.consume();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
rawText.subscribe(val -> {
|
||||
PlatformThread.runLaterIfNeeded(() -> {
|
||||
if (!Objects.equals(filter.getText(), val) && !(val == null && "".equals(filter.getText()))) {
|
||||
filter.setText(val);
|
||||
}
|
||||
|
||||
Platform.runLater(() -> {
|
||||
if (val == null || isSearch()) {
|
||||
StoreViewState.get().getFilterString().setValue(val);
|
||||
} else {
|
||||
StoreViewState.get().getFilterString().setValue(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
filter.textProperty().addListener((observable, oldValue, n) -> {
|
||||
// Handle pasted xpipe URLs
|
||||
if (n != null && n.startsWith("xpipe://")) {
|
||||
AppOpenArguments.handle(List.of(n));
|
||||
filter.setText(null);
|
||||
return;
|
||||
}
|
||||
|
||||
rawText.setValue(n != null && n.length() > 0 ? n : null);
|
||||
});
|
||||
|
||||
return filter;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import io.xpipe.app.icon.SystemIcon;
|
||||
import io.xpipe.app.icon.SystemIconManager;
|
||||
import io.xpipe.app.platform.LabelGraphic;
|
||||
import io.xpipe.app.platform.PlatformThread;
|
||||
import io.xpipe.app.storage.DataStoreEntry;
|
||||
import io.xpipe.app.util.BooleanScope;
|
||||
import io.xpipe.app.util.ThreadHelper;
|
||||
|
||||
@@ -22,8 +23,11 @@ import javafx.scene.text.TextAlignment;
|
||||
|
||||
import atlantafx.base.theme.Styles;
|
||||
import atlantafx.base.theme.Tweaks;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static atlantafx.base.theme.Styles.TEXT_SMALL;
|
||||
|
||||
@@ -35,6 +39,9 @@ public class StoreIconChoiceComp extends ModalOverlayContentComp {
|
||||
private final int columns;
|
||||
private final SimpleStringProperty filter;
|
||||
private final Runnable doubleClick;
|
||||
private final DataStoreEntry entry;
|
||||
|
||||
@Getter
|
||||
private final BooleanProperty busy = new SimpleBooleanProperty();
|
||||
|
||||
public StoreIconChoiceComp(
|
||||
@@ -43,13 +50,15 @@ public class StoreIconChoiceComp extends ModalOverlayContentComp {
|
||||
Set<SystemIcon> icons,
|
||||
int columns,
|
||||
SimpleStringProperty filter,
|
||||
Runnable doubleClick) {
|
||||
Runnable doubleClick, DataStoreEntry entry
|
||||
) {
|
||||
this.reshow = reshow;
|
||||
this.selected = selected;
|
||||
this.icons = icons;
|
||||
this.columns = columns;
|
||||
this.filter = filter;
|
||||
this.doubleClick = doubleClick;
|
||||
this.entry = entry;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -58,7 +67,6 @@ public class StoreIconChoiceComp extends ModalOverlayContentComp {
|
||||
if (modalOverlay != null) {
|
||||
ThreadHelper.runFailableAsync(() -> {
|
||||
BooleanScope.executeExclusive(busy, () -> {
|
||||
SystemIconManager.reloadSourceHashes();
|
||||
SystemIconManager.loadAllAvailableIconImages();
|
||||
});
|
||||
});
|
||||
@@ -68,7 +76,7 @@ public class StoreIconChoiceComp extends ModalOverlayContentComp {
|
||||
@Override
|
||||
protected Region createSimple() {
|
||||
var table = new TableView<List<SystemIcon>>();
|
||||
table.disableProperty().bind(PlatformThread.sync(busy));
|
||||
table.visibleProperty().bind(PlatformThread.sync(busy.not()));
|
||||
initTable(table);
|
||||
filter.addListener((observable, oldValue, newValue) -> updateData(table, newValue));
|
||||
busy.addListener((observable, oldValue, newValue) -> {
|
||||
@@ -77,17 +85,22 @@ public class StoreIconChoiceComp extends ModalOverlayContentComp {
|
||||
}
|
||||
});
|
||||
|
||||
var loading = new LoadingIconComp(busy, AppFontSizes::title)
|
||||
.prefWidth(60)
|
||||
.hide(busy.not())
|
||||
.build();
|
||||
var refresh = createRefreshPane();
|
||||
var loading = createLoadingPane();
|
||||
var stack = new StackPane();
|
||||
stack.getChildren().addAll(table, refresh, loading);
|
||||
stack.getChildren().addAll(table, loading);
|
||||
|
||||
return stack;
|
||||
}
|
||||
|
||||
public void refresh() {
|
||||
ThreadHelper.runFailableAsync(() -> {
|
||||
BooleanScope.executeExclusive(busy, () -> {
|
||||
SystemIconManager.rebuild();
|
||||
reshow.run();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void initTable(TableView<List<SystemIcon>> table) {
|
||||
if (!SystemIconManager.isCacheOutdated()) {
|
||||
for (int i = 0; i < columns; i++) {
|
||||
@@ -108,22 +121,28 @@ public class StoreIconChoiceComp extends ModalOverlayContentComp {
|
||||
table.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_ALL_COLUMNS);
|
||||
table.getSelectionModel().setCellSelectionEnabled(true);
|
||||
table.getStyleClass().add("icon-browser");
|
||||
table.disableProperty().bind(PlatformThread.sync(busy));
|
||||
}
|
||||
|
||||
private Region createRefreshPane() {
|
||||
var refreshing = new SimpleBooleanProperty(false);
|
||||
private Region createLoadingPane() {
|
||||
var refreshButton = new ButtonComp(
|
||||
AppI18n.observable("refreshIcons"),
|
||||
new SimpleObjectProperty<>(new LabelGraphic.IconGraphic("mdi2r-refresh")),
|
||||
() -> {
|
||||
ThreadHelper.runFailableAsync(() -> {
|
||||
try (var ignored = new BooleanScope(refreshing).start()) {
|
||||
BooleanScope.executeExclusive(busy, () -> {
|
||||
SystemIconManager.rebuild();
|
||||
}
|
||||
});
|
||||
reshow.run();
|
||||
});
|
||||
});
|
||||
refreshButton.disable(refreshing);
|
||||
refreshButton.hide(Bindings.createBooleanBinding(
|
||||
() -> {
|
||||
return SystemIconManager.hasLoadedAnyImages();
|
||||
},
|
||||
busy));
|
||||
refreshButton.disable(busy);
|
||||
|
||||
var text = new LabelComp(AppI18n.observable("refreshIconsDescription"));
|
||||
text.apply(struc -> {
|
||||
struc.setWrapText(true);
|
||||
@@ -131,28 +150,17 @@ public class StoreIconChoiceComp extends ModalOverlayContentComp {
|
||||
struc.setPrefWidth(300);
|
||||
});
|
||||
text.style(Styles.TEXT_SUBTLE);
|
||||
text.visible(refreshing);
|
||||
var vbox = new VerticalComp(List.of(refreshButton, text)).spacing(25);
|
||||
vbox.hide(Bindings.createBooleanBinding(
|
||||
() -> {
|
||||
if (busy.get()) {
|
||||
return true;
|
||||
}
|
||||
text.visible(busy);
|
||||
|
||||
var available = icons.stream()
|
||||
.filter(systemIcon -> AppImages.hasImage(
|
||||
"icons/" + systemIcon.getSource().getId() + "/" + systemIcon.getId() + "-40.png"))
|
||||
.sorted(Comparator.comparing(systemIcon -> systemIcon.getId()))
|
||||
.toList();
|
||||
if (available.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
var loading = new LoadingIconComp(busy, AppFontSizes::title);
|
||||
loading.prefWidth(50);
|
||||
loading.prefHeight(50);
|
||||
|
||||
return true;
|
||||
},
|
||||
busy,
|
||||
refreshing));
|
||||
vbox.apply(struc -> struc.setAlignment(Pos.CENTER));
|
||||
var vbox = new VerticalComp(List.of(text, loading, refreshButton)).spacing(25);
|
||||
vbox.apply(struc -> {
|
||||
struc.setAlignment(Pos.CENTER);
|
||||
struc.setPickOnBounds(false);
|
||||
});
|
||||
return vbox.build();
|
||||
}
|
||||
|
||||
@@ -166,24 +174,27 @@ public class StoreIconChoiceComp extends ModalOverlayContentComp {
|
||||
.filter(systemIcon -> AppImages.hasImage(
|
||||
"icons/" + systemIcon.getSource().getId() + "/" + systemIcon.getId() + "-40.png"))
|
||||
.sorted(Comparator.comparing(systemIcon -> systemIcon.getId()))
|
||||
.toList();
|
||||
var filtered = available;
|
||||
if (filterString != null && !filterString.isBlank() && filterString.length() >= 2) {
|
||||
filtered = available.stream()
|
||||
.filter(icon -> containsString(icon.getId(), filterString))
|
||||
.toList();
|
||||
.collect(Collectors.toCollection(ArrayList::new));
|
||||
available.addFirst(new SystemIcon(null, "default"));
|
||||
|
||||
List<SystemIcon> shown;
|
||||
if (filterString != null && !filterString.isBlank() && filterString.strip().length() >= 2) {
|
||||
shown = available.stream()
|
||||
.filter(icon -> containsString(icon.getId(), filterString.strip()))
|
||||
.collect(Collectors.toCollection(ArrayList::new));
|
||||
} else {
|
||||
shown = new ArrayList<>(available);
|
||||
}
|
||||
var data = partitionList(filtered, columns);
|
||||
|
||||
var data = partitionList(shown, columns);
|
||||
table.getItems().setAll(data);
|
||||
|
||||
var selectMatch = filtered.size() == 1
|
||||
|| filtered.stream().anyMatch(systemIcon -> systemIcon.getId().equals(filterString));
|
||||
var selectMatch = shown.size() == 1
|
||||
|| shown.stream().anyMatch(systemIcon -> systemIcon.getId().equals(filterString));
|
||||
// Table updates seem to not always be instant, sometimes the column is not there yet
|
||||
if (selectMatch && table.getColumns().size() > 0) {
|
||||
table.getSelectionModel().select(0, table.getColumns().getFirst());
|
||||
selected.setValue(filtered.getFirst());
|
||||
} else {
|
||||
selected.setValue(null);
|
||||
selected.setValue(shown.getFirst());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,6 +253,13 @@ public class StoreIconChoiceComp extends ModalOverlayContentComp {
|
||||
return;
|
||||
}
|
||||
|
||||
if (icon.getSource() == null) {
|
||||
root.setText(AppI18n.get("default"));
|
||||
image.setValue(entry.getProvider().getDisplayIconFileName(entry.getStore()));
|
||||
setGraphic(root);
|
||||
return;
|
||||
}
|
||||
|
||||
root.setText(icon.getId());
|
||||
image.set(SystemIconManager.getAndLoadIconFile(icon));
|
||||
setGraphic(root);
|
||||
|
||||
@@ -3,10 +3,12 @@ package io.xpipe.app.hub.comp;
|
||||
import io.xpipe.app.comp.base.*;
|
||||
import io.xpipe.app.icon.SystemIcon;
|
||||
import io.xpipe.app.icon.SystemIconManager;
|
||||
import io.xpipe.app.platform.PlatformThread;
|
||||
import io.xpipe.app.prefs.AppPrefs;
|
||||
import io.xpipe.app.storage.DataStoreEntry;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
@@ -47,49 +49,52 @@ public class StoreIconChoiceDialog {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
var comp = new StoreIconChoiceComp(
|
||||
() -> {
|
||||
var showing = overlay.isShowing();
|
||||
overlay.close();
|
||||
if (showing) {
|
||||
Platform.runLater(() -> overlay.show());
|
||||
}
|
||||
},
|
||||
selected,
|
||||
SystemIconManager.getIcons(),
|
||||
5,
|
||||
filterText,
|
||||
() -> {
|
||||
finish();
|
||||
},
|
||||
entry);
|
||||
comp.prefWidth(600);
|
||||
|
||||
var modal = ModalOverlay.of(
|
||||
"chooseCustomIcon",
|
||||
comp);
|
||||
var refresh = new ButtonComp(null, new FontIcon("mdi2r-refresh"), () -> {
|
||||
comp.refresh();
|
||||
}).maxHeight(100).disable(comp.getBusy());
|
||||
var settings = new ButtonComp(null, new FontIcon("mdomz-settings"), () -> {
|
||||
overlay.close();
|
||||
AppPrefs.get().selectCategory("icons");
|
||||
})
|
||||
.disable(comp.getBusy())
|
||||
.maxHeight(100);
|
||||
var modal = ModalOverlay.of(
|
||||
"chooseCustomIcon",
|
||||
new StoreIconChoiceComp(
|
||||
() -> {
|
||||
var showing = overlay.isShowing();
|
||||
overlay.close();
|
||||
if (showing) {
|
||||
Platform.runLater(() -> overlay.show());
|
||||
}
|
||||
},
|
||||
selected,
|
||||
SystemIconManager.getIcons(),
|
||||
5,
|
||||
filterText,
|
||||
() -> {
|
||||
finish();
|
||||
})
|
||||
.prefWidth(600));
|
||||
modal.addButtonBarComp(settings);
|
||||
modal.addButtonBarComp(refresh);
|
||||
modal.addButtonBarComp(filter);
|
||||
modal.addButton(new ModalButton(
|
||||
"clear",
|
||||
() -> {
|
||||
selected.setValue(null);
|
||||
finish();
|
||||
},
|
||||
true,
|
||||
false));
|
||||
modal.addButton(ModalButton.ok(() -> {
|
||||
finish();
|
||||
}))
|
||||
.augment(button -> button.disableProperty().bind(selected.isNull()));
|
||||
.augment(button -> button.disableProperty().bind(Bindings.createBooleanBinding(() -> {
|
||||
return selected.get() == null || comp.getBusy().get();
|
||||
}, selected, PlatformThread.sync(comp.getBusy()))));
|
||||
return modal;
|
||||
}
|
||||
|
||||
private void finish() {
|
||||
entry.setIcon(
|
||||
selected.get() != null
|
||||
selected.get() != null && selected.getValue().getSource() != null
|
||||
? selected.getValue().getSource().getId() + "/"
|
||||
+ selected.getValue().getId()
|
||||
: null,
|
||||
|
||||
@@ -4,6 +4,7 @@ import io.xpipe.app.comp.BaseRegionBuilder;
|
||||
import io.xpipe.app.comp.RegionBuilder;
|
||||
import io.xpipe.app.comp.SimpleRegionBuilder;
|
||||
import io.xpipe.app.comp.base.*;
|
||||
import io.xpipe.app.core.AppI18n;
|
||||
import io.xpipe.app.ext.DataStore;
|
||||
import io.xpipe.app.storage.DataStoreEntryRef;
|
||||
|
||||
@@ -48,7 +49,7 @@ public class StoreListChoiceComp<T extends DataStore> extends SimpleRegionBuilde
|
||||
var listBox = new ListBoxViewComp<>(
|
||||
selectedList,
|
||||
selectedList,
|
||||
t -> {
|
||||
t -> {
|
||||
if (t == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
package io.xpipe.app.hub.comp;
|
||||
|
||||
import lombok.Value;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
@Value
|
||||
public class StoreNotes {
|
||||
|
||||
String commited;
|
||||
String current;
|
||||
|
||||
public boolean isCommited() {
|
||||
return Objects.equals(commited, current);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,43 @@
|
||||
package io.xpipe.app.hub.comp;
|
||||
|
||||
import io.xpipe.app.comp.*;
|
||||
import io.xpipe.app.comp.base.ButtonComp;
|
||||
import io.xpipe.app.comp.base.DialogComp;
|
||||
import io.xpipe.app.comp.base.IconButtonComp;
|
||||
import io.xpipe.app.comp.base.MarkdownEditorComp;
|
||||
import io.xpipe.app.comp.base.*;
|
||||
import io.xpipe.app.core.AppFontSizes;
|
||||
import io.xpipe.app.core.AppI18n;
|
||||
import io.xpipe.app.platform.BindingsHelper;
|
||||
import io.xpipe.app.prefs.AppPrefs;
|
||||
import io.xpipe.app.storage.DataStorage;
|
||||
|
||||
import io.xpipe.app.util.FileOpener;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.beans.property.ReadOnlyStringWrapper;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.event.ActionEvent;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.input.MouseButton;
|
||||
|
||||
import atlantafx.base.controls.Popover;
|
||||
import org.int4.fx.builders.common.AbstractRegionBuilder;
|
||||
import java.util.UUID;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
public class StoreNotesComp extends RegionBuilder<Button> {
|
||||
|
||||
public class StoreNotesComp extends RegionStructureBuilder<Button, StoreNotesComp.Structure> {
|
||||
public static void showDialog(StoreEntryWrapper wrapper, String initial) {
|
||||
var prop = new SimpleStringProperty(initial);
|
||||
var md = new MarkdownEditorComp(prop, "notes-" + wrapper.getName().getValue())
|
||||
.prefWidth(700)
|
||||
.prefHeight(800);
|
||||
|
||||
var modal = ModalOverlay.of(new ReadOnlyStringWrapper(wrapper.getName().getValue()), md, null);
|
||||
if (wrapper.getNotes().getValue() != null) {
|
||||
modal.addButton(new ModalButton("delete", () -> {
|
||||
wrapper.getEntry().setNotes(null);
|
||||
DataStorage.get().saveAsync();
|
||||
}, true, false));
|
||||
}
|
||||
modal.addButton(new ModalButton("cancel", () -> {}, true, false));
|
||||
modal.addButton(new ModalButton("apply", () -> {
|
||||
wrapper.getEntry().setNotes(prop.getValue());
|
||||
DataStorage.get().saveAsync();
|
||||
}, true, true));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
private final StoreEntryWrapper wrapper;
|
||||
|
||||
@@ -33,133 +46,30 @@ public class StoreNotesComp extends RegionStructureBuilder<Button, StoreNotesCom
|
||||
}
|
||||
|
||||
@Override
|
||||
public Structure createBase() {
|
||||
protected Button createSimple() {
|
||||
var n = wrapper.getNotes();
|
||||
var button = new IconButtonComp("mdi2n-note-text-outline")
|
||||
.apply(struc -> AppFontSizes.xs(struc))
|
||||
.describe(d ->
|
||||
d.nameKey("notes").focusTraversal(RegionDescriptor.FocusTraversal.ENABLED_FOR_ACCESSIBILITY))
|
||||
.style("notes-button")
|
||||
.hide(BindingsHelper.map(n, s -> s.getCommited() == null && s.getCurrent() == null))
|
||||
.hide(n.isNull())
|
||||
.build();
|
||||
button.setOpacity(0.85);
|
||||
button.prefWidthProperty().bind(button.heightProperty());
|
||||
|
||||
var prop = new SimpleStringProperty(n.getValue().getCurrent());
|
||||
|
||||
var popover = new AtomicReference<Popover>();
|
||||
button.setOnAction(e -> {
|
||||
if (n.getValue().getCurrent() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (popover.get() != null && popover.get().isShowing()) {
|
||||
e.consume();
|
||||
return;
|
||||
}
|
||||
|
||||
popover.set(createPopover(popover, prop));
|
||||
popover.get().show(button);
|
||||
showDialog(wrapper, wrapper.getNotes().getValue());
|
||||
e.consume();
|
||||
});
|
||||
prop.addListener((observable, oldValue, newValue) -> {
|
||||
n.setValue(new StoreNotes(n.getValue().getCommited(), newValue));
|
||||
});
|
||||
n.addListener((observable, oldValue, s) -> {
|
||||
prop.set(s.getCurrent());
|
||||
// Check for scene existence. If we exited the platform immediately after adding notes, this might throw an
|
||||
// exception
|
||||
if (s.getCurrent() != null
|
||||
&& oldValue.getCommited() == null
|
||||
&& oldValue.isCommited()
|
||||
&& button.getScene() != null) {
|
||||
Platform.runLater(() -> {
|
||||
popover.set(createPopover(popover, prop));
|
||||
popover.get().show(button);
|
||||
});
|
||||
|
||||
var editKey = UUID.randomUUID().toString();
|
||||
button.setOnMouseClicked(e -> {
|
||||
if (e.getButton() == MouseButton.PRIMARY && e.isShiftDown()) {
|
||||
FileOpener.openString("notes.md", editKey, wrapper.getNotes().getValue(), s -> wrapper.getEntry().setNotes(s));
|
||||
e.consume();
|
||||
}
|
||||
});
|
||||
return new Structure(popover.get(), button);
|
||||
}
|
||||
|
||||
private Popover createPopover(AtomicReference<Popover> ref, Property<String> prop) {
|
||||
var n = wrapper.getNotes();
|
||||
var md = new MarkdownEditorComp(prop, "notes-" + wrapper.getName().getValue())
|
||||
.prefWidth(600)
|
||||
.prefHeight(600)
|
||||
.buildStructure();
|
||||
var dialog = new DialogComp() {
|
||||
|
||||
@Override
|
||||
protected String finishKey() {
|
||||
return "apply";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected List<AbstractRegionBuilder<?, ?>> customButtons() {
|
||||
return List.of(new ButtonComp(AppI18n.observable("cancel"), () -> {
|
||||
ref.get().hide();
|
||||
}));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void finish() {
|
||||
n.setValue(
|
||||
new StoreNotes(n.getValue().getCurrent(), n.getValue().getCurrent()));
|
||||
ref.get().hide();
|
||||
}
|
||||
|
||||
@Override
|
||||
public BaseRegionBuilder<?, ?> content() {
|
||||
return RegionBuilder.of(() -> md.get());
|
||||
}
|
||||
|
||||
@Override
|
||||
public BaseRegionBuilder<?, ?> bottom() {
|
||||
return new ButtonComp(AppI18n.observable("delete"), () -> {
|
||||
n.setValue(new StoreNotes(null, null));
|
||||
})
|
||||
.hide(BindingsHelper.map(n, v -> v.getCommited() == null));
|
||||
}
|
||||
}.build();
|
||||
|
||||
var popover = new Popover(dialog);
|
||||
popover.setAutoHide(!AppPrefs.get().limitedTouchscreenMode().get());
|
||||
popover.getScene().setFill(Color.TRANSPARENT);
|
||||
popover.setCloseButtonEnabled(true);
|
||||
popover.setHeaderAlwaysVisible(true);
|
||||
popover.setDetachable(true);
|
||||
popover.setTitle(wrapper.getName().getValue());
|
||||
popover.showingProperty().addListener((observable, oldValue, newValue) -> {
|
||||
if (!newValue) {
|
||||
n.setValue(
|
||||
new StoreNotes(n.getValue().getCommited(), n.getValue().getCommited()));
|
||||
DataStorage.get().saveAsync();
|
||||
ref.set(null);
|
||||
}
|
||||
});
|
||||
AppFontSizes.xs(popover.getContentNode());
|
||||
|
||||
md.getEditButton().addEventFilter(ActionEvent.ANY, event -> {
|
||||
if (!popover.isDetached()) {
|
||||
popover.setDetached(true);
|
||||
event.consume();
|
||||
Platform.runLater(() -> {
|
||||
Platform.runLater(() -> {
|
||||
md.getEditButton().fire();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return popover;
|
||||
}
|
||||
|
||||
public record Structure(Popover popover, Button button) implements RegionStructure<Button> {
|
||||
|
||||
@Override
|
||||
public Button get() {
|
||||
return button;
|
||||
}
|
||||
return button;
|
||||
}
|
||||
}
|
||||
|
||||
110
app/src/main/java/io/xpipe/app/hub/comp/StoreQuickConnect.java
Normal file
110
app/src/main/java/io/xpipe/app/hub/comp/StoreQuickConnect.java
Normal file
@@ -0,0 +1,110 @@
|
||||
package io.xpipe.app.hub.comp;
|
||||
|
||||
import io.xpipe.app.core.AppCache;
|
||||
import io.xpipe.app.core.AppI18n;
|
||||
import io.xpipe.app.core.mode.AppOperationMode;
|
||||
import io.xpipe.app.ext.*;
|
||||
import io.xpipe.app.issue.ErrorEventFactory;
|
||||
import io.xpipe.app.platform.DerivedObservableList;
|
||||
import io.xpipe.app.platform.PlatformThread;
|
||||
import io.xpipe.app.prefs.AppPrefs;
|
||||
import io.xpipe.app.storage.DataStorage;
|
||||
import io.xpipe.app.storage.DataStoreCategory;
|
||||
import io.xpipe.app.storage.DataStoreEntry;
|
||||
import io.xpipe.app.storage.StorageListener;
|
||||
import io.xpipe.app.util.GlobalTimer;
|
||||
import io.xpipe.app.util.ThreadHelper;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.Observable;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.beans.value.ObservableIntegerValue;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import lombok.Getter;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class StoreQuickConnect {
|
||||
|
||||
public static final UUID STORE_ID = UUID.randomUUID();
|
||||
|
||||
private static DataStore quickConnectStore;
|
||||
private static DataStoreEntry quickConnectEntry;
|
||||
|
||||
public static void init() {
|
||||
quickConnectStore = AppCache.getNonNull("quickConnect", DataStore.class, () -> DataStoreProviders.byId("ssh").orElseThrow()
|
||||
.defaultStore(StoreViewState.get().getActiveCategory().getValue()
|
||||
.getCategory()));
|
||||
quickConnectEntry = DataStoreEntry.createNew(STORE_ID, DataStorage.DEFAULT_CATEGORY_UUID, "quick-connect", quickConnectStore);
|
||||
DataStorage.get().addStoreEntryInProgress(quickConnectEntry);
|
||||
}
|
||||
|
||||
public static void update(DataStore store) {
|
||||
quickConnectStore = store;
|
||||
DataStorage.get().updateEntryStore(quickConnectEntry, store);
|
||||
AppCache.update("quickConnect", store);
|
||||
}
|
||||
|
||||
public static boolean launchQuickConnect(String s) {
|
||||
if (s == null || s.isBlank() || !s.contains("@")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (s.startsWith("ssh ")) {
|
||||
s = s.substring(4);
|
||||
}
|
||||
|
||||
var split = s.split("@", 2);
|
||||
if (split.length != 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var user = split[0];
|
||||
|
||||
var target = split[1];
|
||||
String host = target;
|
||||
Integer port = null;
|
||||
|
||||
if (StringUtils.countMatches(target, ":") == 1 || (target.contains("[") && target.contains("]"))) {
|
||||
var index = target.lastIndexOf(":");
|
||||
host = target.substring(0, index);
|
||||
try {
|
||||
port = Integer.parseInt(target.substring(index + 1));
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
var newStore = ProcessControlProvider.get().quickConnectStore(user, host, port, quickConnectStore);
|
||||
DataStorage.get().updateEntryStore(quickConnectEntry, newStore);
|
||||
|
||||
var model = StoreCreationDialog.showEdit(quickConnectEntry, newStore, false, finished -> {
|
||||
update(finished.getStore());
|
||||
ThreadHelper.runAsync(() -> {
|
||||
try {
|
||||
DataStorage.get().addStoreEntryInProgress(quickConnectEntry);
|
||||
quickConnectEntry.getProvider().launch(quickConnectEntry).run();
|
||||
} catch (Exception e) {
|
||||
ErrorEventFactory.fromThrowable(e).handle();
|
||||
} finally {
|
||||
DataStorage.get().removeStoreEntryInProgress(quickConnectEntry);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
var wasCached = AppCache.getNonNull("quickConnect", DataStore.class, () -> null) != null;
|
||||
if (wasCached) {
|
||||
GlobalTimer.delay(() -> {
|
||||
Platform.runLater(() -> {
|
||||
model.finish();
|
||||
});
|
||||
}, Duration.ofMillis(100));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import io.xpipe.app.storage.DataStoreEntry;
|
||||
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.beans.value.ObservableBooleanValue;
|
||||
import javafx.beans.value.ObservableIntegerValue;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
@@ -17,6 +18,7 @@ import lombok.Getter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
@Getter
|
||||
@@ -101,18 +103,44 @@ public class StoreSection {
|
||||
enabled,
|
||||
category,
|
||||
updateObservable);
|
||||
var cached = topLevel.mapped(storeEntryWrapper -> create(
|
||||
List.of(),
|
||||
storeEntryWrapper,
|
||||
1,
|
||||
allEnabled,
|
||||
selected,
|
||||
entryFilter,
|
||||
filterString,
|
||||
category,
|
||||
visibilityObservable,
|
||||
updateObservable,
|
||||
enabled));
|
||||
Predicate<StoreSection> showTopLevel = section -> {
|
||||
// matches filter
|
||||
return (filterString == null || section.matchesFilter(filterString.getValue()))
|
||||
&&
|
||||
// matches selector
|
||||
(section.anyMatches(entryFilter))
|
||||
&&
|
||||
// same category
|
||||
(showInCategory(category.getValue(), section.getWrapper()));
|
||||
};
|
||||
var cached = topLevel.mapped(storeEntryWrapper -> {
|
||||
var section = new SimpleObjectProperty<StoreSection>();
|
||||
var sectionEnabled = Bindings.createBooleanBinding(() -> {
|
||||
if (!enabled.getValue()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return section.get() != null && showTopLevel.test(section.get());
|
||||
},
|
||||
section,
|
||||
enabled,
|
||||
category,
|
||||
filterString,
|
||||
updateObservable);
|
||||
section.set(create(
|
||||
List.of(),
|
||||
storeEntryWrapper,
|
||||
1,
|
||||
allEnabled,
|
||||
selected,
|
||||
entryFilter,
|
||||
filterString,
|
||||
category,
|
||||
visibilityObservable,
|
||||
updateObservable,
|
||||
sectionEnabled));
|
||||
return section.get();
|
||||
});
|
||||
var ordered = sorted(null, cached, updateObservable);
|
||||
var shown = ordered.filtered(
|
||||
section -> {
|
||||
@@ -120,14 +148,7 @@ public class StoreSection {
|
||||
return false;
|
||||
}
|
||||
|
||||
// matches filter
|
||||
return (filterString == null || section.matchesFilter(filterString.getValue()))
|
||||
&&
|
||||
// matches selector
|
||||
(section.anyMatches(entryFilter))
|
||||
&&
|
||||
// same category
|
||||
(showInCategory(category.getValue(), section.getWrapper()));
|
||||
return showTopLevel.test(section);
|
||||
},
|
||||
enabled,
|
||||
category,
|
||||
@@ -175,75 +196,95 @@ public class StoreSection {
|
||||
updateObservable);
|
||||
var l = new ArrayList<>(parents);
|
||||
l.add(e);
|
||||
var cached = allChildren.mapped(c -> create(
|
||||
l,
|
||||
c,
|
||||
depth + 1,
|
||||
all,
|
||||
selected,
|
||||
entryFilter,
|
||||
filterString,
|
||||
category,
|
||||
visibilityObservable,
|
||||
updateObservable,
|
||||
enabled));
|
||||
var ordered = sorted(e, cached, updateObservable);
|
||||
var filtered = ordered.filtered(
|
||||
section -> {
|
||||
if (!enabled.getValue()) {
|
||||
Predicate<StoreSection> showSection = section -> {
|
||||
if (!enabled.getValue()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var isBatchSelected = selected.contains(section.getWrapper());
|
||||
|
||||
var matchesFilter = filterString == null
|
||||
|| section.matchesFilter(filterString.getValue())
|
||||
|| l.stream().anyMatch(p -> p.matchesFilter(filterString.getValue()));
|
||||
if (!isBatchSelected && !matchesFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var hasFilter = filterString != null
|
||||
&& filterString.getValue() != null
|
||||
&& filterString.getValue().length() > 0;
|
||||
if (!isBatchSelected && !hasFilter) {
|
||||
var showProvider = true;
|
||||
try {
|
||||
showProvider = section.getWrapper()
|
||||
.getEntry()
|
||||
.getProvider()
|
||||
.shouldShow(section.getWrapper());
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
if (!showProvider) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var isBatchSelected = selected.contains(section.getWrapper());
|
||||
var matchesSelector = section.anyMatches(entryFilter);
|
||||
if (!isBatchSelected && !matchesSelector) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var matchesFilter = filterString == null
|
||||
|| section.matchesFilter(filterString.getValue())
|
||||
|| l.stream().anyMatch(p -> p.matchesFilter(filterString.getValue()));
|
||||
if (!isBatchSelected && !matchesFilter) {
|
||||
return false;
|
||||
}
|
||||
// Prevent updates for children on category switching by checking depth
|
||||
var showCategory = showInCategory(category.getValue(), section.getWrapper()) || depth > 0;
|
||||
if (!showCategory) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var hasFilter = filterString != null
|
||||
&& filterString.getValue() != null
|
||||
&& filterString.getValue().length() > 0;
|
||||
if (!isBatchSelected && !hasFilter) {
|
||||
var showProvider = true;
|
||||
try {
|
||||
showProvider = section.getWrapper()
|
||||
.getEntry()
|
||||
.getProvider()
|
||||
.shouldShow(section.getWrapper());
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
if (!showProvider) {
|
||||
// If this entry is already shown as root due to a different category than parent, don't
|
||||
// show it
|
||||
// again here
|
||||
var notRoot = !DataStorage.get()
|
||||
.isRootEntry(
|
||||
section.getWrapper().getEntry(),
|
||||
category.getValue().getCategory());
|
||||
if (!notRoot) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
var cached = allChildren.mapped(c -> {
|
||||
var section = new SimpleObjectProperty<StoreSection>();
|
||||
var sectionEnabled = Bindings.createBooleanBinding(() -> {
|
||||
if (!enabled.getValue()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var matchesSelector = section.anyMatches(entryFilter);
|
||||
if (!isBatchSelected && !matchesSelector) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Prevent updates for children on category switching by checking depth
|
||||
var showCategory = showInCategory(category.getValue(), section.getWrapper()) || depth > 0;
|
||||
if (!showCategory) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If this entry is already shown as root due to a different category than parent, don't
|
||||
// show it
|
||||
// again here
|
||||
var notRoot = !DataStorage.get()
|
||||
.isRootEntry(
|
||||
section.getWrapper().getEntry(),
|
||||
category.getValue().getCategory());
|
||||
if (!notRoot) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
return section.get() != null && showSection.test(section.get());
|
||||
},
|
||||
section,
|
||||
enabled,
|
||||
category,
|
||||
filterString,
|
||||
e.getPersistentState(),
|
||||
e.getCache(),
|
||||
visibilityObservable,
|
||||
updateObservable);
|
||||
section.set(create(
|
||||
l,
|
||||
c,
|
||||
depth + 1,
|
||||
all,
|
||||
selected,
|
||||
entryFilter,
|
||||
filterString,
|
||||
category,
|
||||
visibilityObservable,
|
||||
updateObservable,
|
||||
sectionEnabled));
|
||||
return section.get();
|
||||
});
|
||||
var ordered = sorted(e, cached, updateObservable);
|
||||
var filtered = ordered.filtered(showSection,
|
||||
enabled,
|
||||
category,
|
||||
filterString,
|
||||
|
||||
@@ -189,6 +189,9 @@ public class StoreViewState {
|
||||
|
||||
public void selectBatchMode(StoreSection section) {
|
||||
var wrapper = section.getWrapper();
|
||||
if (wrapper != null && wrapper.getEntry().getValidity() == DataStoreEntry.Validity.LOAD_FAILED) {
|
||||
return;
|
||||
}
|
||||
if (wrapper != null && !batchModeSelectionSet.contains(wrapper)) {
|
||||
batchModeSelection.getList().add(wrapper);
|
||||
}
|
||||
@@ -199,6 +202,9 @@ public class StoreViewState {
|
||||
|
||||
public void unselectBatchMode(StoreSection section) {
|
||||
var wrapper = section.getWrapper();
|
||||
if (wrapper != null && wrapper.getEntry().getValidity() == DataStoreEntry.Validity.LOAD_FAILED) {
|
||||
return;
|
||||
}
|
||||
if (wrapper != null) {
|
||||
batchModeSelection.getList().remove(wrapper);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,13 @@ public class SystemIconManager {
|
||||
private static int cacheSourceHash;
|
||||
private static int sourceHash;
|
||||
|
||||
public static boolean hasLoadedAnyImages() {
|
||||
var available = getIcons().stream()
|
||||
.anyMatch(systemIcon -> AppImages.hasImage(
|
||||
"icons/" + systemIcon.getSource().getId() + "/" + systemIcon.getId() + "-40.png"));
|
||||
return available;
|
||||
}
|
||||
|
||||
public static boolean isCacheOutdated() {
|
||||
return cacheSourceHash == 0 || sourceHash != cacheSourceHash;
|
||||
}
|
||||
@@ -72,7 +79,7 @@ public class SystemIconManager {
|
||||
}
|
||||
|
||||
var dir = SystemIconCache.getDirectory(icon.getSource());
|
||||
var res = AppDisplayScale.hasDefaultDisplayScale() ? List.of(16, 24, 40) : List.of(16, 24, 40, 80);
|
||||
var res = AppDisplayScale.hasOnlyDefaultDisplayScale() ? List.of(16, 24, 40) : List.of(16, 24, 40, 80);
|
||||
var files = new ArrayList<Path>();
|
||||
for (Integer re : res) {
|
||||
files.add(dir.resolve(icon.getId() + "-" + re + ".png"));
|
||||
|
||||
@@ -6,6 +6,7 @@ import io.xpipe.app.util.DocumentationLink;
|
||||
import io.xpipe.core.OsType;
|
||||
|
||||
import java.nio.file.AccessDeniedException;
|
||||
import java.nio.file.NoSuchFileException;
|
||||
import java.util.Arrays;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.Locale;
|
||||
@@ -84,7 +85,18 @@ public class ErrorEventFactory {
|
||||
b.expected();
|
||||
}
|
||||
|
||||
if (t instanceof AccessDeniedException) {
|
||||
if (OsType.ofLocal() == OsType.WINDOWS && t.getMessage() != null && t.getMessage().contains("The cloud file provider is not running")) {
|
||||
b.description("The OneDrive cloud file provider is not running. Verify that your cloud storage is working and you are logged in.");
|
||||
b.expected();
|
||||
}
|
||||
|
||||
if (t instanceof AccessDeniedException ade) {
|
||||
b.description("Access is denied: " + ade.getMessage());
|
||||
b.expected();
|
||||
}
|
||||
|
||||
if (t instanceof NoSuchFileException nsfe) {
|
||||
b.description("No such file: " + nsfe.getMessage());
|
||||
b.expected();
|
||||
}
|
||||
|
||||
|
||||
@@ -73,28 +73,48 @@ public class NativeWinWindowControl {
|
||||
}
|
||||
|
||||
public void removeBorders() {
|
||||
var rect = getBounds();
|
||||
|
||||
var style = User32.INSTANCE.GetWindowLong(windowHandle, User32.GWL_STYLE);
|
||||
var mod = style & ~(User32.WS_CAPTION | User32.WS_THICKFRAME | User32.WS_MAXIMIZEBOX);
|
||||
User32.INSTANCE.SetWindowLong(windowHandle, User32.GWL_STYLE, mod);
|
||||
|
||||
User32.INSTANCE.SetWindowPos(windowHandle, null, rect.getX(), rect.getY(), rect.getW() + 1, rect.getH(),
|
||||
User32.SWP_NOACTIVATE | User32.SWP_NOMOVE | User32.SWP_NOZORDER);
|
||||
User32.INSTANCE.SetWindowPos(windowHandle, null, rect.getX(), rect.getY(), rect.getW(), rect.getH(),
|
||||
User32.SWP_NOACTIVATE | User32.SWP_NOMOVE | User32.SWP_NOZORDER);
|
||||
}
|
||||
|
||||
public void takeOwnership(WinDef.HWND owner) {
|
||||
public void restoreBorders() {
|
||||
var rect = getBounds();
|
||||
|
||||
var style = User32.INSTANCE.GetWindowLong(windowHandle, User32.GWL_STYLE);
|
||||
var mod = style | User32.WS_CAPTION | User32.WS_THICKFRAME | User32.WS_MAXIMIZEBOX;
|
||||
User32.INSTANCE.SetWindowLong(windowHandle, User32.GWL_STYLE, mod);
|
||||
|
||||
User32.INSTANCE.SetWindowPos(windowHandle, null, rect.getX(), rect.getY(), rect.getW() + 1, rect.getH(),
|
||||
User32.SWP_NOACTIVATE | User32.SWP_NOMOVE | User32.SWP_NOZORDER);
|
||||
User32.INSTANCE.SetWindowPos(windowHandle, null, rect.getX(), rect.getY(), rect.getW(), rect.getH(),
|
||||
User32.SWP_NOACTIVATE | User32.SWP_NOMOVE | User32.SWP_NOZORDER);
|
||||
}
|
||||
|
||||
public void removeIcon() {
|
||||
var style = User32.INSTANCE.GetWindowLong(windowHandle, User32.GWL_EXSTYLE);
|
||||
var mod = style & ~(WS_EX_APPWINDOW);
|
||||
User32.INSTANCE.SetWindowLong(windowHandle, User32.GWL_EXSTYLE, mod);
|
||||
}
|
||||
|
||||
setWindowsTransitionsEnabled(false);
|
||||
public void restoreIcon() {
|
||||
var style = User32.INSTANCE.GetWindowLong(windowHandle, User32.GWL_EXSTYLE);
|
||||
var mod = style | WS_EX_APPWINDOW;
|
||||
User32.INSTANCE.SetWindowLong(windowHandle, User32.GWL_EXSTYLE, mod);
|
||||
}
|
||||
|
||||
public void takeOwnership(WinDef.HWND owner) {
|
||||
User32Ex.INSTANCE.SetWindowLongPtr(getWindowHandle(), User32.GWL_HWNDPARENT, owner);
|
||||
}
|
||||
|
||||
public void releaseOwnership() {
|
||||
var style = User32.INSTANCE.GetWindowLong(windowHandle, User32.GWL_EXSTYLE);
|
||||
var mod = style | WS_EX_APPWINDOW;
|
||||
User32.INSTANCE.SetWindowLong(windowHandle, User32.GWL_EXSTYLE, mod);
|
||||
|
||||
setWindowsTransitionsEnabled(true);
|
||||
|
||||
User32Ex.INSTANCE.SetWindowLongPtr(getWindowHandle(), User32.GWL_HWNDPARENT, (WinDef.HWND) null);
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ public class OptionsBuilder {
|
||||
private BaseRegionBuilder<?, ?> lastCompHeadReference;
|
||||
private ObservableValue<String> lastNameReference;
|
||||
private boolean focusFirstIncomplete = true;
|
||||
private boolean focusEnabled = true;
|
||||
|
||||
private final BooleanProperty mappingUpdate = new SimpleBooleanProperty();
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package io.xpipe.app.platform;
|
||||
import io.xpipe.app.comp.base.ChoicePaneComp;
|
||||
import io.xpipe.app.core.AppI18n;
|
||||
|
||||
import io.xpipe.app.issue.ErrorEventFactory;
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||
import javafx.beans.property.SimpleIntegerProperty;
|
||||
@@ -15,6 +16,7 @@ import com.fasterxml.jackson.annotation.JsonTypeName;
|
||||
import lombok.Builder;
|
||||
import lombok.SneakyThrows;
|
||||
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
@@ -77,8 +79,9 @@ public class OptionsChoiceBuilder {
|
||||
if (r != null) {
|
||||
return (OptionsBuilder) r;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
} catch (InvocationTargetException e) {
|
||||
ErrorEventFactory.fromThrowable(e).handle();
|
||||
} catch (Exception ignored) {}
|
||||
return new OptionsBuilder();
|
||||
}
|
||||
|
||||
@@ -88,7 +91,9 @@ public class OptionsChoiceBuilder {
|
||||
cd.setAccessible(true);
|
||||
var defValue = cd.invoke(null);
|
||||
return defValue;
|
||||
} catch (Exception ignored) {
|
||||
} catch (InvocationTargetException e) {
|
||||
ErrorEventFactory.fromThrowable(e).handle();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -99,7 +104,9 @@ public class OptionsChoiceBuilder {
|
||||
m.setAccessible(true);
|
||||
var defValue = c.cast(m.invoke(b));
|
||||
return defValue;
|
||||
} catch (Exception ignored) {
|
||||
} catch (InvocationTargetException e) {
|
||||
ErrorEventFactory.fromThrowable(e).handle();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -23,7 +23,6 @@ import io.xpipe.app.terminal.TerminalSplitStrategy;
|
||||
import io.xpipe.app.update.AppDistributionType;
|
||||
import io.xpipe.app.util.*;
|
||||
import io.xpipe.app.vnc.ExternalVncClient;
|
||||
import io.xpipe.app.vnc.InternalVncClient;
|
||||
import io.xpipe.app.vnc.VncCategory;
|
||||
import io.xpipe.core.FilePath;
|
||||
import io.xpipe.core.OsType;
|
||||
@@ -39,10 +38,7 @@ import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.JavaType;
|
||||
import com.fasterxml.jackson.databind.type.SimpleType;
|
||||
import com.fasterxml.jackson.databind.type.TypeFactory;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.Value;
|
||||
import lombok.*;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.util.*;
|
||||
@@ -125,6 +121,12 @@ public final class AppPrefs {
|
||||
.key("enableMcpMutationTools")
|
||||
.valueClass(Boolean.class)
|
||||
.build());
|
||||
final StringProperty mcpAdditionalContext = map(Mapping.builder()
|
||||
.property(new GlobalStringProperty(null))
|
||||
.key("mcpAdditionalContext")
|
||||
.valueClass(String.class)
|
||||
.requiresRestart(true)
|
||||
.build());
|
||||
final BooleanProperty dontAutomaticallyStartVmSshServer =
|
||||
mapVaultShared(new GlobalBooleanProperty(false), "dontAutomaticallyStartVmSshServer", Boolean.class, false);
|
||||
final BooleanProperty dontAcceptNewHostKeys =
|
||||
@@ -239,7 +241,7 @@ public final class AppPrefs {
|
||||
public final BooleanProperty dontCachePasswords =
|
||||
mapVaultShared(new GlobalBooleanProperty(false), "dontCachePasswords", Boolean.class, false);
|
||||
public final Property<ExternalVncClient> vncClient = map(Mapping.builder()
|
||||
.property(new GlobalObjectProperty<>(InternalVncClient.builder().build()))
|
||||
.property(new GlobalObjectProperty<>())
|
||||
.key("vncClient")
|
||||
.valueClass(ExternalVncClient.class)
|
||||
.documentationLink(DocumentationLink.VNC)
|
||||
@@ -340,7 +342,6 @@ public final class AppPrefs {
|
||||
.property(new GlobalBooleanProperty(false))
|
||||
.key("enableTerminalLogging")
|
||||
.valueClass(Boolean.class)
|
||||
.licenseFeatureId("logging")
|
||||
.documentationLink(DocumentationLink.TERMINAL_LOGGING)
|
||||
.build());
|
||||
final BooleanProperty enableTerminalStartupBell = map(Mapping.builder()
|
||||
@@ -419,7 +420,6 @@ public final class AppPrefs {
|
||||
new SyncCategory(),
|
||||
new PasswordManagerCategory(),
|
||||
new TerminalCategory(),
|
||||
new LoggingCategory(),
|
||||
new EditorCategory(),
|
||||
new RdpCategory(),
|
||||
new VncCategory(),
|
||||
@@ -582,6 +582,10 @@ public final class AppPrefs {
|
||||
return enableMcpMutationTools;
|
||||
}
|
||||
|
||||
public ObservableValue<String> mcpAdditionalContext() {
|
||||
return mcpAdditionalContext;
|
||||
}
|
||||
|
||||
public ObservableBooleanValue pinLocalMachineOnStartup() {
|
||||
return pinLocalMachineOnStartup;
|
||||
}
|
||||
@@ -825,6 +829,7 @@ public final class AppPrefs {
|
||||
terminalType.set(ExternalTerminalType.determineDefault(terminalType.get()));
|
||||
rdpClientType.setValue(ExternalRdpClient.determineDefault(rdpClientType.get()));
|
||||
spiceClient.setValue(ExternalSpiceClient.determineDefault(spiceClient.getValue()));
|
||||
vncClient.setValue(ExternalVncClient.determineDefault(vncClient.getValue()));
|
||||
|
||||
PrefsProvider.getAll().forEach(prov -> prov.initDefaultValues());
|
||||
}
|
||||
@@ -861,7 +866,16 @@ public final class AppPrefs {
|
||||
// as the one is set by default and might not be the right one
|
||||
// This happens for example with homebrew ssh
|
||||
var shellVariable = LocalShell.getShell().view().getEnvironmentVariable("SSH_AUTH_SOCK");
|
||||
var socketEnvVariable = shellVariable.isEmpty() ? System.getenv("SSH_AUTH_SOCK") : shellVariable.get();
|
||||
if (shellVariable.isPresent() && PasswordManager.isPasswordManagerSshAgent(shellVariable.get())) {
|
||||
shellVariable = Optional.empty();
|
||||
}
|
||||
|
||||
var envVariable = System.getenv("SSH_AUTH_SOCK");
|
||||
if (envVariable != null && PasswordManager.isPasswordManagerSshAgent(envVariable)) {
|
||||
envVariable = null;
|
||||
}
|
||||
|
||||
var socketEnvVariable = shellVariable.isEmpty() ? envVariable : shellVariable.get();
|
||||
defaultSshAgentSocket.setValue(FilePath.parse(socketEnvVariable));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
package io.xpipe.app.prefs;
|
||||
|
||||
import io.xpipe.app.comp.BaseRegionBuilder;
|
||||
import io.xpipe.app.comp.base.*;
|
||||
import io.xpipe.app.core.AppI18n;
|
||||
import io.xpipe.app.core.AppProperties;
|
||||
import io.xpipe.app.issue.ErrorEventFactory;
|
||||
import io.xpipe.app.platform.LabelGraphic;
|
||||
import io.xpipe.app.platform.OptionsBuilder;
|
||||
import io.xpipe.app.util.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
|
||||
public class LoggingCategory extends AppPrefsCategory {
|
||||
|
||||
@Override
|
||||
protected String getId() {
|
||||
return "logging";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected LabelGraphic getIcon() {
|
||||
return new LabelGraphic.IconGraphic("mdi2t-text-box-search-outline");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected BaseRegionBuilder<?, ?> create() {
|
||||
var prefs = AppPrefs.get();
|
||||
return new OptionsBuilder()
|
||||
.addTitle("sessionLogging")
|
||||
.sub(new OptionsBuilder()
|
||||
.pref(prefs.enableTerminalLogging)
|
||||
.addToggle(prefs.enableTerminalLogging)
|
||||
.nameAndDescription("terminalLoggingDirectory")
|
||||
.documentationLink(DocumentationLink.TERMINAL_LOGGING_FILES)
|
||||
.addComp(new ButtonComp(AppI18n.observable("openSessionLogs"), () -> {
|
||||
var dir = AppProperties.get().getDataDir().resolve("sessions");
|
||||
try {
|
||||
Files.createDirectories(dir);
|
||||
DesktopHelper.browseFile(dir);
|
||||
} catch (IOException e) {
|
||||
ErrorEventFactory.fromThrowable(e).handle();
|
||||
}
|
||||
})
|
||||
.disable(prefs.enableTerminalLogging.not())))
|
||||
.buildComp();
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,22 @@
|
||||
package io.xpipe.app.prefs;
|
||||
|
||||
import atlantafx.base.theme.Styles;
|
||||
import io.xpipe.app.beacon.AppBeaconServer;
|
||||
import io.xpipe.app.comp.BaseRegionBuilder;
|
||||
import io.xpipe.app.comp.RegionBuilder;
|
||||
import io.xpipe.app.comp.base.IntegratedTextAreaComp;
|
||||
import io.xpipe.app.comp.base.TextAreaComp;
|
||||
import io.xpipe.app.core.AppI18n;
|
||||
import io.xpipe.app.core.AppNames;
|
||||
import io.xpipe.app.platform.LabelGraphic;
|
||||
import io.xpipe.app.platform.OptionsBuilder;
|
||||
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.scene.control.Tab;
|
||||
import javafx.scene.control.TabPane;
|
||||
import javafx.scene.control.TextArea;
|
||||
|
||||
public class McpCategory extends AppPrefsCategory {
|
||||
|
||||
@@ -22,26 +30,11 @@ public class McpCategory extends AppPrefsCategory {
|
||||
return new LabelGraphic.IconGraphic("mdi2c-chat-processing-outline");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected BaseRegionBuilder<?, ?> create() {
|
||||
private ObservableValue<String> createMcpConfig(String format) {
|
||||
var prefs = AppPrefs.get();
|
||||
|
||||
var mcpConfig = Bindings.createStringBinding(
|
||||
return Bindings.createStringBinding(
|
||||
() -> {
|
||||
var template = """
|
||||
{
|
||||
"mcpServers": {
|
||||
"%s": {
|
||||
"type": "streamable-http",
|
||||
"url": "http://localhost:%s/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer %s"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
return template.formatted(
|
||||
return format.formatted(
|
||||
AppNames.ofCurrent().getKebapName(),
|
||||
AppBeaconServer.get().getPort(),
|
||||
prefs.apiKey().get() != null
|
||||
@@ -50,22 +43,110 @@ public class McpCategory extends AppPrefsCategory {
|
||||
.strip();
|
||||
},
|
||||
prefs.apiKey());
|
||||
var mcpConfigProp = new SimpleStringProperty();
|
||||
mcpConfigProp.bind(mcpConfig);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected BaseRegionBuilder<?, ?> create() {
|
||||
var prefs = AppPrefs.get();
|
||||
|
||||
var vsCodeTemplate = createMcpConfig("""
|
||||
{
|
||||
"servers": {
|
||||
"%s": {
|
||||
"type": "http",
|
||||
"url": "http://localhost:%s/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer %s"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var cursorTemplate = createMcpConfig("""
|
||||
{
|
||||
"mcpServers": {
|
||||
"%s": {
|
||||
"type": "streamable-http",
|
||||
"url": "http://localhost:%s/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer %s"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var warpTemplate = createMcpConfig("""
|
||||
{
|
||||
"%s": {
|
||||
"serverUrl": "http://localhost:%s/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer %s"
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var tabComp = RegionBuilder.of(() -> {
|
||||
var vsCode = new TextArea();
|
||||
vsCode.setEditable(false);
|
||||
vsCode.textProperty().bind(vsCodeTemplate);
|
||||
vsCode.setPrefRowCount(12);
|
||||
var vsCodeTab = new Tab();
|
||||
vsCodeTab.textProperty().bind(AppI18n.observable("vscode"));
|
||||
vsCodeTab.setContent(vsCode);
|
||||
vsCodeTab.setClosable(false);
|
||||
|
||||
var cursor = new TextArea();
|
||||
cursor.setEditable(false);
|
||||
cursor.textProperty().bind(cursorTemplate);
|
||||
cursor.setPrefRowCount(12);
|
||||
var cursorTab = new Tab();
|
||||
cursorTab.textProperty().bind(AppI18n.observable("cursor"));
|
||||
cursorTab.setContent(cursor);
|
||||
cursorTab.setClosable(false);
|
||||
|
||||
var warp = new TextArea();
|
||||
warp.setEditable(false);
|
||||
warp.textProperty().bind(warpTemplate);
|
||||
warp.setPrefRowCount(12);
|
||||
var warpTab = new Tab();
|
||||
warpTab.textProperty().bind(AppI18n.observable("warp"));
|
||||
warpTab.setContent(warp);
|
||||
warpTab.setClosable(false);
|
||||
|
||||
var claude = new TextArea();
|
||||
claude.setEditable(false);
|
||||
claude.textProperty().bind(vsCodeTemplate);
|
||||
claude.setPrefRowCount(12);
|
||||
var claudeTab = new Tab();
|
||||
claudeTab.textProperty().bind(AppI18n.observable("claude"));
|
||||
claudeTab.setContent(claude);
|
||||
claudeTab.setClosable(false);
|
||||
|
||||
var tabPane = new TabPane();
|
||||
tabPane.getTabs().addAll(vsCodeTab, cursorTab, warpTab, claudeTab);
|
||||
return tabPane;
|
||||
});
|
||||
|
||||
return new OptionsBuilder()
|
||||
.addTitle("mcpServer")
|
||||
.sub(new OptionsBuilder()
|
||||
.pref(prefs.enableMcpServer)
|
||||
.addToggle(prefs.enableMcpServer)
|
||||
.nameAndDescription("mcpClientConfigurationDetails")
|
||||
.addComp(tabComp)
|
||||
.hide(prefs.enableMcpServer.not())
|
||||
.pref(prefs.enableMcpMutationTools)
|
||||
.addToggle(prefs.enableMcpMutationTools)
|
||||
.nameAndDescription("mcpClientConfigurationDetails")
|
||||
.addComp(new TextAreaComp(mcpConfigProp).applyStructure(struc -> {
|
||||
struc.getTextArea().setEditable(false);
|
||||
struc.getTextArea().setPrefRowCount(12);
|
||||
.hide(prefs.enableMcpServer.not())
|
||||
.pref(prefs.mcpAdditionalContext)
|
||||
.addComp(new IntegratedTextAreaComp(prefs.mcpAdditionalContext, false, "prompt", new SimpleStringProperty("txt")).applyStructure(structure -> {
|
||||
structure.getTextArea().promptTextProperty().bind(AppI18n.observable("mcpAdditionalContextSample"));
|
||||
}))
|
||||
.hide(prefs.enableMcpServer.not()))
|
||||
.hide(prefs.enableMcpServer.not())
|
||||
)
|
||||
.buildComp();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,20 +20,8 @@ import org.kordamp.ikonli.javafx.FontIcon;
|
||||
|
||||
public class PasswordManagerCategory extends AppPrefsCategory {
|
||||
|
||||
@Override
|
||||
protected String getId() {
|
||||
return "passwordManager";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected LabelGraphic getIcon() {
|
||||
return new LabelGraphic.IconGraphic("mdomz-vpn_key");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected BaseRegionBuilder<?, ?> create() {
|
||||
public static OptionsBuilder passwordManagerChoice() {
|
||||
var prefs = AppPrefs.get();
|
||||
var testPasswordManagerValue = new SimpleStringProperty();
|
||||
|
||||
var choiceBuilder = OptionsChoiceBuilder.builder()
|
||||
.property(prefs.passwordManager)
|
||||
@@ -63,18 +51,26 @@ public class PasswordManagerCategory extends AppPrefsCategory {
|
||||
.build();
|
||||
var choice = choiceBuilder.build().buildComp().maxWidth(600);
|
||||
|
||||
var testInput = new PasswordManagerTestComp(testPasswordManagerValue, true);
|
||||
testInput.maxWidth(getCompWidth());
|
||||
testInput.hgrow();
|
||||
return new OptionsBuilder()
|
||||
.pref(prefs.passwordManager)
|
||||
.addComp(choice);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getId() {
|
||||
return "passwordManager";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected LabelGraphic getIcon() {
|
||||
return new LabelGraphic.IconGraphic("mdomz-vpn_key");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected BaseRegionBuilder<?, ?> create() {
|
||||
return new OptionsBuilder()
|
||||
.addTitle("passwordManager")
|
||||
.sub(new OptionsBuilder()
|
||||
.pref(prefs.passwordManager)
|
||||
.addComp(choice)
|
||||
.nameAndDescription("passwordManagerCommandTest")
|
||||
.addComp(testInput)
|
||||
.hide(BindingsHelper.map(prefs.passwordManager, p -> p == null)))
|
||||
.sub(passwordManagerChoice())
|
||||
.buildComp();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
package io.xpipe.app.prefs;
|
||||
|
||||
import io.xpipe.app.comp.SimpleRegionBuilder;
|
||||
import io.xpipe.app.comp.base.ButtonComp;
|
||||
import io.xpipe.app.comp.base.HorizontalComp;
|
||||
import io.xpipe.app.comp.base.LabelComp;
|
||||
import io.xpipe.app.comp.base.TextFieldComp;
|
||||
import io.xpipe.app.comp.base.*;
|
||||
import io.xpipe.app.core.AppI18n;
|
||||
import io.xpipe.app.util.GlobalTimer;
|
||||
import io.xpipe.app.util.ThreadHelper;
|
||||
@@ -13,6 +10,7 @@ import javafx.application.Platform;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.input.KeyCode;
|
||||
import javafx.scene.layout.Region;
|
||||
@@ -21,6 +19,7 @@ import atlantafx.base.theme.Styles;
|
||||
import org.kordamp.ikonli.javafx.FontIcon;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
@@ -30,6 +29,10 @@ public class PasswordManagerTestComp extends SimpleRegionBuilder {
|
||||
private final boolean handleEnter;
|
||||
private final AtomicInteger counter = new AtomicInteger(0);
|
||||
|
||||
public PasswordManagerTestComp(boolean handleEnter) {
|
||||
this(new SimpleStringProperty(), handleEnter);
|
||||
}
|
||||
|
||||
public PasswordManagerTestComp(StringProperty value, boolean handleEnter) {
|
||||
this.value = value;
|
||||
this.handleEnter = handleEnter;
|
||||
@@ -49,7 +52,6 @@ public class PasswordManagerTestComp extends SimpleRegionBuilder {
|
||||
: "?";
|
||||
},
|
||||
prefs.passwordManager)))
|
||||
.style(Styles.LEFT_PILL)
|
||||
.hgrow();
|
||||
if (handleEnter) {
|
||||
field.apply(struc -> struc.setOnKeyPressed(event -> {
|
||||
@@ -60,28 +62,20 @@ public class PasswordManagerTestComp extends SimpleRegionBuilder {
|
||||
}));
|
||||
}
|
||||
|
||||
var button = new ButtonComp(null, new FontIcon("mdi2p-play"), () -> {
|
||||
var button = new ButtonComp(AppI18n.observable("test"), new FontIcon("mdi2p-play"), () -> {
|
||||
testPasswordManager(value.get(), testPasswordManagerResult);
|
||||
})
|
||||
.describe(d -> d.nameKey("test"))
|
||||
.style(Styles.RIGHT_PILL);
|
||||
});
|
||||
button.padding(new Insets(6, 9, 6, 9));
|
||||
button.disable(value.isNull());
|
||||
|
||||
var testInput = new HorizontalComp(List.of(field, button));
|
||||
testInput.apply(struc -> {
|
||||
struc.setFillHeight(true);
|
||||
var first = ((Region) struc.getChildren().get(0));
|
||||
var second = ((Region) struc.getChildren().get(1));
|
||||
second.minHeightProperty().bind(first.heightProperty());
|
||||
second.maxHeightProperty().bind(first.heightProperty());
|
||||
second.prefHeightProperty().bind(first.heightProperty());
|
||||
});
|
||||
testInput.hgrow();
|
||||
|
||||
var testPasswordManager = new HorizontalComp(List.of(
|
||||
testInput, new LabelComp(testPasswordManagerResult).apply(struc -> struc.setOpacity(0.8))))
|
||||
var testRow = new HorizontalComp(List.of(
|
||||
button, new LabelComp(testPasswordManagerResult).apply(struc -> struc.setOpacity(0.8))))
|
||||
.apply(struc -> struc.setAlignment(Pos.CENTER_LEFT))
|
||||
.apply(struc -> struc.setFillHeight(true));
|
||||
return testPasswordManager.build();
|
||||
|
||||
var vbox = new VerticalComp(List.of(field, testRow));
|
||||
vbox.spacing(6);
|
||||
return vbox.build();
|
||||
}
|
||||
|
||||
private void testPasswordManager(String key, StringProperty testPasswordManagerResult) {
|
||||
@@ -96,18 +90,39 @@ public class PasswordManagerTestComp extends SimpleRegionBuilder {
|
||||
testPasswordManagerResult.set(" " + AppI18n.get("querying"));
|
||||
});
|
||||
|
||||
var r = prefs.passwordManager.getValue().retrieveCredentials(key);
|
||||
var r = prefs.passwordManager.getValue().query(key);
|
||||
if (r == null) {
|
||||
Platform.runLater(() -> {
|
||||
testPasswordManagerResult.set(null);
|
||||
testPasswordManagerResult.set(" " + AppI18n.get("queryFailed"));
|
||||
});
|
||||
GlobalTimer.delay(
|
||||
() -> {
|
||||
Platform.runLater(() -> {
|
||||
if (counter.get() == currentIndex) {
|
||||
testPasswordManagerResult.set(null);
|
||||
}
|
||||
});
|
||||
},
|
||||
Duration.ofSeconds(5));
|
||||
return;
|
||||
}
|
||||
|
||||
var pass = r.getPassword() != null ? r.getPassword().getSecretValue() : "?";
|
||||
var format = (r.getUsername() != null ? r.getUsername() + " [" + pass + "]" : pass);
|
||||
List<String> elements = new ArrayList<>();
|
||||
if (r.getCredentials() != null && r.getCredentials().getUsername() != null) {
|
||||
elements.add(r.getCredentials().getUsername());
|
||||
}
|
||||
|
||||
if (r.getCredentials() != null && r.getCredentials().getPassword() != null) {
|
||||
elements.add("[" + r.getCredentials().getPassword().getSecretValue() + "]");
|
||||
}
|
||||
|
||||
if (r.getSshKey() != null) {
|
||||
elements.add("[" + AppI18n.get("sshKey") + "]");
|
||||
}
|
||||
|
||||
var formatted = String.join(" ", elements);
|
||||
Platform.runLater(() -> {
|
||||
testPasswordManagerResult.set(" " + AppI18n.get("retrievedPassword", format));
|
||||
testPasswordManagerResult.set(" " + AppI18n.get("retrievedPassword", formatted));
|
||||
});
|
||||
GlobalTimer.delay(
|
||||
() -> {
|
||||
|
||||
@@ -1,15 +1,34 @@
|
||||
package io.xpipe.app.prefs;
|
||||
|
||||
import atlantafx.base.theme.Styles;
|
||||
import io.xpipe.app.comp.BaseRegionBuilder;
|
||||
import io.xpipe.app.comp.base.ButtonComp;
|
||||
import io.xpipe.app.comp.base.ContextualFileReferenceChoiceComp;
|
||||
import io.xpipe.app.core.AppI18n;
|
||||
import io.xpipe.app.cred.CustomAgentStrategy;
|
||||
import io.xpipe.app.cred.SshAgentTestComp;
|
||||
import io.xpipe.app.cred.SshIdentityStateManager;
|
||||
import io.xpipe.app.ext.ProcessControlProvider;
|
||||
import io.xpipe.app.issue.ErrorEventFactory;
|
||||
import io.xpipe.app.platform.LabelGraphic;
|
||||
import io.xpipe.app.platform.OptionsBuilder;
|
||||
import io.xpipe.app.process.LocalShell;
|
||||
import io.xpipe.app.process.ShellScript;
|
||||
import io.xpipe.app.storage.DataStorage;
|
||||
import io.xpipe.app.terminal.TerminalLaunch;
|
||||
import io.xpipe.app.util.ThreadHelper;
|
||||
import io.xpipe.core.OsType;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.layout.Region;
|
||||
import org.kordamp.ikonli.javafx.FontIcon;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
public class SshCategory extends AppPrefsCategory {
|
||||
|
||||
@@ -30,6 +49,8 @@ public class SshCategory extends AppPrefsCategory {
|
||||
if (OsType.ofLocal() == OsType.WINDOWS) {
|
||||
options.addComp(prefs.getCustomOptions("x11WslInstance").buildComp());
|
||||
}
|
||||
|
||||
var agentTest = new SshAgentTestComp(new SimpleObjectProperty<>(CustomAgentStrategy.builder().build()));
|
||||
if (OsType.ofLocal() != OsType.WINDOWS) {
|
||||
var choice = new ContextualFileReferenceChoiceComp(
|
||||
new ReadOnlyObjectWrapper<>(DataStorage.get().local().ref()),
|
||||
@@ -41,7 +62,7 @@ public class SshCategory extends AppPrefsCategory {
|
||||
choice.setPrompt(prefs.defaultSshAgentSocket);
|
||||
choice.maxWidth(600);
|
||||
options.sub(
|
||||
new OptionsBuilder().nameAndDescription("sshAgentSocket").addComp(choice, prefs.sshAgentSocket));
|
||||
new OptionsBuilder().nameAndDescription("sshAgentSocket").addComp(choice, prefs.sshAgentSocket).addComp(agentTest));
|
||||
}
|
||||
return options.buildComp();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import io.xpipe.app.comp.BaseRegionBuilder;
|
||||
import io.xpipe.app.comp.RegionBuilder;
|
||||
import io.xpipe.app.comp.base.*;
|
||||
import io.xpipe.app.core.AppI18n;
|
||||
import io.xpipe.app.core.AppProperties;
|
||||
import io.xpipe.app.ext.PrefsChoiceValue;
|
||||
import io.xpipe.app.ext.ProcessControlProvider;
|
||||
import io.xpipe.app.ext.ShellStore;
|
||||
@@ -33,6 +34,8 @@ import javafx.scene.layout.Region;
|
||||
|
||||
import org.kordamp.ikonli.javafx.FontIcon;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
@@ -151,20 +154,6 @@ public class TerminalCategory extends AppPrefsCategory {
|
||||
@Override
|
||||
protected BaseRegionBuilder<?, ?> create() {
|
||||
var prefs = AppPrefs.get();
|
||||
prefs.enableTerminalLogging.addListener((observable, oldValue, newValue) -> {
|
||||
var feature = LicenseProvider.get().getFeature("logging");
|
||||
if (newValue && !feature.isSupported()) {
|
||||
try {
|
||||
// Disable it again so people don't forget that they left it on
|
||||
Platform.runLater(() -> {
|
||||
prefs.enableTerminalLogging.set(false);
|
||||
});
|
||||
feature.throwIfUnsupported();
|
||||
} catch (LicenseRequiredException ex) {
|
||||
ErrorEventFactory.fromThrowable(ex).handle();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var tabsSettingSupported = Bindings.createBooleanBinding(
|
||||
() -> {
|
||||
@@ -182,6 +171,22 @@ public class TerminalCategory extends AppPrefsCategory {
|
||||
.sub(terminalProxy())
|
||||
.sub(terminalMultiplexer())
|
||||
// .sub(terminalInitScript())
|
||||
.addTitle("sessionLogging")
|
||||
.sub(new OptionsBuilder()
|
||||
.pref(prefs.enableTerminalLogging)
|
||||
.addToggle(prefs.enableTerminalLogging)
|
||||
.nameAndDescription("terminalLoggingDirectory")
|
||||
.documentationLink(DocumentationLink.TERMINAL_LOGGING_FILES)
|
||||
.addComp(new ButtonComp(AppI18n.observable("openSessionLogs"), () -> {
|
||||
var dir = AppProperties.get().getDataDir().resolve("sessions");
|
||||
try {
|
||||
Files.createDirectories(dir);
|
||||
DesktopHelper.browseFile(dir);
|
||||
} catch (IOException e) {
|
||||
ErrorEventFactory.fromThrowable(e).handle();
|
||||
}
|
||||
})
|
||||
.disable(prefs.enableTerminalLogging.not())))
|
||||
.addTitle("terminalBehaviour")
|
||||
.sub(
|
||||
new OptionsBuilder()
|
||||
@@ -220,8 +225,6 @@ public class TerminalCategory extends AppPrefsCategory {
|
||||
.pref(prefs.enableTerminalStartupBell)
|
||||
.addToggle(prefs.enableTerminalStartupBell)
|
||||
.hide(OsType.ofLocal() == OsType.WINDOWS)
|
||||
// .pref(prefs.terminalPromptForRestart)
|
||||
// .addToggle(prefs.terminalPromptForRestart)
|
||||
)
|
||||
.buildComp();
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@ public interface ParentSystemAccess {
|
||||
|
||||
static ParentSystemAccess none() {
|
||||
return new ParentSystemAccess() {
|
||||
@Override
|
||||
public boolean samePermissions() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsSameUsers() {
|
||||
return false;
|
||||
@@ -45,6 +50,11 @@ public interface ParentSystemAccess {
|
||||
|
||||
static ParentSystemAccess identity() {
|
||||
return new ParentSystemAccess() {
|
||||
@Override
|
||||
public boolean samePermissions() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsSameUsers() {
|
||||
return true;
|
||||
@@ -84,6 +94,11 @@ public interface ParentSystemAccess {
|
||||
|
||||
static ParentSystemAccess combine(ParentSystemAccess a1, ParentSystemAccess a2) {
|
||||
return new ParentSystemAccess() {
|
||||
@Override
|
||||
public boolean samePermissions() {
|
||||
return a1.samePermissions() && a2.samePermissions();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsSameUsers() {
|
||||
return a1.supportsSameUsers() && a2.supportsSameUsers();
|
||||
@@ -121,10 +136,56 @@ public interface ParentSystemAccess {
|
||||
};
|
||||
}
|
||||
|
||||
static ParentSystemAccess userChange() {
|
||||
return new ParentSystemAccess() {
|
||||
@Override
|
||||
public boolean samePermissions() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsSameUsers() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsFileSystemAccess() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsExecutables() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsExecutableEnvironment() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FilePath translateFromLocalSystemPath(FilePath path) {
|
||||
return path;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FilePath translateToLocalSystemPath(FilePath path) {
|
||||
return path;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isIdentity() {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
default boolean supportsAnyAccess() {
|
||||
return supportsFileSystemAccess();
|
||||
}
|
||||
|
||||
boolean samePermissions();
|
||||
|
||||
boolean supportsSameUsers();
|
||||
|
||||
boolean supportsFileSystemAccess();
|
||||
|
||||
@@ -125,6 +125,10 @@ public class ShellView {
|
||||
return userHome;
|
||||
}
|
||||
|
||||
public void moveFile(FilePath source, FilePath dest) throws Exception {
|
||||
getDialect().getFileMoveCommand(shellControl, source.toString(), dest.toString()).execute();
|
||||
}
|
||||
|
||||
public boolean fileExists(FilePath path) throws Exception {
|
||||
return getDialect()
|
||||
.createFileExistsCommand(shellControl, path.toString())
|
||||
@@ -271,8 +275,8 @@ public class ShellView {
|
||||
} else if (ShellDialects.isPowershell(shellControl)) {
|
||||
administrator = shellControl
|
||||
.command(String.format(
|
||||
"$currentPrincipal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent());"
|
||||
+ "try {if (-not $($currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator))) {$host.ui"
|
||||
"try {$currentPrincipal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent());"
|
||||
+ "if (-not $($currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator))) {$host.ui"
|
||||
+ ".WriteErrorLine(\"%s\"); throw \"error\"}} catch {}",
|
||||
"Not Administrator"))
|
||||
.executeAndCheck();
|
||||
|
||||
@@ -1,28 +1,46 @@
|
||||
package io.xpipe.app.pwman;
|
||||
|
||||
import io.xpipe.app.comp.base.ButtonComp;
|
||||
import io.xpipe.app.core.AppCache;
|
||||
import io.xpipe.app.core.AppI18n;
|
||||
import io.xpipe.app.core.AppSystemInfo;
|
||||
import io.xpipe.app.ext.ProcessControlProvider;
|
||||
import io.xpipe.app.issue.ErrorEventFactory;
|
||||
import io.xpipe.app.platform.OptionsBuilder;
|
||||
import io.xpipe.app.platform.OptionsChoiceBuilder;
|
||||
import io.xpipe.app.prefs.PasswordManagerTestComp;
|
||||
import io.xpipe.app.process.*;
|
||||
import io.xpipe.app.terminal.TerminalLaunch;
|
||||
import io.xpipe.app.util.*;
|
||||
import io.xpipe.core.InPlaceSecretValue;
|
||||
import io.xpipe.core.JacksonMapper;
|
||||
import io.xpipe.core.OsType;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonTypeName;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.scene.layout.Region;
|
||||
import lombok.Builder;
|
||||
import lombok.extern.jackson.Jacksonized;
|
||||
import org.kordamp.ikonli.javafx.FontIcon;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
@JsonTypeName("bitwarden")
|
||||
@Builder
|
||||
@Jacksonized
|
||||
public class BitwardenPasswordManager implements PasswordManager {
|
||||
|
||||
private static ShellControl SHELL;
|
||||
private static boolean copied;
|
||||
private final PasswordManagerKeyStrategy keyStrategy;
|
||||
|
||||
private static synchronized ShellControl getOrStartShell() throws Exception {
|
||||
if (SHELL == null) {
|
||||
@@ -41,6 +59,54 @@ public class BitwardenPasswordManager implements PasswordManager {
|
||||
return SHELL;
|
||||
}
|
||||
|
||||
private static Path getSocketLocation() {
|
||||
var socket = switch (OsType.ofLocal()) {
|
||||
case OsType.Linux ignored -> AppSystemInfo.ofLinux().getUserHome().resolve(".bitwarden-ssh-agent.sock");
|
||||
case OsType.MacOs macOs -> AppSystemInfo.ofMacOs().getUserHome().resolve("Library", "Containers", "com.bitwarden.desktop", "Data", ".bitwarden-ssh-agent.sock");
|
||||
case OsType.Windows windows -> null;
|
||||
};
|
||||
return socket;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public static OptionsBuilder createOptions(Property<BitwardenPasswordManager> p) {
|
||||
var keyStrategy = new SimpleObjectProperty<>(p.getValue().keyStrategy);
|
||||
|
||||
AtomicReference<Region> button = new AtomicReference<>();
|
||||
var syncButton = new ButtonComp(AppI18n.observable("sync"), new FontIcon("mdi2r-refresh"), () -> {
|
||||
button.get().setDisable(true);
|
||||
ThreadHelper.runFailableAsync(() -> {
|
||||
sync();
|
||||
Platform.runLater(() -> {
|
||||
button.get().setDisable(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
syncButton.apply(struc -> button.set(struc));
|
||||
syncButton.padding(new Insets(6, 10, 6, 6));
|
||||
|
||||
var keyStrategyChoice = OptionsChoiceBuilder.builder()
|
||||
.allowNull(true)
|
||||
.available(List.of(PasswordManagerKeyStrategy.Agent.class))
|
||||
.property(keyStrategy)
|
||||
.customConfiguration(PasswordManagerKeyStrategy.OptionsConfig.builder()
|
||||
.defaultSocketLocation(getSocketLocation())
|
||||
.allowSocketChoice(false)
|
||||
.build())
|
||||
.build();
|
||||
|
||||
return new OptionsBuilder()
|
||||
.addComp(syncButton)
|
||||
.nameAndDescription("passwordManagerTest")
|
||||
.addComp(new PasswordManagerTestComp(true))
|
||||
.nameAndDescription("passwordManagerKeyStrategy")
|
||||
.sub(keyStrategyChoice.build(), keyStrategy)
|
||||
.bind(() -> {
|
||||
return BitwardenPasswordManager.builder().keyStrategy(keyStrategy.getValue()).build();
|
||||
}, p);
|
||||
}
|
||||
|
||||
|
||||
private static boolean moveAppDir() throws Exception {
|
||||
var path = SHELL.view().findProgram("bw");
|
||||
return OsType.ofLocal() != OsType.LINUX
|
||||
@@ -48,8 +114,79 @@ public class BitwardenPasswordManager implements PasswordManager {
|
||||
|| !path.get().toString().contains("snap");
|
||||
}
|
||||
|
||||
private static void sync() throws Exception {
|
||||
// Copy existing file if possible to retain configuration. Only once per session
|
||||
copyConfigIfNeeded();
|
||||
|
||||
if (!loginOrUnlock()) {
|
||||
return;
|
||||
}
|
||||
|
||||
getOrStartShell().command(CommandBuilder.of().add("bw", "sync")).execute();
|
||||
}
|
||||
|
||||
private static void copyConfigIfNeeded() {
|
||||
if (copied) {
|
||||
return;
|
||||
}
|
||||
|
||||
var cacheDataFile = AppCache.getBasePath().resolve("data.json");
|
||||
var def = getDefaultConfigPath();
|
||||
if (Files.exists(def)) {
|
||||
try {
|
||||
var defIsNewer = Files.getLastModifiedTime(def).compareTo(Files.getLastModifiedTime(cacheDataFile)) > 0;
|
||||
if (defIsNewer) {
|
||||
Files.copy(def, cacheDataFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
ErrorEventFactory.fromThrowable(e).handle();
|
||||
}
|
||||
}
|
||||
copied = true;
|
||||
}
|
||||
|
||||
private static boolean loginOrUnlock() throws Exception {
|
||||
var sc = getOrStartShell();
|
||||
var command = sc.command(CommandBuilder.of().add("bw", "get", "item", "xpipe-test", "--nointeraction"));
|
||||
var r = command.readStdoutAndStderr();
|
||||
if (r[1].contains("You are not logged in")) {
|
||||
var script = ShellScript.lines(
|
||||
moveAppDir()
|
||||
? LocalShell.getDialect()
|
||||
.getSetEnvironmentVariableCommand(
|
||||
"BITWARDENCLI_APPDATA_DIR",
|
||||
AppCache.getBasePath().toString())
|
||||
: null,
|
||||
sc.getShellDialect().getEchoCommand("Log in into your Bitwarden account from the CLI:", false),
|
||||
"bw login");
|
||||
TerminalLaunch.builder()
|
||||
.title("Bitwarden login")
|
||||
.localScript(script)
|
||||
.logIfEnabled(false)
|
||||
.preferTabs(false)
|
||||
.pauseOnExit(true)
|
||||
.launch();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (r[1].contains("Vault is locked")) {
|
||||
var pw = AskpassAlert.queryRaw("Unlock vault with your Bitwarden master password", null, false);
|
||||
if (pw.getSecret() == null) {
|
||||
return false;
|
||||
}
|
||||
var cmd = sc.command(CommandBuilder.of()
|
||||
.add("bw", "unlock", "--raw", "--passwordenv", "BW_PASSWORD")
|
||||
.fixedEnvironment("BW_PASSWORD", pw.getSecret().getSecretValue()));
|
||||
cmd.sensitive();
|
||||
var out = cmd.readStdoutOrThrow();
|
||||
sc.view().setSensitiveEnvironmentVariable("BW_SESSION", out);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized CredentialResult retrieveCredentials(String key) {
|
||||
public synchronized Result query(String key) {
|
||||
try {
|
||||
CommandSupport.isInLocalPathOrThrow("Bitwarden CLI", "bw");
|
||||
} catch (Exception e) {
|
||||
@@ -60,76 +197,48 @@ public class BitwardenPasswordManager implements PasswordManager {
|
||||
}
|
||||
|
||||
// Copy existing file if possible to retain configuration. Only once per session
|
||||
if (!copied) {
|
||||
var cacheDataFile = AppCache.getBasePath().resolve("data.json");
|
||||
var def = getDefaultConfigPath();
|
||||
if (Files.exists(def)) {
|
||||
try {
|
||||
Files.copy(def, cacheDataFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (IOException e) {
|
||||
ErrorEventFactory.fromThrowable(e).handle();
|
||||
}
|
||||
copied = true;
|
||||
}
|
||||
}
|
||||
copyConfigIfNeeded();
|
||||
|
||||
try {
|
||||
var sc = getOrStartShell();
|
||||
var command = sc.command(CommandBuilder.of().add("bw", "get", "item", "xpipe-test", "--nointeraction"));
|
||||
var r = command.readStdoutAndStderr();
|
||||
if (r[1].contains("You are not logged in")) {
|
||||
var script = ShellScript.lines(
|
||||
moveAppDir()
|
||||
? LocalShell.getDialect()
|
||||
.getSetEnvironmentVariableCommand(
|
||||
"BITWARDENCLI_APPDATA_DIR",
|
||||
AppCache.getBasePath().toString())
|
||||
: null,
|
||||
sc.getShellDialect().getEchoCommand("Log in into your Bitwarden account from the CLI:", false),
|
||||
"bw login");
|
||||
TerminalLaunch.builder()
|
||||
.title("Bitwarden login")
|
||||
.localScript(script)
|
||||
.logIfEnabled(false)
|
||||
.preferTabs(false)
|
||||
.pauseOnExit(true)
|
||||
.launch();
|
||||
if (!loginOrUnlock()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (r[1].contains("Vault is locked")) {
|
||||
var pw = AskpassAlert.queryRaw("Unlock vault with your Bitwarden master password", null, false);
|
||||
if (pw.getSecret() == null) {
|
||||
return null;
|
||||
}
|
||||
var cmd = sc.command(CommandBuilder.of()
|
||||
.add("bw", "unlock", "--raw", "--passwordenv", "BW_PASSWORD")
|
||||
.fixedEnvironment("BW_PASSWORD", pw.getSecret().getSecretValue()));
|
||||
cmd.sensitive();
|
||||
var out = cmd.readStdoutOrThrow();
|
||||
sc.view().setSensitiveEnvironmentVariable("BW_SESSION", out);
|
||||
}
|
||||
|
||||
var sc = getOrStartShell();
|
||||
var cmd =
|
||||
CommandBuilder.of().add("bw", "get", "item").addLiteral(key).add("--nointeraction");
|
||||
var json = JacksonMapper.getDefault()
|
||||
.readTree(sc.command(cmd).sensitive().readStdoutOrThrow());
|
||||
var login = json.get("login");
|
||||
if (login == null) {
|
||||
throw ErrorEventFactory.expected(
|
||||
new IllegalArgumentException("No usable login found for item name " + key));
|
||||
|
||||
SshKey credentialSshKey;
|
||||
var sshKey = json.get("sshKey");
|
||||
if (sshKey != null) {
|
||||
var privateKey = Optional.ofNullable(sshKey.get("privateKey")).map(jsonNode -> jsonNode.textValue()).orElse(null);
|
||||
var publicKey = Optional.ofNullable(sshKey.get("publicKey")).map(jsonNode -> jsonNode.textValue()).orElse(null);
|
||||
var fingerprint = Optional.ofNullable(sshKey.get("fingerprint")).map(jsonNode -> jsonNode.textValue()).orElse(null);
|
||||
credentialSshKey = SshKey.of(fingerprint, publicKey, privateKey);
|
||||
} else {
|
||||
credentialSshKey = null;
|
||||
}
|
||||
|
||||
var user = login.required("username");
|
||||
var password = login.required("password");
|
||||
return new CredentialResult(user.isNull() ? null : user.asText(), InPlaceSecretValue.of(password.asText()));
|
||||
Credentials creds;
|
||||
var login = json.get("login");
|
||||
if (login != null) {
|
||||
var username = Optional.ofNullable(login.get("username")).map(jsonNode -> jsonNode.textValue()).orElse(null);
|
||||
var password = Optional.ofNullable(login.get("password")).map(jsonNode -> jsonNode.textValue()).orElse(null);
|
||||
creds = Credentials.of(username, password);
|
||||
} else {
|
||||
creds = null;
|
||||
}
|
||||
|
||||
return Result.of(creds, credentialSshKey);
|
||||
} catch (Exception ex) {
|
||||
ErrorEventFactory.fromThrowable(ex).expected().handle();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Path getDefaultConfigPath() {
|
||||
private static Path getDefaultConfigPath() {
|
||||
return switch (OsType.ofLocal()) {
|
||||
case OsType.Linux ignored -> {
|
||||
if (System.getenv("XDG_CONFIG_HOME") != null) {
|
||||
@@ -163,4 +272,9 @@ public class BitwardenPasswordManager implements PasswordManager {
|
||||
public String getWebsite() {
|
||||
return "https://bitwarden.com/";
|
||||
}
|
||||
|
||||
@Override
|
||||
public PasswordManagerKeyConfiguration getKeyConfiguration() {
|
||||
return PasswordManagerKeyConfiguration.of(true, false, true, keyStrategy, getSocketLocation());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,50 @@
|
||||
package io.xpipe.app.pwman;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import io.xpipe.app.comp.base.ButtonComp;
|
||||
import io.xpipe.app.core.AppI18n;
|
||||
import io.xpipe.app.ext.ProcessControlProvider;
|
||||
import io.xpipe.app.issue.ErrorEventFactory;
|
||||
import io.xpipe.app.platform.OptionsBuilder;
|
||||
import io.xpipe.app.platform.OptionsChoiceBuilder;
|
||||
import io.xpipe.app.prefs.PasswordManagerTestComp;
|
||||
import io.xpipe.app.process.CommandBuilder;
|
||||
import io.xpipe.app.process.CommandSupport;
|
||||
import io.xpipe.app.process.ShellControl;
|
||||
import io.xpipe.app.process.ShellScript;
|
||||
import io.xpipe.app.terminal.TerminalLaunch;
|
||||
import io.xpipe.core.InPlaceSecretValue;
|
||||
import io.xpipe.app.util.ThreadHelper;
|
||||
import io.xpipe.core.JacksonMapper;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonTypeName;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.scene.layout.Region;
|
||||
import lombok.Builder;
|
||||
import lombok.extern.jackson.Jacksonized;
|
||||
import org.kordamp.ikonli.javafx.FontIcon;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
@JsonTypeName("dashlane")
|
||||
@Builder
|
||||
@Jacksonized
|
||||
public class DashlanePasswordManager implements PasswordManager {
|
||||
|
||||
private static ShellControl SHELL;
|
||||
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public static OptionsBuilder createOptions(Property<DashlanePasswordManager> p) {
|
||||
return new OptionsBuilder()
|
||||
.nameAndDescription("passwordManagerTest")
|
||||
.addComp(new PasswordManagerTestComp(true));
|
||||
}
|
||||
|
||||
private static synchronized ShellControl getOrStartShell() throws Exception {
|
||||
if (SHELL == null) {
|
||||
SHELL = ProcessControlProvider.get().createLocalProcessControl(true);
|
||||
@@ -26,7 +54,7 @@ public class DashlanePasswordManager implements PasswordManager {
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized CredentialResult retrieveCredentials(String key) {
|
||||
public synchronized Result query(String key) {
|
||||
try {
|
||||
CommandSupport.isInLocalPathOrThrow("Dashlane CLI", "dcli");
|
||||
} catch (Exception e) {
|
||||
@@ -58,11 +86,9 @@ public class DashlanePasswordManager implements PasswordManager {
|
||||
.addLiteral(key));
|
||||
var out = cmd.sensitive().readStdoutOrThrow();
|
||||
var tree = JacksonMapper.getDefault().readTree(out);
|
||||
var login = tree.get("login");
|
||||
var password = tree.get("password");
|
||||
return new CredentialResult(
|
||||
login != null ? login.asText() : null,
|
||||
password != null ? InPlaceSecretValue.of(password.asText()) : null);
|
||||
var login = Optional.ofNullable(tree.get("login")).map(JsonNode::textValue).orElse(null);
|
||||
var password = Optional.ofNullable(tree.get("password")).map(JsonNode::textValue).orElse(null);
|
||||
return Result.of(Credentials.of(login, password), null);
|
||||
} catch (Exception ex) {
|
||||
ErrorEventFactory.fromThrowable(ex).handle();
|
||||
return null;
|
||||
@@ -78,4 +104,9 @@ public class DashlanePasswordManager implements PasswordManager {
|
||||
public String getWebsite() {
|
||||
return "https://www.dashlane.com/";
|
||||
}
|
||||
|
||||
@Override
|
||||
public PasswordManagerKeyConfiguration getKeyConfiguration() {
|
||||
return PasswordManagerKeyConfiguration.none();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package io.xpipe.app.pwman;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import io.xpipe.app.comp.base.ContextualFileReferenceChoiceComp;
|
||||
import io.xpipe.app.core.AppSystemInfo;
|
||||
import io.xpipe.app.ext.ProcessControlProvider;
|
||||
import io.xpipe.app.issue.ErrorEventFactory;
|
||||
import io.xpipe.app.platform.OptionsBuilder;
|
||||
import io.xpipe.app.prefs.PasswordManagerTestComp;
|
||||
import io.xpipe.app.process.CommandBuilder;
|
||||
import io.xpipe.app.process.CommandSupport;
|
||||
import io.xpipe.app.process.ShellControl;
|
||||
@@ -13,7 +15,6 @@ import io.xpipe.app.secret.SecretPromptStrategy;
|
||||
import io.xpipe.app.storage.DataStorage;
|
||||
import io.xpipe.app.util.*;
|
||||
import io.xpipe.core.FilePath;
|
||||
import io.xpipe.core.InPlaceSecretValue;
|
||||
import io.xpipe.core.JacksonMapper;
|
||||
|
||||
import javafx.application.Platform;
|
||||
@@ -30,6 +31,7 @@ import lombok.extern.jackson.Jacksonized;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -44,6 +46,11 @@ public class EnpassPasswordManager implements PasswordManager {
|
||||
private static ShellControl SHELL;
|
||||
private final FilePath vaultPath;
|
||||
|
||||
@Override
|
||||
public PasswordManagerKeyConfiguration getKeyConfiguration() {
|
||||
return PasswordManagerKeyConfiguration.none();
|
||||
}
|
||||
|
||||
private static synchronized ShellControl getOrStartShell() throws Exception {
|
||||
if (SHELL == null) {
|
||||
SHELL = ProcessControlProvider.get().createLocalProcessControl(true);
|
||||
@@ -81,6 +88,8 @@ public class EnpassPasswordManager implements PasswordManager {
|
||||
return new OptionsBuilder()
|
||||
.nameAndDescription("enpassVaultFile")
|
||||
.addComp(comp, prop)
|
||||
.nameAndDescription("passwordManagerTest")
|
||||
.addComp(new PasswordManagerTestComp(true))
|
||||
.bind(
|
||||
() -> {
|
||||
return EnpassPasswordManager.builder()
|
||||
@@ -91,7 +100,7 @@ public class EnpassPasswordManager implements PasswordManager {
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized CredentialResult retrieveCredentials(String key) {
|
||||
public synchronized Result query(String key) {
|
||||
try {
|
||||
CommandSupport.isInLocalPathOrThrow("Enpass CLI", "enpass-cli");
|
||||
} catch (Exception e) {
|
||||
@@ -159,10 +168,9 @@ public class EnpassPasswordManager implements PasswordManager {
|
||||
"Ambiguous item name, multiple password entries match: " + String.join(", ", matches)));
|
||||
}
|
||||
|
||||
var login = json.get(0).required("login").asText();
|
||||
var secret = json.get(0).required("password").asText();
|
||||
return new CredentialResult(
|
||||
!login.isEmpty() ? login : null, !secret.isEmpty() ? InPlaceSecretValue.of(secret) : null);
|
||||
var login = Optional.ofNullable(json.get(0).get("login")).map(JsonNode::textValue).orElse(null);
|
||||
var secret = Optional.ofNullable(json.get(0).get("password")).map(JsonNode::textValue).orElse(null);
|
||||
return Result.of(Credentials.of(login, secret), null);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
ErrorEventFactory.fromThrowable(ex).handle();
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
package io.xpipe.app.pwman;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||
import com.fasterxml.jackson.annotation.JsonTypeName;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
|
||||
import io.xpipe.app.comp.base.SecretFieldComp;
|
||||
import io.xpipe.app.comp.base.TextFieldComp;
|
||||
import io.xpipe.app.core.AppI18n;
|
||||
import io.xpipe.app.ext.ProcessControlProvider;
|
||||
import io.xpipe.app.issue.ErrorEventFactory;
|
||||
import io.xpipe.app.platform.OptionsBuilder;
|
||||
import io.xpipe.app.platform.OptionsChoiceBuilder;
|
||||
import io.xpipe.app.prefs.PasswordManagerTestComp;
|
||||
import io.xpipe.app.process.*;
|
||||
import io.xpipe.app.secret.SecretQueryState;
|
||||
import io.xpipe.app.terminal.TerminalLaunch;
|
||||
import io.xpipe.app.util.AskpassAlert;
|
||||
import io.xpipe.app.util.HttpHelper;
|
||||
import io.xpipe.core.*;
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.ToString;
|
||||
import lombok.Value;
|
||||
import lombok.experimental.NonFinal;
|
||||
import lombok.extern.jackson.Jacksonized;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
@ToString
|
||||
@Jacksonized
|
||||
@JsonTypeName("hashicorpVault")
|
||||
public class HashicorpVaultPasswordManager implements PasswordManager {
|
||||
|
||||
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
|
||||
public interface VaultAuth {
|
||||
|
||||
static List<Class<?>> getClasses() {
|
||||
var l = new ArrayList<Class<?>>();
|
||||
l.add(Existing.class);
|
||||
l.add(Token.class);
|
||||
l.add(AppRole.class);
|
||||
return l;
|
||||
}
|
||||
|
||||
String retrieveToken(HashicorpVaultPasswordManager pwman) throws Exception;
|
||||
|
||||
@JsonTypeName("existing")
|
||||
@Value
|
||||
@Jacksonized
|
||||
@Builder
|
||||
class Existing implements VaultAuth {
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public static String getOptionsNameKey() {
|
||||
return "hashicorpVaultAuthExisting";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String retrieveToken(HashicorpVaultPasswordManager pwman) throws Exception {
|
||||
var sc = getOrStartShell();
|
||||
var script = ShellScript.lines(
|
||||
sc.getShellDialect().getSetEnvironmentVariableCommand("VAULT_ADDR", pwman.getVaultAddress()),
|
||||
pwman.getVaultNamespace() != null ?
|
||||
sc.getShellDialect().getSetEnvironmentVariableCommand("VAULT_NAMESPACE", pwman.getVaultNamespace()) : null,
|
||||
sc.getShellDialect().getEchoCommand(
|
||||
"Your current vault login is expired. Please log in again with your currently selected auth method. The proper environment variables for your vault have already been configured in this session. The command syntax for this is:",
|
||||
false),
|
||||
sc.getShellDialect().getEchoCommand("", false),
|
||||
sc.getShellDialect().getEchoCommand("vault login --method=<auth_method> [optional auth method specific parameters]", false)
|
||||
);
|
||||
var scriptFile = ScriptHelper.createExecScript(sc, script.toString());
|
||||
TerminalLaunch.builder().localScript(ShellScript.of(sc.getShellDialect().terminalInitCommand(sc, scriptFile.toString(), false))).
|
||||
title("Vault login").pauseOnExit(false).launch();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@JsonTypeName("token")
|
||||
@Value
|
||||
@Jacksonized
|
||||
@Builder
|
||||
class Token implements VaultAuth {
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public static String getOptionsNameKey() {
|
||||
return "hashicorpVaultAuthToken";
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public static OptionsBuilder createOptions(Property<Token> p) {
|
||||
var token = new SimpleObjectProperty<>(p.getValue().getToken());
|
||||
return new OptionsBuilder()
|
||||
.nameAndDescription("hashicorpVaultToken")
|
||||
.addComp(new SecretFieldComp(token, true).maxWidth(600), token)
|
||||
.nonNull()
|
||||
.bind(() -> {
|
||||
return Token.builder().token(token.get()).build();
|
||||
}, p);
|
||||
}
|
||||
|
||||
InPlaceSecretValue token;
|
||||
|
||||
@Override
|
||||
public String retrieveToken(HashicorpVaultPasswordManager pwman) throws Exception {
|
||||
if (token == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return token.getSecretValue();
|
||||
}
|
||||
}
|
||||
|
||||
@JsonTypeName("appRole")
|
||||
@Value
|
||||
@Jacksonized
|
||||
@Builder
|
||||
class AppRole implements VaultAuth {
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public static String getOptionsNameKey() {
|
||||
return "hashicorpVaultAuthAppRole";
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public static OptionsBuilder createOptions(Property<AppRole> p) {
|
||||
var roleId = new SimpleStringProperty(p.getValue().getRoleId());
|
||||
var secretId = new SimpleObjectProperty<>(p.getValue().getSecretId());
|
||||
return new OptionsBuilder()
|
||||
.nameAndDescription("hashicorpVaultRoleId")
|
||||
.addString(roleId)
|
||||
.nonNull()
|
||||
.nameAndDescription("hashicorpVaultSecretId")
|
||||
.addComp(new SecretFieldComp(secretId, true).maxWidth(600), secretId)
|
||||
.nonNull()
|
||||
.bind(
|
||||
() -> {
|
||||
return AppRole.builder().roleId(roleId.get()).secretId(secretId.get()).build();
|
||||
},
|
||||
p);
|
||||
}
|
||||
|
||||
String roleId;
|
||||
InPlaceSecretValue secretId;
|
||||
|
||||
@Override
|
||||
public String retrieveToken(HashicorpVaultPasswordManager pwman) throws Exception {
|
||||
if (roleId == null || secretId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = JsonNodeFactory.instance.objectNode();
|
||||
json.put("role_id", roleId);
|
||||
json.put("secret_id", secretId.getSecretValue());
|
||||
var req = HttpRequest.newBuilder().uri(URI.create(pwman.getVaultAddress() + "/v1/auth/approle/login"));
|
||||
req.POST(HttpRequest.BodyPublishers.ofString(json.toPrettyString()));
|
||||
if (pwman.getVaultNamespace() != null) {
|
||||
req.header("X-Vault-Namespace", pwman.getVaultNamespace());
|
||||
}
|
||||
|
||||
var res = HttpHelper.client().send(req.build(), HttpResponse.BodyHandlers.ofString());
|
||||
if (res.statusCode() >= 400) {
|
||||
throw new IOException(res.body());
|
||||
}
|
||||
|
||||
var resJson = JacksonMapper.getDefault().readTree(res.body());
|
||||
if (!resJson.isObject()) {
|
||||
throw new IOException(res.body());
|
||||
}
|
||||
|
||||
var auth = resJson.get("auth");
|
||||
if (auth == null || auth.get("client_token") == null) {
|
||||
throw new IOException(res.body());
|
||||
}
|
||||
|
||||
return auth.required("client_token").textValue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static ShellControl SHELL;
|
||||
|
||||
private final String vaultAddress;
|
||||
private final String vaultNamespace;
|
||||
private final VaultAuth vaultAuth;
|
||||
|
||||
@Override
|
||||
public PasswordManagerKeyConfiguration getKeyConfiguration() {
|
||||
return PasswordManagerKeyConfiguration.none();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public static OptionsBuilder createOptions(Property<HashicorpVaultPasswordManager> p) {
|
||||
var vaultAddress = new SimpleStringProperty(p.getValue().getVaultAddress());
|
||||
var vaultNamespace = new SimpleStringProperty(p.getValue().getVaultNamespace());
|
||||
var vaultAuth = new SimpleObjectProperty<>(p.getValue().getVaultAuth() != null ? p.getValue().getVaultAuth() : new VaultAuth.Existing());
|
||||
|
||||
return new OptionsBuilder()
|
||||
.nameAndDescription("hashicorpVaultAddress")
|
||||
.addComp(
|
||||
new TextFieldComp(vaultAddress)
|
||||
.apply(struc -> {
|
||||
struc.setPromptText("https://my.vault.example.com:8200");
|
||||
})
|
||||
.maxWidth(600),
|
||||
vaultAddress)
|
||||
.nonNull()
|
||||
.nameAndDescription("hashicorpVaultNamespace")
|
||||
.addString(vaultNamespace)
|
||||
.nameAndDescription("hashicorpVaultAuthType")
|
||||
.sub(OptionsChoiceBuilder.builder().available(VaultAuth.getClasses()).property(vaultAuth).build().build(), vaultAuth)
|
||||
.nonNull()
|
||||
.nameAndDescription("passwordManagerTest")
|
||||
.addComp(new PasswordManagerTestComp(true))
|
||||
.bind(
|
||||
() -> {
|
||||
return HashicorpVaultPasswordManager.builder()
|
||||
.vaultAddress(vaultAddress.get())
|
||||
.vaultNamespace(vaultNamespace.get())
|
||||
.vaultAuth(vaultAuth.get())
|
||||
.build();
|
||||
},
|
||||
p);
|
||||
}
|
||||
|
||||
private static synchronized ShellControl getOrStartShell() throws Exception {
|
||||
if (SHELL == null) {
|
||||
SHELL = ProcessControlProvider.get().createLocalProcessControl(true);
|
||||
}
|
||||
SHELL.start();
|
||||
return SHELL;
|
||||
}
|
||||
|
||||
private boolean isLoginValid() throws Exception {
|
||||
var sc = getOrStartShell();
|
||||
var b = CommandBuilder.of().add("vault", "token", "lookup", "-non-interactive", "--format=json");
|
||||
if (getVaultNamespace() != null) {
|
||||
b.fixedEnvironment("VAULT_NAMESPACE", getVaultNamespace());
|
||||
}
|
||||
b.fixedEnvironment("VAULT_ADDR", getVaultAddress());
|
||||
var valid = sc.command(b).sensitive().executeAndCheck();
|
||||
return valid;
|
||||
}
|
||||
|
||||
private boolean login() throws Exception {
|
||||
if (isLoginValid()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
var token = vaultAuth.retrieveToken(HashicorpVaultPasswordManager.this);
|
||||
if (token == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var b = CommandBuilder.of().add("vault", "login", "-non-interactive");
|
||||
if (vaultNamespace != null) {
|
||||
b.fixedEnvironment("VAULT_NAMESPACE", vaultNamespace);
|
||||
}
|
||||
b.addLiteral(token);
|
||||
b.fixedEnvironment("VAULT_ADDR", vaultAddress);
|
||||
getOrStartShell().command(b).sensitive().execute();
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized Result query(String key) {
|
||||
if (vaultAddress == null || vaultAuth == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
CommandSupport.isInLocalPathOrThrow("Hashicorp Vault CLI", "vault");
|
||||
} catch (Exception e) {
|
||||
ErrorEventFactory.fromThrowable(e)
|
||||
.expected()
|
||||
.link("https://developer.hashicorp.com/vault/docs/commands")
|
||||
.handle();
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!login()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var keySplit = key.split(":", 2);
|
||||
if (keySplit.length != 2 || keySplit[0].isEmpty() || keySplit[1].isEmpty()) {
|
||||
throw ErrorEventFactory.expected(new IllegalArgumentException("Invalid secret reference format"));
|
||||
}
|
||||
|
||||
var secretPath = keySplit[0];
|
||||
var keys = Arrays.stream(keySplit[1].split(",")).toList();
|
||||
|
||||
var b = CommandBuilder.of().add("vault", "read", "--format=json", "-non-interactive");
|
||||
if (vaultNamespace != null) {
|
||||
b.fixedEnvironment("VAULT_NAMESPACE", vaultNamespace);
|
||||
}
|
||||
b.addLiteral(secretPath);
|
||||
b.fixedEnvironment("VAULT_ADDR", vaultAddress);
|
||||
|
||||
var out = getOrStartShell().command(b).sensitive().readStdoutOrThrow();
|
||||
var json = JacksonMapper.getDefault().readTree(out);
|
||||
var data = json.get("data");
|
||||
if (data == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var subData = data.get("data");
|
||||
if (subData == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (keys.size() > 1) {
|
||||
var username = Optional.ofNullable(subData.get(keys.getFirst())).map(JsonNode::textValue).orElse(null);
|
||||
var password = Optional.ofNullable(subData.get(keys.get(1))).map(JsonNode::textValue).orElse(null);
|
||||
var creds = Credentials.of(username, password);
|
||||
return Result.of(creds, null);
|
||||
} else {
|
||||
var password = Optional.ofNullable(subData.get(keys.getFirst())).map(JsonNode::textValue).orElse(null);
|
||||
var creds = Credentials.of(null, password);
|
||||
return Result.of(creds, null);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
ErrorEventFactory.fromThrowable(e).handle();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getKeyPlaceholder() {
|
||||
return AppI18n.get("hashicorpVaultPlaceholder");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getWebsite() {
|
||||
return "https://www.hashicorp.com/en/products/vault";
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@ import io.xpipe.app.core.AppI18n;
|
||||
import io.xpipe.app.issue.ErrorEventFactory;
|
||||
import io.xpipe.app.platform.DerivedObservableList;
|
||||
import io.xpipe.app.platform.OptionsBuilder;
|
||||
import io.xpipe.app.platform.OptionsChoiceBuilder;
|
||||
import io.xpipe.app.prefs.AppPrefs;
|
||||
import io.xpipe.app.prefs.PasswordManagerTestComp;
|
||||
import io.xpipe.app.process.LocalShell;
|
||||
import io.xpipe.app.util.*;
|
||||
import io.xpipe.core.OsType;
|
||||
@@ -14,6 +16,7 @@ import io.xpipe.core.OsType;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonTypeName;
|
||||
@@ -38,9 +41,29 @@ public class KeePassXcPasswordManager implements PasswordManager {
|
||||
private static KeePassXcProxyClient client;
|
||||
|
||||
private final List<KeePassXcAssociationKey> associationKeys;
|
||||
private final PasswordManagerKeyStrategy keyStrategy;
|
||||
|
||||
@Override
|
||||
public PasswordManagerKeyConfiguration getKeyConfiguration() {
|
||||
return PasswordManagerKeyConfiguration.of(false, false, false, keyStrategy, null);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public static OptionsBuilder createOptions(Property<KeePassXcPasswordManager> p) {
|
||||
List<Class<?>> strategyList = OsType.ofLocal() == OsType.WINDOWS ?
|
||||
List.of(PasswordManagerKeyStrategy.KeePassXcOpenSshAgent.class, PasswordManagerKeyStrategy.KeePassXcPageant.class) :
|
||||
List.of(PasswordManagerKeyStrategy.KeePassXcOpenSshAgent.class);
|
||||
var keyStrategy = new SimpleObjectProperty<>(p.getValue().getKeyStrategy());
|
||||
var keyStrategyChoice = OptionsChoiceBuilder.builder()
|
||||
.allowNull(true)
|
||||
.available(strategyList)
|
||||
.property(keyStrategy)
|
||||
.customConfiguration(PasswordManagerKeyStrategy.OptionsConfig.builder()
|
||||
.defaultSocketLocation(null)
|
||||
.allowSocketChoice(false)
|
||||
.build())
|
||||
.build();
|
||||
|
||||
var prop = FXCollections.<KeePassXcAssociationKey>observableArrayList();
|
||||
p.subscribe(keePassXcManager -> {
|
||||
DerivedObservableList.wrap(prop, true)
|
||||
@@ -78,9 +101,13 @@ public class KeePassXcPasswordManager implements PasswordManager {
|
||||
}))
|
||||
.hide(Bindings.isEmpty(prop))
|
||||
.addProperty(prop)
|
||||
.nameAndDescription("passwordManagerTest")
|
||||
.addComp(new PasswordManagerTestComp(true))
|
||||
.nameAndDescription("passwordManagerKeyStrategy")
|
||||
.sub(keyStrategyChoice.build(), keyStrategy)
|
||||
.bind(
|
||||
() -> {
|
||||
return new KeePassXcPasswordManager(prop);
|
||||
return new KeePassXcPasswordManager(prop, keyStrategy.getValue());
|
||||
},
|
||||
p);
|
||||
}
|
||||
@@ -228,7 +255,7 @@ public class KeePassXcPasswordManager implements PasswordManager {
|
||||
}
|
||||
|
||||
@Override
|
||||
public CredentialResult retrieveCredentials(String key) {
|
||||
public Result query(String key) {
|
||||
try {
|
||||
var hasScheme = Pattern.compile("^\\w+://").matcher(key).find();
|
||||
var fixedKey = hasScheme ? key : "https://" + key;
|
||||
@@ -241,7 +268,7 @@ public class KeePassXcPasswordManager implements PasswordManager {
|
||||
.getAssociationKeys()
|
||||
: associationKeys;
|
||||
var credentials = client.getCredentials(effectiveKeys, fixedKey);
|
||||
return credentials;
|
||||
return Result.of(credentials, null);
|
||||
} catch (Exception e) {
|
||||
ErrorEventFactory.fromThrowable(e).handle();
|
||||
return null;
|
||||
|
||||
@@ -275,7 +275,7 @@ public class KeePassXcProxyClient {
|
||||
throw new IllegalStateException("Login query failed for an unknown reason");
|
||||
}
|
||||
|
||||
public PasswordManager.CredentialResult getCredentials(List<KeePassXcAssociationKey> associationKeys, String key)
|
||||
public PasswordManager.Credentials getCredentials(List<KeePassXcAssociationKey> associationKeys, String key)
|
||||
throws IOException {
|
||||
var message = getLoginsMessage(associationKeys, key);
|
||||
var tree = JacksonMapper.getDefault().readTree(message);
|
||||
@@ -295,11 +295,9 @@ public class KeePassXcProxyClient {
|
||||
}
|
||||
|
||||
var object = (ObjectNode) tree.required("entries").get(0);
|
||||
var usernameField = object.required("login").asText();
|
||||
var passwordField = object.required("password").asText();
|
||||
return new PasswordManager.CredentialResult(
|
||||
usernameField.isEmpty() ? null : usernameField,
|
||||
passwordField.isEmpty() ? null : InPlaceSecretValue.of(passwordField));
|
||||
var login = Optional.ofNullable(object.get("login")).map(JsonNode::textValue).orElse(null);
|
||||
var secret = Optional.ofNullable(object.get("password")).map(JsonNode::textValue).orElse(null);
|
||||
return PasswordManager.Credentials.of(login, secret);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package io.xpipe.app.pwman;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||
import io.xpipe.app.core.AppI18n;
|
||||
import io.xpipe.app.core.AppSystemInfo;
|
||||
import io.xpipe.app.ext.ProcessControlProvider;
|
||||
import io.xpipe.app.issue.ErrorEventFactory;
|
||||
import io.xpipe.app.platform.OptionsBuilder;
|
||||
import io.xpipe.app.platform.OptionsChoiceBuilder;
|
||||
import io.xpipe.app.prefs.PasswordManagerTestComp;
|
||||
import io.xpipe.app.process.*;
|
||||
import io.xpipe.app.secret.SecretManager;
|
||||
import io.xpipe.app.secret.SecretPromptStrategy;
|
||||
@@ -13,7 +17,7 @@ import io.xpipe.app.util.AskpassAlert;
|
||||
import io.xpipe.core.*;
|
||||
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
@@ -23,12 +27,13 @@ import com.fasterxml.jackson.databind.JsonNode;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.ToString;
|
||||
import lombok.Value;
|
||||
import lombok.extern.jackson.Jacksonized;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
import java.util.UUID;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@JsonTypeName("keeper")
|
||||
@Getter
|
||||
@@ -37,11 +42,376 @@ import java.util.UUID;
|
||||
@Jacksonized
|
||||
public class KeeperPasswordManager implements PasswordManager {
|
||||
|
||||
private static Path getSocketLocation() {
|
||||
var socket = switch (OsType.ofLocal()) {
|
||||
case OsType.Linux ignored -> AppSystemInfo.ofLinux().getConfigDir().resolve("Keeper Password Manager", "keeper-ssh-agent.sock");
|
||||
case OsType.MacOs macOs -> AppSystemInfo.ofMacOs().getUserHome().resolve("Library", "Application Support", "Keeper Password Manager", "keeper-ssh-agent.sock");
|
||||
case OsType.Windows windows -> null;
|
||||
};
|
||||
return socket;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PasswordManagerKeyConfiguration getKeyConfiguration() {
|
||||
var socket = getSocketLocation();
|
||||
return PasswordManagerKeyConfiguration.of(true, true, true, keyStrategy, socket);
|
||||
}
|
||||
|
||||
private final PasswordManagerKeyStrategy keyStrategy;
|
||||
|
||||
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
|
||||
public interface KeeperAuth {
|
||||
|
||||
static List<Class<?>> getClasses() {
|
||||
var l = new ArrayList<Class<?>>();
|
||||
l.add(None.class);
|
||||
l.add(Sms.class);
|
||||
l.add(AuthenticatorApp.class);
|
||||
l.add(SecurityKey.class);
|
||||
l.add(Other.class);
|
||||
return l;
|
||||
}
|
||||
|
||||
|
||||
default List<String> getTotpDurationValues() {
|
||||
var values = List.of("login", "12_hours", "24_hours", "30_days", "forever");
|
||||
return values;
|
||||
}
|
||||
|
||||
String constructKeeperInput(KeeperPasswordManager passwordManager, SecretValue password) throws Exception;
|
||||
|
||||
Duration getCacheDuration();
|
||||
|
||||
Duration getCommandTimeout();
|
||||
|
||||
String cleanMessage(String output);
|
||||
|
||||
@JsonTypeName("sms")
|
||||
@Value
|
||||
@Jacksonized
|
||||
@Builder
|
||||
class Sms implements KeeperAuth {
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public static OptionsBuilder createOptions(Property<Sms> p) {
|
||||
var duration = new SimpleStringProperty(p.getValue().getTotpDuration());
|
||||
return new OptionsBuilder()
|
||||
.name("keeperTotpDuration")
|
||||
.description(AppI18n.observable(
|
||||
"keeperTotpDurationDescription", "login | 12_hours | 24_hours | 30_days | forever"))
|
||||
.addString(duration)
|
||||
.bind(
|
||||
() -> {
|
||||
return Sms.builder()
|
||||
.totpDuration(duration.get())
|
||||
.build();
|
||||
},
|
||||
p);
|
||||
}
|
||||
|
||||
String totpDuration;
|
||||
|
||||
private int getTotpDurationIndex() {
|
||||
var values = getTotpDurationValues();
|
||||
var index = totpDuration != null ? values.indexOf(totpDuration) : -1;
|
||||
return index;
|
||||
}
|
||||
|
||||
private boolean sendInitialSms(SecretValue password) throws Exception {
|
||||
var sc = getOrStartShell();
|
||||
var b = CommandBuilder.of()
|
||||
.add(getExecutable(), "get")
|
||||
.addLiteral("xpipe-test")
|
||||
.add("--password")
|
||||
.addLiteral(password.getSecretValue());
|
||||
var file = sc.getSystemTemporaryDirectory().join("keeper" + Math.abs(new Random().nextInt()) + ".txt");
|
||||
var input = """
|
||||
|
||||
1
|
||||
-
|
||||
q
|
||||
""";
|
||||
sc.view().writeTextFile(file, input);
|
||||
|
||||
var fullB = CommandBuilder.of()
|
||||
.add(sc.getShellDialect() == ShellDialects.CMD ? "type" : "cat")
|
||||
.addFile(file)
|
||||
.add("|")
|
||||
.add(b);
|
||||
|
||||
var command = sc.command(fullB);
|
||||
command.killOnTimeout(CountDown.of().start(30_000));
|
||||
command.sensitive();
|
||||
var success = command.executeAndCheck();
|
||||
// A fail indicates the query went through but the entry was not found
|
||||
if (!success) {
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String constructKeeperInput(KeeperPasswordManager passwordManager, SecretValue password) throws Exception {
|
||||
var sent = sendInitialSms(password);
|
||||
|
||||
var index = getTotpDurationIndex();
|
||||
if (!sent || (passwordManager.isHasCompletedRequestInSession() && index > 0)) {
|
||||
var input = """
|
||||
|
||||
1
|
||||
|
||||
""";
|
||||
return input;
|
||||
} else {
|
||||
var totp = AskpassAlert.queryRaw("Enter Keeper Commander SMS Code", null, true);
|
||||
if (totp.getState() != SecretQueryState.NORMAL) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var input = """
|
||||
|
||||
1%s
|
||||
%s
|
||||
|
||||
""".formatted(
|
||||
index != -1 ? "\n" + getTotpDurationValues().get(index) : "",
|
||||
totp.getSecret().getSecretValue());
|
||||
return input;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Duration getCacheDuration() {
|
||||
return getTotpDurationIndex() < 1 ? Duration.ofDays(1) : Duration.ofSeconds(30);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Duration getCommandTimeout() {
|
||||
return Duration.ofSeconds(25);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String cleanMessage(String output) {
|
||||
return output
|
||||
.replaceFirst("""
|
||||
Select your 2FA method:
|
||||
1. Send SMS Code.+
|
||||
q. Cancel login
|
||||
""", "")
|
||||
.replace(" Invalid entry, additional factors of authentication shown may be configured if not currently enabled.", "")
|
||||
.replace("""
|
||||
2FA Code Duration: Require Every Login.
|
||||
To change duration: 2fa_duration=login|12_hours|24_hours|30_days|forever
|
||||
""", "");
|
||||
}
|
||||
}
|
||||
|
||||
@JsonTypeName("authenticatorApp")
|
||||
@Value
|
||||
@Jacksonized
|
||||
@Builder
|
||||
class AuthenticatorApp implements KeeperAuth {
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public static OptionsBuilder createOptions(Property<AuthenticatorApp> p) {
|
||||
var duration = new SimpleStringProperty(p.getValue().getTotpDuration());
|
||||
return new OptionsBuilder()
|
||||
.name("keeperTotpDuration")
|
||||
.description(AppI18n.observable(
|
||||
"keeperTotpDurationDescription", "login | 12_hours | 24_hours | 30_days | forever"))
|
||||
.addString(duration)
|
||||
.bind(
|
||||
() -> {
|
||||
return AuthenticatorApp.builder()
|
||||
.totpDuration(duration.get())
|
||||
.build();
|
||||
},
|
||||
p);
|
||||
}
|
||||
|
||||
String totpDuration;
|
||||
|
||||
private int getTotpDurationIndex() {
|
||||
var values = getTotpDurationValues();
|
||||
var index = totpDuration != null ? values.indexOf(totpDuration) : -1;
|
||||
return index;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String constructKeeperInput(KeeperPasswordManager passwordManager, SecretValue password) {
|
||||
var index = getTotpDurationIndex();
|
||||
if (passwordManager.isHasCompletedRequestInSession() && index > 0) {
|
||||
var input = """
|
||||
|
||||
1
|
||||
|
||||
""";
|
||||
return input;
|
||||
} else {
|
||||
var totp = AskpassAlert.queryRaw("Enter Keeper 2FA Code", null, true);
|
||||
if (totp.getState() != SecretQueryState.NORMAL) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var input = """
|
||||
|
||||
1%s
|
||||
%s
|
||||
|
||||
""".formatted(
|
||||
index != -1 ? "\n" + getTotpDurationValues().get(index) : "",
|
||||
totp.getSecret().getSecretValue());
|
||||
return input;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Duration getCacheDuration() {
|
||||
return getTotpDurationIndex() < 1 ? Duration.ofDays(1) : Duration.ofSeconds(30);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Duration getCommandTimeout() {
|
||||
return Duration.ofSeconds(25);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String cleanMessage(String output) {
|
||||
return output.replace("""
|
||||
Select your 2FA method:
|
||||
1. TOTP (Google and Microsoft Authenticator) \s
|
||||
q. Cancel login
|
||||
""", "")
|
||||
.replace(
|
||||
"""
|
||||
Selection: Invalid entry, additional factors of authentication shown may be configured if not currently enabled.
|
||||
Selection:\s
|
||||
2FA Code Duration: Require Every Login.
|
||||
To change duration: 2fa_duration=login|12_hours|24_hours|30_days|forever
|
||||
""", "")
|
||||
.replace(
|
||||
"""
|
||||
This account requires 2FA Authentication
|
||||
|
||||
1. TOTP (Google and Microsoft Authenticator) \s
|
||||
q. Quit login attempt and return to Commander prompt
|
||||
""", "");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@JsonTypeName("securityKey")
|
||||
@Value
|
||||
@Jacksonized
|
||||
@Builder
|
||||
class SecurityKey implements KeeperAuth {
|
||||
|
||||
@Override
|
||||
public String constructKeeperInput(KeeperPasswordManager passwordManager, SecretValue password) {
|
||||
var input = """
|
||||
|
||||
1
|
||||
|
||||
""";
|
||||
return input;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Duration getCacheDuration() {
|
||||
return Duration.ofDays(1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Duration getCommandTimeout() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String cleanMessage(String output) {
|
||||
return output.replace("""
|
||||
Select your 2FA method:
|
||||
1. WebAuthN (FIDO2 Security Key) \s
|
||||
q. Cancel login
|
||||
""", "")
|
||||
.replace(" Invalid entry, additional factors of authentication shown may be configured if not currently enabled.", "");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@JsonTypeName("other")
|
||||
@Value
|
||||
@Jacksonized
|
||||
@Builder
|
||||
class Other implements KeeperAuth {
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public static String getOptionsNameKey() {
|
||||
return "keeperOtherAuth";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Duration getCommandTimeout() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String cleanMessage(String output) {
|
||||
return output;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String constructKeeperInput(KeeperPasswordManager passwordManager, SecretValue password) {
|
||||
var input = """
|
||||
|
||||
1
|
||||
|
||||
""";
|
||||
return input;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Duration getCacheDuration() {
|
||||
return Duration.ofDays(1);
|
||||
}
|
||||
}
|
||||
|
||||
@JsonTypeName("none")
|
||||
@Value
|
||||
@Jacksonized
|
||||
@Builder
|
||||
class None implements KeeperAuth {
|
||||
|
||||
@Override
|
||||
public Duration getCommandTimeout() {
|
||||
return Duration.ofSeconds(25);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String cleanMessage(String output) {
|
||||
return output;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String constructKeeperInput(KeeperPasswordManager passwordManager, SecretValue password) {
|
||||
var input = """
|
||||
|
||||
1
|
||||
|
||||
""";
|
||||
return input;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Duration getCacheDuration() {
|
||||
return Duration.ofSeconds(30);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final UUID KEEPER_PASSWORD_ID = UUID.randomUUID();
|
||||
private static ShellControl SHELL;
|
||||
private final Boolean mfa;
|
||||
private final String totpDuration;
|
||||
|
||||
private final KeeperAuth twoFactorAuth;
|
||||
@JsonIgnore
|
||||
private boolean hasCompletedRequestInSession;
|
||||
|
||||
@@ -53,35 +423,50 @@ public class KeeperPasswordManager implements PasswordManager {
|
||||
return SHELL;
|
||||
}
|
||||
|
||||
private String getExecutable(ShellControl sc) {
|
||||
private static String getExecutable() {
|
||||
return OsType.ofLocal() == OsType.WINDOWS ? "keeper-commander" : "keeper";
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public static OptionsBuilder createOptions(Property<KeeperPasswordManager> p) {
|
||||
var mfa = new SimpleBooleanProperty(
|
||||
p.getValue().getMfa() != null ? p.getValue().getMfa() : false);
|
||||
var duration = new SimpleStringProperty(p.getValue().getTotpDuration());
|
||||
var keyStrategy = new SimpleObjectProperty<>(p.getValue().getKeyStrategy());
|
||||
var mfa = new SimpleObjectProperty<>(p.getValue().getTwoFactorAuth() != null ? p.getValue().getTwoFactorAuth() : new KeeperAuth.None());
|
||||
|
||||
var choice = OptionsChoiceBuilder.builder()
|
||||
.allowNull(false)
|
||||
.available(KeeperAuth.getClasses())
|
||||
.property(mfa)
|
||||
.build();
|
||||
var keyStrategyChoice = OptionsChoiceBuilder.builder()
|
||||
.allowNull(true)
|
||||
.available(List.of(PasswordManagerKeyStrategy.Agent.class, PasswordManagerKeyStrategy.Inline.class))
|
||||
.property(keyStrategy)
|
||||
.customConfiguration(PasswordManagerKeyStrategy.OptionsConfig.builder()
|
||||
.defaultSocketLocation(getSocketLocation())
|
||||
.allowSocketChoice(false)
|
||||
.build())
|
||||
.build();
|
||||
|
||||
|
||||
return new OptionsBuilder()
|
||||
.nameAndDescription("keeperUseMfa")
|
||||
.addToggle(mfa)
|
||||
.name("keeperTotpDuration")
|
||||
.description(AppI18n.observable(
|
||||
"keeperTotpDurationDescription", "login | 12_hours | 24_hours | 30_days | forever"))
|
||||
.addString(duration)
|
||||
.hide(mfa.not())
|
||||
.nameAndDescription("keeper2fa")
|
||||
.sub(choice.build(), mfa)
|
||||
.nameAndDescription("passwordManagerTest")
|
||||
.addComp(new PasswordManagerTestComp(true))
|
||||
.nameAndDescription("passwordManagerKeyStrategy")
|
||||
.sub(keyStrategyChoice.build(), keyStrategy)
|
||||
.bind(
|
||||
() -> {
|
||||
return KeeperPasswordManager.builder()
|
||||
.mfa(mfa.get())
|
||||
.totpDuration(duration.get())
|
||||
.twoFactorAuth(mfa.get())
|
||||
.keyStrategy(keyStrategy.get())
|
||||
.build();
|
||||
},
|
||||
p);
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized CredentialResult retrieveCredentials(String key) {
|
||||
public synchronized Result query(String key) {
|
||||
// The copy UID button copies the whole URL in the Keeper UI. Why? ...
|
||||
key = key.replaceFirst("https://\\w+\\.\\w+/vault/#detail/", "");
|
||||
|
||||
@@ -100,7 +485,7 @@ public class KeeperPasswordManager implements PasswordManager {
|
||||
if (!sc.view().fileExists(config)) {
|
||||
var script = ShellScript.lines(
|
||||
sc.getShellDialect().getEchoCommand("Log in into your Keeper account from the CLI:", false),
|
||||
getExecutable(sc) + " login");
|
||||
getExecutable() + " login");
|
||||
TerminalLaunch.builder()
|
||||
.title("Keeper login")
|
||||
.localScript(script)
|
||||
@@ -128,41 +513,19 @@ public class KeeperPasswordManager implements PasswordManager {
|
||||
}
|
||||
|
||||
var b = CommandBuilder.of()
|
||||
.add(getExecutable(sc), "get")
|
||||
.add(getExecutable(), "get")
|
||||
.addLiteral(key)
|
||||
.add("--format", "json", "--unmask")
|
||||
.add("--password")
|
||||
.addLiteral(r.getSecretValue());
|
||||
FilePath file = sc.getSystemTemporaryDirectory().join("keeper" + Math.abs(new Random().nextInt()) + ".txt");
|
||||
if (mfa != null && mfa) {
|
||||
var index = getTotpDurationIndex();
|
||||
if (hasCompletedRequestInSession && index > 0) {
|
||||
var input = """
|
||||
|
||||
1
|
||||
|
||||
""";
|
||||
sc.view().writeTextFile(file, input);
|
||||
} else {
|
||||
var totp = AskpassAlert.queryRaw("Enter Keeper 2FA Code", null, true);
|
||||
if (totp.getState() != SecretQueryState.NORMAL) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var input = """
|
||||
|
||||
1%s
|
||||
%s
|
||||
|
||||
""".formatted(
|
||||
index != -1 ? "\n" + getTotpDurationValues().get(index) : "",
|
||||
totp.getSecret().getSecretValue());
|
||||
sc.view().writeTextFile(file, input);
|
||||
}
|
||||
} else {
|
||||
var input = "\n";
|
||||
sc.view().writeTextFile(file, input);
|
||||
var effectiveTwoFactor = twoFactorAuth != null ? twoFactorAuth : new KeeperAuth.None();
|
||||
var input = effectiveTwoFactor.constructKeeperInput(this, r);
|
||||
if (input == null) {
|
||||
return null;
|
||||
}
|
||||
sc.view().writeTextFile(file, input);
|
||||
|
||||
var fullB = CommandBuilder.of()
|
||||
.add(sc.getShellDialect() == ShellDialects.CMD ? "type" : "cat")
|
||||
@@ -171,7 +534,11 @@ public class KeeperPasswordManager implements PasswordManager {
|
||||
.add(b);
|
||||
var queryCommand = sc.command(fullB);
|
||||
queryCommand.sensitive();
|
||||
queryCommand.killOnTimeout(CountDown.of().start(25_000));
|
||||
|
||||
if (effectiveTwoFactor.getCommandTimeout() != null) {
|
||||
var timeout = effectiveTwoFactor.getCommandTimeout().toMillis();
|
||||
queryCommand.killOnTimeout(CountDown.of().start(timeout));
|
||||
}
|
||||
|
||||
var result = queryCommand.readStdoutAndStderr();
|
||||
var exitCode = queryCommand.getExitCode();
|
||||
@@ -179,26 +546,11 @@ public class KeeperPasswordManager implements PasswordManager {
|
||||
sc.view().deleteFileIfPossible(file);
|
||||
|
||||
var out = result[0]
|
||||
.replace("\r\n", "\n")
|
||||
.replace("""
|
||||
Select your 2FA method:
|
||||
1. TOTP (Google and Microsoft Authenticator) \s
|
||||
q. Cancel login
|
||||
""", "")
|
||||
.replace("""
|
||||
Selection: Invalid entry, additional factors of authentication shown may be configured if not currently enabled.
|
||||
Selection:\s
|
||||
2FA Code Duration: Require Every Login.
|
||||
To change duration: 2fa_duration=login|12_hours|24_hours|30_days|forever
|
||||
""", "")
|
||||
.replace("""
|
||||
This account requires 2FA Authentication
|
||||
|
||||
1. TOTP (Google and Microsoft Authenticator) \s
|
||||
q. Quit login attempt and return to Commander prompt
|
||||
""", "")
|
||||
.replace("Selection:", "")
|
||||
.replace("\r\n", "\n");
|
||||
out = effectiveTwoFactor.cleanMessage(out);
|
||||
out = out.replace("Selection:", "")
|
||||
.strip();
|
||||
|
||||
var err = result[1]
|
||||
.replace("\r\n", "\n")
|
||||
.replace("EOF when reading a line", "")
|
||||
@@ -211,6 +563,8 @@ public class KeeperPasswordManager implements PasswordManager {
|
||||
}
|
||||
|
||||
var outPrefix = jsonStart <= 0 ? out : out.substring(0, jsonStart);
|
||||
outPrefix = outPrefix.lines().filter(s -> !s.isBlank()).map(s -> s.strip()).collect(Collectors.joining("\n"));
|
||||
|
||||
var outJson = jsonStart <= 0
|
||||
? (jsonEnd != -1 ? out.substring(0, jsonEnd) : out)
|
||||
: (jsonEnd != -1 ? out.substring(jsonStart, jsonEnd) : out.substring(jsonStart));
|
||||
@@ -269,43 +623,44 @@ public class KeeperPasswordManager implements PasswordManager {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new CredentialResult(login, password != null ? InPlaceSecretValue.of(password) : null);
|
||||
var creds = Credentials.of(login, password);
|
||||
return Result.of(creds, null);
|
||||
}
|
||||
|
||||
String login = null;
|
||||
String password = null;
|
||||
for (JsonNode field : fields) {
|
||||
var type = field.required("type").asText();
|
||||
if (type.equals("login")) {
|
||||
var v = field.required("value");
|
||||
if (v.size() > 0) {
|
||||
login = v.get(0).asText();
|
||||
}
|
||||
}
|
||||
if (type.equals("password")) {
|
||||
var v = field.required("value");
|
||||
if (v.size() > 0) {
|
||||
password = v.get(0).asText();
|
||||
}
|
||||
}
|
||||
var username = Optional.ofNullable(getValue(tree, "login")).map(n -> n.size() > 0 ? n.get(0).textValue() : null).orElse(null);
|
||||
var password = Optional.ofNullable(getValue(tree, "password")).map(n -> n.size() > 0 ? n.get(0).textValue() : null).orElse(null);
|
||||
var creds = Credentials.of(username, password);
|
||||
|
||||
var keyPairNode = getValue(tree, "keyPair");
|
||||
SshKey sshKey = null;
|
||||
if (keyPairNode != null && keyPairNode.size() > 0) {
|
||||
var publicKey = Optional.ofNullable(keyPairNode.get(0).get("publicKey")).map(JsonNode::textValue).orElse(null);
|
||||
var privateKey = Optional.ofNullable(keyPairNode.get(0).get("privateKey")).map(JsonNode::textValue).orElse(null);
|
||||
sshKey = SshKey.of(null, publicKey, privateKey);
|
||||
}
|
||||
|
||||
return new CredentialResult(login, password != null ? InPlaceSecretValue.of(password) : null);
|
||||
return Result.of(creds, sshKey);
|
||||
} catch (Exception ex) {
|
||||
ErrorEventFactory.fromThrowable(ex).handle();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> getTotpDurationValues() {
|
||||
var values = List.of("login", "12_hours", "24_hours", "30_days", "forever");
|
||||
return values;
|
||||
}
|
||||
private JsonNode getValue(JsonNode node, String name) {
|
||||
var fields = node.get("fields");
|
||||
if (fields == null || !fields.isArray()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
private int getTotpDurationIndex() {
|
||||
var values = getTotpDurationValues();
|
||||
var index = totpDuration != null ? values.indexOf(totpDuration) : -1;
|
||||
return index;
|
||||
for (JsonNode field : fields) {
|
||||
var id = field.get("type");
|
||||
if (id != null && id.textValue().equals(name)) {
|
||||
var value = field.get("value");
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -320,6 +675,7 @@ public class KeeperPasswordManager implements PasswordManager {
|
||||
|
||||
@Override
|
||||
public Duration getCacheDuration() {
|
||||
return (mfa != null && mfa && getTotpDurationIndex() < 1) ? Duration.ofDays(10) : Duration.ofSeconds(30);
|
||||
var effectiveTwoFactor = twoFactorAuth != null ? twoFactorAuth : new KeeperAuth.None();
|
||||
return effectiveTwoFactor.getCacheDuration();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,47 @@
|
||||
package io.xpipe.app.pwman;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import io.xpipe.app.ext.ProcessControlProvider;
|
||||
import io.xpipe.app.issue.ErrorEventFactory;
|
||||
import io.xpipe.app.platform.OptionsBuilder;
|
||||
import io.xpipe.app.prefs.PasswordManagerTestComp;
|
||||
import io.xpipe.app.process.CommandBuilder;
|
||||
import io.xpipe.app.process.CommandSupport;
|
||||
import io.xpipe.app.process.ShellControl;
|
||||
import io.xpipe.app.process.ShellScript;
|
||||
import io.xpipe.app.terminal.TerminalLaunch;
|
||||
import io.xpipe.app.util.*;
|
||||
import io.xpipe.core.InPlaceSecretValue;
|
||||
import io.xpipe.core.JacksonMapper;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonTypeName;
|
||||
import javafx.beans.property.Property;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.extern.jackson.Jacksonized;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Optional;
|
||||
|
||||
@JsonTypeName("lastpass")
|
||||
@Builder
|
||||
@Jacksonized
|
||||
@Getter
|
||||
public class LastpassPasswordManager implements PasswordManager {
|
||||
|
||||
@Override
|
||||
public PasswordManagerKeyConfiguration getKeyConfiguration() {
|
||||
return PasswordManagerKeyConfiguration.none();
|
||||
}
|
||||
|
||||
private static ShellControl SHELL;
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public static OptionsBuilder createOptions(Property<LastpassPasswordManager> p) {
|
||||
return new OptionsBuilder()
|
||||
.nameAndDescription("passwordManagerTest")
|
||||
.addComp(new PasswordManagerTestComp(true));
|
||||
}
|
||||
|
||||
private static synchronized ShellControl getOrStartShell() throws Exception {
|
||||
if (SHELL == null) {
|
||||
SHELL = ProcessControlProvider.get().createLocalProcessControl(true);
|
||||
@@ -29,7 +51,7 @@ public class LastpassPasswordManager implements PasswordManager {
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized CredentialResult retrieveCredentials(String key) {
|
||||
public synchronized Result query(String key) {
|
||||
try {
|
||||
CommandSupport.isInLocalPathOrThrow("LastPass CLI", "lpass");
|
||||
} catch (Exception e) {
|
||||
@@ -80,11 +102,9 @@ public class LastpassPasswordManager implements PasswordManager {
|
||||
"Ambiguous item name, multiple password entries match: " + String.join(", ", matches)));
|
||||
}
|
||||
|
||||
var username = tree.get(0).required("username").asText();
|
||||
var password = tree.get(0).required("password").asText();
|
||||
return new CredentialResult(
|
||||
!username.isEmpty() ? username : null,
|
||||
!password.isEmpty() ? InPlaceSecretValue.of(password) : null);
|
||||
var login = Optional.ofNullable(tree.get(0).get("username")).map(JsonNode::textValue).orElse(null);
|
||||
var secret = Optional.ofNullable(tree.get(0).get("password")).map(JsonNode::textValue).orElse(null);
|
||||
return Result.of(Credentials.of(login, secret), null);
|
||||
} catch (Exception ex) {
|
||||
ErrorEventFactory.fromThrowable(ex).handle();
|
||||
return null;
|
||||
|
||||
@@ -1,24 +1,86 @@
|
||||
package io.xpipe.app.pwman;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import io.xpipe.app.core.AppI18n;
|
||||
import io.xpipe.app.core.AppSystemInfo;
|
||||
import io.xpipe.app.ext.ProcessControlProvider;
|
||||
import io.xpipe.app.issue.ErrorEventFactory;
|
||||
import io.xpipe.app.platform.OptionsBuilder;
|
||||
import io.xpipe.app.platform.OptionsChoiceBuilder;
|
||||
import io.xpipe.app.prefs.PasswordManagerTestComp;
|
||||
import io.xpipe.app.process.CommandBuilder;
|
||||
import io.xpipe.app.process.CommandSupport;
|
||||
import io.xpipe.app.process.ProcessOutputException;
|
||||
import io.xpipe.app.process.ShellControl;
|
||||
import io.xpipe.app.util.DocumentationLink;
|
||||
import io.xpipe.core.InPlaceSecretValue;
|
||||
import io.xpipe.app.util.ThreadHelper;
|
||||
import io.xpipe.core.JacksonMapper;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonTypeName;
|
||||
import io.xpipe.core.OsType;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.collections.FXCollections;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.extern.jackson.Jacksonized;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@JsonTypeName("onePassword")
|
||||
@Builder
|
||||
@Jacksonized
|
||||
@Getter
|
||||
public class OnePasswordManager implements PasswordManager {
|
||||
|
||||
@Override
|
||||
public PasswordManagerKeyConfiguration getKeyConfiguration() {
|
||||
return PasswordManagerKeyConfiguration.of(true, false, true, keyStrategy, getSocketLocation());
|
||||
}
|
||||
|
||||
private static ShellControl SHELL;
|
||||
private static final MapProperty<String, String> availableAccounts = new SimpleMapProperty<>(FXCollections.observableMap(new LinkedHashMap<>()));
|
||||
|
||||
private final String account;
|
||||
private final PasswordManagerKeyStrategy keyStrategy;
|
||||
|
||||
private static Path getSocketLocation() {
|
||||
var socket = switch (OsType.ofLocal()) {
|
||||
case OsType.Linux ignored -> AppSystemInfo.ofLinux().getUserHome().resolve(".1password", "agent.sock");
|
||||
case OsType.MacOs macOs -> AppSystemInfo.ofMacOs().getUserHome().resolve("Library", "Group Containers", "2BUA8C4S2C.com.1password", "t", "agent.sock");
|
||||
case OsType.Windows windows -> null;
|
||||
};
|
||||
return socket;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public static OptionsBuilder createOptions(Property<OnePasswordManager> p) {
|
||||
var account = new SimpleStringProperty(p.getValue().getAccount());
|
||||
var keyStrategy = new SimpleObjectProperty<>(p.getValue().getKeyStrategy());
|
||||
|
||||
var keyStrategyChoice = OptionsChoiceBuilder.builder()
|
||||
.allowNull(true)
|
||||
.available(List.of(PasswordManagerKeyStrategy.Agent.class))
|
||||
.property(keyStrategy)
|
||||
.customConfiguration(PasswordManagerKeyStrategy.OptionsConfig.builder()
|
||||
.defaultSocketLocation(getSocketLocation())
|
||||
.allowSocketChoice(false)
|
||||
.build())
|
||||
.build();
|
||||
|
||||
return new OptionsBuilder()
|
||||
.nameAndDescription("onePasswordManagerAccount")
|
||||
.addString(account)
|
||||
.hide(account.isNull().and(availableAccounts.emptyProperty()))
|
||||
.nameAndDescription("passwordManagerTest")
|
||||
.addComp(new PasswordManagerTestComp(true))
|
||||
.nameAndDescription("passwordManagerKeyStrategy")
|
||||
.sub(keyStrategyChoice.build(), keyStrategy)
|
||||
.bind(() -> {
|
||||
return OnePasswordManager.builder().keyStrategy(keyStrategy.getValue()).account(account.get()).build();
|
||||
}, p);
|
||||
}
|
||||
|
||||
private static synchronized ShellControl getOrStartShell() throws Exception {
|
||||
if (SHELL == null) {
|
||||
@@ -28,8 +90,63 @@ public class OnePasswordManager implements PasswordManager {
|
||||
return SHELL;
|
||||
}
|
||||
|
||||
private SequencedMap<String, String> listAccounts() throws Exception {
|
||||
var out = getOrStartShell().command(CommandBuilder.of().add("op", "account", "list", "--format", "json")).sensitive().readStdoutOrThrow();
|
||||
var json = JacksonMapper.getDefault().readTree(out);
|
||||
if (!json.isArray()) {
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
|
||||
var emails = new LinkedHashMap<String, String>();
|
||||
for (JsonNode jsonNode : json) {
|
||||
emails.put(jsonNode.required("email").textValue(), jsonNode.required("user_uuid").textValue());
|
||||
}
|
||||
return emails;
|
||||
}
|
||||
|
||||
private String getActiveAccount() throws Exception {
|
||||
if (!availableAccounts.isEmpty()) {
|
||||
if (account != null) {
|
||||
if (availableAccounts.get(account) == null) {
|
||||
throw ErrorEventFactory.expected(new IllegalArgumentException("Account " + account + " is not registered to the 1password CLI"));
|
||||
}
|
||||
return availableAccounts.get(account);
|
||||
}
|
||||
|
||||
var first = availableAccounts.entrySet().iterator().next().getValue();
|
||||
return first;
|
||||
}
|
||||
|
||||
var accounts = listAccounts();
|
||||
// Running commands instantly after each other breaks 1password
|
||||
ThreadHelper.sleep(1500);
|
||||
availableAccounts.clear();
|
||||
availableAccounts.putAll(accounts);
|
||||
if (availableAccounts.isEmpty()) {
|
||||
throw ErrorEventFactory.expected(new IllegalStateException("No accounts are registered to the 1password CLI"));
|
||||
}
|
||||
return availableAccounts.entrySet().iterator().next().getValue();
|
||||
}
|
||||
|
||||
private String getValue(JsonNode node, String name) {
|
||||
var fields = node.get("fields");
|
||||
if (fields == null || !fields.isArray()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (JsonNode field : fields) {
|
||||
var id = field.get("id");
|
||||
if (id != null && id.textValue().equals(name)) {
|
||||
var value = field.get("value");
|
||||
return value != null ? value.textValue() : null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized CredentialResult retrieveCredentials(String key) {
|
||||
public synchronized Result query(String key) {
|
||||
try {
|
||||
CommandSupport.isInLocalPathOrThrow("1Password CLI", "op");
|
||||
} catch (Exception e) {
|
||||
@@ -52,25 +169,30 @@ public class OnePasswordManager implements PasswordManager {
|
||||
}
|
||||
|
||||
try {
|
||||
var account = getActiveAccount();
|
||||
var b = CommandBuilder.of()
|
||||
.add("op", "item", "get")
|
||||
.addLiteral(name)
|
||||
.add("--format", "json", "--fields", "username,password");
|
||||
.add("--account").addLiteral(account)
|
||||
.add("--format", "json");
|
||||
if (vault != null) {
|
||||
b.add("--vault").addLiteral(vault);
|
||||
}
|
||||
|
||||
var r = getOrStartShell().command(b).sensitive().readStdoutOrThrow();
|
||||
var tree = JacksonMapper.getDefault().readTree(r);
|
||||
if (!tree.isArray() || tree.size() != 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var username = tree.get(0).get("value");
|
||||
var password = tree.get(1).get("value");
|
||||
return new CredentialResult(
|
||||
username != null ? username.asText() : null,
|
||||
password != null ? InPlaceSecretValue.of(password.asText()) : null);
|
||||
|
||||
var username = getValue(tree, "username");
|
||||
var password = getValue(tree, "password");
|
||||
var creds = Credentials.of(username, password);
|
||||
|
||||
var fingerprint = getValue(tree, "fingerprint");
|
||||
var publicKey = getValue(tree, "public_key");
|
||||
var privateKey = getValue(tree, "private_key");
|
||||
var sshKey = SshKey.of(fingerprint, publicKey, privateKey);
|
||||
|
||||
return Result.of(creds, sshKey);
|
||||
} catch (Exception e) {
|
||||
var event = ErrorEventFactory.fromThrowable(e);
|
||||
if (!key.startsWith("op://")
|
||||
|
||||
@@ -9,6 +9,7 @@ import io.xpipe.app.ext.ProcessControlProvider;
|
||||
import io.xpipe.app.issue.ErrorEventFactory;
|
||||
import io.xpipe.app.platform.OptionsBuilder;
|
||||
import io.xpipe.app.prefs.AppPrefs;
|
||||
import io.xpipe.app.prefs.PasswordManagerTestComp;
|
||||
import io.xpipe.app.process.CommandBuilder;
|
||||
import io.xpipe.app.process.CommandSupport;
|
||||
import io.xpipe.app.process.ShellControl;
|
||||
@@ -45,6 +46,11 @@ public class PassboltPasswordManager implements PasswordManager {
|
||||
private final InPlaceSecretValue passphrase;
|
||||
private final Path privateKey;
|
||||
|
||||
@Override
|
||||
public PasswordManagerKeyConfiguration getKeyConfiguration() {
|
||||
return PasswordManagerKeyConfiguration.none();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public static OptionsBuilder createOptions(Property<PassboltPasswordManager> p) {
|
||||
var serverUrl = new SimpleStringProperty(p.getValue().getServerUrl());
|
||||
@@ -76,6 +82,8 @@ public class PassboltPasswordManager implements PasswordManager {
|
||||
.nameAndDescription("passboltPrivateKey")
|
||||
.addComp(chooser, privateKey)
|
||||
.nonNull()
|
||||
.nameAndDescription("passwordManagerTest")
|
||||
.addComp(new PasswordManagerTestComp(true))
|
||||
.bind(
|
||||
() -> {
|
||||
return PassboltPasswordManager.builder()
|
||||
@@ -123,7 +131,11 @@ public class PassboltPasswordManager implements PasswordManager {
|
||||
private boolean mfaTotpInteractiveConfigured;
|
||||
|
||||
@Override
|
||||
public synchronized CredentialResult retrieveCredentials(String key) {
|
||||
public synchronized Result query(String key) {
|
||||
if (serverUrl == null || passphrase == null || privateKey == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
CommandSupport.isInLocalPathOrThrow("Passbolt CLI", "passbolt");
|
||||
} catch (Exception e) {
|
||||
@@ -174,8 +186,7 @@ public class PassboltPasswordManager implements PasswordManager {
|
||||
var r = JacksonMapper.getDefault().readTree(cmd.readStdoutOrThrow());
|
||||
var username = r.required("username").asText();
|
||||
var password = r.required("password").asText();
|
||||
return new CredentialResult(
|
||||
username.isEmpty() ? null : username, password.isEmpty() ? null : InPlaceSecretValue.of(password));
|
||||
return Result.of(Credentials.of(username, password), null);
|
||||
} catch (Exception e) {
|
||||
ErrorEventFactory.fromThrowable(e).handle();
|
||||
return null;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package io.xpipe.app.pwman;
|
||||
|
||||
import io.xpipe.core.InPlaceSecretValue;
|
||||
import io.xpipe.core.OsType;
|
||||
import io.xpipe.core.SecretValue;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||
import lombok.Value;
|
||||
import lombok.*;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
@@ -13,17 +15,37 @@ import java.util.List;
|
||||
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
|
||||
public interface PasswordManager {
|
||||
|
||||
@SneakyThrows
|
||||
static boolean isPasswordManagerSshAgent(String s) {
|
||||
for (Class<?> c : PasswordManager.getClasses()) {
|
||||
var bm = c.getDeclaredMethod("builder");
|
||||
bm.setAccessible(true);
|
||||
var b = bm.invoke(null);
|
||||
|
||||
var m = b.getClass().getDeclaredMethod("build");
|
||||
m.setAccessible(true);
|
||||
var defValue = (PasswordManager) c.cast(m.invoke(b));
|
||||
var config = defValue.getKeyConfiguration();
|
||||
if (config.getDefaultSocketLocation() != null && config.getDefaultSocketLocation().toString().equals(s)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static List<Class<?>> getClasses() {
|
||||
var l = new ArrayList<Class<?>>();
|
||||
l.add(OnePasswordManager.class);
|
||||
l.add(KeePassXcPasswordManager.class);
|
||||
l.add(BitwardenPasswordManager.class);
|
||||
l.add(DashlanePasswordManager.class);
|
||||
l.add(KeeperPasswordManager.class);
|
||||
// l.add(ProtonPasswordManager.class);
|
||||
l.add(HashicorpVaultPasswordManager.class);
|
||||
if (OsType.ofLocal() != OsType.WINDOWS) {
|
||||
l.add(LastpassPasswordManager.class);
|
||||
l.add(EnpassPasswordManager.class);
|
||||
}
|
||||
l.add(KeeperPasswordManager.class);
|
||||
l.add(DashlanePasswordManager.class);
|
||||
l.add(PsonoPasswordManager.class);
|
||||
l.add(PassboltPasswordManager.class);
|
||||
if (OsType.ofLocal() == OsType.WINDOWS) {
|
||||
@@ -33,18 +55,65 @@ public interface PasswordManager {
|
||||
return l;
|
||||
}
|
||||
|
||||
CredentialResult retrieveCredentials(String key);
|
||||
Result query(String key);
|
||||
|
||||
String getKeyPlaceholder();
|
||||
|
||||
String getWebsite();
|
||||
|
||||
PasswordManagerKeyConfiguration getKeyConfiguration();
|
||||
|
||||
default Duration getCacheDuration() {
|
||||
return Duration.ofSeconds(30);
|
||||
}
|
||||
|
||||
@Value
|
||||
class CredentialResult {
|
||||
@Getter
|
||||
@FieldDefaults(level = AccessLevel.PRIVATE)
|
||||
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
class Result {
|
||||
|
||||
public static Result of(Credentials creds, SshKey sshKey) {
|
||||
if (creds == null && sshKey == null) {
|
||||
return null;
|
||||
}
|
||||
return new Result(creds, sshKey);
|
||||
}
|
||||
|
||||
Credentials credentials;
|
||||
SshKey sshKey;
|
||||
}
|
||||
|
||||
@Getter
|
||||
@FieldDefaults(level = AccessLevel.PRIVATE)
|
||||
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
class SshKey {
|
||||
|
||||
public static SshKey of(String fingerprint, String publicKey, String privateKey) {
|
||||
if (fingerprint == null && publicKey == null && privateKey == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SshKey(fingerprint, publicKey, privateKey != null ? InPlaceSecretValue.of(privateKey) : null);
|
||||
}
|
||||
|
||||
String fingerprint;
|
||||
String publicKey;
|
||||
SecretValue privateKey;
|
||||
}
|
||||
|
||||
@Getter
|
||||
@FieldDefaults(level = AccessLevel.PRIVATE)
|
||||
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
class Credentials {
|
||||
|
||||
public static Credentials of(String username, String password) {
|
||||
if (username == null && password == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Credentials(username != null && !username.isEmpty() ? username : null,
|
||||
password != null && !password.isEmpty() ? InPlaceSecretValue.of(password) : null);
|
||||
}
|
||||
|
||||
String username;
|
||||
SecretValue password;
|
||||
|
||||
@@ -13,8 +13,6 @@ import io.xpipe.app.prefs.ExternalApplicationHelper;
|
||||
import io.xpipe.app.process.ShellControl;
|
||||
import io.xpipe.app.process.ShellScript;
|
||||
import io.xpipe.app.storage.DataStorage;
|
||||
import io.xpipe.core.InPlaceSecretValue;
|
||||
import io.xpipe.core.SecretValue;
|
||||
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
@@ -34,6 +32,11 @@ public class PasswordManagerCommand implements PasswordManager {
|
||||
private static ShellControl SHELL;
|
||||
ShellScript script;
|
||||
|
||||
@Override
|
||||
public PasswordManagerKeyConfiguration getKeyConfiguration() {
|
||||
return PasswordManagerKeyConfiguration.none();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
static OptionsBuilder createOptions(Property<PasswordManagerCommand> property) {
|
||||
var template = new SimpleObjectProperty<PasswordManagerCommandTemplate>();
|
||||
@@ -77,7 +80,7 @@ public class PasswordManagerCommand implements PasswordManager {
|
||||
return SHELL;
|
||||
}
|
||||
|
||||
public static SecretValue retrieveWithCommand(String cmd) {
|
||||
public static String retrieveWithCommand(String cmd) {
|
||||
try (var cc = getOrStartShell().command(cmd).start()) {
|
||||
var out = cc.readStdoutOrThrow();
|
||||
|
||||
@@ -89,7 +92,7 @@ public class PasswordManagerCommand implements PasswordManager {
|
||||
.orElse("");
|
||||
}
|
||||
|
||||
return InPlaceSecretValue.of(out);
|
||||
return out;
|
||||
} catch (Exception ex) {
|
||||
ErrorEventFactory.fromThrowable("Unable to retrieve password with command " + cmd, ex)
|
||||
.expected()
|
||||
@@ -99,14 +102,14 @@ public class PasswordManagerCommand implements PasswordManager {
|
||||
}
|
||||
|
||||
@Override
|
||||
public CredentialResult retrieveCredentials(String key) {
|
||||
public Result query(String key) {
|
||||
if (script == null || script.getValue().isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var cmd = ExternalApplicationHelper.replaceVariableArgument(script.getValue(), "KEY", key);
|
||||
var secret = retrieveWithCommand(cmd);
|
||||
return new CredentialResult(null, secret);
|
||||
return Result.of(Credentials.of(null, secret), null);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package io.xpipe.app.pwman;
|
||||
|
||||
import io.xpipe.app.cred.SshIdentityAgentStrategy;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
public interface PasswordManagerKeyConfiguration {
|
||||
|
||||
static PasswordManagerKeyConfiguration of(boolean inline, boolean joined, boolean supportsAgentKeyNames, PasswordManagerKeyStrategy strategy, Path socket) {
|
||||
return new PasswordManagerKeyConfiguration() {
|
||||
@Override
|
||||
public boolean useInline() {
|
||||
return (strategy == null || !strategy.useAgent()) && inline && joined;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean useAgent() {
|
||||
return strategy != null && strategy.useAgent();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsAgentKeyNames() {
|
||||
return supportsAgentKeyNames;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SshIdentityAgentStrategy getSshIdentityStrategy(String publicKey, boolean forward) {
|
||||
return strategy.getSshIdentityStrategy(publicKey, forward);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Path getDefaultSocketLocation() {
|
||||
return socket;
|
||||
}
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
static PasswordManagerKeyConfiguration none() {
|
||||
return new PasswordManagerKeyConfiguration() {
|
||||
@Override
|
||||
public boolean useInline() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean useAgent() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsAgentKeyNames() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SshIdentityAgentStrategy getSshIdentityStrategy(String publicKey, boolean forward) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Path getDefaultSocketLocation() {
|
||||
return null;
|
||||
}
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
boolean useInline();
|
||||
|
||||
boolean useAgent();
|
||||
|
||||
boolean supportsAgentKeyNames();
|
||||
|
||||
SshIdentityAgentStrategy getSshIdentityStrategy(String publicKey, boolean forward);
|
||||
|
||||
Path getDefaultSocketLocation();
|
||||
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package io.xpipe.app.pwman;
|
||||
|
||||
import io.xpipe.app.comp.base.ContextualFileReferenceChoiceComp;
|
||||
import io.xpipe.app.cred.*;
|
||||
import io.xpipe.app.platform.OptionsBuilder;
|
||||
import io.xpipe.app.process.CommandBuilder;
|
||||
import io.xpipe.app.process.ShellControl;
|
||||
import io.xpipe.app.storage.DataStorage;
|
||||
import io.xpipe.core.FilePath;
|
||||
import io.xpipe.core.KeyValue;
|
||||
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||
import com.fasterxml.jackson.annotation.JsonTypeName;
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
import lombok.extern.jackson.Jacksonized;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
|
||||
public interface PasswordManagerKeyStrategy {
|
||||
|
||||
@Value
|
||||
@Builder
|
||||
public class OptionsConfig {
|
||||
|
||||
boolean allowSocketChoice;
|
||||
Path defaultSocketLocation;
|
||||
}
|
||||
|
||||
@JsonTypeName("inline")
|
||||
@Value
|
||||
@Jacksonized
|
||||
@Builder
|
||||
class Inline implements PasswordManagerKeyStrategy {
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public static String getOptionsNameKey() {
|
||||
return "inlineKey";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean useAgent() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SshIdentityAgentStrategy getSshIdentityStrategy(String publicKey, boolean forward) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@JsonTypeName("agent")
|
||||
@Value
|
||||
@Jacksonized
|
||||
@Builder
|
||||
class Agent implements PasswordManagerKeyStrategy {
|
||||
|
||||
FilePath socket;
|
||||
|
||||
@Override
|
||||
public boolean useAgent() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public static String getOptionsNameKey() {
|
||||
return "keyAgent";
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
static OptionsBuilder createOptions(Property<Agent> property, OptionsConfig config) {
|
||||
var customSocket = new SimpleObjectProperty<>(property.getValue().getSocket());
|
||||
if (config.getDefaultSocketLocation() != null && customSocket.get() == null) {
|
||||
customSocket.set(FilePath.of(config.getDefaultSocketLocation()));
|
||||
}
|
||||
|
||||
var choice = new ContextualFileReferenceChoiceComp(
|
||||
new ReadOnlyObjectWrapper<>(DataStorage.get().local().ref()),
|
||||
customSocket,
|
||||
null,
|
||||
List.of(),
|
||||
e -> e.equals(DataStorage.get().local()),
|
||||
false);
|
||||
if (config.getDefaultSocketLocation() != null) {
|
||||
choice.setPrompt(new ReadOnlyObjectWrapper<>(FilePath.of(config.getDefaultSocketLocation())));
|
||||
}
|
||||
if (!config.isAllowSocketChoice()) {
|
||||
choice.disable();
|
||||
}
|
||||
choice.style("agent-socket-choice");
|
||||
|
||||
return new OptionsBuilder()
|
||||
.addComp(new SshAgentTestComp(Bindings.createObjectBinding(() -> {
|
||||
return property.getValue().getSshIdentityStrategy(null, false);
|
||||
}, property)))
|
||||
.nameAndDescription("passwordManagerSshAgentSocket")
|
||||
.addComp(choice, customSocket)
|
||||
.hide(!config.isAllowSocketChoice() && config.getDefaultSocketLocation() == null)
|
||||
.bind(
|
||||
() -> Agent.builder()
|
||||
.socket(customSocket.get())
|
||||
.build(),
|
||||
property);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SshIdentityAgentStrategy getSshIdentityStrategy(String publicKey, boolean forward) {
|
||||
return new SshIdentityAgentStrategy() {
|
||||
@Override
|
||||
public void prepareParent(ShellControl parent) throws Exception {
|
||||
if (parent.isLocal()) {
|
||||
SshIdentityStateManager.prepareLocalExternalAgent(socket);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public FilePath determinetAgentSocketLocation(ShellControl parent) throws Exception {
|
||||
return socket != null ? socket.resolveTildeHome(parent.view().userHome()) : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void buildCommand(CommandBuilder builder) {}
|
||||
|
||||
@Override
|
||||
public List<KeyValue> configOptions(ShellControl sc) throws Exception {
|
||||
var file = SshIdentityStrategy.getPublicKeyPath(sc, publicKey);
|
||||
var l = new ArrayList<>(List.of(
|
||||
new KeyValue("IdentitiesOnly", file.isPresent() ? "yes" : "no"),
|
||||
new KeyValue("ForwardAgent", forward ? "yes" : "no"),
|
||||
new KeyValue("IdentityFile", file.isPresent() ? file.get().toString() : "none"),
|
||||
new KeyValue("PKCS11Provider", "none")));
|
||||
if (socket != null) {
|
||||
l.add(new KeyValue("IdentityAgent", "\"" + socket + "\""));
|
||||
}
|
||||
return l;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PublicKeyStrategy getPublicKeyStrategy() {
|
||||
return PublicKeyStrategy.Fixed.of(publicKey);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@JsonTypeName("keePassXcOpenSshAgent")
|
||||
@Value
|
||||
@Jacksonized
|
||||
@Builder
|
||||
class KeePassXcOpenSshAgent implements PasswordManagerKeyStrategy {
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
static OptionsBuilder createOptions(Property<KeePassXcOpenSshAgent> property) {
|
||||
return new OptionsBuilder()
|
||||
.addComp(new SshAgentTestComp(Bindings.createObjectBinding(() -> {
|
||||
return property.getValue().getSshIdentityStrategy(null, false);
|
||||
}, property)))
|
||||
.bind(
|
||||
() -> KeePassXcOpenSshAgent.builder().build(),
|
||||
property);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean useAgent() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SshIdentityAgentStrategy getSshIdentityStrategy(String publicKey, boolean forward) {
|
||||
return OpenSshAgentStrategy.builder().build();
|
||||
}
|
||||
}
|
||||
|
||||
@JsonTypeName("keePassXcPageant")
|
||||
@Value
|
||||
@Jacksonized
|
||||
@Builder
|
||||
class KeePassXcPageant implements PasswordManagerKeyStrategy {
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
static OptionsBuilder createOptions(Property<KeePassXcPageant> property) {
|
||||
return new OptionsBuilder()
|
||||
.addComp(new SshAgentTestComp(Bindings.createObjectBinding(() -> {
|
||||
return property.getValue().getSshIdentityStrategy(null, false);
|
||||
}, property)))
|
||||
.bind(
|
||||
() -> KeePassXcPageant.builder().build(),
|
||||
property);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean useAgent() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SshIdentityAgentStrategy getSshIdentityStrategy(String publicKey, boolean forward) {
|
||||
return PageantStrategy.builder().build();
|
||||
}
|
||||
}
|
||||
|
||||
boolean useAgent();
|
||||
|
||||
SshIdentityAgentStrategy getSshIdentityStrategy(String publicKey, boolean forward);
|
||||
|
||||
static List<Class<?>> getClasses() {
|
||||
var l = new ArrayList<Class<?>>();
|
||||
l.add(Agent.class);
|
||||
l.add(KeePassXcOpenSshAgent.class);
|
||||
l.add(KeePassXcPageant.class);
|
||||
l.add(Inline.class);
|
||||
return l;
|
||||
}
|
||||
}
|
||||
131
app/src/main/java/io/xpipe/app/pwman/ProtonPasswordManager.java
Normal file
131
app/src/main/java/io/xpipe/app/pwman/ProtonPasswordManager.java
Normal file
@@ -0,0 +1,131 @@
|
||||
package io.xpipe.app.pwman;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonTypeName;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import io.xpipe.app.core.AppCache;
|
||||
import io.xpipe.app.core.AppI18n;
|
||||
import io.xpipe.app.core.AppSystemInfo;
|
||||
import io.xpipe.app.ext.ProcessControlProvider;
|
||||
import io.xpipe.app.issue.ErrorEventFactory;
|
||||
import io.xpipe.app.platform.OptionsBuilder;
|
||||
import io.xpipe.app.platform.OptionsChoiceBuilder;
|
||||
import io.xpipe.app.prefs.PasswordManagerTestComp;
|
||||
import io.xpipe.app.process.*;
|
||||
import io.xpipe.app.terminal.TerminalLaunch;
|
||||
import io.xpipe.app.util.DocumentationLink;
|
||||
import io.xpipe.app.util.ThreadHelper;
|
||||
import io.xpipe.core.JacksonMapper;
|
||||
import io.xpipe.core.OsType;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.collections.FXCollections;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.extern.jackson.Jacksonized;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.SequencedMap;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@JsonTypeName("protonPass")
|
||||
@Builder
|
||||
@Jacksonized
|
||||
@Getter
|
||||
public class ProtonPasswordManager implements PasswordManager {
|
||||
|
||||
@Override
|
||||
public PasswordManagerKeyConfiguration getKeyConfiguration() {
|
||||
return PasswordManagerKeyConfiguration.of(false, false, true, keyStrategy, getSocketLocation());
|
||||
}
|
||||
|
||||
private static ShellControl SHELL;
|
||||
|
||||
private final PasswordManagerKeyStrategy keyStrategy;
|
||||
|
||||
private static Path getSocketLocation() {
|
||||
var socket = switch (OsType.ofLocal()) {
|
||||
case OsType.Linux ignored -> AppSystemInfo.ofLinux().getUserHome().resolve(".1password", "agent.sock");
|
||||
case OsType.MacOs macOs -> AppSystemInfo.ofMacOs().getUserHome().resolve("Library", "Group Containers", "2BUA8C4S2C.com.1password", "t", "agent.sock");
|
||||
case OsType.Windows windows -> null;
|
||||
};
|
||||
return socket;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public static OptionsBuilder createOptions(Property<ProtonPasswordManager> p) {
|
||||
var keyStrategy = new SimpleObjectProperty<>(p.getValue().getKeyStrategy());
|
||||
|
||||
var keyStrategyChoice = OptionsChoiceBuilder.builder()
|
||||
.allowNull(true)
|
||||
.available(List.of(PasswordManagerKeyStrategy.Agent.class))
|
||||
.property(keyStrategy)
|
||||
.customConfiguration(PasswordManagerKeyStrategy.OptionsConfig.builder()
|
||||
.defaultSocketLocation(getSocketLocation())
|
||||
.allowSocketChoice(false)
|
||||
.build())
|
||||
.build();
|
||||
|
||||
return new OptionsBuilder()
|
||||
.nameAndDescription("passwordManagerTest")
|
||||
.addComp(new PasswordManagerTestComp(true))
|
||||
.nameAndDescription("passwordManagerKeyStrategy")
|
||||
.sub(keyStrategyChoice.build(), keyStrategy)
|
||||
.bind(() -> {
|
||||
return ProtonPasswordManager.builder().keyStrategy(keyStrategy.getValue()).build();
|
||||
}, p);
|
||||
}
|
||||
|
||||
private static synchronized ShellControl getOrStartShell() throws Exception {
|
||||
if (SHELL == null) {
|
||||
SHELL = ProcessControlProvider.get().createLocalProcessControl(true);
|
||||
}
|
||||
SHELL.start();
|
||||
return SHELL;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized Result query(String key) {
|
||||
try {
|
||||
CommandSupport.isInLocalPathOrThrow("ProtonPass CLI", "pass-cli");
|
||||
} catch (Exception e) {
|
||||
ErrorEventFactory.fromThrowable(e)
|
||||
.expected()
|
||||
.link("https://proton.me/pass")
|
||||
.handle();
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
var sc = getOrStartShell();
|
||||
var loggedIn = sc.command(CommandBuilder.of().add("pass-cli", "info")).executeAndCheck();
|
||||
if (!loggedIn) {
|
||||
var script = ShellScript.lines(
|
||||
"pass-cli login");
|
||||
TerminalLaunch.builder()
|
||||
.title("Proton Pass login")
|
||||
.localScript(script)
|
||||
.logIfEnabled(false)
|
||||
.preferTabs(false)
|
||||
.pauseOnExit(true)
|
||||
.launch();
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
ErrorEventFactory.fromThrowable(e).handle();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getKeyPlaceholder() {
|
||||
return AppI18n.get("protonPassPasswordPlaceholder");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getWebsite() {
|
||||
return "https://proton.me/pass";
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
package io.xpipe.app.pwman;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import io.xpipe.app.comp.base.SecretFieldComp;
|
||||
import io.xpipe.app.comp.base.TextFieldComp;
|
||||
import io.xpipe.app.core.AppI18n;
|
||||
import io.xpipe.app.ext.ProcessControlProvider;
|
||||
import io.xpipe.app.issue.ErrorEventFactory;
|
||||
import io.xpipe.app.platform.OptionsBuilder;
|
||||
import io.xpipe.app.prefs.PasswordManagerTestComp;
|
||||
import io.xpipe.app.process.CommandBuilder;
|
||||
import io.xpipe.app.process.CommandSupport;
|
||||
import io.xpipe.app.process.ShellControl;
|
||||
@@ -22,6 +24,8 @@ import lombok.Getter;
|
||||
import lombok.ToString;
|
||||
import lombok.extern.jackson.Jacksonized;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
@ToString
|
||||
@@ -34,6 +38,11 @@ public class PsonoPasswordManager implements PasswordManager {
|
||||
private final InPlaceSecretValue apiSecretKey;
|
||||
private final String serverUrl;
|
||||
|
||||
@Override
|
||||
public PasswordManagerKeyConfiguration getKeyConfiguration() {
|
||||
return PasswordManagerKeyConfiguration.none();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public static OptionsBuilder createOptions(Property<PsonoPasswordManager> p) {
|
||||
var apiKey = new SimpleObjectProperty<>(p.getValue().getApiKey());
|
||||
@@ -52,6 +61,8 @@ public class PsonoPasswordManager implements PasswordManager {
|
||||
.addComp(new SecretFieldComp(apiKey, false).maxWidth(600), apiKey)
|
||||
.nameAndDescription("psonoApiSecretKey")
|
||||
.addComp(new SecretFieldComp(apiSecretKey, false).maxWidth(600), apiSecretKey)
|
||||
.nameAndDescription("passwordManagerTest")
|
||||
.addComp(new PasswordManagerTestComp(true))
|
||||
.bind(
|
||||
() -> {
|
||||
return PsonoPasswordManager.builder()
|
||||
@@ -72,7 +83,7 @@ public class PsonoPasswordManager implements PasswordManager {
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized CredentialResult retrieveCredentials(String key) {
|
||||
public synchronized Result query(String key) {
|
||||
if (serverUrl == null || apiKey == null || apiSecretKey == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -102,11 +113,9 @@ public class PsonoPasswordManager implements PasswordManager {
|
||||
.add("json"))
|
||||
.sensitive();
|
||||
var r = JacksonMapper.getDefault().readTree(cmd.readStdoutOrThrow());
|
||||
var username = r.required("username");
|
||||
var password = r.required("password");
|
||||
return new CredentialResult(
|
||||
username.isNull() ? null : username.asText(),
|
||||
password.isNull() ? null : InPlaceSecretValue.of(password.asText()));
|
||||
var username = Optional.of(r.required("username")).filter(n -> !n.isNull()).map(JsonNode::textValue).orElse(null);
|
||||
var password = Optional.of(r.required("password")).filter(n -> !n.isNull()).map(JsonNode::textValue).orElse(null);;
|
||||
return Result.of(Credentials.of(username, password), null);
|
||||
} catch (Exception e) {
|
||||
ErrorEventFactory.fromThrowable(e).handle();
|
||||
return null;
|
||||
|
||||
@@ -1,20 +1,38 @@
|
||||
package io.xpipe.app.pwman;
|
||||
|
||||
import io.xpipe.app.issue.ErrorEventFactory;
|
||||
import io.xpipe.app.platform.OptionsBuilder;
|
||||
import io.xpipe.app.prefs.PasswordManagerTestComp;
|
||||
import io.xpipe.app.process.LocalShell;
|
||||
import io.xpipe.core.InPlaceSecretValue;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonTypeName;
|
||||
import javafx.beans.property.Property;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.Value;
|
||||
import lombok.extern.jackson.Jacksonized;
|
||||
|
||||
@JsonTypeName("windowsCredentialManager")
|
||||
@Value
|
||||
@Builder
|
||||
@Jacksonized
|
||||
public class WindowsCredentialManager implements PasswordManager {
|
||||
|
||||
private static boolean loaded = false;
|
||||
|
||||
@Override
|
||||
public synchronized CredentialResult retrieveCredentials(String key) {
|
||||
public PasswordManagerKeyConfiguration getKeyConfiguration() {
|
||||
return PasswordManagerKeyConfiguration.none();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public static OptionsBuilder createOptions(Property<WindowsCredentialManager> p) {
|
||||
return new OptionsBuilder()
|
||||
.nameAndDescription("passwordManagerTest")
|
||||
.addComp(new PasswordManagerTestComp(true));
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized Result query(String key) {
|
||||
try {
|
||||
if (!loaded) {
|
||||
loaded = true;
|
||||
@@ -106,7 +124,7 @@ public class WindowsCredentialManager implements PasswordManager {
|
||||
.command("[CredManager.Credential]::GetUserPassword(\"" + key.replaceAll("\"", "`\"") + "\")")
|
||||
.sensitive()
|
||||
.readStdoutOrThrow();
|
||||
return new CredentialResult(username, password.isEmpty() ? null : InPlaceSecretValue.of(password));
|
||||
return Result.of(Credentials.of(username, password), null);
|
||||
} catch (Exception ex) {
|
||||
ErrorEventFactory.fromThrowable(ex).expected().handle();
|
||||
return null;
|
||||
|
||||
@@ -80,12 +80,12 @@ public class SecretPasswordManagerStrategy implements SecretRetrievalStrategy {
|
||||
return new SecretQueryResult(null, SecretQueryState.RETRIEVAL_FAILURE);
|
||||
}
|
||||
|
||||
var r = pm.retrieveCredentials(key);
|
||||
if (r == null || r.getPassword() == null) {
|
||||
var r = pm.query(key);
|
||||
if (r == null || r.getCredentials() == null || r.getCredentials().getPassword() == null) {
|
||||
return new SecretQueryResult(null, SecretQueryState.RETRIEVAL_FAILURE);
|
||||
}
|
||||
|
||||
r.getPassword().withSecretValue(chars -> {
|
||||
r.getCredentials().getPassword().withSecretValue(chars -> {
|
||||
var seq = CharBuffer.wrap(chars);
|
||||
var newline = seq.chars().anyMatch(value -> value == 10);
|
||||
if (seq.length() == 0 || newline) {
|
||||
@@ -98,7 +98,7 @@ public class SecretPasswordManagerStrategy implements SecretRetrievalStrategy {
|
||||
+ " you will have to change the command and/or password key."));
|
||||
}
|
||||
});
|
||||
return new SecretQueryResult(r.getPassword(), SecretQueryState.NORMAL);
|
||||
return new SecretQueryResult(r.getCredentials().getPassword(), SecretQueryState.NORMAL);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -381,8 +381,7 @@ public abstract class DataStorage {
|
||||
var categoryChanged = !entry.getCategoryUuid().equals(newEntry.getCategoryUuid());
|
||||
|
||||
if (entry.getStore() != null
|
||||
&& newEntry.getStore() != null
|
||||
&& !entry.getStore().equals(newEntry.getStore())) {
|
||||
&& newEntry.getStore() != null) {
|
||||
synchronized (storeMoveCache) {
|
||||
storeMoveCache.put(entry.getStore(), newEntry.getStore());
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ public class DataStorageQuery {
|
||||
var narrow = found.stream()
|
||||
.filter(dataStoreEntry -> dataStoreEntry.getName().equalsIgnoreCase(connection))
|
||||
.toList();
|
||||
if (narrow.size() == 1) {
|
||||
if (narrow.size() >= 1) {
|
||||
return narrow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,15 +10,21 @@ public abstract class ControllableTerminalSession extends TerminalView.TerminalS
|
||||
protected Rect lastBounds;
|
||||
protected boolean customBounds;
|
||||
|
||||
protected ControllableTerminalSession(ProcessHandle terminalProcess) {
|
||||
super(terminalProcess);
|
||||
protected ControllableTerminalSession(ProcessHandle terminalProcess, ExternalTerminalType terminalType) {
|
||||
super(terminalProcess, terminalType);
|
||||
}
|
||||
|
||||
public abstract void own();
|
||||
|
||||
public abstract void disown();
|
||||
|
||||
public abstract void removeBorders();
|
||||
public abstract void removeIcon();
|
||||
|
||||
public abstract void restoreIcon();
|
||||
|
||||
public abstract void removeStyle();
|
||||
|
||||
public abstract void restoreStyle();
|
||||
|
||||
public abstract void show();
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user