Fix font size

This commit is contained in:
crschnick
2026-03-07 18:48:45 +00:00
parent b8f97e3fad
commit 4e1ec5b389
189 changed files with 5054 additions and 1427 deletions

View File

@@ -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));
}

View File

@@ -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() {

View File

@@ -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());

View File

@@ -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) {

View File

@@ -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();
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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;

View File

@@ -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))) {

View File

@@ -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;
}
}

View File

@@ -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();

View File

@@ -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();
}

View File

@@ -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) {

View File

@@ -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()));

View File

@@ -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);
}

View File

@@ -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");

View File

@@ -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() {

View File

@@ -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));
}
});
}

View File

@@ -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) {

View File

@@ -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"),

View File

@@ -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"));

View File

@@ -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();
});
});
}

View File

@@ -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");
},

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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();
});
}
}

View 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;
}
}
}

View 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;
}
}

View 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();
}
}

View 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();
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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();
}

View File

@@ -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;

View File

@@ -1,4 +1,4 @@
package io.xpipe.ext.base.identity;
package io.xpipe.app.cred;
import io.xpipe.core.FailableSupplier;

View File

@@ -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;
}
}

View File

@@ -27,6 +27,10 @@ import java.util.UUID;
public interface DataStoreProvider {
default boolean allowCreation() {
return true;
}
default boolean showIncompleteInfo() {
return false;
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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();

View File

@@ -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);

View File

@@ -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();

View File

@@ -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()));

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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());

View File

@@ -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();

View File

@@ -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");

View File

@@ -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());

View 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;
}
}

View File

@@ -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);

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View 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;
}
}

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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"));

View File

@@ -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();
}

View File

@@ -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);
}

View File

@@ -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();

View File

@@ -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 {

View File

@@ -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));
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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(
() -> {

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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();

View File

@@ -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();

View File

@@ -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());
}
}

View File

@@ -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();
}
}

View File

@@ -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();

View File

@@ -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";
}
}

View File

@@ -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;

View File

@@ -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);
}
/**

View File

@@ -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();
}
}

View File

@@ -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;

View File

@@ -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://")

View File

@@ -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;

View File

@@ -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;

View File

@@ -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

View File

@@ -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();
}

View File

@@ -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;
}
}

View 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";
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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

View File

@@ -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());
}

View File

@@ -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;
}
}

View File

@@ -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