Squash merge branch 23-release into master

This commit is contained in:
crschnick
2026-05-23 05:38:40 +00:00
parent a1729d51e8
commit caed3e1b89
344 changed files with 12455 additions and 5196 deletions
+2 -2
View File
@@ -196,8 +196,8 @@ are not able to resolve and install any dependency packages.
### RHEL-based distros
The rpm releases are signed with the GPG key https://xpipe.io/signatures/crschnick.asc.
You can import it via `rpm --import https://xpipe.io/signatures/crschnick.asc` to allow your rpm-based package manager to verify the release signature.
The rpm releases are signed with the GPG key https://xpipe.io/signatures/0xDD3E0AD0.asc.
You can import it via `rpm --import https://xpipe.io/signatures/0xDD3E0AD0.asc` to allow your rpm-based package manager to verify the release signature.
The following rpm installers are available:
+2 -2
View File
@@ -64,11 +64,11 @@ dependencies {
}
api "com.github.weisj:jsvg:1.7.2"
api 'io.xpipe:vernacular:1.16'
api 'io.xpipe:vernacular:1.17'
api 'org.bouncycastle:bcprov-jdk18on:1.83'
api 'info.picocli:picocli:4.7.7'
api 'org.apache.commons:commons-lang3:3.20.0'
api 'io.sentry:sentry:8.20.0'
api 'io.sentry:sentry:8.41.0'
api 'commons-io:commons-io:2.21.0'
api "com.fasterxml.jackson.core:jackson-databind:2.21.1"
api "com.fasterxml.jackson.core:jackson-annotations:2.21"
@@ -56,7 +56,7 @@ public class ActionConfigComp extends SimpleRegionBuilder {
});
var choice = new StoreListChoiceComp<>(
listProp, DataStore.class, null, StoreViewState.get().getAllConnectionsCategory());
listProp, DataStore.class, null, StoreViewState.get().getAllConnectionsCategory(), null, null);
choice.hide(listProp.emptyProperty());
choice.maxHeight(450);
return choice;
@@ -79,7 +79,8 @@ public class ActionConfigComp extends SimpleRegionBuilder {
singleProp,
DataStore.class,
ref -> true,
StoreViewState.get().getAllConnectionsCategory());
StoreViewState.get().getAllConnectionsCategory(),
null);
choice.hide(singleProp.isNull());
return choice;
}
@@ -98,7 +99,7 @@ public class ActionConfigComp extends SimpleRegionBuilder {
}
});
var area = new IntegratedTextAreaComp(config, false, "action", new SimpleStringProperty("json"));
var area = new IntegratedTextAreaComp(config, false, "action", new SimpleStringProperty("json"), true);
area.hide(config.isNull());
return area;
}
@@ -54,7 +54,7 @@ public class ActionConfirmComp extends SimpleRegionBuilder {
}
var choice = new StoreListChoiceComp<>(
listProp, DataStore.class, null, StoreViewState.get().getAllConnectionsCategory());
listProp, DataStore.class, null, StoreViewState.get().getAllConnectionsCategory(), null, null);
choice.maxHeight(450);
choice.setEditable(false);
choice.hide(listProp.emptyProperty());
@@ -38,7 +38,7 @@ public interface QuickConnectProvider extends ActionProvider {
boolean skipDialogIfPossible();
default void open(DataStoreEntry e) throws Exception {
default void open(DataStoreEntry e) {
OpenHubMenuLeafProvider.Action.builder().ref(e.ref()).build().executeSync();
}
}
@@ -15,9 +15,12 @@ import com.sun.net.httpserver.HttpServer;
import lombok.Getter;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.net.HttpURLConnection;
import java.net.Inet4Address;
import java.net.InetSocketAddress;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.file.Files;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.*;
@@ -44,18 +47,16 @@ public class AppBeaconServer {
@Getter
private String localAuthSecret;
private FileChannel localLockFileChannel;
private FileLock localLockFileLock;
private AppBeaconServer(int port) {
this.port = port;
}
public static void setupPort() {
int port = BeaconConfig.getUsedPort();
INSTANCE = new AppBeaconServer(port);
}
public static void init() {
try {
INSTANCE = new AppBeaconServer(BeaconConfig.getUsedPort());
INSTANCE.initAuthSecret();
INSTANCE.start();
TrackEvent.withInfo("Started http server")
@@ -113,20 +114,26 @@ public class AppBeaconServer {
var file = BeaconConfig.getLocalBeaconAuthFile();
// Create and set temp dir permissions for Linux
AppLocalTemp.getLocalTempDataDirectory();
var id = UUID.randomUUID().toString();
Files.writeString(file, id);
if (OsType.ofLocal() != OsType.WINDOWS) {
Files.setPosixFilePermissions(file, PosixFilePermissions.fromString("rw-rw----"));
}
localAuthSecret = id;
var lockFile = BeaconConfig.getLocalBeaconLockFile();
localLockFileChannel = new RandomAccessFile(lockFile.toFile(), "rw").getChannel();
localLockFileLock = localLockFileChannel.tryLock();
}
private void deleteAuthSecret() {
var file = BeaconConfig.getLocalBeaconAuthFile();
try {
Files.delete(file);
} catch (IOException ignored) {
}
localLockFileLock.release();
localLockFileChannel.close();
} catch (IOException ignored) {}
}
private void start() throws IOException {
@@ -139,8 +146,11 @@ public class AppBeaconServer {
});
return t;
});
server = HttpServer.create(
new InetSocketAddress(Inet4Address.getByAddress(new byte[] {0x7f, 0x00, 0x00, 0x01}), port), 10);
var external = AppPrefs.get().allowExternalApiRequests().get() || Boolean.getBoolean("XPIPE_API_SERVER");
var addr = external
? Inet4Address.getByAddress(new byte[] {0, 0, 0, 0})
: Inet4Address.getByAddress(new byte[] {0x7f, 0x00, 0x00, 0x01});
server = HttpServer.create(new InetSocketAddress(addr, port), 10);
BeaconInterface.getAll().forEach(beaconInterface -> {
var handler = new BeaconRequestHandler<>(beaconInterface);
server.createContext(beaconInterface.getPath(), exchange -> {
@@ -173,7 +183,7 @@ public class AppBeaconServer {
private boolean handleCorsHeaders(HttpExchange exchange) throws IOException {
if (AppPrefs.get().enableHttpApi().get()) {
exchange.getResponseHeaders()
.add("Origin", "http://localhost:" + AppBeaconServer.get().getPort());
.add("Origin", "http://localhost:" + getPort());
exchange.getResponseHeaders().add("Vary", "Origin");
exchange.getResponseHeaders().add("Access-Control-Allow-Origin", "*");
exchange.getResponseHeaders().add("Access-Control-Allow-Credentials", "true");
@@ -142,10 +142,13 @@ public class BeaconRequestHandler<T> implements HttpHandler {
try {
var emptyResponseClass = beaconInterface.getResponseClass().getDeclaredFields().length == 0;
if (!emptyResponseClass && response != null) {
var redact = AppPrefs.get() == null || !AppPrefs.get().developerMode().getValue() || !AppPrefs.get().developerShowSensitiveCommands().get();
var redact = AppPrefs.get() == null
|| !AppPrefs.get().developerMode().getValue()
|| !AppPrefs.get().developerShowSensitiveCommands().get();
var mapper = redact ? JacksonMapper.getRedactedSecretMapper() : JacksonMapper.getUnredactSecretMapper();
TrackEvent.trace("Sending response:\n" + response);
TrackEvent.trace("Sending raw response:\n" + mapper.valueToTree(response).toPrettyString());
TrackEvent.trace(
"Sending raw response:\n" + mapper.valueToTree(response).toPrettyString());
var bytes = JacksonMapper.getDefault()
.valueToTree(response)
.toPrettyString()
@@ -1,6 +1,8 @@
package io.xpipe.app.beacon.impl;
import io.xpipe.app.core.AppCache;
import io.xpipe.app.core.AppLayoutModel;
import io.xpipe.app.core.window.AppDialog;
import io.xpipe.app.issue.ErrorEventFactory;
import io.xpipe.app.platform.LabelGraphic;
import io.xpipe.app.secret.SecretManager;
@@ -32,6 +34,12 @@ public class AskpassExchangeImpl extends AskpassExchange {
var shown = AppLayoutModel.get().getQueueEntries().stream().anyMatch(queueEntry -> msg.getPrompt()
.equals(queueEntry.getName().getValue()));
if (!shown) {
var dialogShown = AppCache.getBoolean("touchDialogShown", false);
if (!dialogShown) {
AppDialog.information("touchNotice");
AppCache.update("touchDialogShown", true);
}
var qe = new AppLayoutModel.QueueEntry(
new SimpleStringProperty(msg.getPrompt()),
new LabelGraphic.IconGraphic("mdi2f-fingerprint"),
@@ -41,7 +49,7 @@ public class AskpassExchangeImpl extends AskpassExchange {
() -> {
AppLayoutModel.get().getQueueEntries().remove(qe);
},
Duration.ofSeconds(10));
Duration.ofSeconds(15));
}
return Response.builder().value(InPlaceSecretValue.of("")).build();
}
@@ -21,7 +21,6 @@ import lombok.Value;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
@Value
public class AppMcpServer {
@@ -30,8 +29,7 @@ public class AppMcpServer {
McpSyncServer mcpSyncServer;
HttpStreamableServerTransportProvider transportProvider;
List<McpServerFeatures.SyncToolSpecification> readOnlyTools;
List<McpServerFeatures.SyncToolSpecification> mutationTools;
List<McpServerFeatures.SyncToolSpecification> tools;
public static AppMcpServer get() {
return INSTANCE;
@@ -47,9 +45,10 @@ public class AppMcpServer {
null);
var prompt = McpSchemaFiles.load("prompt.md");
var effectivePrompt = AppPrefs.get().mcpAdditionalContext().getValue() != null ?
prompt.replace("__CUSTOM__", AppPrefs.get().mcpAdditionalContext().getValue()) :
prompt.replace("__CUSTOM__", "");
var effectivePrompt = AppPrefs.get().mcpAdditionalContext().getValue() != null
? prompt.replace(
"__CUSTOM__", AppPrefs.get().mcpAdditionalContext().getValue())
: prompt.replace("__CUSTOM__", "");
McpSyncServer syncServer = io.modelcontextprotocol.server.McpServer.sync(transportProvider)
.serverInfo(AppNames.ofCurrent().getName(), AppProperties.get().getVersion())
@@ -61,44 +60,27 @@ public class AppMcpServer {
.instructions(effectivePrompt)
.build();
var readOnlyTools = new ArrayList<McpServerFeatures.SyncToolSpecification>();
readOnlyTools.add(McpTools.help());
readOnlyTools.add(McpTools.listSystems());
readOnlyTools.add(McpTools.readFile());
readOnlyTools.add(McpTools.listFiles());
readOnlyTools.add(McpTools.findFile());
readOnlyTools.add(McpTools.getFileInfo());
var tools = new ArrayList<McpServerFeatures.SyncToolSpecification>();
tools.add(McpTools.help());
tools.add(McpTools.listSystems());
tools.add(McpTools.readFile());
tools.add(McpTools.listFiles());
tools.add(McpTools.findFile());
tools.add(McpTools.getFileInfo());
tools.add(McpTools.openTerminal());
tools.add(McpTools.createFile());
tools.add(McpTools.writeFile());
tools.add(McpTools.createDirectory());
tools.add(McpTools.runCommand());
tools.add(McpTools.runScript());
tools.add(McpTools.toggleState());
tools.add(McpTools.callApi());
var mutationTools = new ArrayList<McpServerFeatures.SyncToolSpecification>();
mutationTools.add(McpTools.openTerminal());
mutationTools.add(McpTools.createFile());
mutationTools.add(McpTools.writeFile());
mutationTools.add(McpTools.createDirectory());
mutationTools.add(McpTools.runCommand());
mutationTools.add(McpTools.runScript());
mutationTools.add(McpTools.toggleState());
mutationTools.add(McpTools.callApi());
for (McpServerFeatures.SyncToolSpecification readOnlyTool : readOnlyTools) {
for (McpServerFeatures.SyncToolSpecification readOnlyTool : tools) {
syncServer.addTool(readOnlyTool);
}
var toolsAdded = new AtomicBoolean();
AppPrefs.get().enableMcpMutationTools().subscribe(value -> {
for (var mutationTool : mutationTools) {
if (value) {
syncServer.addTool(mutationTool);
} else if (toolsAdded.get()) {
syncServer.removeTool(mutationTool.tool().name());
}
}
if (value) {
toolsAdded.set(true);
}
syncServer.notifyToolsListChanged();
});
INSTANCE = new AppMcpServer(syncServer, transportProvider, readOnlyTools, mutationTools);
INSTANCE = new AppMcpServer(syncServer, transportProvider, tools);
}
public static void reset() {
@@ -5,7 +5,6 @@ import io.xpipe.app.issue.ErrorEventFactory;
import io.xpipe.app.process.ShellControl;
import io.xpipe.app.storage.DataStorage;
import io.xpipe.app.storage.DataStorageQuery;
import io.xpipe.app.storage.DataStoreEntry;
import io.xpipe.app.storage.DataStoreEntryRef;
import io.xpipe.beacon.BeaconClientException;
import io.xpipe.core.FilePath;
@@ -34,13 +33,19 @@ public interface McpToolHandler
} catch (BeaconClientException e) {
ErrorEventFactory.fromThrowable(e).expected().omit().handle();
return McpSchema.CallToolResult.builder()
.addTextContent(e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName())
.addTextContent(
e.getMessage() != null
? e.getMessage()
: e.getClass().getSimpleName())
.isError(true)
.build();
} catch (Throwable e) {
ErrorEventFactory.fromThrowable(e).omit().handle();
return McpSchema.CallToolResult.builder()
.addTextContent(e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName())
.addTextContent(
e.getMessage() != null
? e.getMessage()
: e.getClass().getSimpleName())
.isError(true)
.build();
}
@@ -131,7 +136,10 @@ public interface McpToolHandler
if (found.size() > 1) {
throw new BeaconClientException("Multiple connections found: "
+ found.stream().map(entry -> DataStorage.get().getStorePath(entry).toString()).toList());
+ found.stream()
.map(entry ->
DataStorage.get().getStorePath(entry).toString())
.toList());
}
var e = found.getFirst();
@@ -8,8 +8,6 @@ import io.xpipe.app.hub.comp.StoreViewState;
import io.xpipe.app.prefs.AppPrefs;
import io.xpipe.app.process.ScriptHelper;
import io.xpipe.app.process.ShellControl;
import io.xpipe.app.process.TerminalInitScriptConfig;
import io.xpipe.app.process.WorkingDirectoryFunction;
import io.xpipe.app.storage.DataStorage;
import io.xpipe.app.storage.DataStorageQuery;
import io.xpipe.app.terminal.TerminalLaunch;
@@ -46,36 +44,19 @@ public final class McpTools {
return McpServerFeatures.SyncToolSpecification.builder()
.tool(tool)
.callHandler(McpToolHandler.of((req) -> {
var ro = AppMcpServer.get().getReadOnlyTools().stream()
var tools = AppMcpServer.get().getTools().stream()
.filter(syncToolSpecification ->
!syncToolSpecification.tool().name().equals("help"))
.toList();
var mu = AppMcpServer.get().getMutationTools();
var roList = ro.stream()
var toolsList = tools.stream()
.map(syncToolSpecification ->
"- " + syncToolSpecification.tool().name() + ": "
+ syncToolSpecification.tool().description())
.collect(Collectors.joining("\n"));
var muList = mu.stream()
.map(syncToolSpecification ->
"- " + syncToolSpecification.tool().name() + ": "
+ syncToolSpecification.tool().description())
.collect(Collectors.joining("\n"));
var muEnabled = AppPrefs.get().enableMcpMutationTools().get();
var muStatus = muEnabled ? "Right now, the mutation tools are enabled." : "Right now, the mutation tools are disabled. When you enable them in the settings menu, the MCP client might need a reconnect to see the changes.";
var text = """
The XPipe MCP server offers the following read-only tools:
The XPipe MCP server offers the following tools:
%s
These tools will not modify anything on your system and are safe to use.
You can also enable the following potentially destructive tools in the settings menu:
%s
These tools can perform write operations and other actions that might be potentially destructive.
%s
""".formatted(roList, muList, muStatus);
""".formatted(toolsList);
return McpSchema.CallToolResult.builder()
.addTextContent(text)
@@ -102,9 +83,14 @@ public final class McpTools {
throw new BeaconClientException("No API endpoint found for path " + path);
}
var httpReq = HttpRequest.newBuilder().uri(URI.create("http://localhost:" + AppBeaconServer.get().getPort() + path))
.header("Authorization", "Bearer " + AppPrefs.get().apiKey().get())
.POST(HttpRequest.BodyPublishers.ofString(payloadJson.toPrettyString())).build();
var httpReq = HttpRequest.newBuilder()
.uri(URI.create(
"http://localhost:" + AppBeaconServer.get().getPort() + path))
.header(
"Authorization",
"Bearer " + AppPrefs.get().apiKey().get())
.POST(HttpRequest.BodyPublishers.ofString(payloadJson.toPrettyString()))
.build();
var httpRes = HttpHelper.client().send(httpReq, HttpResponse.BodyHandlers.ofString());
var resJson = JacksonMapper.getDefault().readTree(httpRes.body());
@@ -170,7 +170,7 @@ public class BrowserFileChooserSessionComp extends ModalOverlayContentComp {
var splitPane = new LeftSplitPaneComp(vertical, stack)
.withInitialWidth(AppLayoutModel.get().getSavedState().getBrowserConnectionsWidth())
.applyStructure(struc -> {
struc.getLeft().setMinWidth(250);
struc.getLeft().setMinWidth(270);
struc.getLeft().setMaxWidth(500);
});
splitPane.disable(model.getBusy());
@@ -90,7 +90,7 @@ public class BrowserFullSessionComp extends SimpleRegionBuilder {
leftSplit.set(d);
});
splitPane.applyStructure(struc -> {
struc.getLeft().setMinWidth(250);
struc.getLeft().setMinWidth(270);
struc.getLeft().setMaxWidth(500);
struc.get().setPickOnBounds(false);
});
@@ -6,7 +6,6 @@ import io.xpipe.app.action.StoreContextAction;
import io.xpipe.app.browser.file.BrowserFileTransferOperation;
import io.xpipe.app.core.AppCache;
import io.xpipe.app.core.window.AppDialog;
import io.xpipe.app.ext.ConnectionFileSystem;
import io.xpipe.app.ext.FileSystemStore;
import io.xpipe.app.process.ParentSystemAccess;
import io.xpipe.app.process.ShellControl;
@@ -53,12 +52,16 @@ public class TransferFilesActionProvider implements ActionProvider {
}
var sourceFs = operation.getFiles().getFirst().getFileSystem();
var sourceAccess = sourceFs.getShell().map(ShellControl::getLocalSystemAccess).orElse(null);
var sourceSlowRemote = sourceAccess != null && ParentSystemAccess.isEquivalent(sourceAccess, ParentSystemAccess.none());
var sourceAccess =
sourceFs.getShell().map(ShellControl::getLocalSystemAccess).orElse(null);
var sourceSlowRemote =
sourceAccess != null && ParentSystemAccess.isEquivalent(sourceAccess, ParentSystemAccess.none());
var targetFs = operation.getTarget().getFileSystem();
var targetAccess = targetFs.getShell().map(ShellControl::getLocalSystemAccess).orElse(null);
var targetSlowRemote = targetAccess != null && ParentSystemAccess.isEquivalent(targetAccess, ParentSystemAccess.none());
var targetAccess =
targetFs.getShell().map(ShellControl::getLocalSystemAccess).orElse(null);
var targetSlowRemote =
targetAccess != null && ParentSystemAccess.isEquivalent(targetAccess, ParentSystemAccess.none());
if (!sourceSlowRemote && !targetSlowRemote) {
return true;
@@ -11,7 +11,6 @@ import javafx.scene.input.ClipboardContent;
import javafx.scene.input.DataFormat;
import javafx.scene.input.Dragboard;
import lombok.SneakyThrows;
import lombok.Value;
import java.awt.datatransfer.Clipboard;
@@ -9,6 +9,7 @@ import javafx.beans.binding.Bindings;
import javafx.beans.property.*;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.collections.SetChangeListener;
import javafx.css.PseudoClass;
import javafx.scene.control.Button;
import javafx.scene.layout.Region;
@@ -21,6 +22,7 @@ import java.util.function.Predicate;
public final class BrowserConnectionListComp extends SimpleRegionBuilder {
private static final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected");
private static final PseudoClass BUSY = PseudoClass.getPseudoClass("busy");
private final ObservableValue<DataStoreEntry> selected;
private final Predicate<StoreEntryWrapper> applicable;
private final BiConsumer<StoreEntryWrapper, BooleanProperty> action;
@@ -58,6 +60,11 @@ public final class BrowserConnectionListComp extends SimpleRegionBuilder {
&& newValue.equals(s.getWrapper().getEntry()));
});
});
busyEntries.addListener((SetChangeListener<? super StoreSection>) change -> {
PlatformThread.runLaterIfNeeded(() -> {
struc.pseudoClassStateChanged(BUSY, change.getSet().contains(s));
});
});
});
};
@@ -35,14 +35,16 @@ public final class BrowserConnectionListFilterComp extends SimpleRegionBuilder {
this.category,
true,
ignored -> true)
.hgrow()
.maxWidth(10000)
.style(Styles.LEFT_PILL)
.apply(struc -> {
AppFontSizes.base(struc);
});
var filter = FilterComp.ofStoreFilter(this.filter)
.style(Styles.RIGHT_PILL)
.minWidth(0)
.hgrow()
.minWidth(100)
.prefWidth(120)
.apply(struc -> {
AppFontSizes.base(struc);
filterTrigger.subscribe(() -> {
@@ -53,18 +53,6 @@ public final class BrowserFileListComp extends SimpleRegionBuilder {
this.fileList = fileList;
}
private static void prepareTableScrollFix(TableView<BrowserEntry> table) {
table.lookupAll(".scroll-bar").stream()
.filter(node -> node.getPseudoClassStates().contains(PseudoClass.getPseudoClass("horizontal")))
.findFirst()
.ifPresent(node -> {
Region region = (Region) node;
region.setMinHeight(0);
region.setPrefHeight(0);
region.setMaxHeight(0);
});
}
@Override
protected Region createSimple() {
return createTable();
@@ -162,11 +150,16 @@ public final class BrowserFileListComp extends SimpleRegionBuilder {
return null;
}
if (fileList.getFileSystemModel().getFilter().getValue() != null) {
return AppI18n.get("emptyFilteredDirectory");
}
return AppI18n.get("emptyDirectory");
},
AppI18n.activeLanguage(),
fileList.getFileSystemModel().getBusy(),
fileList.getFileSystemModel().getCurrentPath());
fileList.getFileSystemModel().getCurrentPath(),
fileList.getFileSystemModel().getFilter());
placeholder.textProperty().bind(PlatformThread.sync(placeholderText));
table.setPlaceholder(placeholder);
AppFontSizes.base(placeholder);
@@ -182,7 +175,6 @@ public final class BrowserFileListComp extends SimpleRegionBuilder {
table.setFixedCellSize(30.0);
prepareColumnVisibility(table, filenameCol, mtimeCol, modeCol, ownerCol, sizeCol);
prepareTableScrollFix(table);
prepareTableSelectionModel(table);
prepareTableShortcuts(table);
prepareTableEntries(table);
@@ -299,7 +299,8 @@ public final class BrowserFileSystemTabModel extends BrowserStoreSessionTab<File
return getCurrentDirectory();
}
return FileEntry.ofDirectory(getFileSystem(), fallbackFile.getRawFileEntry().getPath().getParent());
return FileEntry.ofDirectory(
getFileSystem(), fallbackFile.getRawFileEntry().getPath().getParent());
}
public void cdAsync(FilePath path) {
@@ -10,7 +10,6 @@ import io.xpipe.app.platform.DerivedObservableList;
import io.xpipe.app.platform.LabelGraphic;
import io.xpipe.app.prefs.AppPrefs;
import io.xpipe.app.storage.DataStorage;
import io.xpipe.app.util.DocumentationLink;
import io.xpipe.app.util.ThreadHelper;
import javafx.beans.binding.Bindings;
@@ -117,13 +116,6 @@ public class BrowserHistoryTabComp extends SimpleRegionBuilder {
}
private BaseRegionBuilder<?, ?> createEmptyDisplay() {
var docs = new IntroComp("browserWelcomeDocs", new LabelGraphic.IconGraphic("mdi2b-book-open-variant"));
docs.setButtonAction(() -> {
DocumentationLink.INTRO.open();
});
docs.setButtonGraphic(new LabelGraphic.IconGraphic("mdi2w-web"));
docs.setButtonDefault(true);
var open = new IntroComp(
"browserWelcomeEmpty",
new LabelGraphic.CompGraphic(PrettyImageHelper.ofSpecificFixedSize("welcome/hips.svg", 100, 122)));
@@ -133,7 +125,7 @@ public class BrowserHistoryTabComp extends SimpleRegionBuilder {
DataStorage.get().local().ref(), null, null, null);
});
var list = new IntroListComp(List.of(docs, open));
var list = new IntroListComp(List.of(open));
return list;
}
@@ -103,9 +103,7 @@ public class BrowserNavBarComp extends RegionStructureBuilder<HBox, BrowserNavBa
pathRegion.prefHeightProperty().bind(stack.heightProperty());
stack.widthProperty().addListener((observable, oldValue, newValue) -> {
Platform.runLater(() -> {
setAlignment(stack, breadcrumbsRegion);
});
setAlignment(stack, breadcrumbsRegion);
});
model.getCurrentPath().addListener((observable, oldValue, newValue) -> {
PlatformThread.runLaterIfNeeded(() -> {
@@ -10,10 +10,7 @@ import io.xpipe.app.core.AppLayoutModel;
import io.xpipe.app.core.window.AppDialog;
import io.xpipe.app.prefs.AppPrefs;
import io.xpipe.app.storage.DataStoreColor;
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.terminal.*;
import io.xpipe.app.util.GlobalTimer;
import io.xpipe.app.util.ThreadHelper;
@@ -76,11 +73,10 @@ public final class BrowserTerminalDockTabModel extends BrowserSessionTab {
ThreadHelper.sleep(250);
}
var controllable = session.getTerminal().controllable();
if (controllable.isEmpty()) {
if (!(session.getTerminal() instanceof TerminalView.ControllableTerminalSession t)) {
return;
}
dockModel.trackTerminal(controllable.get(), true);
dockModel.trackTerminal(t, true);
}
@Override
@@ -96,15 +92,16 @@ public final class BrowserTerminalDockTabModel extends BrowserSessionTab {
.filter(shellSession -> shellSession.getTerminal().equals(session.getTerminal()))
.count();
if (others == 0) {
session.getTerminal().controllable().ifPresent(controllableTerminalSession -> {
controllableTerminalSession.close();
});
if (session.getTerminal() instanceof TerminalView.ControllableTerminalSession t) {
t.getControllable().close();
}
}
}
}
@Override
public void onTerminalClosed(TerminalView.TerminalSession instance) {
dockModel.removeTerminal(instance);
refreshShowingState();
}
};
@@ -153,7 +150,6 @@ public final class BrowserTerminalDockTabModel extends BrowserSessionTab {
GlobalTimer.scheduleUntil(Duration.ofMillis(300), false, () -> {
if (viewActive.get()) {
dockModel.clearDeadTerminals();
dockModel.updateCustomBounds();
}
return closed;
@@ -2,9 +2,11 @@ package io.xpipe.app.browser.file;
import io.xpipe.app.browser.BrowserFullSessionModel;
import io.xpipe.app.browser.action.impl.TransferFilesActionProvider;
import io.xpipe.app.core.AppCache;
import io.xpipe.app.core.AppLocalTemp;
import io.xpipe.app.core.AppSystemInfo;
import io.xpipe.app.core.mode.AppOperationMode;
import io.xpipe.app.core.window.AppDialog;
import io.xpipe.app.issue.ErrorEventFactory;
import io.xpipe.app.prefs.AppPrefs;
import io.xpipe.app.process.OsFileSystem;
@@ -62,6 +64,16 @@ public class BrowserTransferModel {
}
});
thread.start();
transferring.addListener((observable, oldValue, newValue) -> {
if (!newValue) {
var shown = AppCache.getBoolean("downloadDialogShown", false);
if (!shown) {
AppDialog.information("downloadDialog");
AppCache.update("downloadDialogShown", true);
}
}
});
}
public List<Item> getCurrentItems() {
@@ -10,6 +10,7 @@ import io.xpipe.app.ext.FileKind;
import io.xpipe.app.platform.LabelGraphic;
import io.xpipe.app.prefs.AppPrefs;
import io.xpipe.app.util.ThreadHelper;
import io.xpipe.core.OsType;
import javafx.beans.value.ObservableValue;
import javafx.scene.input.KeyCode;
@@ -46,7 +47,12 @@ public class EditFileMenuProvider implements BrowserMenuLeafProvider {
@Override
public KeyCombination getShortcut() {
return new KeyCodeCombination(KeyCode.E, KeyCombination.SHORTCUT_DOWN);
return switch (OsType.ofLocal()) {
case OsType.Linux linux -> new KeyCodeCombination(KeyCode.ENTER, KeyCombination.SHORTCUT_DOWN);
case OsType.MacOs macOs ->
new KeyCodeCombination(KeyCode.DOWN, KeyCombination.SHORTCUT_DOWN, KeyCombination.SHIFT_DOWN);
case OsType.Windows windows -> new KeyCodeCombination(KeyCode.ENTER, KeyCombination.SHORTCUT_DOWN);
};
}
@Override
@@ -8,6 +8,7 @@ import io.xpipe.app.browser.menu.BrowserMenuCategory;
import io.xpipe.app.browser.menu.BrowserMenuLeafProvider;
import io.xpipe.app.core.AppI18n;
import io.xpipe.app.platform.LabelGraphic;
import io.xpipe.core.OsType;
import javafx.beans.value.ObservableValue;
import javafx.scene.input.KeyCode;
@@ -35,7 +36,11 @@ public class OpenFileDefaultMenuProvider implements BrowserMenuLeafProvider {
@Override
public KeyCombination getShortcut() {
return new KeyCodeCombination(KeyCode.ENTER);
return switch (OsType.ofLocal()) {
case OsType.Linux ignored -> new KeyCodeCombination(KeyCode.ENTER);
case OsType.MacOs ignored -> new KeyCodeCombination(KeyCode.DOWN, KeyCombination.SHORTCUT_DOWN);
case OsType.Windows ignored -> new KeyCodeCombination(KeyCode.ENTER);
};
}
@Override
@@ -12,9 +12,6 @@ import io.xpipe.app.platform.LabelGraphic;
import io.xpipe.core.OsType;
import javafx.beans.value.ObservableValue;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyCodeCombination;
import javafx.scene.input.KeyCombination;
import java.util.List;
@@ -42,11 +39,6 @@ public class OpenFileWithMenuProvider implements BrowserMenuLeafProvider {
return BrowserMenuCategory.OPEN;
}
@Override
public KeyCombination getShortcut() {
return new KeyCodeCombination(KeyCode.ENTER, KeyCombination.SHIFT_DOWN);
}
@Override
public ObservableValue<String> getName(BrowserFileSystemTabModel model, List<BrowserEntry> entries) {
return AppI18n.observable("openFileWith");
@@ -8,6 +8,7 @@ import io.xpipe.app.browser.menu.BrowserMenuCategory;
import io.xpipe.app.browser.menu.BrowserMenuLeafProvider;
import io.xpipe.app.core.AppI18n;
import io.xpipe.app.platform.LabelGraphic;
import io.xpipe.core.OsType;
import javafx.beans.value.ObservableValue;
import javafx.scene.input.KeyCode;
@@ -30,7 +31,11 @@ public class OpenNativeFileDetailsMenuProvider implements BrowserMenuLeafProvide
@Override
public KeyCombination getShortcut() {
return new KeyCodeCombination(KeyCode.ENTER, KeyCombination.ALT_DOWN);
return switch (OsType.ofLocal()) {
case OsType.Linux ignored -> new KeyCodeCombination(KeyCode.ENTER, KeyCombination.ALT_DOWN);
case OsType.MacOs ignored -> new KeyCodeCombination(KeyCode.I, KeyCombination.SHORTCUT_DOWN);
case OsType.Windows ignored -> new KeyCodeCombination(KeyCode.ENTER, KeyCombination.ALT_DOWN);
};
}
@Override
@@ -7,6 +7,7 @@ import io.xpipe.app.browser.menu.BrowserMenuLeafProvider;
import io.xpipe.app.core.AppI18n;
import io.xpipe.app.ext.FileKind;
import io.xpipe.app.platform.LabelGraphic;
import io.xpipe.core.OsType;
import javafx.beans.value.ObservableValue;
import javafx.scene.input.KeyCode;
@@ -44,7 +45,11 @@ public class RenameMenuProvider implements BrowserMenuLeafProvider {
@Override
public KeyCombination getShortcut() {
return new KeyCodeCombination(KeyCode.R, KeyCombination.SHORTCUT_DOWN);
return switch (OsType.ofLocal()) {
case OsType.Linux linux -> new KeyCodeCombination(KeyCode.F2);
case OsType.MacOs macOs -> new KeyCodeCombination(KeyCode.ENTER);
case OsType.Windows windows -> new KeyCodeCombination(KeyCode.F2);
};
}
@Override
@@ -30,6 +30,7 @@ public class CompressMenuProvider implements BrowserMenuBranchProvider {
sc.view().isInPath("tar", true);
sc.view().isInPath("zip", true);
sc.view().isInPath("gzip", true);
}
@Override
@@ -53,7 +54,7 @@ public class CompressMenuProvider implements BrowserMenuBranchProvider {
return false;
}
var ext = List.of("zip", "tar", "tar.gz", "tgz", "rar", "xar");
var ext = List.of("zip", "tar", "tar.gz", "tgz", "rar", "xar", "gz", "gzip");
if (entries.stream().anyMatch(browserEntry -> ext.stream().anyMatch(s -> browserEntry
.getRawFileEntry()
.getPath()
@@ -89,7 +90,8 @@ public class CompressMenuProvider implements BrowserMenuBranchProvider {
protected String getExtension() {
return "tar";
}
});
},
new GzipActionProvider(false));
}
private abstract static class LeafProvider implements BrowserMenuLeafProvider {
@@ -107,7 +109,7 @@ public class CompressMenuProvider implements BrowserMenuBranchProvider {
@Override
public void execute(BrowserFileSystemTabModel model, List<BrowserEntry> entries) {
var name = new SimpleStringProperty(directory ? entries.getFirst().getFileName() : null);
var name = new SimpleStringProperty(entries.size() == 1 ? entries.getFirst().getFileName() + "." + getExtension() : null);
var modal = ModalOverlay.of(
"archiveName",
RegionBuilder.of(() -> {
@@ -178,7 +180,28 @@ public class CompressMenuProvider implements BrowserMenuBranchProvider {
protected String getExtension() {
return "tar";
}
});
},
new GzipActionProvider(directory));
}
}
private class GzipActionProvider extends LeafProvider {
private GzipActionProvider(boolean directory) {
super(directory);
}
@Override
protected void create(String fileName, BrowserFileSystemTabModel model, List<BrowserEntry> entries) {
var builder = io.xpipe.app.browser.menu.impl.compress.GzipActionProvider.Action.builder();
builder.initEntries(model, entries);
builder.target(model.getTargetDirectoryPath(entries.getFirst()).join(fileName));
builder.build().executeAsync();
}
@Override
protected String getExtension() {
return "gz";
}
}
@@ -0,0 +1,47 @@
package io.xpipe.app.browser.menu.impl.compress;
import io.xpipe.app.browser.action.BrowserAction;
import io.xpipe.app.browser.action.BrowserActionProvider;
import io.xpipe.app.browser.file.BrowserEntry;
import io.xpipe.app.browser.file.BrowserFileSystemTabModel;
import io.xpipe.app.process.CommandBuilder;
import io.xpipe.app.process.ShellControl;
import io.xpipe.app.process.ShellDialects;
import io.xpipe.core.FilePath;
import io.xpipe.core.OsType;
import lombok.experimental.SuperBuilder;
import lombok.extern.jackson.Jacksonized;
public class GunzipActionProvider implements BrowserActionProvider {
public static FilePath getTarget(FilePath name) {
return FilePath.of(name.toString().replaceAll("\\.gz$", "").replaceAll("\\.gzip$", ""));
}
@Override
public String getId() {
return "gunzip";
}
@Jacksonized
@SuperBuilder
public static class Action extends BrowserAction {
@Override
public void executeImpl() throws Exception {
var sc = model.getFileSystem().getShell().orElseThrow();
var b = CommandBuilder.of().add("gunzip", "--keep", "--force");
for (BrowserEntry entry : getEntries()) {
b.addFile(entry.getRawFileEntry().getPath());
}
sc.command(b).execute();
model.refreshSync();
}
@Override
public boolean isMutation() {
return true;
}
}
}
@@ -0,0 +1,77 @@
package io.xpipe.app.browser.menu.impl.compress;
import io.xpipe.app.action.AbstractAction;
import io.xpipe.app.browser.file.BrowserEntry;
import io.xpipe.app.browser.file.BrowserFileSystemTabModel;
import io.xpipe.app.browser.icon.BrowserIconFileType;
import io.xpipe.app.browser.icon.BrowserIcons;
import io.xpipe.app.browser.menu.BrowserApplicationPathMenuProvider;
import io.xpipe.app.browser.menu.BrowserMenuCategory;
import io.xpipe.app.browser.menu.BrowserMenuLeafProvider;
import io.xpipe.app.core.AppI18n;
import io.xpipe.app.platform.LabelGraphic;
import io.xpipe.app.process.OsFileSystem;
import io.xpipe.core.OsType;
import javafx.beans.value.ObservableValue;
import java.util.List;
public class GunzipUnixMenuProvider implements BrowserMenuLeafProvider, BrowserApplicationPathMenuProvider {
@Override
public LabelGraphic getIcon() {
return new LabelGraphic.CompGraphic(BrowserIcons.createContextMenuIcon(BrowserIconFileType.byId("zip")));
}
@Override
public BrowserMenuCategory getCategory() {
return BrowserMenuCategory.CUSTOM;
}
@Override
public boolean automaticallyResolveLinks() {
return false;
}
@Override
public ObservableValue<String> getName(BrowserFileSystemTabModel model, List<BrowserEntry> entries) {
var dir = entries.size() > 1
? "[...]"
: GunzipActionProvider.getTarget(
entries.getFirst().getRawFileEntry().getPath())
.getFileName();
return AppI18n.observable("gunzipDirectory", dir);
}
@Override
public String getExecutable() {
return "gunzip";
}
@Override
public boolean isApplicable(BrowserFileSystemTabModel model, List<BrowserEntry> entries) {
if (!BrowserApplicationPathMenuProvider.super.isApplicable(model, entries)
|| !BrowserMenuLeafProvider.super.isApplicable(model, entries)) {
return false;
}
return entries.stream()
.allMatch(entry -> {
var s = entry.getRawFileEntry().getPath().toString();
if (s.endsWith(".tar.gz") || s.endsWith(".tgz") || s.equals("tar.gzip")) {
return false;
}
return s.endsWith(".gz") || s.endsWith(".gzip");
})
&& model.getFileSystem().getShell().orElseThrow().getOsType() != OsType.WINDOWS;
}
@Override
public AbstractAction createAction(BrowserFileSystemTabModel model, List<BrowserEntry> entries) {
var builder = GunzipActionProvider.Action.builder();
builder.initEntries(model, entries);
return builder.build();
}
}
@@ -0,0 +1,44 @@
package io.xpipe.app.browser.menu.impl.compress;
import io.xpipe.app.browser.action.BrowserAction;
import io.xpipe.app.browser.action.BrowserActionProvider;
import io.xpipe.app.browser.file.BrowserEntry;
import io.xpipe.app.process.CommandBuilder;
import io.xpipe.core.FilePath;
import lombok.NonNull;
import lombok.experimental.SuperBuilder;
import lombok.extern.jackson.Jacksonized;
public class GzipActionProvider implements BrowserActionProvider {
@Override
public String getId() {
return "gzip";
}
@Jacksonized
@SuperBuilder
public static class Action extends BrowserAction {
@NonNull
private final FilePath target;
@Override
public void executeImpl() throws Exception {
var sc = model.getFileSystem().getShell().orElseThrow();
var b = CommandBuilder.of().add("gzip", "--keep", "--force", "--stdout");
for (BrowserEntry entry : getEntries()) {
b.addFile(entry.getRawFileEntry().getPath());
}
b.add(">").addFile(target);
sc.command(b).execute();
model.refreshSync();
}
@Override
public boolean isMutation() {
return true;
}
}
}
@@ -40,7 +40,8 @@ public class UntarActionProvider implements BrowserActionProvider {
.withWorkingDirectory(
toDirectory
? target
: model.getTargetDirectoryPath(getEntries().getFirst()))
: model.getTargetDirectoryPath(
getEntries().getFirst()))
.execute();
}
model.refreshSync();
@@ -50,7 +50,8 @@ public class UnzipActionProvider implements BrowserActionProvider {
.addFile(getTarget(entry.getRawFileEntry().getPath()));
}
try (var cc = sc.command(command)
.withWorkingDirectory(model.getTargetDirectoryPath(getEntries().getFirst()))
.withWorkingDirectory(
model.getTargetDirectoryPath(getEntries().getFirst()))
.start()) {
cc.discardOrThrow();
}
@@ -73,7 +74,8 @@ public class UnzipActionProvider implements BrowserActionProvider {
}
command.add("-Path").addFile(entry.getRawFileEntry().getPath());
sc.command(command)
.withWorkingDirectory(model.getTargetDirectoryPath(getEntries().getFirst()))
.withWorkingDirectory(
model.getTargetDirectoryPath(getEntries().getFirst()))
.execute();
}
}
@@ -43,8 +43,9 @@ public class ContextMenuAugment<S extends Region> implements Consumer<S> {
var r = struc;
r.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> {
var hidden = hide.get();
if (mouseEventCheck != null && mouseEventCheck.test(event)) {
if (!hide.get()) {
if (!hidden) {
var cm = contextMenu.get();
if (cm != null) {
cm.show(r, event.getScreenX(), event.getScreenY());
@@ -67,15 +68,18 @@ public class ContextMenuAugment<S extends Region> implements Consumer<S> {
}
});
r.addEventHandler(KeyEvent.KEY_PRESSED, event -> {
if (keyEventCheck != null && keyEventCheck.test(event)) {
if (!hide.get()) {
var cm = contextMenu.get();
if (cm != null) {
cm.show(r, Side.BOTTOM, 0, 0);
currentContextMenu.set(cm);
if (keyEventCheck != null) {
var hidden = hide.get();
if (keyEventCheck.test(event)) {
if (!hidden) {
var cm = contextMenu.get();
if (cm != null) {
cm.show(r, Side.BOTTOM, 0, 0);
currentContextMenu.set(cm);
}
}
event.consume();
}
event.consume();
}
});
@@ -68,7 +68,7 @@ public class AppLayoutComp extends RegionStructureBuilder<BorderPane, AppLayoutC
if (new KeyCodeCombination(KeyCode.T, KeyCombination.SHORTCUT_DOWN).match(event)) {
if (TerminalDockHubManager.get().getEnabled().get()) {
TerminalDockHubManager.get().toggleDock();
TerminalDockHubManager.get().triggerDock();
event.consume();
}
}
@@ -1,19 +1,15 @@
package io.xpipe.app.comp.base;
import atlantafx.base.controls.ToggleSwitch;
import io.xpipe.app.comp.RegionBuilder;
import io.xpipe.app.core.AppI18n;
import io.xpipe.app.platform.LabelGraphic;
import io.xpipe.app.platform.PlatformThread;
import javafx.beans.binding.Bindings;
import javafx.beans.property.Property;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.value.ObservableValue;
import javafx.css.PseudoClass;
import javafx.geometry.Pos;
import javafx.scene.control.CheckBox;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import lombok.EqualsAndHashCode;
import lombok.Value;
@@ -6,6 +6,7 @@ import io.xpipe.app.platform.MenuHelper;
import io.xpipe.app.platform.PlatformThread;
import io.xpipe.app.util.Translatable;
import javafx.application.Platform;
import javafx.beans.property.Property;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ObservableValue;
@@ -17,9 +18,11 @@ import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.experimental.FieldDefaults;
import java.lang.ref.WeakReference;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
import java.util.stream.Collectors;
@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE)
@@ -47,7 +50,8 @@ public class ChoiceComp<T> extends RegionBuilder<ComboBox<T>> {
@Override
public ComboBox<T> createSimple() {
var cb = MenuHelper.<T>createComboBox();
cb.setConverter(new StringConverter<>() {
Supplier<StringConverter<T>> converter = () -> new StringConverter<>() {
@Override
public String toString(T object) {
if (object == null) {
@@ -66,7 +70,21 @@ public class ChoiceComp<T> extends RegionBuilder<ComboBox<T>> {
public T fromString(String string) {
throw new UnsupportedOperationException();
}
};
cb.setConverter(converter.get());
// Reset converter on language change to force an update
// This does not work properly in older JFX versions, see JDK-8384006
var ref = new WeakReference<>(cb);
AppI18n.activeLanguage().subscribe((v) -> {
var refValue = ref.get();
if (refValue != null) {
Platform.runLater(() -> {
refValue.setConverter(converter.get());
});
}
});
range.subscribe(c -> {
PlatformThread.runLaterIfNeeded(() -> {
var list = FXCollections.observableArrayList(c.keySet());
@@ -2,9 +2,11 @@ package io.xpipe.app.comp.base;
import io.xpipe.app.comp.BaseRegionBuilder;
import io.xpipe.app.comp.RegionBuilder;
import io.xpipe.app.core.AppI18n;
import io.xpipe.app.platform.MenuHelper;
import io.xpipe.app.platform.PlatformThread;
import javafx.application.Platform;
import javafx.beans.property.Property;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
@@ -16,8 +18,11 @@ import javafx.util.StringConverter;
import lombok.Setter;
import java.lang.ref.WeakReference;
import java.util.HashMap;
import java.util.List;
import java.util.function.Function;
import java.util.function.Supplier;
public class ChoicePaneComp extends RegionBuilder<VBox> {
@@ -44,7 +49,8 @@ public class ChoicePaneComp extends RegionBuilder<VBox> {
}
});
cb.getSelectionModel().select(selected.getValue());
cb.setConverter(new StringConverter<>() {
Supplier<StringConverter<Entry>> converter = () -> new StringConverter<>() {
@Override
public String toString(Entry object) {
if (object == null || object.name() == null) {
@@ -58,6 +64,19 @@ public class ChoicePaneComp extends RegionBuilder<VBox> {
public Entry fromString(String string) {
throw new UnsupportedOperationException();
}
};
cb.setConverter(converter.get());
// Reset converter on language change to force an update
// This does not work properly in older JFX versions, see JDK-8384006
var ref = new WeakReference<>(cb);
AppI18n.activeLanguage().subscribe((v) -> {
var refValue = ref.get();
if (refValue != null) {
Platform.runLater(() -> {
refValue.setConverter(converter.get());
});
}
});
var vbox = new VBox(transformer.apply(cb));
@@ -69,13 +88,15 @@ public class ChoicePaneComp extends RegionBuilder<VBox> {
});
cb.prefWidthProperty().bind(vbox.widthProperty());
var regionMap = new HashMap<Entry, Region>();
cb.valueProperty().subscribe(n -> {
if (n == null) {
if (vbox.getChildren().size() > 1) {
vbox.getChildren().remove(1);
}
} else {
var region = n.comp().build();
var region = regionMap.computeIfAbsent(n, entry -> entry.comp().build());
if (vbox.getChildren().size() == 1) {
vbox.getChildren().add(region);
} else {
@@ -5,6 +5,7 @@ import io.xpipe.app.comp.BaseRegionBuilder;
import io.xpipe.app.comp.RegionBuilder;
import io.xpipe.app.core.AppLayoutModel;
import io.xpipe.app.core.window.AppDialog;
import io.xpipe.app.cred.SshIdentityStrategy;
import io.xpipe.app.ext.FileSystemStore;
import io.xpipe.app.ext.ProcessControlProvider;
import io.xpipe.app.issue.ErrorEventFactory;
@@ -126,9 +127,10 @@ public class ContextualFileReferenceChoiceComp extends RegionBuilder<HBox> {
source.toString().substring(0, source.toString().length() - 4))
: source;
var pubSource = Path.of(sourceBase + ".pub");
var pubSource = SshIdentityStrategy.getPublicKeyPath(FilePath.of(source))
.asLocalPath();
if (Files.exists(pubSource)) {
var pubTarget = Path.of(target.toString() + ".pub");
var pubTarget = sync.getTargetLocation().apply(pubSource);
handler.addDataFile(pubSource, pubTarget, sync.getPerUser().get());
}
@@ -216,20 +218,30 @@ public class ContextualFileReferenceChoiceComp extends RegionBuilder<HBox> {
filePath.subscribe(s -> PlatformThread.runLaterIfNeeded(() -> {
prop.set(s != null ? s.toString() : null);
}));
prop.addListener((observable, oldValue, newValue) -> {
filePath.setValue(newValue != null && !newValue.isBlank() ? FilePath.of(newValue.strip()) : null);
});
var fileNameComp = new TextFieldComp(prop).apply(struc -> HBox.setHgrow(struc, Priority.ALWAYS));
if (prompt != null) {
fileNameComp.apply(struc -> {
fileNameComp.apply(struc -> {
if (prompt != null) {
prompt.subscribe(filePath -> {
PlatformThread.runLaterIfNeeded(() -> {
struc.setPromptText(filePath != null ? filePath.toString() : null);
});
});
}
prop.addListener((observable, oldValue, newValue) -> {
if (!struc.isFocused()) {
filePath.setValue(newValue != null && !newValue.isBlank() ? FilePath.of(newValue.strip()) : null);
}
});
}
struc.focusedProperty().addListener((observable, oldValue, newValue) -> {
if (!newValue) {
var v = prop.getValue();
filePath.setValue(v != null && !v.isBlank() ? FilePath.of(v.strip()) : null);
}
});
});
return fileNameComp;
}
@@ -7,6 +7,7 @@ import io.xpipe.app.core.AppOpenArguments;
import io.xpipe.app.hub.comp.StoreFilter;
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;
@@ -17,8 +18,6 @@ import javafx.scene.input.KeyEvent;
import javafx.scene.input.MouseButton;
import atlantafx.base.controls.CustomTextField;
import javafx.scene.layout.Pane;
import javafx.scene.shape.Rectangle;
import org.kordamp.ikonli.javafx.FontIcon;
import java.util.List;
@@ -68,6 +67,14 @@ public class FilterComp extends RegionBuilder<CustomTextField> {
filter.focusedProperty()));
RegionDescriptor.builder().nameKey("search").build().apply(filter);
filter.focusedProperty().subscribe(f -> {
if (f) {
Platform.runLater(() -> {
filter.selectAll();
});
}
});
filter.addEventFilter(KeyEvent.KEY_PRESSED, event -> {
if (new KeyCodeCombination(KeyCode.ESCAPE).match(event)) {
filter.clear();
@@ -95,15 +102,6 @@ public class FilterComp extends RegionBuilder<CustomTextField> {
filterText.setValue(n != null && n.length() > 0 ? n : null);
});
// Fix caret not being visible on right side when overflowing
filter.setSkin(filter.createDefaultSkin());
Pane pane = (Pane) filter.getChildrenUnmodifiable().getFirst();
var rec = new Rectangle();
rec.widthProperty().bind(pane.widthProperty().add(2));
rec.heightProperty().bind(pane.heightProperty());
rec.setSmooth(false);
filter.getChildrenUnmodifiable().getFirst().setClip(rec);
return filter;
}
}
@@ -14,7 +14,10 @@ import javafx.beans.binding.Bindings;
import javafx.beans.property.Property;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.value.ObservableValue;
import javafx.scene.control.ScrollBar;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.TextArea;
import javafx.scene.control.skin.TextAreaSkin;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.Region;
@@ -28,17 +31,23 @@ public class IntegratedTextAreaComp extends RegionStructureBuilder<AnchorPane, I
private final boolean lazy;
private final String identifier;
private final ObservableValue<String> fileType;
private final boolean fitHeight;
public IntegratedTextAreaComp(
Property<String> value, boolean lazy, String identifier, ObservableValue<String> fileType) {
Property<String> value,
boolean lazy,
String identifier,
ObservableValue<String> fileType,
boolean fitHeight) {
this.value = value;
this.lazy = lazy;
this.identifier = identifier;
this.fileType = fileType;
this.fitHeight = fitHeight;
}
public static IntegratedTextAreaComp script(
ObservableValue<DataStoreEntryRef<ShellStore>> host, Property<ShellScript> value) {
ObservableValue<DataStoreEntryRef<ShellStore>> host, Property<ShellScript> value, boolean fitHeight) {
var type = Bindings.createStringBinding(
() -> {
return host.getValue() != null
@@ -49,10 +58,11 @@ public class IntegratedTextAreaComp extends RegionStructureBuilder<AnchorPane, I
: "sh";
},
host);
return script(value, type);
return script(value, type, fitHeight);
}
public static IntegratedTextAreaComp script(Property<ShellScript> value, ObservableValue<String> fileType) {
public static IntegratedTextAreaComp script(
Property<ShellScript> value, ObservableValue<String> fileType, boolean fitHeight) {
var string = new SimpleStringProperty();
value.subscribe(shellScript -> {
string.set(shellScript != null ? shellScript.getValue() : null);
@@ -60,7 +70,7 @@ public class IntegratedTextAreaComp extends RegionStructureBuilder<AnchorPane, I
string.addListener((observable, oldValue, newValue) -> {
value.setValue(newValue != null ? new ShellScript(newValue) : null);
});
var i = new IntegratedTextAreaComp(string, false, "script", fileType);
var i = new IntegratedTextAreaComp(string, false, "script", fileType, fitHeight);
return i;
}
@@ -110,12 +120,26 @@ public class IntegratedTextAreaComp extends RegionStructureBuilder<AnchorPane, I
var copyButton = createOpenButton();
var pane = new AnchorPane(textAreaStruc.get(), copyButton);
pane.setPickOnBounds(false);
AnchorPane.setTopAnchor(copyButton, 4.0);
AnchorPane.setRightAnchor(copyButton, 4.0);
AnchorPane.setLeftAnchor(textAreaStruc.get(), 0.0);
AnchorPane.setRightAnchor(textAreaStruc.get(), 0.0);
pane.maxHeightProperty().bind(textAreaStruc.get().heightProperty());
return new Structure(pane, textAreaStruc.getTextArea());
if (fitHeight) {
pane.maxHeightProperty().bind(textAreaStruc.get().heightProperty());
} else {
textAreaStruc.getTextArea().prefHeightProperty().bind(pane.heightProperty());
}
TextArea ta = textAreaStruc.getTextArea();
ta.setSkin(new TextAreaSkin(ta));
var tas = (ScrollPane) ta.lookup(".scroll-pane");
tas.viewportBoundsProperty().subscribe(v -> {
var bar = (ScrollBar) tas.lookup(".scroll-bar:vertical");
var visible = bar != null && bar.isVisible();
AnchorPane.setTopAnchor(copyButton, visible ? 14 : 4.0);
AnchorPane.setRightAnchor(copyButton, visible ? 14 : 4.0);
});
return new Structure(pane, ta);
}
@Value
@@ -6,6 +6,7 @@ import io.xpipe.app.comp.RegionBuilder;
import io.xpipe.app.core.AppLayoutModel;
import io.xpipe.app.hub.comp.StoreViewState;
import io.xpipe.app.platform.DerivedObservableList;
import io.xpipe.app.util.GlobalTimer;
import javafx.animation.AnimationTimer;
import javafx.application.Platform;
@@ -22,6 +23,7 @@ import javafx.scene.layout.VBox;
import lombok.Setter;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Function;
@@ -160,6 +162,13 @@ public class ListBoxViewComp<T> extends RegionBuilder<ScrollPane> {
Platform.runLater(() -> {
dirty.set(true);
});
GlobalTimer.delay(
() -> {
Platform.runLater(() -> {
dirty.set(true);
});
},
Duration.ofMillis(50));
});
shown.addListener((ListChangeListener<? super T>) (change) -> {
Platform.runLater(() -> {
@@ -285,8 +294,8 @@ public class ListBoxViewComp<T> extends RegionBuilder<ScrollPane> {
if (pane.getScene().getHeight() > 200) {
var sceneNodeBounds = node.localToScene(node.getBoundsInLocal());
// Add some margin to preload
if (sceneNodeBounds.getMaxY() < -100
|| sceneNodeBounds.getMinY() > pane.getScene().getHeight() + 100) {
if (sceneNodeBounds.getMaxY() < -250
|| sceneNodeBounds.getMinY() > pane.getScene().getHeight() + 250) {
return false;
}
}
@@ -88,6 +88,8 @@ public class MarkdownComp extends RegionBuilder<StackPane> {
@SneakyThrows
private WebView createWebView() {
var wv = new WebView();
wv.setMinWidth(100);
wv.setMinHeight(100);
wv.getEngine().setJavaScriptEnabled(false);
wv.setContextMenuEnabled(false);
wv.setPageFill(Color.TRANSPARENT);
@@ -99,10 +101,9 @@ public class MarkdownComp extends RegionBuilder<StackPane> {
AppPrefs.get().theme().subscribe((v) -> {
var refVal = ref.get();
if (refVal != null && v != null) {
var theme = v.isDark()
? "misc/github-markdown-dark.css"
: "misc/github-markdown-light.css";
var url = AppResources.getResourceURL(AppResources.MAIN_MODULE, theme).orElseThrow();
var theme = v.isDark() ? "misc/github-markdown-dark.css" : "misc/github-markdown-light.css";
var url = AppResources.getResourceURL(AppResources.MAIN_MODULE, theme)
.orElseThrow();
refVal.getEngine().setUserStyleSheetLocation(url.toString());
}
});
@@ -10,6 +10,7 @@ import io.xpipe.app.platform.PlatformThread;
import io.xpipe.app.util.BooleanScope;
import io.xpipe.core.OsType;
import javafx.animation.Timeline;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.property.Property;
@@ -65,11 +66,16 @@ public class ModalOverlayComp extends RegionBuilder<Region> {
if (lastShowValue != null
&& java.time.Duration.between(lastShowValue, Instant.now())
.toMillis()
> 500) {
> 1000) {
mouseHandler.handle(event);
}
});
}
@Override
protected Timeline createCloseBlockedAnimation() {
return new Timeline();
}
});
modal.setInTransitionFactory(
OsType.ofLocal() == OsType.LINUX ? null : node -> Animations.fadeIn(node, Duration.millis(150)));
@@ -216,6 +222,7 @@ public class ModalOverlayComp extends RegionBuilder<Region> {
newValue.getGraphic() != null
? newValue.getGraphic()
: new LabelGraphic.IconGraphic("mdi2i-information-outline")));
l.style("title");
l.apply(struc -> {
struc.setGraphicTextGap(8);
AppFontSizes.xl(struc);
@@ -235,7 +242,7 @@ public class ModalOverlayComp extends RegionBuilder<Region> {
var node = o instanceof ModalButton mb ? toButton(mb) : ((BaseRegionBuilder<?, ?>) o).build();
if (o instanceof ModalButton) {
node.widthProperty().addListener((observable, oldValue, n) -> {
var d = Math.min(Math.max(n.doubleValue(), 70.0), 200.0);
var d = Math.clamp(n.doubleValue(), 70.0, 200.0);
if (d > max.get()) {
max.set(d);
}
@@ -23,7 +23,7 @@ public class ModalOverlayStackComp extends SimpleRegionBuilder {
@Override
protected Region createSimple() {
var current = background;
for (var i = 0; i < 5; i++) {
for (var i = 0; i < 6; i++) {
current = buildModalOverlay(current, i);
}
return current.build();
@@ -35,8 +35,8 @@ public class ModalOverlayStackComp extends SimpleRegionBuilder {
modalOverlay.addListener((ListChangeListener<? super ModalOverlay>) c -> {
var ex = prop.get();
// Don't shift just for an index change
if (ex != null && modalOverlay.contains(ex)) {
currentIndex.set(modalOverlay.indexOf(ex));
if (ex != null && c.getList().contains(ex)) {
currentIndex.set(c.getList().indexOf(ex));
return;
} else {
currentIndex.set(index);
@@ -28,6 +28,7 @@ import atlantafx.base.controls.Spacer;
import atlantafx.base.theme.Styles;
import lombok.Getter;
import org.int4.fx.builders.common.AbstractRegionBuilder;
import org.kordamp.ikonli.javafx.FontIcon;
import java.util.ArrayList;
import java.util.HashMap;
@@ -116,7 +117,9 @@ public class OptionsComp extends RegionBuilder<VBox> {
vbox.accessibleTextProperty().bind(joined);
if (entry.documentationLink() != null) {
var link = new Button("... ?");
var fi = new FontIcon("mdi2b-book-open-variant");
fi.getStyleClass().add("graphic");
var link = new Button("?", fi);
link.setMinWidth(Region.USE_PREF_SIZE);
link.getStyleClass().add(Styles.BUTTON_OUTLINED);
link.getStyleClass().add(Styles.ACCENT);
@@ -149,9 +152,13 @@ public class OptionsComp extends RegionBuilder<VBox> {
descriptionBox.visibleProperty().bind(compRegion.visibleProperty());
descriptionBox.managedProperty().bind(compRegion.managedProperty());
} else {
vbox.getChildren().add(description);
var descriptionBox = new HBox(description);
descriptionBox.getStyleClass().add("description-box");
vbox.getChildren().add(descriptionBox);
vbox.getChildren().add(new Spacer(2, Orientation.VERTICAL));
VBox.setMargin(description, new Insets(0, 0, 0, 1));
VBox.setMargin(descriptionBox, new Insets(0, 0, 0, 1));
descriptionBox.visibleProperty().bind(compRegion.visibleProperty());
descriptionBox.managedProperty().bind(compRegion.managedProperty());
}
line.getChildren().add(vbox);
@@ -15,6 +15,7 @@ import javafx.scene.image.ImageView;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import java.lang.ref.WeakReference;
import java.util.function.Consumer;
public class PrettyImageComp extends SimpleRegionBuilder {
@@ -113,8 +114,12 @@ public class PrettyImageComp extends SimpleRegionBuilder {
value.subscribe(update);
if (AppPrefs.get() != null) {
var ref = new WeakReference<>(update);
AppPrefs.get().theme().addListener((observable, oldValue, newValue) -> {
update.accept(value.getValue());
var v = ref.get();
if (v != null) {
v.accept(value.getValue());
}
});
}
@@ -10,6 +10,7 @@ import io.xpipe.app.platform.PlatformThread;
import io.xpipe.core.InPlaceSecretValue;
import javafx.application.Platform;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.Property;
import javafx.beans.property.SimpleObjectProperty;
import javafx.geometry.Insets;
@@ -34,11 +35,11 @@ import java.util.Objects;
public class SecretFieldComp extends RegionStructureBuilder<InputGroup, SecretFieldComp.Structure> {
private final Property<InPlaceSecretValue> value;
private final ObjectProperty<InPlaceSecretValue> value;
private final boolean allowCopy;
private final List<BaseRegionBuilder<?, ?>> additionalButtons = new ArrayList<>();
public SecretFieldComp(Property<InPlaceSecretValue> value, boolean allowCopy) {
public SecretFieldComp(ObjectProperty<InPlaceSecretValue> value, boolean allowCopy) {
this.value = value;
this.allowCopy = allowCopy;
}
@@ -123,6 +124,7 @@ public class SecretFieldComp extends RegionStructureBuilder<InputGroup, SecretFi
var copyButton = new ButtonComp(null, new FontIcon("mdi2c-clipboard-multiple-outline"), () -> {
ClipboardHelper.copyPassword(value.getValue(), true);
})
.disable(value.isNull())
.describe(d -> d.nameKey("copy"));
var list = new ArrayList<BaseRegionBuilder<?, ?>>();
@@ -58,29 +58,34 @@ public class SideMenuBarComp extends RegionBuilder<VBox> {
value.setValue(e);
}
});
b.describe(d -> d.name(e.name()));
var stack = createStyle(e, b);
var stack = createStyle(e, b, false);
var shortcut = e.combination();
if (shortcut != null) {
stack.apply(struc -> struc.getProperties().put("shortcut", shortcut));
}
b.describe(d -> {
d.name(e.name());
if (shortcut != null) {
d.shortcut(shortcut);
}
});
vbox.getChildren().add(stack.build());
}
{
var b = new IconButtonComp("mdi2u-update", () -> {
var r = UpdateAvailableDialog.showIfNeeded(false);
if (!r) {
AppPrefs.get().selectCategory("about");
ThreadHelper.runFailableAsync(() -> {
ThreadHelper.runFailableAsync(() -> {
var r = UpdateAvailableDialog.showIfNeeded(false);
if (!r) {
AppPrefs.get().selectCategory("about");
UpdateHandler uh = AppDistributionType.get().getUpdateHandler();
uh.prepareUpdate();
});
}
}
});
});
b.describe(d -> d.nameKey("updateAvailableTooltip"));
var stack = createStyle(null, b);
var stack = createStyle(null, b, false);
var h = AppDistributionType.get().getUpdateHandler();
stack.hide(Bindings.createBooleanBinding(
() -> {
@@ -94,7 +99,7 @@ public class SideMenuBarComp extends RegionBuilder<VBox> {
if (!AppProperties.get().isStaging()) {
var b = new IconButtonComp("mdoal-insights", () -> Hyperlinks.open(Hyperlinks.GITHUB_PTB));
b.describe(d -> d.nameKey("ptbAvailableTooltip"));
var stack = createStyle(null, b);
var stack = createStyle(null, b, false);
stack.hide(AppLayoutModel.get().getPtbAvailable().not());
vbox.getChildren().add(stack.build());
}
@@ -125,8 +130,10 @@ public class SideMenuBarComp extends RegionBuilder<VBox> {
e.consume();
});
});
var stack = createStyle(null, b);
(item.isTop() ? topQueueButtons : bottomQueueButtons).getChildren().add(stack.build());
var stack = createStyle(null, b, !item.isTop());
(item.isTop() ? topQueueButtons : bottomQueueButtons)
.getChildren()
.add(stack.build());
}
});
});
@@ -138,7 +145,7 @@ public class SideMenuBarComp extends RegionBuilder<VBox> {
return vbox;
}
private BaseRegionBuilder<?, ?> createStyle(AppLayoutModel.Entry e, IconButtonComp b) {
private BaseRegionBuilder<?, ?> createStyle(AppLayoutModel.Entry e, IconButtonComp b, boolean highlight) {
var selected = PseudoClass.getPseudoClass("selected");
b.apply(struc -> {
@@ -189,14 +196,14 @@ public class SideMenuBarComp extends RegionBuilder<VBox> {
.backgroundProperty()
.bind(Bindings.createObjectBinding(
() -> {
if (value.getValue().equals(e)) {
return selectedBorder.get();
}
if (struc.isHover()) {
return hoverBorder.get();
}
if (highlight || value.getValue().equals(e)) {
return selectedBorder.get();
}
return noneBorder.get();
},
struc.hoverProperty(),
@@ -0,0 +1,66 @@
package io.xpipe.app.comp.base;
import io.xpipe.app.comp.RegionBuilder;
import io.xpipe.app.core.AppI18n;
import io.xpipe.app.issue.ErrorEventFactory;
import io.xpipe.app.util.ThreadHelper;
import io.xpipe.core.FailableSupplier;
import javafx.application.Platform;
import javafx.geometry.Insets;
import javafx.scene.control.Button;
import javafx.scene.layout.Region;
import atlantafx.base.theme.Styles;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.kordamp.ikonli.javafx.FontIcon;
import java.util.concurrent.atomic.AtomicReference;
@Getter
@AllArgsConstructor
public class TestButtonComp extends RegionBuilder<Button> {
private final FailableSupplier<Boolean> run;
@Override
public Button createSimple() {
AtomicReference<Region> button = new AtomicReference<>();
var testButton = new ButtonComp(AppI18n.observable("test"), new FontIcon("mdi2p-play"), () -> {
ThreadHelper.runAsync(() -> {
Platform.runLater(() -> {
button.get().getStyleClass().removeAll(Styles.SUCCESS, Styles.DANGER, Styles.ACCENT);
button.get().getStyleClass().add(Styles.ACCENT);
button.get().setDisable(true);
});
try {
boolean r;
try {
r = run.get();
} finally {
Platform.runLater(() -> {
button.get().setDisable(false);
button.get().getStyleClass().removeAll(Styles.SUCCESS, Styles.DANGER, Styles.ACCENT);
});
}
Platform.runLater(() -> {
if (r) {
button.get().getStyleClass().add(Styles.SUCCESS);
} else {
button.get().getStyleClass().add(Styles.DANGER);
}
});
} catch (Throwable e) {
Platform.runLater(() -> {
button.get().getStyleClass().add(Styles.DANGER);
});
ErrorEventFactory.fromThrowable(e).expected().handle();
}
});
});
testButton.apply(struc -> button.set(struc));
testButton.padding(new Insets(6, 10, 6, 6));
return testButton.build();
}
}
@@ -6,10 +6,7 @@ import io.xpipe.app.platform.PlatformThread;
import javafx.beans.property.Property;
import javafx.beans.property.SimpleStringProperty;
import javafx.scene.control.TextField;
import javafx.scene.control.skin.TextFieldSkin;
import javafx.scene.input.KeyCode;
import javafx.scene.layout.Pane;
import javafx.scene.shape.Rectangle;
import java.util.Objects;
@@ -73,15 +70,6 @@ public class TextFieldComp extends RegionBuilder<TextField> {
}
});
// Fix caret not being visible on right side when overflowing
text.setSkin(new TextFieldSkin(text));
Pane pane = (Pane) text.getChildrenUnmodifiable().getFirst();
var rec = new Rectangle();
rec.widthProperty().bind(pane.widthProperty().add(2));
rec.heightProperty().bind(pane.heightProperty());
rec.setSmooth(false);
text.getChildrenUnmodifiable().getFirst().setClip(rec);
return text;
}
}
@@ -0,0 +1,295 @@
package io.xpipe.app.core;
import io.xpipe.app.comp.base.ModalButton;
import io.xpipe.app.comp.base.ModalOverlay;
import io.xpipe.app.comp.base.TextAreaComp;
import io.xpipe.app.issue.ErrorEventFactory;
import io.xpipe.app.platform.OptionsBuilder;
import io.xpipe.app.process.OsFileSystem;
import io.xpipe.app.util.ThreadHelper;
import io.xpipe.app.util.TlsCertificateFormat;
import javafx.beans.property.SimpleStringProperty;
import lombok.Getter;
import lombok.SneakyThrows;
import lombok.Value;
import org.apache.commons.io.FilenameUtils;
import java.io.ByteArrayInputStream;
import java.io.FileInputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyStore;
import java.security.cert.*;
import java.util.*;
import javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.X509TrustManager;
public class AppCertStore {
@Value
static class Entry {
String name;
Path file;
X509Certificate certificate;
}
private class SavingTrustManager implements X509TrustManager {
@Override
public X509Certificate[] getAcceptedIssuers() {
return trustManager.getAcceptedIssuers();
}
public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
trustManager.checkClientTrusted(chain, authType);
}
public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
try {
trustManager.checkServerTrusted(chain, authType);
} catch (CertificateException e) {
var cause = e.getCause();
var nonTrusted = cause != null
&& cause.getClass()
.getName()
.equals("sun.security.provider.certpath.SunCertPathBuilderException");
if (nonTrusted) {
showTrustDialog(chain[chain.length - 1]);
ErrorEventFactory.preconfigure(
ErrorEventFactory.fromThrowable(e).expected().omit());
throw e;
} else {
throw ErrorEventFactory.expected(e);
}
}
}
}
@Getter
private final List<Entry> certificates;
private X509TrustManager trustManager;
private boolean modalShowing;
private final SavingTrustManager savingTrustManager = new SavingTrustManager();
private AppCertStore(List<Entry> certificates) {
this.certificates = certificates;
}
public static Path getDir() {
return AppProperties.get().getDataDir().resolve("cacerts");
}
public static Path getBundleFileFilePath() {
return getDir().resolve("bundle.pem");
}
public static Optional<Path> getBundleFile() {
var file = getBundleFileFilePath();
return Files.exists(file) ? Optional.of(file) : Optional.empty();
}
public synchronized void addCertificate(String name, X509Certificate certificate) {
if (certificates.stream().anyMatch(entry -> entry.certificate.equals(certificate))) {
return;
}
try {
var dir = getDir();
Files.createDirectories(dir);
var compatName = OsFileSystem.ofLocal().makeFileSystemCompatible(name);
var pemFile = dir.resolve(name + ".pem");
var pem = convertToPem(certificate);
Files.writeString(pemFile, pem);
var cerFile = dir.resolve(name + ".cer");
var cer = certificate.getEncoded();
Files.write(cerFile, cer);
var entry = new Entry(compatName, pemFile, certificate);
certificates.add(entry);
refreshCertBundle(true);
updateTrustManager();
} catch (Exception e) {
ErrorEventFactory.fromThrowable(e).handle();
}
}
private void refreshCertBundle(boolean force) throws Exception {
var file = getBundleFileFilePath();
if (certificates.isEmpty()) {
Files.deleteIfExists(file);
return;
}
if (!force && Files.exists(file)) {
return;
}
var s = new StringBuilder();
var ks = KeyStore.getInstance("JKS");
var caCertsFile = Path.of(System.getProperty("java.home") + "/lib/security/cacerts");
try (FileInputStream fis = new FileInputStream(caCertsFile.toFile())) {
ks.load(fis, null);
}
Enumeration<String> list = ks.aliases();
while (list.hasMoreElements()) {
String alias = list.nextElement();
// Check if this cert is labeled a trust anchor.
if (alias.contains(" [jdk")) {
X509Certificate cert = (X509Certificate) ks.getCertificate(alias);
s.append(convertToPem(cert));
}
}
for (Entry e : certificates) {
s.append(convertToPem(e.certificate));
}
Files.writeString(file, s);
}
public X509TrustManager getCustomTrustManager() {
return savingTrustManager;
}
@SneakyThrows
private void updateTrustManager() {
KeyStore ks = KeyStore.getInstance("JKS");
var caCertsFile = Path.of(System.getProperty("java.home") + "/lib/security/cacerts");
try (FileInputStream fis = new FileInputStream(caCertsFile.toFile())) {
ks.load(fis, null);
}
// For testing TLS cert acceptance dialogs
// without setting up a custom proxy
// var e = ks.aliases();
// var list = new ArrayList<String>();
// while (e.hasMoreElements()) {
// String alias = e.nextElement();
// list.add(alias);
// }
// for (String s : list) {
// ks.deleteEntry(s);
// }
for (Entry certificate : certificates) {
ks.setCertificateEntry(certificate.getName(), certificate.getCertificate());
}
TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmf.init(ks);
trustManager = (X509TrustManager) tmf.getTrustManagers()[0];
}
private synchronized void showTrustDialog(X509Certificate certificate) {
if (modalShowing) {
return;
}
modalShowing = true;
ThreadHelper.runAsync(() -> {
var format = TlsCertificateFormat.format(certificate);
var content = new TextAreaComp(new SimpleStringProperty(format))
.applyStructure(structure -> {
structure.getTextArea().setEditable(false);
})
.prefHeight(450);
var name = new SimpleStringProperty();
var options = new OptionsBuilder()
.nameAndDescription("certificateDetails")
.addComp(content)
.nameAndDescription("certificateName")
.addString(name)
.nonNull()
.buildComp()
.prefWidth(650);
var modal = ModalOverlay.of("untrustedCertificateTitle", options);
modal.addButton(ModalButton.cancel());
modal.addButton(new ModalButton(
"trust",
() -> {
ThreadHelper.runAsync(() -> {
addCertificate(name.getValue(), certificate);
});
},
true,
true)
.augment(button -> {
button.disableProperty().bind(name.isNull());
}));
modal.showAndWait();
modalShowing = false;
});
}
private static AppCertStore INSTANCE;
public static AppCertStore get() {
return INSTANCE;
}
public static void init() {
var dir = getDir();
if (!Files.exists(dir)) {
INSTANCE = new AppCertStore(new ArrayList<>());
INSTANCE.updateTrustManager();
return;
}
var list = new ArrayList<Entry>();
try (var stream = Files.list(dir)) {
var files = stream.toList();
for (Path f : files) {
if (f.equals(getBundleFileFilePath())) {
continue;
}
if (!f.getFileName().toString().endsWith(".pem")) {
continue;
}
var cert = parseCertificate(f);
var name = FilenameUtils.getBaseName(f.getFileName().toString());
list.add(new Entry(name, f, cert));
}
} catch (Exception e) {
ErrorEventFactory.fromThrowable(e).expected().handle();
}
INSTANCE = new AppCertStore(list);
try {
INSTANCE.refreshCertBundle(!AppProperties.get().isDevelopmentEnvironment()
&& AppProperties.get().isNewBuildSession());
} catch (Exception e) {
ErrorEventFactory.fromThrowable(e).expected().handle();
}
INSTANCE.updateTrustManager();
}
public static void reset() {
INSTANCE = null;
}
private static X509Certificate parseCertificate(Path file) throws Exception {
var b = Files.readAllBytes(file);
return (X509Certificate)
CertificateFactory.getInstance("X.509").generateCertificate(new ByteArrayInputStream(b));
}
private static String convertToPem(X509Certificate cert) throws CertificateEncodingException {
String begin = "-----BEGIN CERTIFICATE-----\n";
String end = "\n-----END CERTIFICATE-----\n";
byte[] derCert = cert.getEncoded();
Base64.Encoder encoder = Base64.getMimeEncoder(64, "\n".getBytes(StandardCharsets.UTF_8));
String pemCertPre = encoder.encodeToString(derCert);
String pemCert = begin + pemCertPre + end;
return pemCert;
}
}
@@ -8,8 +8,8 @@ import io.xpipe.app.platform.OptionsBuilder;
import io.xpipe.app.platform.PlatformState;
import io.xpipe.app.prefs.*;
import io.xpipe.app.util.DocumentationLink;
import io.xpipe.core.OsType;
import javafx.application.Platform;
import javafx.scene.layout.Region;
@@ -32,12 +32,12 @@ public class AppConfigurationDialog {
.sub(PersonalizationCategory.themeChoice())
.sub(TerminalCategory.terminalChoice(false));
if (OsType.ofLocal() != OsType.WINDOWS && AppPrefs.get().terminalMultiplexer().getValue() != null) {
if (OsType.ofLocal() != OsType.WINDOWS
&& AppPrefs.get().terminalMultiplexer().getValue() != null) {
options.sub(TerminalCategory.terminalMultiplexerChoice());
}
var optionsComp = options
.sub(EditorCategory.editorChoice())
var optionsComp = options.sub(EditorCategory.editorChoice())
.sub(PasswordManagerCategory.passwordManagerChoice())
.buildComp();
optionsComp.style("initial-setup");
@@ -5,6 +5,7 @@ import io.xpipe.app.core.mode.AppOperationMode;
import io.xpipe.app.issue.ErrorEventFactory;
import io.xpipe.app.platform.PlatformState;
import io.xpipe.app.prefs.AppPrefs;
import io.xpipe.app.util.ThreadHelper;
import io.xpipe.core.OsType;
import java.awt.*;
@@ -51,16 +52,6 @@ public class AppDesktopIntegration {
// This will initialize the toolkit on macOS and create the dock icon
// macOS does not like applications that run fully in the background, so always do it
if (OsType.ofLocal() == OsType.MACOS && Desktop.isDesktopSupported()) {
Desktop.getDesktop().setPreferencesHandler(e -> {
if (PlatformState.getCurrent() != PlatformState.RUNNING) {
return;
}
if (AppLayoutModel.get() != null) {
AppLayoutModel.get().selectSettings();
}
});
// URL open operations have to be handled in a special way on macOS!
Desktop.getDesktop().setOpenURIHandler(e -> {
AppOpenArguments.handle(List.of(e.getURI().toString()));
@@ -91,9 +82,44 @@ public class AppDesktopIntegration {
.handle();
}
}
Desktop.getDesktop().setQuitHandler((e, response) -> {
response.cancelQuit();
ThreadHelper.runAsync(() -> {
AppOperationMode.externalShutdown();
});
});
}
} catch (Throwable ex) {
ErrorEventFactory.fromThrowable(ex).term().handle();
}
}
public static void initMenuBar() {
if (OsType.ofLocal() == OsType.MACOS && Desktop.isDesktopSupported()) {
// TODO: These don't show up any more
// JavaFX broke them
Desktop.getDesktop().setPreferencesHandler(e -> {
if (PlatformState.getCurrent() != PlatformState.RUNNING) {
return;
}
if (AppLayoutModel.get() != null) {
AppLayoutModel.get().selectSettings();
}
});
Desktop.getDesktop().setAboutHandler(e -> {
if (PlatformState.getCurrent() != PlatformState.RUNNING) {
return;
}
if (AppLayoutModel.get() != null) {
AppLayoutModel.get().selectSettings();
}
});
}
}
}
@@ -52,7 +52,7 @@ public class AppDisplayScale {
if (AppPrefs.get() != null) {
var s = AppPrefs.get().uiScale().getValue();
if (s != null) {
var i = Math.min(300, Math.max(25, s));
var i = Math.clamp(s, 25, 300);
var percent = i / 100.0;
return percent;
}
@@ -4,15 +4,20 @@ import io.xpipe.app.beacon.AppBeaconServer;
import io.xpipe.app.core.mode.AppOperationMode;
import io.xpipe.app.issue.ErrorEventFactory;
import io.xpipe.app.issue.TrackEvent;
import io.xpipe.app.util.DocumentationLink;
import io.xpipe.app.util.ThreadHelper;
import io.xpipe.beacon.BeaconClient;
import io.xpipe.beacon.BeaconClientInformation;
import io.xpipe.beacon.BeaconConfig;
import io.xpipe.beacon.BeaconServer;
import io.xpipe.beacon.api.DaemonFocusExchange;
import io.xpipe.beacon.api.DaemonOpenExchange;
import io.xpipe.core.OsType;
import java.awt.*;
import java.io.RandomAccessFile;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
@@ -33,8 +38,42 @@ public class AppInstance {
}
private static void checkStart(int attemptCounter) {
var port = AppBeaconServer.get().getPort();
var port = BeaconConfig.getUsedPort();
var reachable = BeaconServer.isReachable(port);
if (reachable) {
// If an instance is running as another user, we cannot connect to it as the xpipe_auth file is inaccessible
var authFile = BeaconConfig.getLocalBeaconAuthFile();
var hasAuthFile = Files.exists(authFile);
// Make sure that it is not a leftover
if (hasAuthFile) {
try (var channel = new RandomAccessFile(BeaconConfig.getLocalBeaconLockFile().toFile(), "rw").getChannel()) {
var lock = channel.tryLock();
if (lock != null) {
lock.release();
Files.delete(authFile);
hasAuthFile = false;
}
} catch (Exception ignored) {}
}
if (!hasAuthFile) {
var replacement = BeaconConfig.fallBackToAnotherPort();
if (replacement.isEmpty()) {
ErrorEventFactory.fromMessage("Unable to find free beacon port")
.term()
.documentationLink(DocumentationLink.BEACON_PORT_BIND)
.expected()
.handle();
AppOperationMode.halt(1);
} else {
port = replacement.getAsInt();
reachable = false;
}
}
}
if (!reachable) {
// Even in case we are unable to reach another beacon server
// there might be another instance running, for example
@@ -52,11 +91,9 @@ public class AppInstance {
var client = tryEstablishConnection(port);
if (client.isEmpty()) {
// If an instance is running as another user, we cannot connect to it as the xpipe_auth file is inaccessible
// Therefore the beacon client is not present.
// We still should check whether it is somehow occupied, otherwise beacon server startup will fail
TrackEvent.info(
"Another instance is already running on this port as another user or is not reachable. Quitting ...");
"Another instance is already running on this port but is not reachable. Quitting ...");
AppOperationMode.halt(1);
return;
}
@@ -236,8 +236,15 @@ public class AppLayoutModel {
@AllArgsConstructor
public static class QueueEntry {
public static QueueEntry ofNotification(String key, String value) {
return new QueueEntry(AppI18n.observable(key), new LabelGraphic.IconGraphic(value), () -> true);
public static QueueEntry ofNotification(String nameKey, String icon) {
return new QueueEntry(AppI18n.observable(nameKey), new LabelGraphic.IconGraphic(icon), () -> true);
}
public static QueueEntry ofNotification(String nameKey, String modalKey, String icon, boolean hideOnClick) {
return new QueueEntry(AppI18n.observable(nameKey), new LabelGraphic.IconGraphic(icon), () -> {
AppDialog.information(modalKey);
return hideOnClick;
});
}
public QueueEntry(ObservableValue<String> name, LabelGraphic icon, Supplier<Boolean> action) {
@@ -1,9 +1,7 @@
package io.xpipe.app.core;
import io.xpipe.app.core.mode.AppOperationMode;
import io.xpipe.app.process.LocalShell;
import io.xpipe.app.process.ShellDialect;
import io.xpipe.app.process.ShellDialects;
import io.xpipe.app.process.*;
import io.xpipe.app.update.AppDistributionType;
import io.xpipe.core.OsType;
@@ -17,27 +15,24 @@ public class AppRestart {
var loc = AppProperties.get().isDevelopmentEnvironment()
? AppInstallation.ofDefault()
: AppInstallation.ofCurrent();
var suffix = (arguments.size() > 0 ? " " + String.join(" ", arguments) : "");
if (AppDistributionType.get() == AppDistributionType.APP_IMAGE) {
var exec = System.getenv("APPIMAGE");
return "nohup \"" + exec + "\"" + suffix + " </dev/null >/dev/null 2>&1 & disown";
var b = CommandBuilder.of().addQuoted(exec).addAll(arguments);
var async = dialect.launchAsync(b, true);
return async.buildSimple();
} else if (OsType.ofLocal() == OsType.LINUX) {
var exec = loc.getCliExecutablePath();
return "\"" + exec + "\" open" + suffix;
var b = CommandBuilder.of().addFile(exec).add("open").addAll(arguments);
return b.buildSimple();
} else if (OsType.ofLocal() == OsType.MACOS) {
var exec = loc.getCliExecutablePath();
return "\"" + exec + "\" open" + suffix;
var b = CommandBuilder.of().addFile(exec).add("open").addAll(arguments);
return b.buildSimple();
} else {
var exe = loc.getDaemonExecutablePath();
if (ShellDialects.isPowershell(dialect)) {
var escapedList =
arguments.stream().map(s -> s.replaceAll("\"", "`\"")).toList();
var argumentList = String.join(" ", escapedList);
return "echo \"Starting XPipe ...\"; Start-Process -FilePath \"" + exe + "\" -ArgumentList \"" + argumentList + "\"";
} else {
var base = "\"" + exe + "\"" + suffix;
return "echo Starting XPipe ...&start \"\" " + base;
}
var b = CommandBuilder.of().addFile(exe).addAll(arguments);
var async = dialect.launchAsync(b, true);
return async.buildSimple();
}
}
@@ -45,26 +40,29 @@ public class AppRestart {
var loc = AppProperties.get().isDevelopmentEnvironment()
? AppInstallation.ofDefault()
: AppInstallation.ofCurrent();
var suffix = (arguments.size() > 0 ? " " + String.join(" ", arguments) : "");
if (AppDistributionType.get() == AppDistributionType.APP_IMAGE) {
var exec = System.getenv("APPIMAGE");
return "nohup \"" + exec + "\"" + suffix + " </dev/null >/dev/null 2>&1 & disown";
var b = CommandBuilder.of().addQuoted(exec).addAll(arguments);
var async = dialect.launchAsync(b, true);
return async.buildSimple();
} else if (OsType.ofLocal() == OsType.LINUX) {
return "nohup \"" + loc.getDaemonExecutablePath() + "\"" + suffix + " </dev/null >/dev/null 2>&1 & disown";
var exec = loc.getDaemonExecutablePath();
var b = CommandBuilder.of().addFile(exec).addAll(arguments);
var async = dialect.launchAsync(b, true);
return async.buildSimple();
} else if (OsType.ofLocal() == OsType.MACOS) {
return "(sleep 1;open \"" + loc.getBaseInstallationPath() + "\" --args" + suffix
+ " </dev/null &>/dev/null) & disown";
var exec = loc.getBaseInstallationPath();
var b = CommandBuilder.of()
.add("open", "-na")
.addFile(exec)
.addIf(!arguments.isEmpty(), "--args")
.addAll(arguments);
return b.buildSimple();
} else {
var exe = loc.getDaemonExecutablePath();
if (ShellDialects.isPowershell(dialect)) {
var escapedList =
arguments.stream().map(s -> s.replaceAll("\"", "`\"")).toList();
var argumentList = String.join(" ", escapedList);
return "Start-Process -FilePath \"" + exe + "\" -ArgumentList \"" + argumentList + "\"";
} else {
var base = "\"" + exe + "\"" + suffix;
return "start \"\" " + base;
}
var b = CommandBuilder.of().addFile(exe).addAll(arguments);
var async = dialect.launchAsync(b, true);
return async.buildSimple();
}
}
@@ -215,7 +215,7 @@ public abstract class AppSystemInfo {
var r = Shell32Util.getKnownFolderPath(KnownFolders.FOLDERID_Downloads);
// Replace 8.3 filename
return (downloads = Path.of(r).toRealPath());
} catch (Throwable e) {
} catch (Throwable e) {
if (!(e instanceof Win32Exception)) {
ErrorEventFactory.fromThrowable(e).handle();
}
@@ -2,7 +2,6 @@ package io.xpipe.app.core.check;
import io.xpipe.app.core.AppCache;
import io.xpipe.app.issue.ErrorEventFactory;
import io.xpipe.app.util.LocalExec;
import io.xpipe.core.OsType;
public class AppHardwareAccelerationDisableCheck {
@@ -64,6 +64,7 @@ public class AppBaseMode extends AppOperationMode {
// if (true) throw new IllegalStateException();
TrackEvent.info("Initializing base mode components ...");
AppCertStore.init();
AppMainWindow.loadingText("checkingLicense");
LicenseProvider.get().init();
AppMainWindow.loadingText("initializingApp");
@@ -157,6 +158,7 @@ public class AppBaseMode extends AppOperationMode {
TerminalView.init();
TerminalLauncherManager.init();
TerminalDockHubManager.init();
RemoteDesktopWindow.init();
TrackEvent.info("File/Terminal initialization thread completed");
},
() -> {
@@ -182,7 +184,7 @@ public class AppBaseMode extends AppOperationMode {
TrackEvent.info("Browser initialization thread completed");
});
AppGreetingsDialog.showAndWaitIfNeeded();
// AppGreetingsDialog.showAndWaitIfNeeded();
TrackEvent.info("Waiting for startup dialogs to close");
AppDialog.waitForAllDialogsClose();
UpdateChangelogDialog.showIfNeeded();
@@ -206,6 +208,7 @@ public class AppBaseMode extends AppOperationMode {
AbstractAction.reset();
AppMcpServer.reset();
WorkspaceManager.reset();
RemoteDesktopWindow.reset();
AppPrefs.reset();
DataStorage.reset();
DataStorageSyncHandler.getInstance().reset();
@@ -227,6 +230,7 @@ public class AppBaseMode extends AppOperationMode {
AppDataLock.unlock();
BlobManager.reset();
FileBridge.reset();
AppCertStore.reset();
AppFileWatcher.reset();
GlobalTimer.reset();
LocalFileTracker.reset();
@@ -1,5 +1,6 @@
package io.xpipe.app.core.mode;
import io.xpipe.app.core.window.AppDialog;
import io.xpipe.app.core.window.AppMainWindow;
import io.xpipe.app.issue.TrackEvent;
import io.xpipe.app.platform.PlatformInit;
@@ -25,16 +26,23 @@ public class AppGuiMode extends AppOperationMode {
@Override
public void onSwitchFrom() {
// If we are in an externally started shutdown hook, don't close the windows until the platform exits
// That way, it is kept open to block for shutdowns on Windows systems
if (OsType.ofLocal() != OsType.WINDOWS || !AppOperationMode.isInShutdownHook()) {
PlatformThread.runLaterIfNeededBlocking(() -> {
TrackEvent.info("Closing windows");
Stage.getWindows().stream().toList().forEach(w -> {
w.hide();
});
});
}
TrackEvent.info("Closing windows");
PlatformThread.runLaterIfNeededBlocking(() -> {
// Close dialogs
AppDialog.getModalOverlays().clear();
// Close other windows
Stage.getWindows().stream()
.filter(w -> !w.equals(AppMainWindow.get().getStage()))
.toList()
.forEach(w -> w.hide());
// If we are in an externally started shutdown hook, don't close the windows until the platform exits
// That way, it is kept open to block for shutdowns on Windows systems
if (OsType.ofLocal() != OsType.WINDOWS || !AppOperationMode.isInShutdownHook()) {
AppMainWindow.get().hide();
}
});
}
@Override
@@ -10,6 +10,7 @@ import io.xpipe.app.platform.PlatformState;
import io.xpipe.app.platform.PlatformThreadWatcher;
import io.xpipe.app.prefs.AppPrefs;
import io.xpipe.app.prefs.CloseBehaviour;
import io.xpipe.app.prefs.CloseBehaviourDialog;
import io.xpipe.app.process.LocalShell;
import io.xpipe.app.storage.DataStorage;
import io.xpipe.app.update.AppDistributionType;
@@ -120,7 +121,6 @@ public abstract class AppOperationMode {
AppExtensionManager.init();
AppI18n.init();
AppPrefs.initLocal();
AppBeaconServer.setupPort();
AppInstance.init();
// Initialize early to load in parallel
PlatformInit.init(false);
@@ -281,7 +281,7 @@ public abstract class AppOperationMode {
public static void onWindowClose() {
CloseBehaviour action;
if (AppPrefs.get() != null && !isInStartup() && !isInShutdown()) {
if (AppPrefs.get() != null && !isInStartup() && !isInShutdown() && !CloseBehaviourDialog.showIfNeeded()) {
action = AppPrefs.get().closeBehaviour().getValue();
} else {
action = CloseBehaviour.QUIT;
@@ -6,12 +6,11 @@ import io.xpipe.app.core.*;
import io.xpipe.app.core.mode.AppOperationMode;
import io.xpipe.app.issue.ErrorEventFactory;
import io.xpipe.app.issue.TrackEvent;
import io.xpipe.app.platform.NativeWinWindowControl;
import io.xpipe.app.platform.PlatformThread;
import io.xpipe.app.prefs.AppPrefs;
import io.xpipe.app.prefs.CloseBehaviourDialog;
import io.xpipe.app.update.AppDistributionType;
import io.xpipe.app.util.GlobalTimer;
import io.xpipe.app.util.NativeWinWindowControl;
import io.xpipe.core.OsType;
import javafx.application.Platform;
@@ -23,7 +22,6 @@ import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyCodeCombination;
import javafx.scene.input.KeyCombination;
import javafx.scene.input.KeyEvent;
import javafx.scene.paint.Color;
import javafx.stage.Screen;
import javafx.stage.Stage;
@@ -108,7 +106,7 @@ public class AppMainWindow {
var scene = new Scene(content, -1, -1, false);
content.prefWidthProperty().bind(scene.widthProperty());
content.prefHeightProperty().bind(scene.heightProperty());
scene.setFill(Color.TRANSPARENT);
AppWindowStyle.setSceneFill(scene);
stage.setScene(scene);
if (AppPrefs.get() != null) {
@@ -118,7 +116,7 @@ public class AppMainWindow {
AppWindowStyle.addStylesheets(stage.getScene());
AppWindowStyle.addClickShield(stage);
AppWindowStyle.addMaximizedPseudoClass(stage);
AppWindowStyle.addFontSize(stage);
AppWindowStyle.addFontSize(scene);
AppTheme.initThemeHandlers(stage);
AppWindowTitle.getTitle().subscribe(s -> {
@@ -167,7 +165,7 @@ public class AppMainWindow {
return INSTANCE;
}
public void show() {
public synchronized void show() {
stage.show();
if (OsType.ofLocal() == OsType.WINDOWS) {
@@ -294,23 +292,8 @@ public class AppMainWindow {
});
stage.setOnCloseRequest(e -> {
if (!AppOperationMode.isInStartup()
&& !AppOperationMode.isInShutdown()
&& !CloseBehaviourDialog.showIfNeeded()) {
e.consume();
return;
}
// Close dialogs
AppDialog.getModalOverlays().clear();
// Close other windows
Stage.getWindows().stream().filter(w -> !w.equals(stage)).toList().forEach(w -> w.fireEvent(e));
// Close self
stage.close();
AppOperationMode.onWindowClose();
e.consume();
AppOperationMode.onWindowClose();
});
stage.addEventHandler(KeyEvent.KEY_PRESSED, event -> {
@@ -1,10 +1,11 @@
package io.xpipe.app.core.window;
import io.xpipe.app.issue.ErrorEventFactory;
import io.xpipe.app.platform.NativeMacOsWindowControl;
import io.xpipe.app.platform.NativeWinWindowControl;
import io.xpipe.app.platform.PlatformThread;
import io.xpipe.app.prefs.AppPrefs;
import io.xpipe.app.util.NativeMacOsWindowControl;
import io.xpipe.app.util.NativeWinWindowControl;
import io.xpipe.app.util.RemoteDesktopWindow;
import io.xpipe.core.OsType;
import javafx.animation.PauseTransition;
@@ -126,7 +127,8 @@ public class AppModifiedStage extends Stage {
if (AppPrefs.get().performanceMode().get()
|| !mergeFrame()
|| AppMainWindow.get() == null
|| stage != AppMainWindow.get().getStage()) {
|| (stage != AppMainWindow.get().getStage()
&& stage != RemoteDesktopWindow.get().getStage())) {
seamlessFrame = false;
} else {
// This is not available on Windows 10
@@ -1,6 +1,7 @@
package io.xpipe.app.core.window;
import io.xpipe.app.platform.PlatformInit;
import io.xpipe.app.util.RemoteDesktopWindow;
import javafx.application.Platform;
import javafx.scene.control.Alert;
@@ -9,7 +10,7 @@ import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyCodeCombination;
import javafx.scene.input.KeyCombination;
import javafx.scene.input.KeyEvent;
import javafx.scene.paint.Color;
import javafx.stage.Modality;
import javafx.stage.Stage;
import java.util.Optional;
@@ -39,7 +40,7 @@ public class AppSideWindow {
event.consume();
});
AppWindowBounds.fixInvalidStagePosition(s);
AppWindowStyle.addFontSize(s);
AppWindowStyle.addFontSize(s.getScene());
a.getDialogPane().getScene().addEventHandler(KeyEvent.KEY_PRESSED, event -> {
if (new KeyCodeCombination(KeyCode.W, KeyCombination.SHORTCUT_DOWN).match(event)) {
s.close();
@@ -83,10 +84,15 @@ public class AppSideWindow {
public static Alert createEmptyAlert() {
Alert alert = new Alert(Alert.AlertType.NONE);
if (AppMainWindow.get() != null && AppMainWindow.get().getStage().isShowing() && !AppMainWindow.get().getStage().isIconified()) {
alert.initModality(Modality.NONE);
if (AppMainWindow.get() != null
&& AppMainWindow.get().getStage().isShowing()
&& !AppMainWindow.get().getStage().isIconified()
&& (RemoteDesktopWindow.get().getStage() == null
|| !RemoteDesktopWindow.get().getStage().isShowing())) {
alert.initOwner(AppMainWindow.get().getStage());
}
alert.getDialogPane().getScene().setFill(Color.TRANSPARENT);
AppWindowStyle.setSceneFill(alert.getDialogPane().getScene());
var stage = (Stage) alert.getDialogPane().getScene().getWindow();
AppModifiedStage.prepareStage(stage);
AppWindowStyle.addIcons(stage);
@@ -2,15 +2,18 @@ package io.xpipe.app.core.window;
import io.xpipe.app.core.*;
import io.xpipe.app.issue.TrackEvent;
import io.xpipe.app.prefs.AppPrefs;
import io.xpipe.app.util.GlobalTimer;
import io.xpipe.core.OsType;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.css.PseudoClass;
import javafx.scene.Scene;
import javafx.scene.input.*;
import javafx.scene.paint.Color;
import javafx.stage.Stage;
import java.time.Duration;
@@ -19,6 +22,23 @@ import java.util.List;
public class AppWindowStyle {
public static void setSceneFill(Scene scene) {
if (OsType.ofLocal() != OsType.LINUX) {
scene.setFill(Color.TRANSPARENT);
return;
}
scene.fillProperty()
.bind(Bindings.createObjectBinding(
() -> {
return AppPrefs.get() != null
&& AppPrefs.get().theme().getValue().isDark()
? Color.BLACK
: Color.WHITE;
},
AppPrefs.get().theme()));
}
public static void addMaximizedPseudoClass(Stage stage) {
stage.getScene().rootProperty().subscribe(root -> {
stage.maximizedProperty().subscribe(v -> {
@@ -27,8 +47,8 @@ public class AppWindowStyle {
});
}
public static void addFontSize(Stage stage) {
stage.getScene().rootProperty().subscribe(root -> {
public static void addFontSize(Scene scene) {
scene.rootProperty().subscribe(root -> {
AppFontSizes.base(root);
});
}
@@ -0,0 +1,389 @@
package io.xpipe.app.cred;
import io.xpipe.app.comp.RegionBuilder;
import io.xpipe.app.comp.base.*;
import io.xpipe.app.core.AppSystemInfo;
import io.xpipe.app.ext.ShellStore;
import io.xpipe.app.ext.ValidationException;
import io.xpipe.app.issue.ErrorEventFactory;
import io.xpipe.app.platform.BindingsHelper;
import io.xpipe.app.platform.LabelGraphic;
import io.xpipe.app.platform.OptionsBuilder;
import io.xpipe.app.platform.OptionsChoiceBuilder;
import io.xpipe.app.process.CommandBuilder;
import io.xpipe.app.process.ShellControl;
import io.xpipe.app.secret.SecretRetrievalStrategy;
import io.xpipe.app.secret.SecretStrategyChoiceConfig;
import io.xpipe.app.storage.ContextualFileReference;
import io.xpipe.app.storage.DataStorage;
import io.xpipe.app.util.LicenseProvider;
import io.xpipe.app.util.ThreadHelper;
import io.xpipe.app.util.Validators;
import io.xpipe.core.FilePath;
import io.xpipe.core.KeyValue;
import io.xpipe.core.OsType;
import javafx.application.Platform;
import javafx.beans.property.Property;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.property.SimpleObjectProperty;
import atlantafx.base.theme.Styles;
import com.fasterxml.jackson.annotation.JsonTypeName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Value;
import lombok.extern.jackson.Jacksonized;
import java.util.List;
@Value
@Jacksonized
@Builder
@JsonTypeName("certificateFile")
@AllArgsConstructor
public class CertificateKeyFileStrategy implements SshIdentityStrategy {
@SuppressWarnings("unused")
public static String getOptionsNameKey() {
return "certificateFile";
}
@SuppressWarnings("unused")
public static OptionsBuilder createOptions(
Property<CertificateKeyFileStrategy> p, SshIdentityStrategyChoiceConfig config) {
var keyPath = new SimpleObjectProperty<>(
p.getValue() != null && p.getValue().getFile() != null
? p.getValue().getFile().toAbsoluteFilePath(null)
: null);
var keyPasswordProperty =
new SimpleObjectProperty<>(p.getValue() != null ? p.getValue().getPassword() : null);
var certificate = new SimpleObjectProperty<>(
p.getValue() != null && p.getValue().getCertificate() != null
? p.getValue().getCertificate().toAbsoluteFilePath(null)
: null);
var shortLivedCertImpl = new SimpleObjectProperty<>(p.getValue().getShortLivedCertImpl());
var shortLivedCertImplConfig = BindingsHelper.flatMap(
shortLivedCertImpl,
implValue -> implValue != null
? implValue.getCacheableConfiguration().getValue()
: new ReadOnlyObjectWrapper<>());
p.addListener((observable, oldValue, newValue) -> {
if (keyPath.get() != null
&& newValue != null
&& !ContextualFileReference.of(keyPath.get()).equals(newValue.getFile())) {
return;
}
keyPath.setValue(
newValue != null && newValue.getFile() != null
? newValue.getFile().toAbsoluteFilePath(null)
: null);
});
keyPath.addListener((observable, oldValue, newValue) -> {
if (certificate.get() != null) {
return;
}
if (newValue == null) {
return;
}
ThreadHelper.runFailableAsync(() -> {
var baseName = newValue.getExtension().isPresent() ? newValue.getBaseName() : newValue;
var pubCert = FilePath.of(baseName + "-cert.pub");
var fs =
config.getFileSystem() != null && config.getFileSystem().getValue() != null
? config.getFileSystem().getValue().getStore()
: (ShellStore) DataStorage.get().local().getStore();
var ex = fs.getOrStartSession().view().fileExists(pubCert);
if (ex) {
Platform.runLater(() -> {
certificate.set(pubCert);
});
}
});
});
var passwordChoice = OptionsChoiceBuilder.builder()
.allowNull(false)
.property(keyPasswordProperty)
.customConfiguration(SecretStrategyChoiceConfig.builder()
.allowNone(true)
.passwordKey("passphrase")
.build())
.available(SecretRetrievalStrategy.getClasses())
.build()
.build();
var certificateField = new ContextualFileReferenceChoiceComp(
config.getFileSystem() != null
? config.getFileSystem()
: new ReadOnlyObjectWrapper<>(DataStorage.get().local().ref()),
certificate,
null,
List.of(),
e -> {
if (config.getFileSystem() == null) {
return e.equals(DataStorage.get().local());
}
var fs = config.getFileSystem().getValue();
if (fs == null) {
return e.equals(DataStorage.get().local());
} else {
return e.equals(fs.get());
}
},
false);
certificateField.apply(hBox -> {
hBox.getChildren().getLast().getStyleClass().remove(Styles.RIGHT_PILL);
hBox.getChildren().getLast().getStyleClass().add(Styles.CENTER_PILL);
});
var checkButton = new ButtonComp(null, new LabelGraphic.IconGraphic("mdi2i-information-outline"), () -> {
ThreadHelper.runFailableAsync(() -> {
var fs = config.getFileSystem() != null
&& config.getFileSystem().getValue() != null
? config.getFileSystem().getValue().getStore()
: (ShellStore) DataStorage.get().local().getStore();
ShortLivedCertificateImpl.showDialogAndWait(
keyPath.get(), certificate.get(), shortLivedCertImpl.get());
});
})
.describe(d -> d.nameKey("checkValidity"))
.disable(certificate.isNull());
var certificateBox = new InputGroupComp(List.of(certificateField, checkButton));
certificateBox.setMainReference(certificateField);
var implChoice = OptionsChoiceBuilder.builder()
.property(shortLivedCertImpl)
.allowNull(true)
.available(ShortLivedCertificateImpl.getClasses())
.transformer(entryComboBox -> {
var button = new ButtonComp(null, new LabelGraphic.IconGraphic("mdi2w-wrench-outline"), () -> {
shortLivedCertImpl.get().configure();
});
button.describe(d -> d.nameKey("configure"));
button.disable(
BindingsHelper.mapBoolean(shortLivedCertImpl, v -> v == null || !v.supportsConfigure()));
var hbox = new InputGroupComp(List.of(RegionBuilder.of(() -> entryComboBox), button))
.setMainReference(0)
.build();
return hbox;
})
.build();
return new OptionsBuilder()
.name("location")
.description("locationDescription")
.addComp(
new ContextualFileReferenceChoiceComp(
config.getFileSystem() != null
? config.getFileSystem()
: new ReadOnlyObjectWrapper<>(
DataStorage.get().local().ref()),
keyPath,
null,
List.of(),
e -> {
if (config.getFileSystem() == null) {
return e.equals(DataStorage.get().local());
}
var fs = config.getFileSystem().getValue();
if (fs == null) {
return e.equals(DataStorage.get().local());
} else {
return e.equals(fs.get());
}
},
false),
keyPath)
.nonNull()
.nameAndDescription("keyPassphrase")
.sub(passwordChoice, keyPasswordProperty)
.nonNull()
.nameAndDescription("certificatePublicKey")
.addComp(certificateBox, certificate)
.nonNull()
.nameAndDescription("shortLivedCertImpl")
.sub(implChoice.build(), shortLivedCertImpl)
.addProperty(shortLivedCertImplConfig)
.checkComplete()
.bind(
() -> {
return new CertificateKeyFileStrategy(
ContextualFileReference.of(keyPath.get()),
keyPasswordProperty.get(),
ContextualFileReference.of(certificate.get()),
shortLivedCertImpl.get());
},
p);
}
ContextualFileReference file;
SecretRetrievalStrategy password;
ContextualFileReference certificate;
ShortLivedCertificateImpl shortLivedCertImpl;
public void checkComplete() throws ValidationException {
Validators.nonNull(file);
Validators.nonNull(password);
Validators.nonNull(certificate);
if (shortLivedCertImpl != null) {
shortLivedCertImpl.checkComplete();
}
}
@Override
public void prepareParent(ShellControl parent) throws Exception {
parent.requireLicensedFeature(LicenseProvider.get().getFeature("sshCertificateFile"));
preparePrivateKey(parent);
prepareCertificateKey(parent, false);
}
private void preparePrivateKey(ShellControl parent) throws Exception {
if (file == null) {
return;
}
var s = file.toAbsoluteFilePath(parent).resolveTildeHome(parent.view().userHome());
if (!parent.view().fileExists(s)) {
var systemName = parent.getSourceStore()
.flatMap(shellStore -> DataStorage.get().getStoreEntryIfPresent(shellStore, false))
.map(e -> DataStorage.get().getStoreEntryDisplayName(e));
var msg = "Private key file " + s + " does not exist"
+ (systemName.isPresent() ? " on system " + systemName.get() : "");
throw ErrorEventFactory.expected(new IllegalArgumentException(msg));
}
if (s.toString().endsWith(".pub")) {
throw ErrorEventFactory.expected(new IllegalArgumentException("Identity file " + s
+ " is marked to be a public key file, SSH authentication requires the private key"));
}
if (parent.getOsType() != OsType.WINDOWS) {
// Try to preserve the same permission set
parent.command(CommandBuilder.of()
.add("test", "-w")
.addFile(s)
.add("&&", "chmod", "600")
.addFile(s)
.add("||", "chmod", "400")
.addFile(s))
.executeAndCheck();
}
}
private void prepareCertificateKey(ShellControl parent, boolean alreadyRenewed) throws Exception {
if (certificate == null) {
return;
}
var s = certificate
.toAbsoluteFilePath(parent)
.resolveTildeHome(parent.view().userHome());
if (parent.view().fileExists(s)) {
if (parent.getOsType() != OsType.WINDOWS) {
// Try to preserve the same permission set
parent.command(CommandBuilder.of()
.add("test", "-w")
.addFile(s)
.add("&&", "chmod", "600")
.addFile(s)
.add("||", "chmod", "400")
.addFile(s))
.executeAndCheck();
}
var summary = ShortLivedCertificateImpl.queryCertificateSummary(parent, s);
var valid = ShortLivedCertificateImpl.checkValid(summary);
if (!valid) {
var pubKey = SshIdentityStrategy.getPublicKeyPath(file.toAbsoluteFilePath(parent)
.resolveTildeHome(parent.view().userHome()));
if (!parent.view().fileExists(pubKey)) {
var systemName = parent.getSourceStore()
.flatMap(shellStore -> DataStorage.get().getStoreEntryIfPresent(shellStore, false))
.map(e -> DataStorage.get().getStoreEntryDisplayName(e));
var msg = "Public key file " + pubKey + " does not exist"
+ (systemName.isPresent() ? " on system " + systemName.get() : "");
throw ErrorEventFactory.expected(new IllegalArgumentException(msg));
}
if (!alreadyRenewed
&& parent.isLocal()
&& shortLivedCertImpl != null
&& shortLivedCertImpl.isComplete()
&& shortLivedCertImpl.supportsRenew()) {
ShortLivedCertificateImpl.showDialogAndWait(
file.toAbsoluteFilePath(parent)
.resolveTildeHome(parent.view().userHome()),
s,
shortLivedCertImpl);
prepareCertificateKey(parent, true);
} else {
throw ErrorEventFactory.expected(new IllegalStateException("Certificate " + s.getFileName()
+ " is expired" + (alreadyRenewed ? " and failed to renew" : "")));
}
}
} else {
if (!alreadyRenewed
&& parent.isLocal()
&& shortLivedCertImpl != null
&& shortLivedCertImpl.isComplete()
&& shortLivedCertImpl.supportsRenew()) {
shortLivedCertImpl.renew(
file.toAbsoluteFilePath(parent)
.resolveTildeHome(parent.view().userHome()),
s);
prepareCertificateKey(parent, true);
} else {
throw ErrorEventFactory.expected(
new IllegalStateException("Certificate file " + s + " does not exist"));
}
}
}
@Override
public void buildCommand(CommandBuilder builder) {}
@Override
public List<KeyValue> configOptions(ShellControl sc) {
return List.of(
KeyValue.raw("IdentitiesOnly", "yes"),
KeyValue.raw("IdentityAgent", "none"),
KeyValue.escape("IdentityFile", resolveFilePath(sc, file)),
KeyValue.escape("CertificateFile", resolveFilePath(sc, certificate)),
KeyValue.raw("PKCS11Provider", "none"));
}
@Override
public SecretRetrievalStrategy getAskpassStrategy() {
return password;
}
private FilePath resolveFilePath(ShellControl sc, ContextualFileReference f) {
var s = f.toAbsoluteFilePath(sc);
// The ~ is supported on all platforms, so manually replace it here for Windows
if (s.startsWith("~")) {
s = s.resolveTildeHome(FilePath.of(AppSystemInfo.ofCurrent().getUserHome()));
}
return s;
}
@Override
public boolean supportsIdentityApply() {
// This is managed by the server in the trusted user ca keys
return false;
}
public PublicKeyStrategy getPublicKeyStrategy() {
return null;
}
}
@@ -4,12 +4,14 @@ 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.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.util.DocumentationLink;
import io.xpipe.app.util.Validators;
import io.xpipe.core.FilePath;
import io.xpipe.core.KeyValue;
import io.xpipe.core.OsType;
@@ -85,7 +87,7 @@ public class CustomAgentStrategy implements SshIdentityAgentStrategy {
}))
.nameAndDescription("publicKey")
.documentationLink(DocumentationLink.SSH_AGENT_PUBLIC_KEYS)
.addComp(new SshAgentKeyListComp(config.getFileSystem(), p, publicKey, false), publicKey)
.addComp(new SshAgentKeyListComp(config.getFileSystem(), p, publicKey, false, false), publicKey)
.bind(
() -> {
return new CustomAgentStrategy(publicKey.get());
@@ -95,6 +97,11 @@ public class CustomAgentStrategy implements SshIdentityAgentStrategy {
String publicKey;
@Override
public void checkComplete() throws ValidationException {
Validators.nonNull(AppPrefs.get().defaultSshAgentSocket().getValue());
}
@Override
public void prepareParent(ShellControl parent) throws Exception {
if (parent.isLocal()) {
@@ -107,7 +114,7 @@ public class CustomAgentStrategy implements SshIdentityAgentStrategy {
}
@Override
public FilePath determinetAgentSocketLocation(ShellControl sc) throws Exception {
public FilePath determineAgentSocketLocation(ShellControl sc) throws Exception {
if (!sc.isLocal() || sc.getOsType() == OsType.WINDOWS) {
return null;
}
@@ -132,13 +139,13 @@ public class CustomAgentStrategy implements SshIdentityAgentStrategy {
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("IdentityFile", file.isPresent() ? file.get().toString() : "none"),
new KeyValue("PKCS11Provider", "none")));
KeyValue.raw("IdentitiesOnly", file.isPresent() ? "yes" : "no"),
KeyValue.raw("IdentityFile", file.isPresent() ? file.get().toString() : "none"),
KeyValue.raw("PKCS11Provider", "none")));
var agent = determinetAgentSocketLocation(sc);
var agent = determineAgentSocketLocation(sc);
if (agent != null) {
l.add(new KeyValue("IdentityAgent", "\"" + agent + "\""));
l.add(KeyValue.escape("IdentityAgent", agent));
}
return l;
@@ -1,122 +0,0 @@
package io.xpipe.app.cred;
import io.xpipe.app.comp.base.ContextualFileReferenceChoiceComp;
import io.xpipe.app.ext.ValidationException;
import io.xpipe.app.issue.ErrorEventFactory;
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.app.util.LicenseProvider;
import io.xpipe.app.util.Validators;
import io.xpipe.core.FilePath;
import io.xpipe.core.KeyValue;
import io.xpipe.core.OsType;
import javafx.beans.property.Property;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.property.SimpleObjectProperty;
import com.fasterxml.jackson.annotation.JsonTypeName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Value;
import lombok.extern.jackson.Jacksonized;
import java.io.IOException;
import java.util.List;
@Value
@Jacksonized
@Builder
@JsonTypeName("customPkcs11")
@AllArgsConstructor
public class CustomPkcs11LibraryStrategy implements SshIdentityStrategy {
@SuppressWarnings("unused")
public static String getOptionsNameKey() {
return "customPkcs11Library";
}
@SuppressWarnings("unused")
public static OptionsBuilder createOptions(
Property<CustomPkcs11LibraryStrategy> p, SshIdentityStrategyChoiceConfig config) {
var file =
new SimpleObjectProperty<>(p.getValue() != null ? p.getValue().getFile() : null);
return new OptionsBuilder()
.nameAndDescription("pkcs11Library")
.addComp(
new ContextualFileReferenceChoiceComp(
config.getFileSystem() != null
? config.getFileSystem()
: new ReadOnlyObjectWrapper<>(
DataStorage.get().local().ref()),
file,
null,
List.of(),
e -> {
if (config.getFileSystem() == null) {
return e.equals(DataStorage.get().local());
}
var fs = config.getFileSystem().getValue();
if (fs == null) {
return e.equals(DataStorage.get().local());
} else {
return e.equals(fs.get());
}
},
false),
file)
.nonNull()
.bind(
() -> {
return new CustomPkcs11LibraryStrategy(file.get());
},
p);
}
FilePath file;
@Override
public void checkComplete() throws ValidationException {
Validators.nonNull(file);
}
@Override
public void prepareParent(ShellControl parent) throws Exception {
parent.requireLicensedFeature(LicenseProvider.get().getFeature("pkcs11Identity"));
if (!parent.getShellDialect()
.createFileExistsCommand(parent, file.toString())
.executeAndCheck()) {
throw ErrorEventFactory.expected(new IOException("PKCS11 library at " + file + " not found"));
}
}
@Override
public void buildCommand(CommandBuilder builder) {
builder.setup(sc -> {
var dir = file.getParent();
if (sc.getOsType() == OsType.WINDOWS) {
builder.addToPath(dir, true);
} else {
builder.addToEnvironmentPath("LD_LIBRARY_PATH", dir, true);
}
});
}
@Override
public List<KeyValue> configOptions(ShellControl sc) {
return List.of(
new KeyValue("IdentitiesOnly", "no"),
new KeyValue("PKCS11Provider", "\"" + file.toString() + "\""),
new KeyValue("IdentityFile", "none"),
new KeyValue("IdentityAgent", "none"));
}
public PublicKeyStrategy getPublicKeyStrategy() {
return null;
}
}
@@ -36,12 +36,7 @@ public class GpgAgentStrategy implements SshIdentityAgentStrategy {
return new OptionsBuilder()
.nameAndDescription("publicKey")
.documentationLink(DocumentationLink.SSH_AGENT_PUBLIC_KEYS)
.addComp(new SshAgentKeyListComp(config.getFileSystem(), p, publicKey, false), publicKey)
.addComp(
new TextFieldComp(publicKey)
.apply(struc -> struc.setPromptText(
"ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIBmhLUTJiP...== Your Comment")),
publicKey)
.addComp(new SshAgentKeyListComp(config.getFileSystem(), p, publicKey, false, false), publicKey)
.bind(
() -> {
return new GpgAgentStrategy(publicKey.get());
@@ -67,16 +62,18 @@ public class GpgAgentStrategy implements SshIdentityAgentStrategy {
String publicKey;
@Override
public void checkComplete() {}
@Override
public void prepareParent(ShellControl parent) throws Exception {
parent.requireLicensedFeature(LicenseProvider.get().getFeature("gpgAgent"));
if (parent.isLocal()) {
SshIdentityStateManager.prepareLocalGpgAgent();
}
}
@Override
public FilePath determinetAgentSocketLocation(ShellControl sc) throws Exception {
public FilePath determineAgentSocketLocation(ShellControl sc) throws Exception {
if (sc.getOsType() == OsType.WINDOWS) {
return null;
}
@@ -96,13 +93,13 @@ public class GpgAgentStrategy implements SshIdentityAgentStrategy {
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("IdentityFile", file.isPresent() ? file.get().toString() : "none"),
new KeyValue("PKCS11Provider", "none")));
KeyValue.raw("IdentitiesOnly", file.isPresent() ? "yes" : "no"),
KeyValue.escape("IdentityFile", file.isPresent() ? file.get() : "none"),
KeyValue.raw("PKCS11Provider", "none")));
var agent = determinetAgentSocketLocation(sc);
var agent = determineAgentSocketLocation(sc);
if (agent != null) {
l.add(new KeyValue("IdentityAgent", "\"" + agent + "\""));
l.add(KeyValue.escape("IdentityAgent", agent));
}
return l;
@@ -8,10 +8,7 @@ import io.xpipe.app.core.AppI18n;
import io.xpipe.app.core.AppSystemInfo;
import io.xpipe.app.ext.ProcessControlProvider;
import io.xpipe.app.ext.ValidationException;
import io.xpipe.app.platform.ClipboardHelper;
import io.xpipe.app.platform.LabelGraphic;
import io.xpipe.app.platform.OptionsBuilder;
import io.xpipe.app.platform.OptionsChoiceBuilder;
import io.xpipe.app.platform.*;
import io.xpipe.app.process.CommandBuilder;
import io.xpipe.app.process.ShellControl;
import io.xpipe.app.secret.SecretRetrievalStrategy;
@@ -24,6 +21,7 @@ import io.xpipe.core.*;
import javafx.beans.binding.Bindings;
import javafx.beans.property.Property;
import javafx.beans.property.SimpleObjectProperty;
import com.fasterxml.jackson.annotation.JsonTypeName;
import lombok.AllArgsConstructor;
@@ -33,8 +31,7 @@ import lombok.extern.jackson.Jacksonized;
import org.kordamp.ikonli.javafx.FontIcon;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Objects;
import java.util.*;
import java.util.stream.Collectors;
@Value
@@ -44,6 +41,12 @@ import java.util.stream.Collectors;
@AllArgsConstructor
public class InPlaceKeyStrategy implements SshIdentityStrategy {
private static final Set<String> KEYS = new HashSet<>();
public static boolean isInPlaceKey(String keyName) {
return KEYS.contains(keyName);
}
@SuppressWarnings("unused")
public static OptionsBuilder createOptions(Property<InPlaceKeyStrategy> p, SshIdentityStrategyChoiceConfig config) {
var options = new OptionsBuilder();
@@ -71,17 +74,26 @@ public class InPlaceKeyStrategy implements SshIdentityStrategy {
AppI18n.activeLanguage()));
struc.setEditable(false);
});
var generatedKeyBase = new SimpleObjectProperty<>(key.get());
var generateButtonDisabled = Bindings.createBooleanBinding(
() -> {
return key.get() == null
|| (publicKey.get() != null && key.get().equals(generatedKeyBase.get()));
},
key,
publicKey);
var generateButton = new ButtonComp(null, new LabelGraphic.IconGraphic("mdi2c-cog-refresh-outline"), () -> {
ThreadHelper.runAsync(() -> {
var generated = ProcessControlProvider.get()
.generatePublicSshKey(InPlaceSecretValue.of(key.get()), keyPasswordProperty.get());
if (generated != null) {
publicKey.set(generated);
generatedKeyBase.set(key.getValue());
}
});
})
.describe(d -> d.nameKey("generatePublicKey"))
.disable(key.isNull().or(publicKey.isNotNull()).or(keyPasswordProperty.isNull()));
.disable(generateButtonDisabled);
var copyButton = new ButtonComp(null, new FontIcon("mdi2c-clipboard-multiple-outline"), () -> {
ClipboardHelper.copyText(publicKey.get());
})
@@ -104,8 +116,7 @@ public class InPlaceKeyStrategy implements SshIdentityStrategy {
}),
key)
.nonNull()
.name("keyPassword")
.description("sshConfigHost.identityPassphraseDescription")
.nameAndDescription("keyPassphrase")
.sub(passwordChoice, keyPasswordProperty)
.nonNull()
.nameAndDescription("inPlacePublicKey")
@@ -167,10 +178,10 @@ public class InPlaceKeyStrategy implements SshIdentityStrategy {
@Override
public List<KeyValue> configOptions(ShellControl sc) {
return List.of(
new KeyValue("IdentitiesOnly", "yes"),
new KeyValue("IdentityAgent", "none"),
new KeyValue("IdentityFile", "\"" + getTargetFilePath(sc) + "\""),
new KeyValue("PKCS11Provider", "none"));
KeyValue.raw("IdentitiesOnly", "yes"),
KeyValue.raw("IdentityAgent", "none"),
KeyValue.escape("IdentityFile", getTargetFilePath(sc)),
KeyValue.raw("PKCS11Provider", "none"));
}
@Override
@@ -179,9 +190,9 @@ public class InPlaceKeyStrategy implements SshIdentityStrategy {
}
private FilePath getTargetFilePath(ShellControl sc) {
var temp = sc.getSystemTemporaryDirectory()
.join("xpipe-"
+ Math.abs(Objects.hash(this, AppSystemInfo.ofCurrent().getUser())) + ".key");
var hash = Math.abs(Objects.hash(this, AppSystemInfo.ofCurrent().getUser()));
var temp = sc.getSystemTemporaryDirectory().join("xpipe-" + hash + ".key");
KEYS.add(temp.getFileName());
return temp;
}
@@ -2,8 +2,8 @@ package io.xpipe.app.cred;
import io.xpipe.app.comp.base.*;
import io.xpipe.app.core.AppI18n;
import io.xpipe.app.core.AppSystemInfo;
import io.xpipe.app.ext.ProcessControlProvider;
import io.xpipe.app.ext.ShellStore;
import io.xpipe.app.ext.ValidationException;
import io.xpipe.app.issue.ErrorEventFactory;
import io.xpipe.app.platform.ClipboardHelper;
@@ -20,7 +20,6 @@ import io.xpipe.app.storage.DataStorage;
import io.xpipe.app.util.DocumentationLink;
import io.xpipe.app.util.ThreadHelper;
import io.xpipe.app.util.Validators;
import io.xpipe.core.FilePath;
import io.xpipe.core.InPlaceSecretValue;
import io.xpipe.core.KeyValue;
import io.xpipe.core.OsType;
@@ -105,12 +104,12 @@ public class KeyFileStrategy implements SshIdentityStrategy {
var sc = config.getFileSystem() != null
? config.getFileSystem().getValue().getStore().getOrStartSession()
: LocalShell.getShell();
var path = keyPath.get();
var path = keyPath.get().resolveTildeHome(sc.view().userHome());
if (!sc.view().fileExists(path)) {
return;
}
var pubKeyPath = FilePath.of(path + ".pub");
var pubKeyPath = SshIdentityStrategy.getPublicKeyPath(path);
if (sc.view().fileExists(pubKeyPath)) {
var contents = sc.view().readTextFile(pubKeyPath).strip();
Platform.runLater(() -> {
@@ -140,6 +139,28 @@ public class KeyFileStrategy implements SshIdentityStrategy {
var publicKeyBox = new InputGroupComp(List.of(publicKeyField, copyButton, generateButton));
publicKeyBox.setMainReference(publicKeyField);
keyPath.addListener((observable, oldValue, newValue) -> {
if (newValue == null) {
return;
}
ThreadHelper.runFailableAsync(() -> {
var pubFile = SshIdentityStrategy.getPublicKeyPath(newValue);
var fs =
config.getFileSystem() != null && config.getFileSystem().getValue() != null
? config.getFileSystem().getValue().getStore()
: (ShellStore) DataStorage.get().local().getStore();
var ex = fs.getOrStartSession().view().fileExists(pubFile);
if (ex) {
var contents =
fs.getOrStartSession().view().readTextFile(pubFile).strip();
Platform.runLater(() -> {
publicKey.set(contents);
});
}
});
});
return new OptionsBuilder()
.name("location")
.description("locationDescription")
@@ -167,8 +188,7 @@ public class KeyFileStrategy implements SshIdentityStrategy {
false),
keyPath)
.nonNull()
.name("keyPassword")
.description("sshConfigHost.identityPassphraseDescription")
.nameAndDescription("keyPassphrase")
.sub(passwordChoice, keyPasswordProperty)
.nonNull()
.nameAndDescription("inPlacePublicKey")
@@ -247,12 +267,14 @@ public class KeyFileStrategy implements SshIdentityStrategy {
public void buildCommand(CommandBuilder builder) {}
@Override
public List<KeyValue> configOptions(ShellControl sc) {
public List<KeyValue> configOptions(ShellControl sc) throws Exception {
return List.of(
new KeyValue("IdentitiesOnly", "yes"),
new KeyValue("IdentityAgent", "none"),
new KeyValue("IdentityFile", "\"" + resolveFilePath(sc).toString() + "\""),
new KeyValue("PKCS11Provider", "none"));
KeyValue.raw("IdentitiesOnly", "yes"),
KeyValue.raw("IdentityAgent", "none"),
KeyValue.escape(
"IdentityFile",
file.toAbsoluteFilePath(sc).resolveTildeHome(sc.view().userHome())),
KeyValue.raw("PKCS11Provider", "none"));
}
@Override
@@ -260,15 +282,6 @@ public class KeyFileStrategy implements SshIdentityStrategy {
return password;
}
private FilePath resolveFilePath(ShellControl sc) {
var s = file.toAbsoluteFilePath(sc);
// The ~ is supported on all platforms, so manually replace it here for Windows
if (s.startsWith("~")) {
s = s.resolveTildeHome(FilePath.of(AppSystemInfo.ofCurrent().getUserHome()));
}
return s;
}
public PublicKeyStrategy getPublicKeyStrategy() {
return PublicKeyStrategy.Fixed.of(publicKey);
}
@@ -23,10 +23,10 @@ public class NoIdentityStrategy implements SshIdentityStrategy {
public List<KeyValue> configOptions(ShellControl sc) {
// Don't use any agent keys to prevent too many authentication failures
return List.of(
new KeyValue("IdentitiesOnly", "yes"),
new KeyValue("IdentityAgent", "none"),
new KeyValue("IdentityFile", "none"),
new KeyValue("PKCS11Provider", "none"));
KeyValue.raw("IdentitiesOnly", "yes"),
KeyValue.raw("IdentityAgent", "none"),
KeyValue.raw("IdentityFile", "none"),
KeyValue.raw("PKCS11Provider", "none"));
}
@Override
@@ -39,7 +39,7 @@ public class OpenSshAgentStrategy implements SshIdentityAgentStrategy {
.hide(OsType.ofLocal() == OsType.WINDOWS)
.nameAndDescription("publicKey")
.documentationLink(DocumentationLink.SSH_AGENT_PUBLIC_KEYS)
.addComp(new SshAgentKeyListComp(config.getFileSystem(), p, publicKey, false), publicKey)
.addComp(new SshAgentKeyListComp(config.getFileSystem(), p, publicKey, false, false), publicKey)
.bind(
() -> {
return new OpenSshAgentStrategy(publicKey.get());
@@ -58,7 +58,10 @@ public class OpenSshAgentStrategy implements SshIdentityAgentStrategy {
}
@Override
public FilePath determinetAgentSocketLocation(ShellControl sc) throws Exception {
public void checkComplete() {}
@Override
public FilePath determineAgentSocketLocation(ShellControl sc) throws Exception {
if (sc.getOsType() == OsType.WINDOWS) {
return null;
}
@@ -80,13 +83,13 @@ public class OpenSshAgentStrategy implements SshIdentityAgentStrategy {
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("IdentityFile", file.isPresent() ? file.get().toString() : "none"),
new KeyValue("PKCS11Provider", "none")));
KeyValue.raw("IdentitiesOnly", file.isPresent() ? "yes" : "no"),
KeyValue.escape("IdentityFile", file.isPresent() ? file.get() : "none"),
KeyValue.raw("PKCS11Provider", "none")));
var agent = determinetAgentSocketLocation(sc);
var agent = determineAgentSocketLocation(sc);
if (agent != null) {
l.add(new KeyValue("IdentityAgent", "\"" + agent + "\""));
l.add(KeyValue.escape("IdentityAgent", agent));
}
return l;
@@ -1,71 +0,0 @@
package 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.util.DocumentationLink;
import io.xpipe.core.FilePath;
import io.xpipe.core.KeyValue;
import javafx.beans.property.Property;
import javafx.beans.property.SimpleStringProperty;
import com.fasterxml.jackson.annotation.JsonTypeName;
import lombok.Builder;
import lombok.Value;
import lombok.extern.jackson.Jacksonized;
import java.util.List;
@JsonTypeName("otherExternal")
@Value
@Jacksonized
@Builder
public class OtherExternalAgentStrategy implements SshIdentityAgentStrategy {
@SuppressWarnings("unused")
public static OptionsBuilder createOptions(
Property<OtherExternalAgentStrategy> p, SshIdentityStrategyChoiceConfig config) {
var publicKey =
new SimpleStringProperty(p.getValue() != null ? p.getValue().getPublicKey() : null);
return new OptionsBuilder()
.nameAndDescription("publicKey")
.documentationLink(DocumentationLink.SSH_AGENT_PUBLIC_KEYS)
.addComp(new SshAgentKeyListComp(config.getFileSystem(), p, publicKey, false), publicKey)
.bind(
() -> {
return new OtherExternalAgentStrategy(publicKey.get());
},
p);
}
String publicKey;
@Override
public void prepareParent(ShellControl parent) throws Exception {
if (parent.isLocal()) {
SshIdentityStateManager.prepareLocalExternalAgent(null);
}
}
@Override
public FilePath determinetAgentSocketLocation(ShellControl parent) {
return null;
}
@Override
public void buildCommand(CommandBuilder builder) {}
@Override
public List<KeyValue> configOptions(ShellControl sc) throws Exception {
var file = SshIdentityStrategy.getPublicKeyPath(sc, publicKey);
return List.of(
new KeyValue("IdentitiesOnly", file.isPresent() ? "yes" : "no"),
new KeyValue("IdentityFile", file.isPresent() ? file.get().toString() : "none"),
new KeyValue("PKCS11Provider", "none"));
}
public PublicKeyStrategy getPublicKeyStrategy() {
return PublicKeyStrategy.Fixed.of(publicKey);
}
}
@@ -0,0 +1,43 @@
package 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.util.DocumentationLink;
import io.xpipe.core.FilePath;
import io.xpipe.core.KeyValue;
import javafx.beans.property.Property;
import javafx.beans.property.SimpleStringProperty;
import com.fasterxml.jackson.annotation.JsonTypeName;
import lombok.Builder;
import lombok.Value;
import lombok.extern.jackson.Jacksonized;
import java.util.List;
@JsonTypeName("otherExternal")
@Value
@Jacksonized
@Builder
public class OtherExternalIdentityStrategy implements SshIdentityStrategy {
@Override
public void prepareParent(ShellControl parent) throws Exception {}
@Override
public void checkComplete() {}
@Override
public void buildCommand(CommandBuilder builder) {}
@Override
public List<KeyValue> configOptions(ShellControl sc) throws Exception {
return List.of();
}
public PublicKeyStrategy getPublicKeyStrategy() {
return null;
}
}
@@ -37,7 +37,7 @@ public class PageantStrategy implements SshIdentityAgentStrategy {
new SimpleStringProperty(p.getValue() != null ? p.getValue().getPublicKey() : null);
return new OptionsBuilder()
.nameAndDescription("publicKey")
.addComp(new SshAgentKeyListComp(config.getFileSystem(), p, publicKey, false), publicKey)
.addComp(new SshAgentKeyListComp(config.getFileSystem(), p, publicKey, false, false), publicKey)
.bind(
() -> {
return new PageantStrategy(publicKey.get());
@@ -87,7 +87,7 @@ public class PageantStrategy implements SshIdentityAgentStrategy {
}
@Override
public FilePath determinetAgentSocketLocation(ShellControl sc) {
public FilePath determineAgentSocketLocation(ShellControl sc) {
if (sc.isLocal() && sc.getOsType() == OsType.WINDOWS) {
return FilePath.of(getPageantWindowsPipe());
}
@@ -95,6 +95,9 @@ public class PageantStrategy implements SshIdentityAgentStrategy {
return null;
}
@Override
public void checkComplete() {}
@Override
public void buildCommand(CommandBuilder builder) {}
@@ -102,13 +105,13 @@ public class PageantStrategy implements SshIdentityAgentStrategy {
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("IdentityFile", file.isPresent() ? file.get().toString() : "none"),
new KeyValue("PKCS11Provider", "none")));
KeyValue.raw("IdentitiesOnly", file.isPresent() ? "yes" : "no"),
KeyValue.escape("IdentityFile", file.isPresent() ? file.get() : "none"),
KeyValue.raw("PKCS11Provider", "none")));
var agent = determinetAgentSocketLocation(sc);
var agent = determineAgentSocketLocation(sc);
if (agent != null) {
l.add(new KeyValue("IdentityAgent", "\"" + agent + "\""));
l.add(KeyValue.escape("IdentityAgent", agent));
}
return l;
@@ -11,7 +11,6 @@ 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.storage.DataStoreEntry;
import io.xpipe.app.util.DocumentationLink;
import io.xpipe.app.util.Validators;
import io.xpipe.core.FilePath;
@@ -81,7 +80,8 @@ public class PasswordManagerAgentStrategy implements SshIdentityAgentStrategy {
.hide(pwmanErrorProp.isNull())
.nameAndDescription(useKeyName() ? "agentKeyName" : "publicKeyRequired")
.documentationLink(DocumentationLink.SSH_AGENT_PUBLIC_KEYS)
.addComp(new SshAgentKeyListComp(config.getFileSystem(), p, identifier, useKeyName()), identifier)
.addComp(
new SshAgentKeyListComp(config.getFileSystem(), p, identifier, useKeyName(), false), identifier)
.disable(pwmanErrorProp.isNotNull())
.nonNull()
.bind(
@@ -126,7 +126,7 @@ public class PasswordManagerAgentStrategy implements SshIdentityAgentStrategy {
}
@Override
public FilePath determinetAgentSocketLocation(ShellControl parent) {
public FilePath determineAgentSocketLocation(ShellControl parent) {
var config = getConfig();
return config != null ? FilePath.of(config.getDefaultSocketLocation()) : null;
}
@@ -1,83 +0,0 @@
package io.xpipe.app.cred;
import io.xpipe.app.comp.base.*;
import io.xpipe.app.core.App;
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 javafx.beans.binding.Bindings;
import javafx.beans.property.Property;
import com.fasterxml.jackson.annotation.JsonTypeName;
import lombok.Builder;
import lombok.Value;
import lombok.extern.jackson.Jacksonized;
import org.kordamp.ikonli.javafx.FontIcon;
import java.util.List;
@JsonTypeName("passwordManagerInPlaceKey")
@Value
@Jacksonized
@Builder
public class PasswordManagerInPlaceKeyStrategy implements SshIdentityAgentStrategy {
@SuppressWarnings("unused")
public static OptionsBuilder createOptions(
Property<PasswordManagerInPlaceKeyStrategy> p, SshIdentityStrategyChoiceConfig config) {
var options = new OptionsBuilder();
var prefs = AppPrefs.get();
var keyProperty = options.map(p, PasswordManagerInPlaceKeyStrategy::getKey);
var field = new TextFieldComp(keyProperty).apply(struc -> struc.promptTextProperty()
.bind(Bindings.createStringBinding(
() -> {
return prefs.passwordManager().getValue() != null
? prefs.passwordManager().getValue().getKeyPlaceholder()
: "?";
},
prefs.passwordManager())));
var button = new ButtonComp(null, new FontIcon("mdomz-settings"), () -> {
AppPrefs.get().selectCategory("passwordManager");
App.getApp().getStage().requestFocus();
});
var content = new InputGroupComp(List.of(field, button));
content.setMainReference(field);
return options.nameAndDescription("passwordManagerInPlaceKeyKey")
.addComp(content, keyProperty)
.nonNull()
.bind(
() -> {
return PasswordManagerInPlaceKeyStrategy.builder()
.key(keyProperty.get())
.build();
},
p);
}
String key;
@Override
public void prepareParent(ShellControl parent) {}
@Override
public void buildCommand(CommandBuilder builder) {}
@Override
public List<KeyValue> configOptions(ShellControl sc) {
return List.of();
}
@Override
public PublicKeyStrategy getPublicKeyStrategy() {
return null;
}
@Override
public FilePath determinetAgentSocketLocation(ShellControl parent) {
return null;
}
}
@@ -0,0 +1,227 @@
package io.xpipe.app.cred;
import io.xpipe.app.comp.base.ContextualFileReferenceChoiceComp;
import io.xpipe.app.ext.ValidationException;
import io.xpipe.app.issue.ErrorEventFactory;
import io.xpipe.app.platform.OptionsBuilder;
import io.xpipe.app.process.ShellControl;
import io.xpipe.app.storage.DataStorage;
import io.xpipe.app.util.Validators;
import io.xpipe.core.FilePath;
import io.xpipe.core.OsType;
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.util.ArrayList;
import java.util.List;
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
public interface SecurityKeyImpl {
static List<Class<?>> getClasses() {
var l = new ArrayList<Class<?>>();
l.add(OpenSc.class);
l.add(YubikeyPiv.class);
l.add(MacOsKeychain.class);
l.add(Custom.class);
return l;
}
static List<Class<?>> getAvailable() {
var l = new ArrayList<Class<?>>();
l.add(OpenSc.class);
l.add(YubikeyPiv.class);
if (OsType.ofLocal() == OsType.MACOS) {
l.add(MacOsKeychain.class);
}
l.add(Custom.class);
return l;
}
default boolean showLibraryPath() {
return true;
}
default void checkComplete() throws ValidationException {}
FilePath determineLibraryPath(ShellControl sc) throws Exception;
String getLink();
@JsonTypeName("yubikeyPiv")
@Value
@Jacksonized
@Builder
class YubikeyPiv implements SecurityKeyImpl {
@Override
public FilePath determineLibraryPath(ShellControl sc) throws Exception {
var file =
switch (sc.getOsType()) {
case OsType.MacOs ignored -> FilePath.of("/usr/local/lib/libykcs11.dylib");
case OsType.Windows ignored -> {
var x64 = FilePath.of(
sc.view().getEnvironmentVariableOrThrow("ProgramFiles"),
"Yubico\\Yubico PIV Tool\\bin\\libykcs11.dll");
if (sc.view().fileExists(x64)) {
yield x64;
}
var x86 = FilePath.of(
sc.view().getEnvironmentVariableOrThrow("ProgramFiles(x86)"),
"Yubico\\Yubico PIV Tool\\bin\\libykcs11.dll");
if (sc.view().fileExists(x86)) {
yield x86;
}
yield x64;
}
default -> FilePath.of("/usr/local/lib/libykcs11.so");
};
return file;
}
@Override
public String getLink() {
return "https://developers.yubico.com/yubico-piv-tool/YKCS11/";
}
}
@JsonTypeName("openSc")
@Value
@Jacksonized
@Builder
class OpenSc implements SecurityKeyImpl {
@Override
public String getLink() {
return "https://github.com/opensc/opensc";
}
@Override
public FilePath determineLibraryPath(ShellControl sc) throws Exception {
var file =
switch (sc.getOsType()) {
case OsType.MacOs ignored -> FilePath.of("/Library/OpenSC/lib/opensc-pkcs11.so");
case OsType.Windows ignored -> {
var x64 = FilePath.of(
sc.view().getEnvironmentVariableOrThrow("ProgramFiles"),
"OpenSC Project\\OpenSC\\pkcs11\\opensc-pkcs11.dll");
if (sc.view().fileExists(x64)) {
yield x64;
}
var x86 = FilePath.of(
sc.view().getEnvironmentVariableOrThrow("ProgramFiles(x86)"),
"OpenSC Project\\OpenSC\\pkcs11\\opensc-pkcs11.dll");
if (sc.view().fileExists(x86)) {
yield x86;
}
yield x64;
}
default -> FilePath.of("/usr/lib/pkcs11/opensc-pkcs11.so");
};
return file;
}
}
@JsonTypeName("macOsKeychain")
@Value
@Jacksonized
@Builder
class MacOsKeychain implements SecurityKeyImpl {
@Override
public String getLink() {
return "https://support.apple.com/en-gb/guide/keychain-access/welcome/mac";
}
@Override
public FilePath determineLibraryPath(ShellControl sc) {
var file =
switch (sc.getOsType()) {
case OsType.MacOs ignored -> FilePath.of("/usr/lib/ssh-keychain.dylib");
default ->
throw ErrorEventFactory.expected(
new UnsupportedOperationException(
"macOS keychain is not supported as a PKCS#11 provider on other operating systems"));
};
return file;
}
}
@JsonTypeName("customLibrary")
@Value
@Jacksonized
@Builder
class Custom implements SecurityKeyImpl {
@SuppressWarnings("unused")
public static OptionsBuilder createOptions(Property<Custom> p, SshIdentityStrategyChoiceConfig config) {
var file = new SimpleObjectProperty<>(p.getValue().getFile());
return new OptionsBuilder()
.nameAndDescription("pkcs11Library")
.addComp(
new ContextualFileReferenceChoiceComp(
config.getFileSystem() != null
? config.getFileSystem()
: new ReadOnlyObjectWrapper<>(
DataStorage.get().local().ref()),
file,
null,
List.of(),
e -> {
if (config.getFileSystem() == null) {
return e.equals(DataStorage.get().local());
}
var fs = config.getFileSystem().getValue();
if (fs == null) {
return e.equals(DataStorage.get().local());
} else {
return e.equals(fs.get());
}
},
false),
file)
.nonNull()
.bind(
() -> {
return new Custom(file.get());
},
p);
}
FilePath file;
@Override
public boolean showLibraryPath() {
return false;
}
@Override
public FilePath determineLibraryPath(ShellControl sc) {
return file;
}
@Override
public String getLink() {
return null;
}
@Override
public void checkComplete() throws ValidationException {
Validators.nonNull(file);
}
}
}
@@ -0,0 +1,183 @@
package io.xpipe.app.cred;
import io.xpipe.app.core.AppInstallation;
import io.xpipe.app.ext.ProcessControlProvider;
import io.xpipe.app.ext.ShellStore;
import io.xpipe.app.ext.ValidationException;
import io.xpipe.app.issue.ErrorEventFactory;
import io.xpipe.app.platform.OptionsBuilder;
import io.xpipe.app.platform.OptionsChoiceBuilder;
import io.xpipe.app.process.CommandBuilder;
import io.xpipe.app.process.ShellControl;
import io.xpipe.app.storage.DataStorage;
import io.xpipe.app.util.DocumentationLink;
import io.xpipe.app.util.LicenseProvider;
import io.xpipe.app.util.ThreadHelper;
import io.xpipe.app.util.Validators;
import io.xpipe.core.KeyValue;
import io.xpipe.core.OsType;
import javafx.beans.binding.Bindings;
import javafx.beans.property.Property;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import com.fasterxml.jackson.annotation.JsonTypeName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Value;
import lombok.extern.jackson.Jacksonized;
import java.io.IOException;
import java.util.List;
@Value
@Jacksonized
@Builder
@JsonTypeName("hardwareSecurityKey")
@AllArgsConstructor
public class SecurityKeyStrategy implements SshIdentityKeyListStrategy {
@SuppressWarnings("unused")
public static OptionsBuilder createOptions(
Property<SecurityKeyStrategy> p, SshIdentityStrategyChoiceConfig config) {
var publicKey = new SimpleStringProperty(p.getValue().getPublicKey());
var securityKey = new SimpleObjectProperty<>(p.getValue().getSecurityKey());
var filePath = new SimpleObjectProperty<String>();
securityKey.subscribe(impl -> {
if (impl == null) {
filePath.set(null);
return;
}
ThreadHelper.runFailableAsync(() -> {
var fs = config.getFileSystem().getValue() != null
? config.getFileSystem().getValue().getStore()
: (ShellStore) DataStorage.get().local().getStore().asNeeded();
var path = impl.determineLibraryPath(fs.getOrStartSession());
filePath.set(path != null ? path.toString() : null);
});
});
if (config.getFileSystem() != null) {
config.getFileSystem().subscribe(fs -> {
if (fs == null) {
filePath.set(null);
return;
}
ThreadHelper.runFailableAsync(() -> {
var impl = securityKey.get();
if (impl != null) {
filePath.set(impl.determineLibraryPath(fs.getStore().getOrStartSession())
.toString());
} else {
filePath.set(null);
}
});
});
}
var choice = OptionsChoiceBuilder.builder()
.property(securityKey)
.available(SecurityKeyImpl.getAvailable())
.customConfiguration(config)
.build()
.build();
var showLibraryPath = Bindings.createBooleanBinding(() -> {
if (filePath.get() == null) {
return false;
}
if (securityKey.get() == null) {
return false;
}
return securityKey.get().showLibraryPath();
}, filePath, securityKey);
return new OptionsBuilder()
.nameAndDescription("pkcs11Impl")
.sub(choice, securityKey)
.nonNull()
.nameAndDescription("pkcs11Library")
.addStaticString(filePath)
.hide(Bindings.not(showLibraryPath))
.nameAndDescription("publicKey")
.documentationLink(DocumentationLink.SSH_AGENT_PUBLIC_KEYS)
.addComp(new SshAgentKeyListComp(config.getFileSystem(), p, publicKey, false, true), publicKey)
.bind(
() -> {
return SecurityKeyStrategy.builder()
.securityKey(securityKey.get())
.publicKey(publicKey.get())
.build();
},
p);
}
SecurityKeyImpl securityKey;
String publicKey;
@Override
public void checkComplete() throws ValidationException {
Validators.nonNull(securityKey);
securityKey.checkComplete();
}
@Override
public void prepareParent(ShellControl parent) throws Exception {
var file = securityKey.determineLibraryPath(parent);
if (!parent.view().fileExists(file)) {
var ex = new IOException("PKCS11 library at " + file + " not found");
var event = ErrorEventFactory.fromThrowable(ex).expected();
if (securityKey.getLink() != null) {
event.link(securityKey.getLink());
}
ErrorEventFactory.preconfigure(event);
throw ex;
}
}
@Override
public CommandBuilder createListCommand() {
var cmd = CommandBuilder.of()
.add("ssh-keygen", "-D")
.addFile(sc -> securityKey.determineLibraryPath(sc).toUnix())
.add("-e")
.fixedEnvironment(
"SSH_ASKPASS",
AppInstallation.ofCurrent().getCliExecutablePath().toString())
.fixedEnvironment("SSH_ASKPASS_REQUIRE", "force");
ProcessControlProvider.get().addAskpassEnvironment(cmd, "[ssh-keygen]", null, null);
return cmd;
}
@Override
public void buildCommand(CommandBuilder builder) {
builder.setup(sc -> {
var dir = securityKey.determineLibraryPath(sc).getParent();
if (sc.getOsType() == OsType.WINDOWS) {
builder.addToPath(dir, true);
} else {
builder.addToEnvironmentPath("LD_LIBRARY_PATH", dir, true);
}
});
}
@Override
public List<KeyValue> configOptions(ShellControl sc) throws Exception {
var file = securityKey.determineLibraryPath(sc);
var key = SshIdentityStrategy.getPublicKeyPath(sc, publicKey);
return List.of(
KeyValue.escape("PKCS11Provider", file.toString()),
KeyValue.raw("IdentitiesOnly", key.isPresent() ? "yes" : "no"),
KeyValue.escape("IdentityFile", key.isPresent() ? key.get().toString() : "none"),
KeyValue.raw("IdentityAgent", "none"));
}
@Override
public PublicKeyStrategy getPublicKeyStrategy() {
return PublicKeyStrategy.Fixed.of(publicKey);
}
}
@@ -0,0 +1,274 @@
package io.xpipe.app.cred;
import io.xpipe.app.comp.base.IntegratedTextAreaComp;
import io.xpipe.app.comp.base.ModalButton;
import io.xpipe.app.comp.base.ModalOverlay;
import io.xpipe.app.comp.base.TextAreaComp;
import io.xpipe.app.core.AppI18n;
import io.xpipe.app.ext.ValidationException;
import io.xpipe.app.issue.ErrorEventFactory;
import io.xpipe.app.platform.OptionsBuilder;
import io.xpipe.app.process.CommandBuilder;
import io.xpipe.app.process.LocalShell;
import io.xpipe.app.process.ShellControl;
import io.xpipe.app.process.ShellScript;
import io.xpipe.app.storage.DataStorage;
import io.xpipe.app.util.*;
import io.xpipe.core.FilePath;
import javafx.beans.property.*;
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.text.ParseException;
import java.text.SimpleDateFormat;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.TimeZone;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
public interface ShortLivedCertificateImpl extends Checkable {
static List<Class<?>> getClasses() {
var l = new ArrayList<Class<?>>();
l.add(HashicorpVault.class);
l.add(OpenBao.class);
l.add(Custom.class);
return l;
}
static void showDialogAndWait(FilePath privateKey, FilePath certificate, ShortLivedCertificateImpl impl)
throws Exception {
var summary = queryCertificateSummary(LocalShell.getShell(), certificate);
var text = new TextAreaComp(new ReadOnlyObjectWrapper<>(summary));
text.prefWidth(600);
text.prefHeight(350);
var modal = ModalOverlay.of(
AppI18n.observable(
!checkValid(summary) ? "certificateDialogExpiredTitle" : "certificateDialogTitle",
certificate.getFileName()),
text,
null);
var canRenew = impl != null && impl.supportsRenew();
var renew = new SimpleBooleanProperty();
if (canRenew) {
modal.addButton(ModalButton.cancel());
modal.addButton(new ModalButton(
"renew",
() -> {
renew.set(true);
},
true,
true));
} else {
modal.addButton(ModalButton.ok());
}
modal.showAndWait();
if (impl != null && renew.get()) {
impl.renew(privateKey, certificate);
}
}
static boolean checkValid(String text) {
var matcher = Pattern.compile("Valid: from (\\S+) to (\\S+)").matcher(text);
if (!matcher.find()) {
return true;
}
try {
var parser = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US);
parser.setTimeZone(TimeZone.getTimeZone("UTC"));
var from = parser.parse(matcher.group(1)).toInstant();
var to = parser.parse(matcher.group(2)).toInstant();
return from.isBefore(Instant.now()) && to.isAfter(Instant.now());
} catch (ParseException e) {
ErrorEventFactory.fromThrowable(e).omit().handle();
return true;
}
}
static String queryCertificateSummary(ShellControl sc, FilePath f) throws Exception {
var out = sc.command(
CommandBuilder.of().add("ssh-keygen").add("-L", "-f").addFile(f))
.readStdoutOrThrow();
var minIndent = out.lines()
.skip(1)
.mapToInt(s -> {
var m = Pattern.compile("^ *").matcher(s);
return m.find() ? m.group().length() : 0;
})
.min()
.orElse(0);
var text = out.lines().skip(1).map(s -> s.substring(minIndent)).collect(Collectors.joining("\n"));
return text;
}
default boolean supportsRenew() {
return true;
}
default boolean supportsConfigure() {
return true;
}
default void checkComplete() throws ValidationException {}
void renew(FilePath privateKey, FilePath certificate) throws Exception;
void configure();
CacheableConfiguration<?> getCacheableConfiguration();
@JsonTypeName("openBao")
@Value
@Jacksonized
@Builder
class OpenBao implements ShortLivedCertificateImpl {
@SuppressWarnings("unused")
public static OptionsBuilder createOptions(Property<OpenBao> p) {
var role = new SimpleStringProperty(p.getValue().getRole());
return new OptionsBuilder()
.nameAndDescription("certificateRole")
.addString(role)
.nonNull()
.bind(
() -> {
return OpenBao.builder().role(role.get()).build();
},
p);
}
String role;
@Override
public void checkComplete() throws ValidationException {
Validators.nonNull(role);
OpenBaoConfig.get().get().checkComplete();
}
@Override
public void renew(FilePath privateKey, FilePath certificate) throws Exception {
OpenBaoConfig.get().get().renew(role, privateKey, certificate);
}
@Override
public void configure() {
OpenBaoConfig.showDialog();
}
@Override
public CacheableConfiguration<?> getCacheableConfiguration() {
return OpenBaoConfig.get();
}
}
@JsonTypeName("hashicorpVault")
@Value
@Jacksonized
@Builder
class HashicorpVault implements ShortLivedCertificateImpl {
@SuppressWarnings("unused")
public static OptionsBuilder createOptions(Property<HashicorpVault> p) {
var role = new SimpleStringProperty(p.getValue().getRole());
return new OptionsBuilder()
.nameAndDescription("certificateRole")
.addString(role)
.nonNull()
.bind(
() -> {
return HashicorpVault.builder().role(role.get()).build();
},
p);
}
String role;
@Override
public void checkComplete() throws ValidationException {
Validators.nonNull(role);
HashicorpVaultConfig.get().get().checkComplete();
}
@Override
public void renew(FilePath privateKey, FilePath certificate) throws Exception {
HashicorpVaultConfig.get().get().renew(role, privateKey, certificate);
}
@Override
public void configure() {
HashicorpVaultConfig.showDialog();
}
@Override
public CacheableConfiguration<?> getCacheableConfiguration() {
return HashicorpVaultConfig.get();
}
}
@JsonTypeName("custom")
@Value
@Jacksonized
@Builder
class Custom implements ShortLivedCertificateImpl {
@SuppressWarnings("unused")
public static OptionsBuilder createOptions(Property<Custom> p) {
var command = new SimpleObjectProperty<>(p.getValue().getCommand());
return new OptionsBuilder()
.nameAndDescription("certificateRenewCommand")
.addComp(
IntegratedTextAreaComp.script(
new ReadOnlyObjectWrapper<>(
DataStorage.get().local().ref()),
command,
true),
command)
.nonNull()
.bind(
() -> {
return Custom.builder().command(command.get()).build();
},
p);
}
ShellScript command;
@Override
public void checkComplete() throws ValidationException {
Validators.nonNull(command);
}
@Override
public void renew(FilePath privateKey, FilePath certificate) throws Exception {
var sc = LocalShell.get(Custom.class);
sc.command(command.getValue()).execute();
}
@Override
public boolean supportsConfigure() {
return false;
}
@Override
public void configure() {}
@Override
public CacheableConfiguration<?> getCacheableConfiguration() {
return HashicorpVaultConfig.get();
}
}
}
@@ -2,7 +2,6 @@ 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.process.LocalShell;
import io.xpipe.app.storage.DataStoreEntryRef;
@@ -30,7 +29,8 @@ public class SshAgentKeyList {
}
public static Entry findAgentIdentity(
DataStoreEntryRef<ShellStore> ref, SshIdentityAgentStrategy strategy, String identifier) throws Exception {
DataStoreEntryRef<ShellStore> ref, SshIdentityKeyListStrategy strategy, String identifier)
throws Exception {
var all = listAgentIdentities(ref, strategy);
var list = all.stream()
.filter(entry -> {
@@ -73,16 +73,14 @@ public class SshAgentKeyList {
return list.getFirst();
}
public static List<Entry> listAgentIdentities(DataStoreEntryRef<ShellStore> ref, SshIdentityAgentStrategy strategy)
throws Exception {
public static List<Entry> listAgentIdentities(
DataStoreEntryRef<ShellStore> ref, SshIdentityKeyListStrategy strategy) throws Exception {
var session = ref != null ? ref.getStore().getOrStartSession() : LocalShell.getShell();
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 cmd = strategy.createListCommand();
strategy.buildCommand(cmd);
var out = session.command(cmd).readStdoutOrThrow();
var pattern = Pattern.compile("([^ ]+) ([^ ]+)\\s*(?: (.+))?");
var lines = out.lines().toList();
var list = new ArrayList<Entry>();
@@ -12,6 +12,7 @@ import io.xpipe.app.storage.DataStoreEntryRef;
import io.xpipe.app.util.ThreadHelper;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.property.StringProperty;
import javafx.beans.value.ObservableValue;
import javafx.geometry.Insets;
@@ -29,19 +30,22 @@ import java.util.List;
public class SshAgentKeyListComp extends SimpleRegionBuilder {
private final ObservableValue<DataStoreEntryRef<ShellStore>> ref;
private final ObservableValue<? extends SshIdentityAgentStrategy> sshIdentityStrategy;
private final ObservableValue<? extends SshIdentityKeyListStrategy> sshIdentityStrategy;
private final StringProperty value;
private final boolean useKeyNames;
private final boolean requireComplete;
public SshAgentKeyListComp(
ObservableValue<DataStoreEntryRef<ShellStore>> ref,
ObservableValue<? extends SshIdentityAgentStrategy> sshIdentityStrategy,
ObservableValue<? extends SshIdentityKeyListStrategy> sshIdentityStrategy,
StringProperty value,
boolean useKeyNames) {
boolean useKeyNames,
boolean requireComplete) {
this.ref = ref;
this.sshIdentityStrategy = sshIdentityStrategy;
this.value = value;
this.useKeyNames = useKeyNames;
this.requireComplete = requireComplete;
}
@Override
@@ -50,6 +54,18 @@ public class SshAgentKeyListComp extends SimpleRegionBuilder {
field.apply(struc -> struc.setPromptText(
useKeyNames ? "<name>" : "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIBmhLUTJiP...== <key comment>"));
var button = new ButtonComp(null, new LabelGraphic.IconGraphic("mdi2m-magnify-scan"), null);
if (requireComplete) {
button.disable(Bindings.createBooleanBinding(
() -> {
try {
sshIdentityStrategy.getValue().checkComplete();
return false;
} catch (Exception e) {
return true;
}
},
sshIdentityStrategy));
}
button.apply(struc -> {
struc.setOnAction(event -> {
DataStoreEntryRef<ShellStore> refToUse = ref != null && ref.getValue() != null
@@ -24,10 +24,10 @@ import atlantafx.base.theme.Styles;
public class SshAgentTestComp extends SimpleRegionBuilder {
private final Runnable beforeTest;
private final ObservableValue<? extends SshIdentityAgentStrategy> sshIdentityStrategy;
private final ObservableValue<? extends SshIdentityKeyListStrategy> sshIdentityStrategy;
public SshAgentTestComp(
Runnable beforeTest, ObservableValue<? extends SshIdentityAgentStrategy> sshIdentityStrategy) {
Runnable beforeTest, ObservableValue<? extends SshIdentityKeyListStrategy> sshIdentityStrategy) {
this.beforeTest = beforeTest;
this.sshIdentityStrategy = sshIdentityStrategy;
}
@@ -1,11 +1,18 @@
package io.xpipe.app.cred;
import io.xpipe.app.process.CommandBuilder;
import io.xpipe.app.process.ShellControl;
import io.xpipe.core.FilePath;
public interface SshIdentityAgentStrategy extends SshIdentityStrategy {
public interface SshIdentityAgentStrategy extends SshIdentityKeyListStrategy {
void prepareParent(ShellControl parent) throws Exception;
@Override
default CommandBuilder createListCommand() {
return CommandBuilder.of().add("ssh-add", "-L").environment("SSH_AUTH_SOCK", sc -> {
var socket = determineAgentSocketLocation(sc);
return socket != null ? socket.toString() : null;
});
}
FilePath determinetAgentSocketLocation(ShellControl parent) throws Exception;
FilePath determineAgentSocketLocation(ShellControl parent) throws Exception;
}
@@ -0,0 +1,14 @@
package io.xpipe.app.cred;
import io.xpipe.app.ext.ValidationException;
import io.xpipe.app.process.CommandBuilder;
import io.xpipe.app.process.ShellControl;
public interface SshIdentityKeyListStrategy extends SshIdentityStrategy {
void checkComplete() throws ValidationException;
void prepareParent(ShellControl parent) throws Exception;
CommandBuilder createListCommand();
}
@@ -52,7 +52,8 @@ public class SshIdentityStateManager {
if (gpg && gpgRunning) {
// This sometimes takes a long time if the agent is not running. Why?
sc.command(CommandBuilder.of().add("gpg-connect-agent", "killagent", "/bye")).executeAndCheck();
sc.command(CommandBuilder.of().add("gpg-connect-agent", "killagent", "/bye"))
.executeAndCheck();
}
if (openssh && opensshRunning) {
@@ -27,6 +27,8 @@ public interface SshIdentityStrategy {
l.add(KeyFileStrategy.class);
l.add(OpenSshAgentStrategy.class);
l.add(PasswordManagerAgentStrategy.class);
l.add(CertificateKeyFileStrategy.class);
l.add(SecurityKeyStrategy.class);
if (OsType.ofLocal() != OsType.WINDOWS) {
l.add(CustomAgentStrategy.class);
}
@@ -36,9 +38,7 @@ public interface SshIdentityStrategy {
if (PageantStrategy.isSupported()) {
l.add(PageantStrategy.class);
}
l.add(YubikeyPivStrategy.class);
l.add(CustomPkcs11LibraryStrategy.class);
l.add(OtherExternalAgentStrategy.class);
l.add(OtherExternalIdentityStrategy.class);
return l;
}
@@ -50,17 +50,24 @@ public interface SshIdentityStrategy {
l.add(KeyFileStrategy.class);
l.add(OpenSshAgentStrategy.class);
l.add(PasswordManagerAgentStrategy.class);
l.add(PasswordManagerInPlaceKeyStrategy.class);
l.add(CertificateKeyFileStrategy.class);
l.add(SecurityKeyStrategy.class);
l.add(CustomAgentStrategy.class);
l.add(GpgAgentStrategy.class);
l.add(PageantStrategy.class);
l.add(YubikeyPivStrategy.class);
l.add(CustomPkcs11LibraryStrategy.class);
l.add(OtherExternalAgentStrategy.class);
l.add(OtherExternalIdentityStrategy.class);
return l;
}
static FilePath getPublicKeyPath(FilePath file) {
if (file.getExtension().isEmpty()) {
return FilePath.of(file + ".pub");
} else {
return FilePath.of(file.getBaseName() + ".pub");
}
}
static Optional<FilePath> getPublicKeyPath(ShellControl sc, String publicKey) throws Exception {
if (publicKey == null || publicKey.isBlank()) {
return Optional.empty();
@@ -103,5 +110,9 @@ public interface SshIdentityStrategy {
return new SecretNoneStrategy();
}
default boolean supportsIdentityApply() {
return true;
}
PublicKeyStrategy getPublicKeyStrategy();
}
@@ -1,87 +0,0 @@
package io.xpipe.app.cred;
import io.xpipe.app.issue.ErrorEventFactory;
import io.xpipe.app.process.CommandBuilder;
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;
import com.fasterxml.jackson.annotation.JsonTypeName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Value;
import lombok.extern.jackson.Jacksonized;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
@Value
@Jacksonized
@Builder
@JsonTypeName("yubikeyPiv")
@AllArgsConstructor
public class YubikeyPivStrategy implements SshIdentityStrategy {
private String getFile(ShellControl sc) {
var file =
switch (sc.getOsType()) {
case OsType.MacOs ignored -> "/usr/local/lib/libykcs11.dylib";
case OsType.Windows ignored -> {
var x64 = "C:\\Program Files\\Yubico\\Yubico PIV Tool\\bin\\libykcs11.dll";
if (Files.exists(Path.of(x64))) {
yield x64;
}
var x86 = "C:\\Program Files (x86)\\Yubico\\Yubico PIV Tool\\bin\\libykcs11.dll";
if (Files.exists(Path.of(x86))) {
yield x86;
}
yield x64;
}
default -> "/usr/local/lib/libykcs11.so";
};
return file;
}
@Override
public void prepareParent(ShellControl parent) throws Exception {
parent.requireLicensedFeature(LicenseProvider.get().getFeature("pkcs11Identity"));
var file = getFile(parent);
if (!parent.getShellDialect().createFileExistsCommand(parent, file).executeAndCheck()) {
throw ErrorEventFactory.expected(new IOException("Yubikey PKCS11 library at " + file + " not found"));
}
}
@Override
public void buildCommand(CommandBuilder builder) {
builder.setup(sc -> {
var file = getFile(sc);
var dir = FilePath.of(file).getParent();
if (sc.getOsType() == OsType.WINDOWS) {
builder.addToPath(dir, true);
} else {
builder.addToEnvironmentPath("LD_LIBRARY_PATH", dir, true);
}
});
}
@Override
public List<KeyValue> configOptions(ShellControl sc) {
return List.of(
new KeyValue("IdentitiesOnly", "no"),
new KeyValue("PKCS11Provider", "\"" + getFile(sc) + "\""),
new KeyValue("IdentityFile", "none"),
new KeyValue("IdentityAgent", "none"));
}
@Override
public PublicKeyStrategy getPublicKeyStrategy() {
return null;
}
}
@@ -1,6 +1,10 @@
package io.xpipe.app.ext;
import io.xpipe.app.process.ShellControl;
import io.xpipe.app.process.ShellDialect;
import io.xpipe.app.process.ShellDialects;
import io.xpipe.app.process.ShellStoreState;
import io.xpipe.core.OsType;
import lombok.AccessLevel;
import lombok.EqualsAndHashCode;
@@ -16,10 +20,47 @@ import lombok.extern.jackson.Jacksonized;
@Jacksonized
public class ContainerStoreState extends ShellStoreState {
public static ShellDialect findSuitableDialect(ShellControl sc) throws Exception {
if (!sc.getShellDialect().getDumbMode().supportsAnyPossibleInteraction()) {
return sc.getOsType() == OsType.WINDOWS ? ShellDialects.CMD : ShellDialects.SH;
}
if (sc.getOsType() != OsType.WINDOWS) {
if (sc.view().findProgram("bash").isPresent()) {
return ShellDialects.BASH;
}
if (sc.view().findProgram("zsh").isPresent()) {
return ShellDialects.ZSH;
}
return ShellDialects.SH;
} else {
if (sc.view().findProgram("pwsh").isPresent()) {
return ShellDialects.POWERSHELL_CORE;
}
if (sc.view().findProgram("powershell").isPresent()) {
return ShellDialects.POWERSHELL;
}
return ShellDialects.CMD;
}
}
String imageName;
String containerState;
ShellDialect availableShellDialect;
Boolean shellMissing;
public ShellDialect getEffectiveDialect(ShellControl sc) {
if (availableShellDialect != null) {
return availableShellDialect;
}
return sc.getOsType() != OsType.WINDOWS ? ShellDialects.SH : ShellDialects.CMD;
}
@Override
public DataStoreState mergeCopy(DataStoreState newer) {
var n = (ContainerStoreState) newer;
@@ -32,6 +73,7 @@ public class ContainerStoreState extends ShellStoreState {
super.mergeBuilder(css, b);
b.containerState(useNewer(containerState, css.getContainerState()));
b.imageName(useNewer(imageName, css.getImageName()));
b.availableShellDialect(useNewer(availableShellDialect, css.getAvailableShellDialect()));
b.shellMissing(useNewer(shellMissing, css.getShellMissing()));
}
}
@@ -1,7 +1,9 @@
package io.xpipe.app.ext;
import io.xpipe.app.comp.BaseRegionBuilder;
import io.xpipe.app.core.AppI18n;
import io.xpipe.app.hub.comp.StoreSection;
import io.xpipe.app.hub.comp.SystemStateComp;
import javafx.beans.binding.Bindings;
import javafx.beans.value.ObservableValue;
@@ -13,25 +15,48 @@ public interface CountGroupStoreProvider extends DataStoreProvider {
return false;
}
@Override
default BaseRegionBuilder<?, ?> stateDisplay(StoreSection section) {
return new SystemStateComp(Bindings.createObjectBinding(
() -> {
return section.getShownChildren().getList().isEmpty()
? SystemStateComp.State.OTHER
: SystemStateComp.State.SUCCESS;
},
section.getShownChildren().getList()));
}
@Override
default ObservableValue<String> informationString(StoreSection section) {
return Bindings.createStringBinding(
() -> {
var all = section.getAllChildren().getList();
var allCount = all.stream()
.filter(s -> !excludeNonCountable()
|| s.getWrapper().getEntry().getProvider().includeInConnectionCount())
.count();
var shown = section.getShownChildren().getList();
if (shown.size() == 0) {
return AppI18n.get("noConnections");
var shownCount = shown.stream()
.filter(s -> !excludeNonCountable()
|| s.getWrapper().getEntry().getProvider().includeInConnectionCount())
.count();
if (allCount == 0) {
return AppI18n.get("no" + getCountTranslationKey() + "s");
}
var string = all.size() == shown.size() ? all.size() : shown.size() + "/" + all.size();
return all.size() > 0
? (all.size() == 1
? AppI18n.get("hasConnection", string)
: AppI18n.get("hasConnections", string))
: AppI18n.get("noConnections");
var string = allCount == shownCount ? allCount : shownCount + "/" + allCount;
return allCount == 1
? AppI18n.get("has" + getCountTranslationKey(), string)
: AppI18n.get("has" + getCountTranslationKey() + "s", string);
},
section.getShownChildren().getList(),
section.getAllChildren().getList(),
AppI18n.activeLanguage());
}
String getCountTranslationKey();
default boolean excludeNonCountable() {
return true;
}
}
@@ -10,18 +10,21 @@ import java.util.UUID;
@AllArgsConstructor
@Getter
public enum DataStoreCreationCategory {
HOST(DataStorage.ALL_CONNECTIONS_CATEGORY_UUID),
SHELL(DataStorage.ALL_CONNECTIONS_CATEGORY_UUID),
COMMAND(DataStorage.ALL_CONNECTIONS_CATEGORY_UUID),
TUNNEL(DataStorage.ALL_CONNECTIONS_CATEGORY_UUID),
SERVICE(DataStorage.ALL_CONNECTIONS_CATEGORY_UUID),
SCRIPT(DataStorage.ALL_SCRIPTS_CATEGORY_UUID),
CLUSTER(DataStorage.ALL_CONNECTIONS_CATEGORY_UUID),
DESKTOP(DataStorage.ALL_CONNECTIONS_CATEGORY_UUID),
SERIAL(DataStorage.ALL_CONNECTIONS_CATEGORY_UUID),
MACRO(DataStorage.ALL_MACROS_CATEGORY_UUID),
FILE_SYSTEM(DataStorage.ALL_CONNECTIONS_CATEGORY_UUID),
IDENTITY(DataStorage.ALL_IDENTITIES_CATEGORY_UUID);
HOST(DataStorage.ALL_CONNECTIONS_CATEGORY_UUID, "ssh"),
SHELL(DataStorage.ALL_CONNECTIONS_CATEGORY_UUID, null),
COMMAND(DataStorage.ALL_CONNECTIONS_CATEGORY_UUID, null),
TUNNEL(DataStorage.ALL_CONNECTIONS_CATEGORY_UUID, "sshLocalTunnel"),
SERVICE(DataStorage.ALL_CONNECTIONS_CATEGORY_UUID, "customService"),
SCRIPT(DataStorage.ALL_SCRIPTS_CATEGORY_UUID, "script"),
SCRIPT_SOURCE(DataStorage.ALL_SCRIPTS_CATEGORY_UUID, "scriptCollectionSource"),
CLUSTER(DataStorage.ALL_CONNECTIONS_CATEGORY_UUID, null),
DESKTOP(DataStorage.ALL_CONNECTIONS_CATEGORY_UUID, null),
SERIAL(DataStorage.ALL_CONNECTIONS_CATEGORY_UUID, null),
MACRO(DataStorage.ALL_MACROS_CATEGORY_UUID, null),
FILE_SYSTEM(DataStorage.ALL_CONNECTIONS_CATEGORY_UUID, null),
IDENTITY(DataStorage.ALL_IDENTITIES_CATEGORY_UUID, "localIdentity"),
NETWORK(DataStorage.ALL_CONNECTIONS_CATEGORY_UUID, null);
private final UUID category;
private final String defaultProvider;
}
@@ -5,6 +5,7 @@ import io.xpipe.app.comp.BaseRegionBuilder;
import io.xpipe.app.comp.RegionBuilder;
import io.xpipe.app.core.AppI18n;
import io.xpipe.app.core.AppImages;
import io.xpipe.app.hub.comp.StoreCreationModel;
import io.xpipe.app.hub.comp.StoreEntryComp;
import io.xpipe.app.hub.comp.StoreEntryWrapper;
import io.xpipe.app.hub.comp.StoreSection;
@@ -115,7 +116,7 @@ public interface DataStoreProvider {
return true;
}
default BaseRegionBuilder<?, ?> stateDisplay(StoreEntryWrapper w) {
default BaseRegionBuilder<?, ?> stateDisplay(StoreSection section) {
return RegionBuilder.empty();
}
@@ -137,7 +138,7 @@ public interface DataStoreProvider {
return DataStoreUsageCategory.COMMAND;
}
if (cc == DataStoreCreationCategory.SCRIPT) {
if (cc == DataStoreCreationCategory.SCRIPT || cc == DataStoreCreationCategory.SCRIPT_SOURCE) {
return DataStoreUsageCategory.SCRIPT;
}
@@ -160,7 +161,7 @@ public interface DataStoreProvider {
return null;
}
default GuiDialog guiDialog(DataStoreEntry entry, Property<DataStore> store) {
default GuiDialog guiDialog(StoreCreationModel model, Property<DataStore> store) {
return null;
}
@@ -1,8 +1,8 @@
package io.xpipe.app.ext;
import io.xpipe.app.platform.OptionsBuilder;
import io.xpipe.app.storage.DataStoreEntry;
import lombok.AllArgsConstructor;
import lombok.Value;
@@ -1,8 +1,5 @@
package io.xpipe.app.ext;
import io.modelcontextprotocol.spec.McpSchema;
import io.xpipe.app.browser.BrowserFullSessionModel;
import io.xpipe.app.browser.BrowserStoreSessionTab;
import io.xpipe.app.comp.base.ModalOverlay;
import io.xpipe.app.process.CommandBuilder;
import io.xpipe.app.process.CommandControl;
@@ -10,14 +7,20 @@ import io.xpipe.app.process.ShellControl;
import io.xpipe.app.process.ShellDialect;
import io.xpipe.app.secret.SecretRetrievalStrategy;
import io.xpipe.app.storage.DataStoreEntryRef;
import io.xpipe.app.util.HttpProxy;
import io.xpipe.app.util.RemoteDesktopDockContentEntry;
import io.xpipe.app.vnc.VncBaseStore;
import io.xpipe.core.SecretValue;
import javafx.beans.property.Property;
import io.modelcontextprotocol.spec.McpSchema;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
import java.util.ServiceLoader;
import java.util.UUID;
public abstract class ProcessControlProvider {
@@ -40,8 +43,8 @@ public abstract class ProcessControlProvider {
public abstract ShellStore subShellEnvironment(DataStoreEntryRef<ShellStore> s, ShellDialect dialect);
public abstract BrowserStoreSessionTab<?> createVncSession(
BrowserFullSessionModel model, DataStoreEntryRef<VncBaseStore> ref);
public abstract RemoteDesktopDockContentEntry createVncSession(
DataStoreEntryRef<VncBaseStore> ref, Runnable onKill);
public abstract DataStoreEntryRef<ShellStore> elevated(DataStoreEntryRef<ShellStore> e);
@@ -79,4 +82,11 @@ public abstract class ProcessControlProvider {
public abstract void cloneRepository(String url, Path target) throws Exception;
public abstract void pullRepository(Path target) throws Exception;
public abstract Optional<HttpProxy> getHttpProxy(DataStoreEntryRef<?> store);
public abstract void addAskpassEnvironment(
CommandBuilder b, String prefix, UUID requestId, UUID secretId, String... askpassName);
public abstract void refreshWsl();
}
@@ -32,6 +32,10 @@ public abstract class Session implements AutoCloseable {
if (!checkAliveQuiet()) {
handleSessionDeath();
}
if (checkInactive()) {
handleSessionDeath();
}
});
return false;
});
@@ -60,6 +64,8 @@ public abstract class Session implements AutoCloseable {
}
}
public abstract boolean checkInactive();
public abstract boolean checkAlive() throws Exception;
@Override
@@ -1,12 +1,15 @@
package io.xpipe.app.ext;
import io.xpipe.app.issue.ErrorEventFactory;
import io.xpipe.app.prefs.AppPrefs;
import io.xpipe.app.process.CommandBuilder;
import io.xpipe.app.process.ShellControl;
import io.xpipe.core.FailableSupplier;
import lombok.Getter;
import java.time.Duration;
@Getter
public class ShellSession extends Session {
@@ -84,6 +87,22 @@ public class ShellSession extends Session {
shellControl.shutdown();
}
@Override
public boolean checkInactive() {
var secs = AppPrefs.get() != null
? AppPrefs.get().backgroundSessionInactivityTimeout().getValue()
: null;
if (secs == null || secs <= 0) {
return false;
}
if (secs < 30) {
secs = 30;
}
return shellControl.isInactive(Duration.ofSeconds(secs));
}
@Override
public boolean checkAlive() throws Exception {
if (shellControl == null) {
@@ -104,11 +123,16 @@ public class ShellSession extends Session {
return true;
}
if (!shellControl.getShellDialect().getDumbMode().supportsAnyPossibleInteraction()) {
return true;
}
try {
// Don't print it constantly
return shellControl
.command(CommandBuilder.of().add("echo", "xpipetest"))
.sensitive()
.noActivity()
.executeAndCheck();
} catch (Exception ex) {
throw ErrorEventFactory.expected(ex);

Some files were not shown because too many files have changed in this diff Show More