diff --git a/.codacy.yml b/.codacy.yml index 85cce24..415cdac 100644 --- a/.codacy.yml +++ b/.codacy.yml @@ -13,5 +13,6 @@ exclude_paths: - "hooks/**" - "src/main/java/gg/effront/anarcry/scanner/**" - "src/main/java/gg/effront/anarcry/exploit/**" + - "src/main/java/gg/effront/anarcry/apiclient/auth/**" - "src/main/java/gg/effront/anarcry/EnvLoader.java" - "SECURITY.md" diff --git a/README.md b/README.md index d493d1b..a6cdb35 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ | (15 providers) + circuit breaker. OAST blind detection infra. | | TUI + Swing GUI + AI Assistant. | | Built-in API Client (Bruno-inspired) with collections, environments, | -| auth injectors, variable interpolation, runner + assertions. | +| auth injectors, variable interpolation, scripting, runner + assertions. | +===========================================================================+ ``` @@ -123,7 +123,7 @@ ```bash ./gradlew shadowJar -java -jar build/libs/anarcry-2.2.0.jar +java -jar build/libs/anarcry-2.2.1.jar ``` Or use the launcher (auto-builds if source changed): @@ -141,7 +141,7 @@ With `.env.local` for pre-configured targets: Launch in GUI mode (Swing + FlatLaf): ```bash -java -jar build/libs/anarcry-2.2.0.jar --gui +java -jar build/libs/anarcry-2.2.1.jar --gui ``` --- @@ -163,7 +163,8 @@ java -jar build/libs/anarcry-2.2.0.jar --gui | Knowledge Base | knowledge_base.json (34 entries, CWE+CVSS+VRT) | | | knowledge-base/*.md (27 enriched entries) | | AI Assistant | Multi-provider (Claude, OpenAI, Gemini, Ollama) | -| GUI | Swing + FlatLaf + RSyntaxTextArea + JFreeChart | +| GUI | Swing + FlatLaf (dark/light) + RSyntaxTextArea | +| | + JFreeChart | | Test | JUnit 5.11.3 | +======================+====================================================+ ``` @@ -222,7 +223,7 @@ without knowing their credentials. ## 05 - Interface -Two interfaces: **TUI** (Lanterna, terminal-native) and **GUI** (Swing + FlatLaf, desktop). Both offer the same core functionality: Target scan, Repeater, Findings, SiteMap, Export. The GUI adds a Dashboard with severity chart (JFreeChart), Preferences dialog, and the AI Assistant chat view. +Two interfaces: **TUI** (Lanterna, terminal-native) and **GUI** (Swing + FlatLaf, desktop). Both offer the same core functionality: Target scan, Repeater, Findings, SiteMap, Export. The GUI adds a Dashboard with severity chart (JFreeChart), Preferences dialog with persistent settings (`~/.anarcry/settings.json`), dark/light theme, and the AI Assistant chat view. ### TUI @@ -318,6 +319,11 @@ Test suite covers: JWT probes (alg:none, weak secret, RS256->HS256, JKU, null si ## 07 - Architecture + + +
+ wanna see? (click to expand) + ``` src/main/java/gg/effront/anarcry/ +-- Main.java Entry point @@ -388,6 +394,7 @@ src/main/java/gg/effront/anarcry/ | +-- SaveDocTool Save markdown documents to ~/.anarcry/reports/ +-- gui/ | +-- AnarCryGui.java Main Swing window (FlatLaf theme) +| +-- AppSettings.java Singleton: ~/.anarcry/settings.json persistence (Gson) | +-- GuiController.java View orchestration and scan lifecycle | +-- PrivacyFilter.java URL/JWT/IP redaction (Ctrl+Shift+P) | +-- views/ @@ -399,10 +406,12 @@ src/main/java/gg/effront/anarcry/ | | +-- ReportView Report generation and export | | +-- ApiClientView API Client with collection tree + request tabs | | +-- AssistantView AI Assistant chat with streaming + tools +| | +-- PreferencesDialog Settings dialog with persistence (10 categories) | | +-- EnvironmentEditorDialog API Client environment variable editor -| | +-- GlobalSearchDialog Cross-tab search (Ctrl+F) +| | +-- GlobalSearchDialog Cross-tab search (Ctrl+K) | | +-- ExportDialog Multi-format export (MD, JSON, SARIF, H1, BC) | +-- components/ Reusable GUI components +| | +-- StatusBarIcons.java Tabler Icons loader (PNG, 16x16) | +-- theme/ FlatLaf theme customization +-- tui/ +-- AnarCryTui.java Main TUI window (Lanterna) @@ -433,6 +442,8 @@ ScanRunner.run(ScanContext) -> fire onFinding callback for each result -> fire onScanFinish when all modules complete ``` +
+ --- @@ -499,16 +510,19 @@ Built-in API client inspired by [Bruno](https://www.usebruno.com/) for manual AP | (built-in > env > collection > folder > request > | | runtime) + circular reference detection | | Request Builder -- method, URL, headers, body (JSON/form/multipart/ | -| XML/GraphQL/file), query/path params | +| XML/GraphQL/file), query/path params, descriptions | +| Scripting -- pre-request + post-response + tests (GraalJS) | | Runner -- sequential execution with variable chaining | | Assertions -- declarative validation (status, JSON path, header, | | regex, timing, size) | -| Import -- Postman v2.1, OpenAPI 3.0, Bruno .bru, cURL | -| Export -- Markdown + JSON collection run reports | +| Import -- Postman v2.1, OpenAPI 3.0, Bruno .bru, Insomnia, | +| cURL | +| Export -- cURL command, Markdown + JSON collection run reports | +| Proxy -- HTTP/HTTPS/SOCKS5 with auth (via Preferences) | +===========================================================================+ ``` -**GUI (F8):** Collection tree, request tabs (color-coded by method), environment editor dialog, right-click context menu (new/duplicate/delete/rename), Send (Ctrl+Enter), Run All. +**GUI (F8):** Collection tree, request tabs (color-coded by method), environment editor dialog, right-click context menu (new/duplicate/delete/rename), pre-request/post-response/test scripts (GraalJS), cURL export, keyboard shortcuts (Ctrl+Enter send, Ctrl+N new request), tab content badges, query/path param split, global search integration. Proxy and SSL configured via Preferences. **TUI (F8):** Simplified collection list, request builder, environment selector, Send/Run All, response viewer. @@ -516,6 +530,53 @@ Built-in API client inspired by [Bruno](https://www.usebruno.com/) for manual AP ## 11 - Changelog +### v2.2.1 + +**GUI + API Client bugfixes, Preferences persistence, light theme** + +Preferences: +- **AppSettings persistence**: singleton saves all preferences to `~/.anarcry/settings.json` via Gson -- loads on startup, saves on Apply, survives restarts +- **Light theme**: AnarCryLaf.installLight() with theme-aware color helpers (fg(), bg(), border(), isDark()), saved theme applied on app launch +- **Preferences rework**: tab reorder (General, Themes, Display, Proxy, Certificates, Notifications, Keybindings, License, Support, About -- info tabs grouped at end) +- **Check for Updates**: dedicated sidebar button queries GitHub Releases API, compares with Version.get(), offers to open release page +- **Proxy centralized**: proxy config moved from API Client sidebar to Preferences -- single source of truth, ApiClientView receives ProxySelector/SSLContext via setNetworkConfig() +- **Certificates panel**: client cert table (Enable, Domain, Type PEM/PKCS12, Cert/PFX File, Key File, Passphrase) with Browse buttons, custom CA certificate with keep-default-CA toggle +- **SSL context builder**: PEM private key loading (RSA/EC/Ed25519), PKCS12 keystore, custom CA merge with JVM defaults, trust-all toggle for SSL verification off +- **Support panel**: support-art.png image, documentation and repository hyperlinks with Desktop.browse() +- **Display defaults**: resolution combo defaults to 1920x1080, saved resolution applied on app launch (including Maximized state) + +API Client enhancements: +- **Auth interpolation**: `{{var}}` resolved in all 12 AuthConfig sealed interface variants (Bearer, Basic, Digest, API Key, OAuth2 CC/PKCE/Password/Implicit, WSSE, AWS SigV4, NTLM, custom) +- **GraalJS scripting**: pre-request, post-response, and test scripts with console output panel +- **cURL export**: generate cURL command from any request (CurlGenerator) +- **Insomnia import**: InsomniaImporter for Insomnia v4 JSON collections +- **Tab content badges**: "Headers 1", "Body *", "Script *" indicators on editor tabs +- **Query/Path params split**: separate KvTableEditors for query and path parameters +- **Keyboard shortcuts**: Ctrl+Enter send, Ctrl+N new request, Ctrl+E edit environment, empty state panel with shortcut hints +- **Description column**: KvTableEditor now has 4 columns (checkbox, Key, Value, Description) +- **Tab state cache**: per-tab editor state and response preserved when switching tabs +- **Auto-select editor tab**: first tab with content is auto-selected on request load +- **Sidebar width**: increased from 250px to 320px for longer request names +- **Method label fix**: full method names shown in tree (POST, DELETE, PATCH, OPTIONS) instead of 3-char truncation + +GUI fixes: +- **Status bar icons**: Tabler Icons (MIT) replace Unicode/emoji text -- settings, theme, GitHub, search, cookie, license, proxy icons as 16x16 PNGs +- **Status bar actions**: Theme toggle, GitHub repo link, License info buttons with icon-only labels +- **Global search rewrite**: empty query shows all items (API requests, findings, sitemap), endpoint matching, wrap-around arrow navigation, single-click select, focus-loss dismissal +- **Global keyboard shortcuts**: Ctrl+K opens global search (works in text fields), Ctrl+, opens Preferences +- **Cookies status bar click**: opens PreferencesDialog +- **Search popup fix**: Enter no longer closes empty search dialog +- **API requests in search**: API Client request names wired into GlobalSearchDialog +- **Status reason phrase**: "401 Unauthorized" instead of "401 401" +- **Response status badges**: color-coded HTTP status in response panel + +Other: +- **RequestExecutor**: auth variable interpolation in all auth config fields +- Version bump to 2.2.1 + +
+Previous releases (click to expand) + ### v2.2.0 **Scanner Expansion -- 13 new modules, OAST, SARIF, FP reduction** @@ -553,10 +614,6 @@ Other: - **Knowledge base**: expanded from 25 to 34 entries with new module coverage - Version bump to 2.2.0 - -
-Previous releases (click to expand) - ### v2.1.0 **API Client Module - Bruno-inspired collections, environments, runner** @@ -573,7 +630,7 @@ Other: **GUI/TUI Integration** - **API Client tab (F8)**: request tabs color-coded by method (GET=green, POST=yellow, DELETE=red), collection tree with right-click menu (new request/folder, duplicate, delete, rename), environment selector with "..." configure button - **Environment Editor**: modal dialog for managing environments and variables (name, value, secret toggle, enabled toggle) -- **PreferencesDialog**: Bruno-style categorized settings (General, Display, Proxy, Certificates, Keybindings, About) - SSL/TLS toggle, proxy config for Burp/ZAP, client certificates table +- **PreferencesDialog**: Bruno-style categorized settings (General, Themes, Display, Proxy, Certificates, Notifications, Keybindings, License, Support, About) - SSL/TLS toggle, proxy config for Burp/ZAP, client certificates table - **StatusBar shortcuts**: Search (jumps to Findings search), Cookies count, Proxy status, Privacy toggle, version display - **F-key shift**: API Client on F8, Assistant moved to F9 diff --git a/anarchrypto b/anarchrypto index 09e9228..48ba12b 100755 --- a/anarchrypto +++ b/anarchrypto @@ -56,15 +56,15 @@ if [ "$TUI_MODE" = true ]; then if command -v kitty &>/dev/null; then export _ANARCRY_READY=1 exec kitty \ - --override initial_window_width=180c \ - --override initial_window_height=82c \ + --override initial_window_width=200c \ + --override initial_window_height=92c \ --title "AnarCry v${VERSION}" \ bash "$SELF" "$@" fi printf '\e[8;54;220t' if command -v xdotool &>/dev/null; then WID=$(xdotool getactivewindow 2>/dev/null) - [ -n "$WID" ] && xdotool windowsize "$WID" 1660 1080 2>/dev/null + [ -n "$WID" ] && xdotool windowsize "$WID" 1920 1200 2>/dev/null fi sleep 0.4 stty rows 54 cols 220 2>/dev/null diff --git a/build.gradle b/build.gradle index e58b505..341144d 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { } group = 'gg.effront' -version = '2.2.0' +version = '2.2.1' java { toolchain { @@ -27,6 +27,10 @@ dependencies { implementation 'com.microsoft.playwright:playwright:1.49.0' implementation 'com.google.code.gson:gson:2.11.0' + // Scripting (GraalJS) + implementation 'org.graalvm.polyglot:polyglot:24.1.1' + implementation 'org.graalvm.polyglot:js-community:24.1.1' + // GUI (Swing + FlatLaf) implementation 'com.formdev:flatlaf:3.5.4' implementation 'com.formdev:flatlaf-intellij-themes:3.5.4' diff --git a/src/main/java/gg/effront/anarcry/apiclient/auth/AuthInjectorRegistry.java b/src/main/java/gg/effront/anarcry/apiclient/auth/AuthInjectorRegistry.java index 62cbd56..f185bd6 100644 --- a/src/main/java/gg/effront/anarcry/apiclient/auth/AuthInjectorRegistry.java +++ b/src/main/java/gg/effront/anarcry/apiclient/auth/AuthInjectorRegistry.java @@ -24,6 +24,10 @@ public AuthInjectorRegistry() { register(new DigestAuthInjector()); register(new OAuth2Injector()); register(new WsseInjector()); + register(new NtlmInjector()); + register(new OAuth1Injector()); + register(new AwsV4Injector()); + register(new EdgeGridInjector()); } public void register(AuthInjector injector) { diff --git a/src/main/java/gg/effront/anarcry/apiclient/auth/AwsV4Injector.java b/src/main/java/gg/effront/anarcry/apiclient/auth/AwsV4Injector.java new file mode 100644 index 0000000..85a2a3e --- /dev/null +++ b/src/main/java/gg/effront/anarcry/apiclient/auth/AwsV4Injector.java @@ -0,0 +1,114 @@ +package gg.effront.anarcry.apiclient.auth; + +import gg.effront.anarcry.apiclient.model.AuthConfig; +import gg.effront.anarcry.apiclient.model.HttpRequestDef; +import gg.effront.anarcry.apiclient.vars.VariableStore; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.HexFormat; +import java.util.TreeMap; +import java.util.stream.Collectors; + +public class AwsV4Injector implements AuthInjector { + + private static final DateTimeFormatter AMZ_DATE_FMT = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'") + .withZone(ZoneOffset.UTC); + private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyyMMdd").withZone(ZoneOffset.UTC); + + @Override + public String mode() { + return "awsv4"; + } + + @Override + public HttpRequestDef inject(HttpRequestDef request, AuthConfig auth, VariableStore vars) { + if (!(auth instanceof AuthConfig.AwsV4 aws)) { + return request; + } + + Instant now = Instant.now(); + String amzDate = AMZ_DATE_FMT.format(now); + String dateStamp = DATE_FMT.format(now); + + URI uri = URI.create(request.url()); + String host = uri.getHost(); + if (uri.getPort() > 0 && uri.getPort() != 443 && uri.getPort() != 80) { + host = host + ":" + uri.getPort(); + } + String path = uri.getRawPath() != null && !uri.getRawPath().isEmpty() ? uri.getRawPath() : "/"; + String query = uri.getRawQuery() != null ? uri.getRawQuery() : ""; + + String service = aws.service() != null && !aws.service().isEmpty() ? aws.service() : "execute-api"; + String region = aws.region() != null && !aws.region().isEmpty() ? aws.region() : "us-east-1"; + + TreeMap headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + headers.put("host", host); + headers.put("x-amz-date", amzDate); + if (aws.sessionToken() != null && !aws.sessionToken().isEmpty()) { + headers.put("x-amz-security-token", aws.sessionToken()); + } + + String signedHeaders = headers.keySet().stream().map(String::toLowerCase).sorted() + .collect(Collectors.joining(";")); + String canonicalHeaders = headers.entrySet().stream().sorted(java.util.Map.Entry.comparingByKey()) + .map(e -> e.getKey().toLowerCase() + ":" + e.getValue().trim()).collect(Collectors.joining("\n")) + + "\n"; + + String payloadHash = sha256Hex(""); + String canonicalRequest = request.method().toUpperCase() + "\n" + path + "\n" + query + "\n" + canonicalHeaders + + "\n" + signedHeaders + "\n" + payloadHash; + + String credentialScope = dateStamp + "/" + region + "/" + service + "/aws4_request"; + String stringToSign = "AWS4-HMAC-SHA256\n" + amzDate + "\n" + credentialScope + "\n" + + sha256Hex(canonicalRequest); + + byte[] signingKey = getSignatureKey(aws.secretAccessKey(), dateStamp, region, service); + String signature = HexFormat.of().formatHex(hmacSha256(signingKey, stringToSign)); + + String authorization = "AWS4-HMAC-SHA256 Credential=" + aws.accessKeyId() + "/" + credentialScope + + ", SignedHeaders=" + signedHeaders + ", Signature=" + signature; + + HttpRequestDef result = AuthInjectorRegistry.addHeader(request, "Authorization", authorization); + result = AuthInjectorRegistry.addHeader(result, "X-Amz-Date", amzDate); + if (aws.sessionToken() != null && !aws.sessionToken().isEmpty()) { + result = AuthInjectorRegistry.addHeader(result, "X-Amz-Security-Token", aws.sessionToken()); + } + return result; + } + + private byte[] getSignatureKey(String key, String dateStamp, String region, String service) { + byte[] kDate = hmacSha256(("AWS4" + key).getBytes(StandardCharsets.UTF_8), dateStamp); + byte[] kRegion = hmacSha256(kDate, region); + byte[] kService = hmacSha256(kRegion, service); + return hmacSha256(kService, "aws4_request"); + } + + private static byte[] hmacSha256(byte[] key, String data) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key, "HmacSHA256")); + return mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + throw new RuntimeException("HMAC-SHA256 failed", e); + } + } + + private static String sha256Hex(String data) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(data.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 not available", e); + } + } +} diff --git a/src/main/java/gg/effront/anarcry/apiclient/auth/DigestAuthInjector.java b/src/main/java/gg/effront/anarcry/apiclient/auth/DigestAuthInjector.java index a9cbc70..d21ec61 100644 --- a/src/main/java/gg/effront/anarcry/apiclient/auth/DigestAuthInjector.java +++ b/src/main/java/gg/effront/anarcry/apiclient/auth/DigestAuthInjector.java @@ -124,7 +124,8 @@ private static String extractUri(String url) { @SuppressWarnings("java:S4790") private static String md5(String input) { try { - MessageDigest md = MessageDigest.getInstance("MD5"); + MessageDigest md = MessageDigest.getInstance("MD5"); // nosemgrep: use-of-md5 -- RFC 7616 Digest Auth + // requires MD5 byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8)); return HexFormat.of().formatHex(digest); } catch (NoSuchAlgorithmException e) { diff --git a/src/main/java/gg/effront/anarcry/apiclient/auth/EdgeGridInjector.java b/src/main/java/gg/effront/anarcry/apiclient/auth/EdgeGridInjector.java new file mode 100644 index 0000000..277cca8 --- /dev/null +++ b/src/main/java/gg/effront/anarcry/apiclient/auth/EdgeGridInjector.java @@ -0,0 +1,79 @@ +package gg.effront.anarcry.apiclient.auth; + +import gg.effront.anarcry.apiclient.model.AuthConfig; +import gg.effront.anarcry.apiclient.model.HttpRequestDef; +import gg.effront.anarcry.apiclient.vars.VariableStore; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.Base64; +import java.util.UUID; + +public class EdgeGridInjector implements AuthInjector { + + private static final DateTimeFormatter TS_FMT = DateTimeFormatter.ofPattern("yyyyMMdd'T'HH:mm:ss+0000") + .withZone(ZoneOffset.UTC); + + @Override + public String mode() { + return "akamai-edgegrid"; + } + + @Override + public HttpRequestDef inject(HttpRequestDef request, AuthConfig auth, VariableStore vars) { + if (!(auth instanceof AuthConfig.EdgeGrid eg)) { + return request; + } + + String timestamp = TS_FMT.format(Instant.now()); + String nonce = UUID.randomUUID().toString(); + + URI uri = URI.create(request.url()); + String path = uri.getRawPath() != null ? uri.getRawPath() : "/"; + if (uri.getRawQuery() != null) { + path = path + "?" + uri.getRawQuery(); + } + + String authHeader = "EG1-HMAC-SHA256 client_token=" + eg.clientToken() + ";access_token=" + eg.accessToken() + + ";timestamp=" + timestamp + ";nonce=" + nonce + ";"; + + String contentHash = sha256Base64(""); + String dataToSign = request.method().toUpperCase() + "\thttps\t" + uri.getHost() + "\t" + path + "\t\t" + + contentHash + "\t" + authHeader; + + byte[] signingKey = hmacSha256(Base64.getDecoder().decode(eg.clientSecret()), + timestamp.getBytes(StandardCharsets.UTF_8)); + String signature = Base64.getEncoder() + .encodeToString(hmacSha256(signingKey, dataToSign.getBytes(StandardCharsets.UTF_8))); + + return AuthInjectorRegistry.addHeader(request, "Authorization", authHeader + "signature=" + signature); + } + + private static String sha256Base64(String data) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(data.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(hash); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 not available", e); + } + } + + private static byte[] hmacSha256(byte[] key, byte[] data) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key, "HmacSHA256")); + return mac.doFinal(data); + } catch (Exception e) { + throw new RuntimeException("HMAC-SHA256 failed", e); + } + } +} diff --git a/src/main/java/gg/effront/anarcry/apiclient/auth/NtlmInjector.java b/src/main/java/gg/effront/anarcry/apiclient/auth/NtlmInjector.java new file mode 100644 index 0000000..88f5c74 --- /dev/null +++ b/src/main/java/gg/effront/anarcry/apiclient/auth/NtlmInjector.java @@ -0,0 +1,47 @@ +package gg.effront.anarcry.apiclient.auth; + +import gg.effront.anarcry.apiclient.model.AuthConfig; +import gg.effront.anarcry.apiclient.model.HttpRequestDef; +import gg.effront.anarcry.apiclient.vars.VariableStore; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +public class NtlmInjector implements AuthInjector { + + @Override + public String mode() { + return "ntlm"; + } + + @Override + public HttpRequestDef inject(HttpRequestDef request, AuthConfig auth, VariableStore vars) { + if (!(auth instanceof AuthConfig.Ntlm ntlm)) { + return request; + } + String domain = ntlm.domain() != null ? ntlm.domain() : ""; + String user = ntlm.username() != null ? ntlm.username() : ""; + byte[] type1 = buildType1Message(domain); + String negotiate = "NTLM " + Base64.getEncoder().encodeToString(type1); + return AuthInjectorRegistry.addHeader(request, "Authorization", negotiate); + } + + private byte[] buildType1Message(String domain) { + byte[] domainBytes = domain.toUpperCase().getBytes(StandardCharsets.US_ASCII); + byte[] msg = new byte[32 + domainBytes.length]; + System.arraycopy("NTLMSSP\0".getBytes(StandardCharsets.US_ASCII), 0, msg, 0, 8); + msg[8] = 1; + int flags = 0x00008206; + msg[12] = (byte) (flags & 0xff); + msg[13] = (byte) ((flags >> 8) & 0xff); + msg[14] = (byte) ((flags >> 16) & 0xff); + msg[15] = (byte) ((flags >> 24) & 0xff); + msg[16] = (byte) (domainBytes.length & 0xff); + msg[17] = (byte) ((domainBytes.length >> 8) & 0xff); + msg[18] = msg[16]; + msg[19] = msg[17]; + msg[20] = 32; + System.arraycopy(domainBytes, 0, msg, 32, domainBytes.length); + return msg; + } +} diff --git a/src/main/java/gg/effront/anarcry/apiclient/auth/OAuth1Injector.java b/src/main/java/gg/effront/anarcry/apiclient/auth/OAuth1Injector.java new file mode 100644 index 0000000..dce769a --- /dev/null +++ b/src/main/java/gg/effront/anarcry/apiclient/auth/OAuth1Injector.java @@ -0,0 +1,91 @@ +package gg.effront.anarcry.apiclient.auth; + +import gg.effront.anarcry.apiclient.model.AuthConfig; +import gg.effront.anarcry.apiclient.model.HttpRequestDef; +import gg.effront.anarcry.apiclient.vars.VariableStore; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.TreeMap; +import java.util.stream.Collectors; + +public class OAuth1Injector implements AuthInjector { + + private static final SecureRandom RNG = new SecureRandom(); + + @Override + public String mode() { + return "oauth1"; + } + + @Override + public HttpRequestDef inject(HttpRequestDef request, AuthConfig auth, VariableStore vars) { + if (!(auth instanceof AuthConfig.OAuth1 o1)) { + return request; + } + + Map oauthParams = new LinkedHashMap<>(); + oauthParams.put("oauth_consumer_key", o1.consumerKey()); + oauthParams.put("oauth_nonce", generateNonce()); + oauthParams.put("oauth_signature_method", "HMAC-SHA1"); + oauthParams.put("oauth_timestamp", String.valueOf(System.currentTimeMillis() / 1000)); + oauthParams.put("oauth_version", "1.0"); + if (o1.accessToken() != null && !o1.accessToken().isEmpty()) { + oauthParams.put("oauth_token", o1.accessToken()); + } + + String baseUrl = request.url().split("\\?")[0]; + TreeMap allParams = new TreeMap<>(oauthParams); + for (var p : request.params().query()) { + if (p.enabled()) { + allParams.put(p.name(), p.value()); + } + } + + String paramString = allParams.entrySet().stream().map(e -> encode(e.getKey()) + "=" + encode(e.getValue())) + .collect(Collectors.joining("&")); + + String baseString = request.method().toUpperCase() + "&" + encode(baseUrl) + "&" + encode(paramString); + + String signingKey = encode(o1.consumerSecret()) + "&" + + encode(o1.tokenSecret() != null ? o1.tokenSecret() : ""); + + String signature = hmacSha1(signingKey, baseString); + oauthParams.put("oauth_signature", signature); + + String header = "OAuth " + oauthParams.entrySet().stream() + .map(e -> encode(e.getKey()) + "=\"" + encode(e.getValue()) + "\"").collect(Collectors.joining(", ")); + + return AuthInjectorRegistry.addHeader(request, "Authorization", header); + } + + private String generateNonce() { + byte[] bytes = new byte[16]; + RNG.nextBytes(bytes); + return HexFormat.of().formatHex(bytes); + } + + private static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20").replace("*", "%2A").replace("%7E", + "~"); + } + + private static String hmacSha1(String key, String data) { + try { + Mac mac = Mac.getInstance("HmacSHA1"); + mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA1")); + byte[] raw = mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(raw); + } catch (Exception e) { + throw new RuntimeException("HMAC-SHA1 failed", e); + } + } +} diff --git a/src/main/java/gg/effront/anarcry/apiclient/auth/WsseInjector.java b/src/main/java/gg/effront/anarcry/apiclient/auth/WsseInjector.java index 4df0efe..a389e93 100644 --- a/src/main/java/gg/effront/anarcry/apiclient/auth/WsseInjector.java +++ b/src/main/java/gg/effront/anarcry/apiclient/auth/WsseInjector.java @@ -63,7 +63,8 @@ private static byte[] concat(byte[]... arrays) { @SuppressWarnings("java:S4790") private static byte[] sha1(byte[] input) { try { - return MessageDigest.getInstance("SHA-1").digest(input); + return MessageDigest.getInstance("SHA-1").digest(input); // nosemgrep: use-of-sha1 -- WSSE PasswordDigest + // spec requires SHA-1 } catch (NoSuchAlgorithmException e) { throw new IllegalStateException("SHA-1 not available", e); } diff --git a/src/main/java/gg/effront/anarcry/apiclient/io/BruImporter.java b/src/main/java/gg/effront/anarcry/apiclient/io/BruImporter.java index 2ca9c09..b9bba79 100644 --- a/src/main/java/gg/effront/anarcry/apiclient/io/BruImporter.java +++ b/src/main/java/gg/effront/anarcry/apiclient/io/BruImporter.java @@ -16,6 +16,7 @@ import gg.effront.anarcry.apiclient.model.RequestItem; import gg.effront.anarcry.apiclient.model.RequestMeta; import gg.effront.anarcry.apiclient.model.RootConfig; +import gg.effront.anarcry.apiclient.model.ScriptDef; import gg.effront.anarcry.apiclient.model.VarsDef; import java.io.IOException; @@ -29,14 +30,77 @@ public class BruImporter { - private static final Pattern BLOCK_PATTERN = Pattern.compile("(\\S[^{\\n]*)\\s*\\{([^}]*)}", Pattern.DOTALL); - private static final Pattern KV_PATTERN = Pattern.compile("^\\s*(~?)\\s*([^:]+):\\s*(.*)$", Pattern.MULTILINE); + private record Block(String tag, String content) { + } + + private static List parseBlocks(String text) { + List blocks = new ArrayList<>(); + int len = text.length(); + int i = 0; + while (i < len) { + // Skip whitespace / blank lines between blocks + while (i < len && Character.isWhitespace(text.charAt(i))) { + i++; + } + if (i >= len) { + break; + } + + // Read the tag -- everything up to the opening '{' + int tagStart = i; + while (i < len && text.charAt(i) != '{') { + i++; + } + if (i >= len) { + break; + } + String tag = text.substring(tagStart, i).trim(); + if (tag.isEmpty()) { + i++; + continue; + } + + // Skip the opening '{' + i++; + int depth = 1; + int contentStart = i; + + // Walk forward counting braces to find the matching '}' + while (i < len && depth > 0) { + char c = text.charAt(i); + if (c == '{') { + depth++; + } else if (c == '}') { + depth--; + } + if (depth > 0) { + i++; + } + } + + String content = text.substring(contentStart, i).trim(); + blocks.add(new Block(tag, content)); + + // Skip the closing '}' + if (i < len) { + i++; + } + } + return blocks; + } + public Collection importDirectory(Path bruDir) throws IOException { String name = bruDir.getFileName().toString(); List items = scanDir(bruDir); + RootConfig rootConfig = RootConfig.empty(); + Path collectionBru = bruDir.resolve("collection.bru"); + if (Files.isRegularFile(collectionBru)) { + rootConfig = parseRootConfig(Files.readString(collectionBru)); + } + List environments = new ArrayList<>(); Path envDir = bruDir.resolve("environments"); if (Files.isDirectory(envDir)) { @@ -47,7 +111,7 @@ public Collection importDirectory(Path bruDir) throws IOException { } } - return new Collection(null, name, "1", environments, items, RootConfig.empty(), null); + return new Collection(null, name, "1", environments, items, rootConfig, null); } public RequestItem importFile(Path bruFile) throws IOException { @@ -63,9 +127,21 @@ private List scanDir(Path dir) throws IOException { if (Files.isDirectory(entry) && !fileName.equals("environments") && !fileName.startsWith(".")) { List children = scanDir(entry); if (!children.isEmpty()) { - items.add(new Folder(null, fileName, children, RootConfig.empty())); + String folderName = fileName; + RootConfig folderRoot = RootConfig.empty(); + Path folderBru = entry.resolve("folder.bru"); + if (Files.isRegularFile(folderBru)) { + String content = Files.readString(folderBru); + String metaName = parseMetaName(content); + if (metaName != null && !metaName.isEmpty()) { + folderName = metaName; + } + folderRoot = parseRootConfig(content); + } + items.add(new Folder(null, folderName, children, folderRoot)); } - } else if (fileName.endsWith(".bru")) { + } else if (fileName.endsWith(".bru") && !"collection.bru".equals(fileName) + && !"folder.bru".equals(fileName)) { items.add(importFile(entry)); } } @@ -86,12 +162,14 @@ RequestItem parseBruFile(String content, String filename) { List postVars = new ArrayList<>(); List assertions = new ArrayList<>(); String docs = ""; + String preRequestScript = ""; + String postResponseScript = ""; + String testsScript = ""; int seq = 0; - Matcher blockMatcher = BLOCK_PATTERN.matcher(content); - while (blockMatcher.find()) { - String tag = blockMatcher.group(1).trim(); - String blockContent = blockMatcher.group(2).trim(); + for (Block block : parseBlocks(content)) { + String tag = block.tag(); + String blockContent = block.content(); switch (tag) { case "meta" -> { @@ -114,6 +192,8 @@ RequestItem parseBruFile(String content, String filename) { for (var kv : parseKv(blockContent)) { if ("url".equals(kv.key())) { url = kv.value(); + } else if ("auth".equals(kv.key()) && "inherit".equals(kv.value())) { + auth = new AuthConfig.Inherit(); } } } @@ -194,6 +274,9 @@ RequestItem parseBruFile(String content, String filename) { } } } + case "script:pre-request" -> preRequestScript = blockContent; + case "script:post-response" -> postResponseScript = blockContent; + case "tests" -> testsScript = blockContent; case "docs" -> docs = blockContent; default -> { } @@ -204,8 +287,9 @@ RequestItem parseBruFile(String content, String filename) { body, null); VarsDef vars = new VarsDef(preVars, postVars); RequestMeta meta = RequestMeta.http(requestName, seq); + ScriptDef scripts = new ScriptDef(preRequestScript, postResponseScript, testsScript); - return new RequestItem(null, requestName, meta, request, vars, assertions, docs); + return new RequestItem(null, requestName, meta, request, vars, assertions, docs, scripts); } private Environment parseEnvironment(Path file) throws IOException { @@ -213,10 +297,9 @@ private Environment parseEnvironment(Path file) throws IOException { String name = file.getFileName().toString().replace(".bru", ""); List variables = new ArrayList<>(); - Matcher blockMatcher = BLOCK_PATTERN.matcher(content); - while (blockMatcher.find()) { - String tag = blockMatcher.group(1).trim(); - String blockContent = blockMatcher.group(2).trim(); + for (Block block : parseBlocks(content)) { + String tag = block.tag(); + String blockContent = block.content(); if ("vars".equals(tag) || "vars:secret".equals(tag)) { boolean secret = tag.contains("secret"); for (var kv : parseKv(blockContent)) { @@ -227,6 +310,77 @@ private Environment parseEnvironment(Path file) throws IOException { return new Environment(null, name, null, variables, null); } + private RootConfig parseRootConfig(String content) { + List
headers = new ArrayList<>(); + String authMode = null; + AuthConfig bearerAuth = null; + AuthConfig basicAuth = null; + + for (Block block : parseBlocks(content)) { + switch (block.tag()) { + case "auth" -> { + for (var kv : parseKv(block.content())) { + if ("mode".equals(kv.key())) { + authMode = kv.value(); + } + } + } + case "auth:bearer" -> { + for (var kv : parseKv(block.content())) { + if ("token".equals(kv.key())) { + bearerAuth = new AuthConfig.Bearer(kv.value()); + } + } + } + case "auth:basic" -> { + String user = ""; + String pass = ""; + for (var kv : parseKv(block.content())) { + if ("username".equals(kv.key())) { + user = kv.value(); + } + if ("password".equals(kv.key())) { + pass = kv.value(); + } + } + basicAuth = new AuthConfig.Basic(user, pass); + } + case "headers" -> { + for (var kv : parseKv(block.content())) { + headers.add(new Header(kv.key(), kv.value(), kv.enabled())); + } + } + default -> { + } + } + } + + AuthConfig auth = new AuthConfig.None(); + if (authMode != null) { + auth = switch (authMode) { + case "bearer" -> bearerAuth != null ? bearerAuth : new AuthConfig.None(); + case "basic" -> basicAuth != null ? basicAuth : new AuthConfig.None(); + case "inherit" -> new AuthConfig.Inherit(); + default -> new AuthConfig.None(); + }; + } + + return new RootConfig(headers, auth, List.of()); + } + + private String parseMetaName(String content) { + for (Block block : parseBlocks(content)) { + if ("meta".equals(block.tag())) { + for (var kv : parseKv(block.content())) { + if ("name".equals(kv.key())) { + return kv.value(); + } + } + } + } + return null; + } + private Assertion parseAssertion(String lhs, String rhs, boolean enabled) { String[] parts = rhs.split("\\s+", 2); if (parts.length < 1) { diff --git a/src/main/java/gg/effront/anarcry/apiclient/io/CurlGenerator.java b/src/main/java/gg/effront/anarcry/apiclient/io/CurlGenerator.java new file mode 100644 index 0000000..4ad7b24 --- /dev/null +++ b/src/main/java/gg/effront/anarcry/apiclient/io/CurlGenerator.java @@ -0,0 +1,144 @@ +package gg.effront.anarcry.apiclient.io; + +import gg.effront.anarcry.apiclient.model.AuthConfig; +import gg.effront.anarcry.apiclient.model.BodyConfig; +import gg.effront.anarcry.apiclient.model.Header; +import gg.effront.anarcry.apiclient.model.HttpRequestDef; +import gg.effront.anarcry.apiclient.model.Param; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +public final class CurlGenerator { + + private CurlGenerator() { + } + + public static String generate(HttpRequestDef req) { + List parts = new ArrayList<>(); + parts.add("curl"); + + if (!"GET".equalsIgnoreCase(req.method())) { + parts.add("-X " + req.method().toUpperCase()); + } + + String url = buildUrl(req); + parts.add(shellQuote(url)); + + for (Header h : req.headers()) { + if (!h.enabled()) { + continue; + } + parts.add("-H " + shellQuote(h.name() + ": " + h.value())); + } + + appendAuth(req.auth(), parts); + appendBody(req.body(), parts); + + return String.join(" \\\n ", parts); + } + + private static String buildUrl(HttpRequestDef req) { + String url = req.url(); + + List pathParams = req.params().path().stream().filter(Param::enabled).toList(); + for (Param p : pathParams) { + url = url.replace(":" + p.name(), URLEncoder.encode(p.value(), StandardCharsets.UTF_8)); + } + + List queryParams = req.params().query().stream().filter(Param::enabled).toList(); + if (!queryParams.isEmpty()) { + StringBuilder sb = new StringBuilder(url); + sb.append(url.contains("?") ? "&" : "?"); + for (int i = 0; i < queryParams.size(); i++) { + if (i > 0) { + sb.append("&"); + } + Param p = queryParams.get(i); + sb.append(URLEncoder.encode(p.name(), StandardCharsets.UTF_8)); + sb.append("="); + sb.append(URLEncoder.encode(p.value(), StandardCharsets.UTF_8)); + } + url = sb.toString(); + } + + return url; + } + + private static void appendAuth(AuthConfig auth, List parts) { + switch (auth) { + case AuthConfig.Bearer b -> parts.add("-H " + shellQuote("Authorization: Bearer " + b.token())); + case AuthConfig.Basic b -> parts.add("-u " + shellQuote(b.username() + ":" + b.password())); + case AuthConfig.ApiKey a -> { + if ("query".equalsIgnoreCase(a.placement())) { + // already handled in URL query params if wired; emit as comment + } else { + parts.add("-H " + shellQuote(a.key() + ": " + a.value())); + } + } + case AuthConfig.Digest d -> parts.add("--digest -u " + shellQuote(d.username() + ":" + d.password())); + default -> { + } + } + } + + private static void appendBody(BodyConfig body, List parts) { + switch (body) { + case BodyConfig.Json j -> { + parts.add("-H " + shellQuote("Content-Type: application/json")); + parts.add("-d " + shellQuote(j.content())); + } + case BodyConfig.Text t -> { + parts.add("-H " + shellQuote("Content-Type: text/plain")); + parts.add("-d " + shellQuote(t.content())); + } + case BodyConfig.Xml x -> { + parts.add("-H " + shellQuote("Content-Type: application/xml")); + parts.add("-d " + shellQuote(x.content())); + } + case BodyConfig.FormUrlEncoded f -> { + for (Param p : f.params()) { + if (p.enabled()) { + parts.add("--data-urlencode " + shellQuote(p.name() + "=" + p.value())); + } + } + } + case BodyConfig.GraphQL g -> { + parts.add("-H " + shellQuote("Content-Type: application/json")); + StringBuilder payload = new StringBuilder("{\"query\":"); + payload.append(jsonQuote(g.query())); + if (g.variables() != null && !g.variables().isBlank()) { + payload.append(",\"variables\":").append(g.variables()); + } + payload.append("}"); + parts.add("-d " + shellQuote(payload.toString())); + } + case BodyConfig.MultipartForm m -> { + for (BodyConfig.MultipartField field : m.fields()) { + if (!field.enabled()) { + continue; + } + if (field.filePath() != null && !field.filePath().isEmpty()) { + parts.add("-F " + shellQuote(field.name() + "=@" + field.filePath())); + } else { + parts.add("-F " + shellQuote(field.name() + "=" + field.value())); + } + } + } + case BodyConfig.FileBody f -> parts.add("--data-binary " + shellQuote("@" + f.filePath())); + case BodyConfig.None ignored -> { + } + } + } + + private static String shellQuote(String s) { + return "'" + s.replace("'", "'\\''") + "'"; + } + + private static String jsonQuote(String s) { + return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r") + .replace("\t", "\\t") + "\""; + } +} diff --git a/src/main/java/gg/effront/anarcry/apiclient/io/InsomniaImporter.java b/src/main/java/gg/effront/anarcry/apiclient/io/InsomniaImporter.java new file mode 100644 index 0000000..9dbc633 --- /dev/null +++ b/src/main/java/gg/effront/anarcry/apiclient/io/InsomniaImporter.java @@ -0,0 +1,203 @@ +package gg.effront.anarcry.apiclient.io; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import gg.effront.anarcry.apiclient.model.AuthConfig; +import gg.effront.anarcry.apiclient.model.BodyConfig; +import gg.effront.anarcry.apiclient.model.Collection; +import gg.effront.anarcry.apiclient.model.CollectionItem; +import gg.effront.anarcry.apiclient.model.Environment; +import gg.effront.anarcry.apiclient.model.EnvVariable; +import gg.effront.anarcry.apiclient.model.Folder; +import gg.effront.anarcry.apiclient.model.Header; +import gg.effront.anarcry.apiclient.model.HttpRequestDef; +import gg.effront.anarcry.apiclient.model.Param; +import gg.effront.anarcry.apiclient.model.ParamsDef; +import gg.effront.anarcry.apiclient.model.RequestItem; +import gg.effront.anarcry.apiclient.model.RequestMeta; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public class InsomniaImporter { + + public Collection importFile(Path file) throws IOException { + String content = Files.readString(file); + JsonObject root = JsonParser.parseString(content).getAsJsonObject(); + + if (root.has("_type") && "export".equals(str(root, "_type"))) { + return importExportV4(root); + } + if (root.has("resources")) { + return importExportV4(root); + } + + throw new IOException("Unsupported Insomnia format"); + } + + private Collection importExportV4(JsonObject root) { + JsonArray resources = root.getAsJsonArray("resources"); + if (resources == null) { + return new Collection(null, "Insomnia Import", null, List.of(), List.of(), null, null); + } + + Map byId = new LinkedHashMap<>(); + for (JsonElement el : resources) { + JsonObject obj = el.getAsJsonObject(); + byId.put(str(obj, "_id"), obj); + } + + String collectionName = "Insomnia Import"; + String workspaceId = null; + for (JsonObject obj : byId.values()) { + if ("workspace".equals(str(obj, "_type"))) { + collectionName = str(obj, "name"); + workspaceId = str(obj, "_id"); + break; + } + } + + Map> children = new LinkedHashMap<>(); + List environments = new ArrayList<>(); + int seq = 0; + + for (JsonObject obj : byId.values()) { + String type = str(obj, "_type"); + String parentId = str(obj, "parentId"); + + if ("request_group".equals(type)) { + children.computeIfAbsent(parentId, k -> new ArrayList<>()); + } else if ("request".equals(type)) { + RequestItem item = parseRequest(obj, seq++); + children.computeIfAbsent(parentId, k -> new ArrayList<>()).add(item); + } else if ("environment".equals(type)) { + environments.add(parseEnvironment(obj)); + } + } + + for (JsonObject obj : byId.values()) { + if ("request_group".equals(str(obj, "_type"))) { + String id = str(obj, "_id"); + String parentId = str(obj, "parentId"); + List items = children.getOrDefault(id, List.of()); + Folder folder = new Folder(null, str(obj, "name"), items, null); + children.computeIfAbsent(parentId, k -> new ArrayList<>()).add(folder); + } + } + + String rootParent = workspaceId != null ? workspaceId : ""; + List topLevel = children.getOrDefault(rootParent, List.of()); + return new Collection(null, collectionName, null, environments, topLevel, null, null); + } + + private RequestItem parseRequest(JsonObject obj, int seq) { + String name = str(obj, "name"); + String method = str(obj, "method"); + if (method.isEmpty()) { + method = "GET"; + } + String url = str(obj, "url"); + + List
headers = new ArrayList<>(); + if (obj.has("headers") && obj.get("headers").isJsonArray()) { + for (JsonElement hel : obj.getAsJsonArray("headers")) { + JsonObject h = hel.getAsJsonObject(); + boolean enabled = !h.has("disabled") || !h.get("disabled").getAsBoolean(); + headers.add(new Header(str(h, "name"), str(h, "value"), enabled)); + } + } + + List queryParams = new ArrayList<>(); + if (obj.has("parameters") && obj.get("parameters").isJsonArray()) { + for (JsonElement pel : obj.getAsJsonArray("parameters")) { + JsonObject p = pel.getAsJsonObject(); + queryParams.add(Param.query(str(p, "name"), str(p, "value"))); + } + } + + BodyConfig body = parseBody(obj); + AuthConfig auth = parseAuth(obj); + + HttpRequestDef request = new HttpRequestDef(method, url, new ParamsDef(queryParams, List.of()), headers, auth, + body, null); + String desc = str(obj, "description"); + RequestMeta meta = RequestMeta.http(name, seq); + return new RequestItem(null, name, meta, request, null, List.of(), desc); + } + + private BodyConfig parseBody(JsonObject obj) { + if (!obj.has("body") || obj.get("body").isJsonNull()) { + return new BodyConfig.None(); + } + JsonObject bodyObj = obj.getAsJsonObject("body"); + String mimeType = str(bodyObj, "mimeType"); + String text = str(bodyObj, "text"); + + if (mimeType.contains("json")) { + return new BodyConfig.Json(text); + } + if (mimeType.contains("xml")) { + return new BodyConfig.Xml(text); + } + if (mimeType.contains("form-urlencoded")) { + List formParams = new ArrayList<>(); + if (bodyObj.has("params") && bodyObj.get("params").isJsonArray()) { + for (JsonElement pel : bodyObj.getAsJsonArray("params")) { + JsonObject p = pel.getAsJsonObject(); + formParams.add(Param.query(str(p, "name"), str(p, "value"))); + } + } + return new BodyConfig.FormUrlEncoded(formParams); + } + if (mimeType.contains("graphql")) { + return new BodyConfig.GraphQL(text, null); + } + if (!text.isEmpty()) { + return new BodyConfig.Text(text); + } + return new BodyConfig.None(); + } + + private AuthConfig parseAuth(JsonObject obj) { + if (!obj.has("authentication") || obj.get("authentication").isJsonNull()) { + return new AuthConfig.None(); + } + JsonObject authObj = obj.getAsJsonObject("authentication"); + String type = str(authObj, "type"); + + return switch (type) { + case "basic" -> new AuthConfig.Basic(str(authObj, "username"), str(authObj, "password")); + case "bearer" -> new AuthConfig.Bearer(str(authObj, "token")); + case "digest" -> new AuthConfig.Digest(str(authObj, "username"), str(authObj, "password")); + default -> new AuthConfig.None(); + }; + } + + private Environment parseEnvironment(JsonObject obj) { + String name = str(obj, "name"); + List variables = new ArrayList<>(); + if (obj.has("data") && obj.get("data").isJsonObject()) { + for (var entry : obj.getAsJsonObject("data").entrySet()) { + String value = entry.getValue().isJsonPrimitive() + ? entry.getValue().getAsString() + : entry.getValue().toString(); + variables.add(EnvVariable.of(entry.getKey(), value)); + } + } + return Environment.of(name, variables); + } + + private static String str(JsonObject obj, String key) { + if (obj.has(key) && obj.get(key).isJsonPrimitive()) { + return obj.get(key).getAsString(); + } + return ""; + } +} diff --git a/src/main/java/gg/effront/anarcry/apiclient/model/RequestItem.java b/src/main/java/gg/effront/anarcry/apiclient/model/RequestItem.java index dca5b41..c381cc6 100644 --- a/src/main/java/gg/effront/anarcry/apiclient/model/RequestItem.java +++ b/src/main/java/gg/effront/anarcry/apiclient/model/RequestItem.java @@ -3,7 +3,12 @@ import java.util.List; public record RequestItem(String uid, String name, RequestMeta meta, HttpRequestDef request, VarsDef vars, - List assertions, String docs) implements CollectionItem { + List assertions, String docs, ScriptDef scripts) implements CollectionItem { + + public RequestItem(String uid, String name, RequestMeta meta, HttpRequestDef request, VarsDef vars, + List assertions, String docs) { + this(uid, name, meta, request, vars, assertions, docs, ScriptDef.empty()); + } public RequestItem { if (uid == null) { @@ -21,5 +26,8 @@ public record RequestItem(String uid, String name, RequestMeta meta, HttpRequest if (assertions == null) { assertions = List.of(); } + if (scripts == null) { + scripts = ScriptDef.empty(); + } } } diff --git a/src/main/java/gg/effront/anarcry/apiclient/model/ScriptDef.java b/src/main/java/gg/effront/anarcry/apiclient/model/ScriptDef.java new file mode 100644 index 0000000..8da15d7 --- /dev/null +++ b/src/main/java/gg/effront/anarcry/apiclient/model/ScriptDef.java @@ -0,0 +1,36 @@ +package gg.effront.anarcry.apiclient.model; + +public record ScriptDef(String preRequest, String postResponse, String tests) { + + public ScriptDef { + if (preRequest == null) { + preRequest = ""; + } + if (postResponse == null) { + postResponse = ""; + } + if (tests == null) { + tests = ""; + } + } + + public static ScriptDef empty() { + return new ScriptDef("", "", ""); + } + + public boolean hasPreRequest() { + return !preRequest.isBlank(); + } + + public boolean hasPostResponse() { + return !postResponse.isBlank(); + } + + public boolean hasTests() { + return !tests.isBlank(); + } + + public boolean isEmpty() { + return !hasPreRequest() && !hasPostResponse() && !hasTests(); + } +} diff --git a/src/main/java/gg/effront/anarcry/apiclient/runner/RequestExecutor.java b/src/main/java/gg/effront/anarcry/apiclient/runner/RequestExecutor.java index 0606013..cde3283 100644 --- a/src/main/java/gg/effront/anarcry/apiclient/runner/RequestExecutor.java +++ b/src/main/java/gg/effront/anarcry/apiclient/runner/RequestExecutor.java @@ -15,7 +15,10 @@ import gg.effront.anarcry.apiclient.model.RequestResult; import gg.effront.anarcry.apiclient.model.RequestSettings; import gg.effront.anarcry.apiclient.model.ResponseData; +import gg.effront.anarcry.apiclient.model.ScriptDef; import gg.effront.anarcry.apiclient.model.VarsDef; +import gg.effront.anarcry.apiclient.script.ScriptEngine; +import gg.effront.anarcry.apiclient.script.ScriptResult; import gg.effront.anarcry.apiclient.vars.Interpolator; import gg.effront.anarcry.apiclient.vars.VariableStore; @@ -42,6 +45,7 @@ public class RequestExecutor { private final Interpolator interpolator; private final AuthInjectorRegistry authRegistry; private final AssertionEngine assertionEngine; + private final ScriptEngine scriptEngine; public RequestExecutor(HttpClient httpClient, Interpolator interpolator, AuthInjectorRegistry authRegistry, AssertionEngine assertionEngine) { @@ -49,6 +53,7 @@ public RequestExecutor(HttpClient httpClient, Interpolator interpolator, AuthInj this.interpolator = interpolator; this.authRegistry = authRegistry; this.assertionEngine = assertionEngine; + this.scriptEngine = new ScriptEngine(); } public RequestResult execute(RequestItem item, RunContext ctx, Collection collection, Consumer log) { @@ -56,11 +61,16 @@ public RequestResult execute(RequestItem item, RunContext ctx, Collection collec try { // Phase 1: MERGE - cascade folder/collection headers, auth, vars - HttpRequestDef merged = mergeInherited(item, collection); + HttpRequestDef merged = mergeInherited(item, collection, ctx.vars()); // Phase 2: PRE-VARS - resolve pre-request variables resolvePreVars(item.vars().pre(), ctx.vars()); + // Phase 2.5: PRE-REQUEST SCRIPT + ScriptDef scripts = item.scripts(); + ScriptResult preScriptResult = scriptEngine.runPreRequest(scripts, merged, ctx.vars()); + logScriptOutput(preScriptResult, "pre-request", log); + // Phase 3: INTERPOLATE - {{var}} in URL, headers, body, auth, params HttpRequestDef interpolated = interpolateRequest(merged, ctx.vars()); @@ -73,11 +83,17 @@ public RequestResult execute(RequestItem item, RunContext ctx, Collection collec // Phase 6: POST-VARS - extract from response resolvePostVars(item.vars().post(), response, ctx.vars()); + // Phase 6.5: POST-RESPONSE SCRIPT + TESTS + ScriptResult postScriptResult = scriptEngine.runPostResponse(scripts, interpolated, response, ctx.vars()); + logScriptOutput(postScriptResult, "post-response", log); + ctx.setLastScriptResult(postScriptResult); + // Phase 7: ASSERTIONS List assertResults = assertionEngine.evaluate(item.assertions(), response, ctx.vars()); long duration = (System.nanoTime() - start) / 1_000_000; - boolean passed = assertResults.stream().allMatch(AssertionResult::passed); + boolean passed = assertResults.stream().allMatch(AssertionResult::passed) + && postScriptResult.testsFailed() == 0; return new RequestResult(item, interpolated, response, assertResults, duration, passed); } catch (Exception e) { @@ -89,7 +105,24 @@ public RequestResult execute(RequestItem item, RunContext ctx, Collection collec } } - HttpRequestDef mergeInherited(RequestItem item, Collection collection) { + private void logScriptOutput(ScriptResult result, String phase, Consumer log) { + if (result == null) { + return; + } + for (String line : result.consoleLog()) { + log.accept(" [script:" + phase + "] " + line); + } + if (result.hasError()) { + log.accept(" [script:" + phase + "] ERROR: " + result.error()); + } + for (ScriptResult.TestResult t : result.testResults()) { + String status = t.passed() ? "PASS" : "FAIL"; + String msg = t.message().isEmpty() ? "" : " -- " + t.message(); + log.accept(" [test] " + status + ": " + t.name() + msg); + } + } + + HttpRequestDef mergeInherited(RequestItem item, Collection collection, VariableStore vars) { HttpRequestDef req = item.request(); List
mergedHeaders = new ArrayList<>(collection.root().headers()); @@ -112,8 +145,7 @@ HttpRequestDef mergeInherited(RequestItem item, Collection collection) { } if (parentFolder != null) { - VariableStore dummyStore = new VariableStore(); - dummyStore.loadFolder(parentFolder.root()); + vars.loadFolder(parentFolder.root()); } return new HttpRequestDef(req.method(), req.url(), req.params(), mergedHeaders, auth, req.body(), @@ -124,7 +156,7 @@ private Folder findParentFolder(RequestItem item, List items) { for (CollectionItem ci : items) { if (ci instanceof Folder folder) { for (CollectionItem child : folder.items()) { - if (child instanceof RequestItem ri && ri.uid().equals(item.uid())) { + if (child instanceof RequestItem ri && java.util.Objects.equals(ri.uid(), item.uid())) { return folder; } if (child instanceof Folder sub) { @@ -141,7 +173,7 @@ private Folder findParentFolder(RequestItem item, List items) { private boolean containsRequest(Folder folder, RequestItem target) { for (CollectionItem ci : folder.items()) { - if (ci instanceof RequestItem ri && ri.uid().equals(target.uid())) { + if (ci instanceof RequestItem ri && java.util.Objects.equals(ri.uid(), target.uid())) { return true; } if (ci instanceof Folder sub && containsRequest(sub, target)) { @@ -183,9 +215,9 @@ HttpRequestDef interpolateRequest(HttpRequestDef req, VariableStore vars) { .toList(); BodyConfig body = interpolateBody(req.body(), vars); + AuthConfig auth = interpolateAuth(req.auth(), vars); - return new HttpRequestDef(req.method(), url, new ParamsDef(query, path), headers, req.auth(), body, - req.settings()); + return new HttpRequestDef(req.method(), url, new ParamsDef(query, path), headers, auth, body, req.settings()); } private BodyConfig interpolateBody(BodyConfig body, VariableStore vars) { @@ -210,6 +242,39 @@ private BodyConfig interpolateBody(BodyConfig body, VariableStore vars) { }; } + private AuthConfig interpolateAuth(AuthConfig auth, VariableStore vars) { + return switch (auth) { + case AuthConfig.None n -> n; + case AuthConfig.Inherit i -> i; + case AuthConfig.Bearer b -> new AuthConfig.Bearer(interpolator.interpolate(b.token(), vars)); + case AuthConfig.Basic b -> new AuthConfig.Basic(interpolator.interpolate(b.username(), vars), + interpolator.interpolate(b.password(), vars)); + case AuthConfig.ApiKey a -> new AuthConfig.ApiKey(interpolator.interpolate(a.key(), vars), + interpolator.interpolate(a.value(), vars), a.placement()); + case AuthConfig.Digest d -> new AuthConfig.Digest(interpolator.interpolate(d.username(), vars), + interpolator.interpolate(d.password(), vars)); + case AuthConfig.OAuth2 o -> + new AuthConfig.OAuth2(o.grantType(), interpolator.interpolate(o.accessTokenUrl(), vars), + interpolator.interpolate(o.refreshTokenUrl(), vars), + interpolator.interpolate(o.clientId(), vars), interpolator.interpolate(o.clientSecret(), vars), + o.scope(), o.callbackUrl(), o.authorizationUrl(), o.pkce(), o.credPlacement(), + o.tokenPlacement(), o.autoRefresh(), o.autoFetch(), o.additionalParams()); + case AuthConfig.AwsV4 a -> new AuthConfig.AwsV4(interpolator.interpolate(a.accessKeyId(), vars), + interpolator.interpolate(a.secretAccessKey(), vars), + interpolator.interpolate(a.sessionToken(), vars), interpolator.interpolate(a.service(), vars), + interpolator.interpolate(a.region(), vars)); + case AuthConfig.OAuth1 o -> new AuthConfig.OAuth1(interpolator.interpolate(o.consumerKey(), vars), + interpolator.interpolate(o.consumerSecret(), vars), interpolator.interpolate(o.accessToken(), vars), + interpolator.interpolate(o.tokenSecret(), vars), o.signatureMethod(), o.placement()); + case AuthConfig.Ntlm n -> new AuthConfig.Ntlm(interpolator.interpolate(n.username(), vars), + interpolator.interpolate(n.password(), vars), interpolator.interpolate(n.domain(), vars)); + case AuthConfig.Wsse w -> new AuthConfig.Wsse(interpolator.interpolate(w.username(), vars), + interpolator.interpolate(w.password(), vars)); + case AuthConfig.EdgeGrid e -> new AuthConfig.EdgeGrid(interpolator.interpolate(e.accessToken(), vars), + interpolator.interpolate(e.clientToken(), vars), interpolator.interpolate(e.clientSecret(), vars)); + }; + } + private ResponseData dispatch(HttpRequestDef req, RunContext ctx, Consumer log) throws IOException, InterruptedException { String url = buildUrl(req); diff --git a/src/main/java/gg/effront/anarcry/apiclient/runner/RunContext.java b/src/main/java/gg/effront/anarcry/apiclient/runner/RunContext.java index 4c8499f..c6b7cf0 100644 --- a/src/main/java/gg/effront/anarcry/apiclient/runner/RunContext.java +++ b/src/main/java/gg/effront/anarcry/apiclient/runner/RunContext.java @@ -1,6 +1,7 @@ package gg.effront.anarcry.apiclient.runner; import gg.effront.anarcry.apiclient.model.RequestResult; +import gg.effront.anarcry.apiclient.script.ScriptResult; import gg.effront.anarcry.apiclient.vars.VariableStore; import java.net.CookieManager; @@ -17,6 +18,7 @@ public class RunContext { private final AtomicBoolean cancel; private volatile boolean stopRequested; private volatile String nextRequest; + private volatile ScriptResult lastScriptResult; public RunContext(VariableStore vars) { this.vars = vars; @@ -64,4 +66,12 @@ public String nextRequest() { public void clearNextRequest() { this.nextRequest = null; } + + public void setLastScriptResult(ScriptResult result) { + this.lastScriptResult = result; + } + + public ScriptResult lastScriptResult() { + return lastScriptResult; + } } diff --git a/src/main/java/gg/effront/anarcry/apiclient/script/BruBridge.java b/src/main/java/gg/effront/anarcry/apiclient/script/BruBridge.java new file mode 100644 index 0000000..34f1afe --- /dev/null +++ b/src/main/java/gg/effront/anarcry/apiclient/script/BruBridge.java @@ -0,0 +1,59 @@ +package gg.effront.anarcry.apiclient.script; + +import gg.effront.anarcry.apiclient.vars.VariableStore; + +import org.graalvm.polyglot.HostAccess; + +public class BruBridge { + + private final VariableStore vars; + + public BruBridge(VariableStore vars) { + this.vars = vars; + } + + @HostAccess.Export + public String getVar(String key) { + return vars.resolve(key); + } + + @HostAccess.Export + public void setVar(String key, String value) { + vars.setRuntime(key, value); + } + + @HostAccess.Export + public String getEnvVar(String key) { + String v = vars.envVars().get(key); + return v != null ? v : ""; + } + + @HostAccess.Export + public void setEnvVar(String key, String value) { + vars.setRuntime(key, value); + } + + @HostAccess.Export + public String getCollectionVar(String key) { + String v = vars.collectionVars().get(key); + return v != null ? v : ""; + } + + @HostAccess.Export + public String getFolderVar(String key) { + String v = vars.folderVars().get(key); + return v != null ? v : ""; + } + + @HostAccess.Export + public String getRequestVar(String key) { + String v = vars.requestVars().get(key); + return v != null ? v : ""; + } + + @HostAccess.Export + public String getProcessEnv(String key) { + String v = System.getenv(key); + return v != null ? v : ""; + } +} diff --git a/src/main/java/gg/effront/anarcry/apiclient/script/ReqBridge.java b/src/main/java/gg/effront/anarcry/apiclient/script/ReqBridge.java new file mode 100644 index 0000000..cdd0a64 --- /dev/null +++ b/src/main/java/gg/effront/anarcry/apiclient/script/ReqBridge.java @@ -0,0 +1,73 @@ +package gg.effront.anarcry.apiclient.script; + +import gg.effront.anarcry.apiclient.model.BodyConfig; +import gg.effront.anarcry.apiclient.model.Header; +import gg.effront.anarcry.apiclient.model.HttpRequestDef; + +import org.graalvm.polyglot.HostAccess; + +import java.util.LinkedHashMap; +import java.util.Map; + +public class ReqBridge { + + private HttpRequestDef request; + private final Map headerOverrides = new LinkedHashMap<>(); + + public ReqBridge(HttpRequestDef request) { + this.request = request; + } + + public void update(HttpRequestDef request) { + this.request = request; + } + + @HostAccess.Export + public String getUrl() { + return request.url(); + } + + @HostAccess.Export + public String getMethod() { + return request.method(); + } + + @HostAccess.Export + public String getHeader(String name) { + String override = headerOverrides.get(name.toLowerCase()); + if (override != null) { + return override; + } + for (Header h : request.headers()) { + if (h.name().equalsIgnoreCase(name)) { + return h.value(); + } + } + return ""; + } + + @HostAccess.Export + public void setHeader(String name, String value) { + headerOverrides.put(name.toLowerCase(), value); + } + + @HostAccess.Export + public String getBody() { + return switch (request.body()) { + case BodyConfig.Json j -> j.content(); + case BodyConfig.Text t -> t.content(); + case BodyConfig.Xml x -> x.content(); + case BodyConfig.GraphQL g -> g.query(); + default -> ""; + }; + } + + @HostAccess.Export + public String getAuthType() { + return request.auth().mode(); + } + + public Map headerOverrides() { + return Map.copyOf(headerOverrides); + } +} diff --git a/src/main/java/gg/effront/anarcry/apiclient/script/ResBridge.java b/src/main/java/gg/effront/anarcry/apiclient/script/ResBridge.java new file mode 100644 index 0000000..c590bf5 --- /dev/null +++ b/src/main/java/gg/effront/anarcry/apiclient/script/ResBridge.java @@ -0,0 +1,58 @@ +package gg.effront.anarcry.apiclient.script; + +import gg.effront.anarcry.apiclient.model.ResponseData; + +import org.graalvm.polyglot.HostAccess; + +import java.util.List; +import java.util.Map; + +public class ResBridge { + + private ResponseData response; + + public ResBridge() { + } + + public void setResponse(ResponseData response) { + this.response = response; + } + + @HostAccess.Export + public int getStatus() { + return response != null ? response.status() : 0; + } + + @HostAccess.Export + public String getBody() { + return response != null ? response.body() : ""; + } + + @HostAccess.Export + public String getHeader(String name) { + if (response == null) { + return ""; + } + return response.header(name); + } + + @HostAccess.Export + public long getResponseTime() { + return response != null ? response.responseTimeMs() : 0; + } + + @HostAccess.Export + public long getSize() { + return response != null ? response.size() : 0; + } + + @HostAccess.Export + public String getStatusText() { + return response != null ? response.statusText() : ""; + } + + @HostAccess.Export + public Map> getHeaders() { + return response != null ? response.headers() : Map.of(); + } +} diff --git a/src/main/java/gg/effront/anarcry/apiclient/script/ScriptEngine.java b/src/main/java/gg/effront/anarcry/apiclient/script/ScriptEngine.java new file mode 100644 index 0000000..c3205de --- /dev/null +++ b/src/main/java/gg/effront/anarcry/apiclient/script/ScriptEngine.java @@ -0,0 +1,320 @@ +package gg.effront.anarcry.apiclient.script; + +import gg.effront.anarcry.apiclient.model.HttpRequestDef; +import gg.effront.anarcry.apiclient.model.ResponseData; +import gg.effront.anarcry.apiclient.model.ScriptDef; +import gg.effront.anarcry.apiclient.vars.VariableStore; + +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.HostAccess; +import org.graalvm.polyglot.PolyglotException; +import org.graalvm.polyglot.Source; +import org.graalvm.polyglot.Value; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public class ScriptEngine { + + private static final long SCRIPT_TIMEOUT_MS = 10_000; + private static final String TEST_FRAMEWORK_JS = """ + var __testResults = []; + function test(name, fn) { + try { + fn(); + __testResults.push({ name: name, passed: true, message: '' }); + } catch (e) { + __testResults.push({ name: name, passed: false, message: e.message || String(e) }); + } + } + function expect(actual) { + return { + toBe: function(expected) { + if (actual !== expected) { + throw new Error('Expected ' + + JSON.stringify(expected) + + ' but got ' + JSON.stringify(actual)); + } + }, + toEqual: function(expected) { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error('Expected ' + + JSON.stringify(expected) + + ' but got ' + JSON.stringify(actual)); + } + }, + toBeGreaterThan: function(expected) { + if (!(actual > expected)) { + throw new Error('Expected ' + actual + ' to be greater than ' + expected); + } + }, + toBeLessThan: function(expected) { + if (!(actual < expected)) { + throw new Error('Expected ' + actual + ' to be less than ' + expected); + } + }, + toBeGreaterThanOrEqual: function(expected) { + if (!(actual >= expected)) { + throw new Error('Expected ' + actual + ' to be >= ' + expected); + } + }, + toBeLessThanOrEqual: function(expected) { + if (!(actual <= expected)) { + throw new Error('Expected ' + actual + ' to be <= ' + expected); + } + }, + toContain: function(expected) { + if (typeof actual === 'string') { + if (actual.indexOf(expected) === -1) { + throw new Error('Expected string to contain ' + JSON.stringify(expected)); + } + } else if (Array.isArray(actual)) { + if (actual.indexOf(expected) === -1) { + throw new Error('Expected array to contain ' + JSON.stringify(expected)); + } + } else { + throw new Error('toContain requires a string or array'); + } + }, + toHaveLength: function(expected) { + var len = actual ? actual.length : 0; + if (len !== expected) { + throw new Error('Expected length ' + expected + ' but got ' + len); + } + }, + toBeTruthy: function() { + if (!actual) { + throw new Error('Expected truthy but got ' + JSON.stringify(actual)); + } + }, + toBeFalsy: function() { + if (actual) { + throw new Error('Expected falsy but got ' + JSON.stringify(actual)); + } + }, + toBeDefined: function() { + if (actual === undefined || actual === null) { + throw new Error('Expected defined but got ' + actual); + } + }, + toBeUndefined: function() { + if (actual !== undefined && actual !== null) { + throw new Error('Expected undefined but got ' + JSON.stringify(actual)); + } + }, + toMatch: function(pattern) { + var regex = (typeof pattern === 'string') ? new RegExp(pattern) : pattern; + if (!regex.test(actual)) { + throw new Error('Expected ' + JSON.stringify(actual) + ' to match ' + pattern); + } + }, + toHaveProperty: function(prop) { + if (actual === null || actual === undefined || !(prop in actual)) { + throw new Error('Expected object to have property ' + JSON.stringify(prop)); + } + }, + not: { + toBe: function(expected) { + if (actual === expected) { + throw new Error('Expected not to be ' + JSON.stringify(expected)); + } + }, + toEqual: function(expected) { + if (JSON.stringify(actual) === JSON.stringify(expected)) { + throw new Error('Expected not to equal ' + JSON.stringify(expected)); + } + }, + toContain: function(expected) { + if (typeof actual === 'string' && actual.indexOf(expected) !== -1) { + throw new Error('Expected string not to contain ' + JSON.stringify(expected)); + } + if (Array.isArray(actual) && actual.indexOf(expected) !== -1) { + throw new Error('Expected array not to contain ' + JSON.stringify(expected)); + } + }, + toBeTruthy: function() { + if (actual) { + throw new Error('Expected falsy but got ' + JSON.stringify(actual)); + } + }, + toBeDefined: function() { + if (actual !== undefined && actual !== null) { + throw new Error('Expected undefined but got ' + JSON.stringify(actual)); + } + }, + toMatch: function(pattern) { + var regex = (typeof pattern === 'string') ? new RegExp(pattern) : pattern; + if (regex.test(actual)) { + throw new Error('Expected ' + JSON.stringify(actual) + ' not to match ' + pattern); + } + } + } + }; + } + """; + + private static final String RES_BRIDGE_WRAPPER_JS = """ + (function() { + var __jres = res; + res = { + getStatus: function() { return __jres.getStatus(); }, + getStatusText: function() { return __jres.getStatusText(); }, + getBody: function() { + var raw = __jres.getBody(); + if (raw && typeof raw === 'string') { + try { return JSON.parse(raw); } catch(e) { return raw; } + } + return raw; + }, + getHeader: function(n) { return __jres.getHeader(n); }, + getHeaders: function() { return __jres.getHeaders(); }, + getResponseTime: function() { return __jres.getResponseTime(); }, + getSize: function() { return __jres.getSize(); } + }; + })(); + """; + + public ScriptResult runPreRequest(ScriptDef scripts, HttpRequestDef request, VariableStore vars) { + if (scripts == null || !scripts.hasPreRequest()) { + return ScriptResult.empty(); + } + return executeScript(scripts.preRequest(), request, null, vars, false); + } + + public ScriptResult runPostResponse(ScriptDef scripts, HttpRequestDef request, ResponseData response, + VariableStore vars) { + if (scripts == null) { + return ScriptResult.empty(); + } + List allLogs = new ArrayList<>(); + List allTests = new ArrayList<>(); + + if (scripts.hasPostResponse()) { + ScriptResult postResult = executeScript(scripts.postResponse(), request, response, vars, false); + allLogs.addAll(postResult.consoleLog()); + if (postResult.hasError()) { + return new ScriptResult(allLogs, allTests, postResult.error()); + } + } + + if (scripts.hasTests()) { + ScriptResult testResult = executeScript(scripts.tests(), request, response, vars, true); + allLogs.addAll(testResult.consoleLog()); + allTests.addAll(testResult.testResults()); + if (testResult.hasError()) { + return new ScriptResult(allLogs, allTests, testResult.error()); + } + } + + return new ScriptResult(allLogs, allTests, null); + } + + private ScriptResult executeScript(String script, HttpRequestDef request, ResponseData response, VariableStore vars, + boolean includeTestFramework) { + List consoleLog = new ArrayList<>(); + BruBridge bru = new BruBridge(vars); + ReqBridge req = new ReqBridge(request); + ResBridge res = new ResBridge(); + if (response != null) { + res.setResponse(response); + } + + HostAccess hostAccess = HostAccess.newBuilder(HostAccess.EXPLICIT) + .targetTypeMapping(Value.class, Object.class, v -> v.hasArrayElements(), v -> { + long size = v.getArraySize(); + Object[] arr = new Object[(int) size]; + for (int i = 0; i < size; i++) { + arr[i] = v.getArrayElement(i).as(Object.class); + } + return arr; + }).targetTypeMapping(Value.class, Map.class, Value::hasMembers, v -> { + var map = new java.util.LinkedHashMap(); + for (String key : v.getMemberKeys()) { + map.put(key, v.getMember(key).as(Object.class)); + } + return map; + }).build(); + + try (Context context = Context.newBuilder("js").allowHostAccess(hostAccess) + .option("engine.WarnInterpreterOnly", "false").build()) { + + Value bindings = context.getBindings("js"); + bindings.putMember("bru", bru); + bindings.putMember("req", req); + bindings.putMember("res", res); + + ConsoleCapture console = new ConsoleCapture(consoleLog); + bindings.putMember("__console", console); + + context.eval(Source.newBuilder("js", + "var console = { log: function() { __console.log(Array.prototype.join.call(arguments, ' ')); }, " + + "warn: function() { __console.warn(Array.prototype.join.call(arguments, ' ')); }, " + + "error: function() { __console.error(Array.prototype.join.call(arguments, ' ')); }, " + + "info: function() { __console.info(Array.prototype.join.call(arguments, ' ')); } };", + "console-setup").build()); + + context.eval(Source.newBuilder("js", RES_BRIDGE_WRAPPER_JS, "res-wrapper").build()); + + if (includeTestFramework) { + context.eval(Source.newBuilder("js", TEST_FRAMEWORK_JS, "test-framework").build()); + } + + context.eval(Source.newBuilder("js", script, "user-script").build()); + + List testResults = new ArrayList<>(); + if (includeTestFramework) { + Value results = context.eval("js", "__testResults"); + if (results.hasArrayElements()) { + for (long i = 0; i < results.getArraySize(); i++) { + Value item = results.getArrayElement(i); + String name = item.getMember("name").asString(); + boolean passed = item.getMember("passed").asBoolean(); + String message = item.getMember("message").asString(); + testResults.add(new ScriptResult.TestResult(name, passed, message)); + } + } + } + + return new ScriptResult(consoleLog, testResults, null); + + } catch (PolyglotException e) { + String msg = e.getMessage(); + if (e.isGuestException()) { + msg = e.getGuestObject() != null ? e.getGuestObject().toString() : e.getMessage(); + } + return new ScriptResult(consoleLog, List.of(), msg); + } catch (Exception e) { + return new ScriptResult(consoleLog, List.of(), e.getMessage()); + } + } + + public static class ConsoleCapture { + + private final List log; + + public ConsoleCapture(List log) { + this.log = log; + } + + @HostAccess.Export + public void log(String message) { + this.log.add("[log] " + message); + } + + @HostAccess.Export + public void warn(String message) { + this.log.add("[warn] " + message); + } + + @HostAccess.Export + public void error(String message) { + this.log.add("[error] " + message); + } + + @HostAccess.Export + public void info(String message) { + this.log.add("[info] " + message); + } + } +} diff --git a/src/main/java/gg/effront/anarcry/apiclient/script/ScriptResult.java b/src/main/java/gg/effront/anarcry/apiclient/script/ScriptResult.java new file mode 100644 index 0000000..ea9c049 --- /dev/null +++ b/src/main/java/gg/effront/anarcry/apiclient/script/ScriptResult.java @@ -0,0 +1,38 @@ +package gg.effront.anarcry.apiclient.script; + +import java.util.List; + +public record ScriptResult(List consoleLog, List testResults, String error) { + + public record TestResult(String name, boolean passed, String message) { + } + + public ScriptResult { + if (consoleLog == null) { + consoleLog = List.of(); + } + if (testResults == null) { + testResults = List.of(); + } + } + + public static ScriptResult empty() { + return new ScriptResult(List.of(), List.of(), null); + } + + public static ScriptResult ofError(String error) { + return new ScriptResult(List.of(), List.of(), error); + } + + public boolean hasError() { + return error != null && !error.isEmpty(); + } + + public int testsPassed() { + return (int) testResults.stream().filter(TestResult::passed).count(); + } + + public int testsFailed() { + return (int) testResults.stream().filter(t -> !t.passed()).count(); + } +} diff --git a/src/main/java/gg/effront/anarcry/apiclient/vars/VariableStore.java b/src/main/java/gg/effront/anarcry/apiclient/vars/VariableStore.java index eabd935..9c6a4b7 100644 --- a/src/main/java/gg/effront/anarcry/apiclient/vars/VariableStore.java +++ b/src/main/java/gg/effront/anarcry/apiclient/vars/VariableStore.java @@ -63,18 +63,28 @@ public void setFolder(String key, String value) { folder.put(key, value); } + private final java.util.Set secretKeys = new java.util.LinkedHashSet<>(); + public void loadEnv(Environment environment) { env.clear(); + secretKeys.clear(); if (environment == null || environment.variables() == null) { return; } for (EnvVariable v : environment.variables()) { if (v.enabled()) { env.put(v.name(), v.value()); + if (v.secret()) { + secretKeys.add(v.name()); + } } } } + public boolean isSecret(String key) { + return secretKeys.contains(key); + } + public void loadCollection(RootConfig root) { collection.clear(); if (root == null || root.vars() == null) { @@ -115,6 +125,22 @@ public Map runtimeVars() { return Collections.unmodifiableMap(runtime); } + public Map requestVars() { + return Collections.unmodifiableMap(request); + } + + public Map folderVars() { + return Collections.unmodifiableMap(folder); + } + + public Map envVars() { + return Collections.unmodifiableMap(env); + } + + public Map collectionVars() { + return Collections.unmodifiableMap(collection); + } + private Map processEnv() { if (processEnvCache == null) { processEnvCache = System.getenv(); diff --git a/src/main/java/gg/effront/anarcry/assistant/AiAssistant.java b/src/main/java/gg/effront/anarcry/assistant/AiAssistant.java index 5897b8b..1fdd4bb 100644 --- a/src/main/java/gg/effront/anarcry/assistant/AiAssistant.java +++ b/src/main/java/gg/effront/anarcry/assistant/AiAssistant.java @@ -167,10 +167,17 @@ private void streamInternal(String systemPrompt, String toolsJson, Consumer { + buildAndShowFrame(); + splash.dismiss(); + }); } private void onFinding(Finding f) { @@ -161,7 +171,7 @@ private void wireScanCallbacks() { reportView.clear(); logPane.clear(); resetSeverityCounts(); - statusBar.setSeverityCounts(0, 0, 0, 0); + statusBar.setSeverityCounts(0, 0, 0, 0, 0); statusBar.resetProgress(); String target = modulesPanel.getTarget(); siteMapView.setRootUrl(target); @@ -217,6 +227,7 @@ private void prefillFromEnv(EnvLoader env) { private void buildAndShowFrame() { frame = new JFrame(FRAME_TITLE); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); + frame.setIconImages(loadAppIcons()); frame.setJMenuBar(buildMenuBar()); installFindingDetailHandler(scanFindingsTable); @@ -244,6 +255,7 @@ private void buildAndShowFrame() { globalSearchDialog = new GlobalSearchDialog(frame); globalSearchDialog.setFindingsSupplier(() -> new ArrayList<>(allFindings)); globalSearchDialog.setSiteMapSupplier(() -> siteMapView.getPaths()); + globalSearchDialog.setApiRequestsSupplier(() -> apiClientView.getRequestNames()); globalSearchDialog.setOnSelect(result -> { switch (result.category()) { case FINDING -> { @@ -257,9 +269,30 @@ private void buildAndShowFrame() { statusBar.setOnSearchClick(() -> globalSearchDialog.showSearch()); - statusBar.setOnProxyClick(() -> new PreferencesDialog(frame, "Proxy").setVisible(true)); + statusBar.setOnCookiesClick(() -> openPreferences("General")); + + statusBar.setOnProxyClick(() -> openPreferences("Proxy")); + + statusBar.setOnSettingsClick(() -> openPreferences(null)); + + statusBar.setOnThemeClick(() -> { + AnarCryLaf.toggle(); + SwingUtilities.updateComponentTreeUI(frame); + AnarCryLaf.fireThemeChanged(frame); + }); + + statusBar.setOnKeybindingsClick(() -> openPreferences("Keybindings")); - statusBar.setOnSettingsClick(() -> new PreferencesDialog(frame).setVisible(true)); + statusBar.setOnGithubClick(() -> { + try { + java.awt.Desktop.getDesktop().browse(java.net.URI.create("https://github.com/effront/anarcry")); + } catch (Exception ignored) { + } + }); + + statusBar.setOnLicenseClick(() -> openPreferences("License")); + + apiClientView.setProxyStatusCallback(on -> statusBar.setProxyEnabled(on)); JSplitPane mainSplit = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, modulesPanel, tabs); mainSplit.setDividerLocation(MODULES_PANEL_WIDTH); @@ -270,11 +303,32 @@ private void buildAndShowFrame() { installKeyShortcuts(); - frame.setSize(FRAME_WIDTH, FRAME_HEIGHT); + String savedRes = AppSettings.get().resolution; + if ("Maximized".equals(savedRes)) { + frame.setSize(FRAME_WIDTH, FRAME_HEIGHT); + frame.setExtendedState(frame.getExtendedState() | JFrame.MAXIMIZED_BOTH); + } else if (savedRes != null && savedRes.contains("x")) { + String[] parts = savedRes.split("x"); + frame.setSize(Integer.parseInt(parts[0].trim()), Integer.parseInt(parts[1].trim())); + } else { + frame.setSize(FRAME_WIDTH, FRAME_HEIGHT); + } frame.setLocationRelativeTo(null); frame.setVisible(true); } + private static java.util.List loadAppIcons() { + java.util.List icons = new ArrayList<>(); + for (String name : new String[]{"/icons/app-icon-64.png", "/icons/app-icon-128.png", + "/icons/app-icon-256.png"}) { + java.net.URL url = AnarCryGui.class.getResource(name); + if (url != null) { + icons.add(new javax.swing.ImageIcon(url).getImage()); + } + } + return icons; + } + private void wireSelectionDetail(FindingsTable table, FindingDetailPanel detail) { JTable jTable = table.getTable(); jTable.getSelectionModel().addListSelectionListener(e -> { @@ -333,10 +387,28 @@ public boolean dispatchKeyEvent(KeyEvent e) { int mods = e.getModifiersEx(); boolean noMods = mods == 0; + boolean ctrl = (mods & InputEvent.CTRL_DOWN_MASK) == InputEvent.CTRL_DOWN_MASK + && (mods & InputEvent.SHIFT_DOWN_MASK) == 0; + boolean ctrlShift = (mods + & (InputEvent.CTRL_DOWN_MASK | InputEvent.SHIFT_DOWN_MASK)) == (InputEvent.CTRL_DOWN_MASK + | InputEvent.SHIFT_DOWN_MASK); + if (code >= KeyEvent.VK_F1 && code <= KeyEvent.VK_F8 && noMods) { tabs.setSelectedIndex(code - KeyEvent.VK_F1); return true; } + if (ctrl && code == KeyEvent.VK_K) { + globalSearchDialog.showSearch(); + return true; + } + if (ctrl && code == KeyEvent.VK_COMMA) { + openPreferences(null); + return true; + } + if (ctrlShift && code == KeyEvent.VK_P) { + PrivacyFilter.toggle(); + return true; + } if (inText) { return false; @@ -346,14 +418,6 @@ public boolean dispatchKeyEvent(KeyEvent e) { openExportDialog(); return true; } - - boolean ctrlShift = (mods - & (InputEvent.CTRL_DOWN_MASK | InputEvent.SHIFT_DOWN_MASK)) == (InputEvent.CTRL_DOWN_MASK - | InputEvent.SHIFT_DOWN_MASK); - if (ctrlShift && code == KeyEvent.VK_P) { - PrivacyFilter.toggle(); - return true; - } return false; } }); @@ -436,13 +500,14 @@ private JMenu buildViewMenu() { toggleTheme.addActionListener(e -> { AnarCryLaf.toggle(); SwingUtilities.updateComponentTreeUI(frame); + AnarCryLaf.fireThemeChanged(frame); }); menu.add(toggleTheme); menu.addSeparator(); JMenuItem prefs = new JMenuItem("Preferences..."); - prefs.addActionListener(e -> new PreferencesDialog(frame).setVisible(true)); + prefs.addActionListener(e -> openPreferences(null)); menu.add(prefs); return menu; @@ -472,8 +537,180 @@ private void incrementSeverityCount(Severity severity) { severityCounts.merge(severity, 1, Integer::sum); statusBar.setSeverityCounts(severityCounts.getOrDefault(Severity.CRITICAL, 0), severityCounts.getOrDefault(Severity.HIGH, 0), severityCounts.getOrDefault(Severity.MEDIUM, 0), - severityCounts.getOrDefault(Severity.LOW, 0)); + severityCounts.getOrDefault(Severity.LOW, 0), severityCounts.getOrDefault(Severity.INFO, 0)); + }); + } + + private void openPreferences(String category) { + PreferencesDialog dlg = category != null + ? new PreferencesDialog(frame, category) + : new PreferencesDialog(frame); + dlg.setOnApply(() -> { + java.net.ProxySelector proxy = buildProxySelector(dlg); + javax.net.ssl.SSLContext sslCtx = buildSslContext(dlg.getCertEntries(), dlg.getCustomCaCertPath(), + dlg.isKeepDefaultCa(), dlg.isSslVerificationEnabled()); + apiClientView.setNetworkConfig(proxy, sslCtx, dlg.isProxyEnabled()); }); + dlg.setVisible(true); + } + + private static java.net.ProxySelector buildProxySelector(PreferencesDialog dlg) { + if (!dlg.isProxyEnabled() || dlg.getProxyHost().isBlank()) { + return null; + } + String protocol = dlg.getProxyProtocol(); + java.net.Proxy.Type proxyType = "socks5".equalsIgnoreCase(protocol) + ? java.net.Proxy.Type.SOCKS + : java.net.Proxy.Type.HTTP; + java.net.InetSocketAddress addr = new java.net.InetSocketAddress(dlg.getProxyHost(), dlg.getProxyPort()); + String user = dlg.getProxyUsername(); + char[] pass = dlg.getProxyPassword(); + if (user != null && !user.isBlank()) { + java.net.Authenticator.setDefault(new java.net.Authenticator() { + + @Override + protected java.net.PasswordAuthentication getPasswordAuthentication() { + if (getRequestorType() == RequestorType.PROXY) { + return new java.net.PasswordAuthentication(user, pass); + } + return null; + } + }); + } + return new java.net.ProxySelector() { + + @Override + public java.util.List select(java.net.URI uri) { + return java.util.List.of(new java.net.Proxy(proxyType, addr)); + } + + @Override + public void connectFailed(java.net.URI uri, java.net.SocketAddress sa, java.io.IOException ioe) { + } + }; + } + + private static javax.net.ssl.SSLContext buildSslContext(java.util.List certs, + String caCertPath, boolean keepDefaultCa, boolean sslVerification) { + try { + if (!sslVerification) { + javax.net.ssl.SSLContext sc = javax.net.ssl.SSLContext.getInstance("TLS"); + sc.init(null, new javax.net.ssl.TrustManager[]{new javax.net.ssl.X509TrustManager() { + + @Override + public void checkClientTrusted(java.security.cert.X509Certificate[] chain, String authType) { + } + + @Override + public void checkServerTrusted(java.security.cert.X509Certificate[] chain, String authType) { + } + + @Override + public java.security.cert.X509Certificate[] getAcceptedIssuers() { + return new java.security.cert.X509Certificate[0]; + } + }}, new java.security.SecureRandom()); + return sc; + } + + java.util.List allKeyManagers = new java.util.ArrayList<>(); + for (PreferencesDialog.CertEntry ce : certs) { + if (!ce.enabled()) { + continue; + } + try { + javax.net.ssl.KeyManagerFactory kmf; + if ("PKCS12".equals(ce.type())) { + java.security.KeyStore ks = java.security.KeyStore.getInstance("PKCS12"); + char[] pp = ce.passphrase().isEmpty() ? null : ce.passphrase().toCharArray(); + try (java.io.FileInputStream fis = new java.io.FileInputStream(ce.certFile())) { + ks.load(fis, pp); + } + kmf = javax.net.ssl.KeyManagerFactory + .getInstance(javax.net.ssl.KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(ks, pp); + } else { + java.security.cert.CertificateFactory cf = java.security.cert.CertificateFactory + .getInstance("X.509"); + java.security.cert.X509Certificate cert; + try (java.io.FileInputStream fis = new java.io.FileInputStream(ce.certFile())) { + cert = (java.security.cert.X509Certificate) cf.generateCertificate(fis); + } + java.security.PrivateKey pk = loadPemPrivateKey(ce.keyFile()); + java.security.KeyStore ks = java.security.KeyStore.getInstance("JKS"); + ks.load(null, null); + char[] pp = ce.passphrase().isEmpty() + ? "changeit".toCharArray() + : ce.passphrase().toCharArray(); + ks.setKeyEntry(ce.domain(), pk, pp, new java.security.cert.Certificate[]{cert}); + kmf = javax.net.ssl.KeyManagerFactory + .getInstance(javax.net.ssl.KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(ks, pp); + } + allKeyManagers.addAll(java.util.Arrays.asList(kmf.getKeyManagers())); + } catch (Exception ex) { + System.err.println("Failed to load client cert for " + ce.domain() + ": " + ex.getMessage()); + } + } + + javax.net.ssl.TrustManagerFactory tmf = javax.net.ssl.TrustManagerFactory + .getInstance(javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm()); + if (caCertPath != null && !caCertPath.isEmpty() + && java.nio.file.Files.exists(java.nio.file.Path.of(caCertPath))) { + java.security.KeyStore ts = java.security.KeyStore.getInstance("JKS"); + ts.load(null, null); + if (keepDefaultCa) { + javax.net.ssl.TrustManagerFactory defTmf = javax.net.ssl.TrustManagerFactory + .getInstance(javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm()); + defTmf.init((java.security.KeyStore) null); + for (javax.net.ssl.TrustManager tm : defTmf.getTrustManagers()) { + if (tm instanceof javax.net.ssl.X509TrustManager xtm) { + for (java.security.cert.X509Certificate c : xtm.getAcceptedIssuers()) { + ts.setCertificateEntry(c.getSubjectX500Principal().getName(), c); + } + } + } + } + java.security.cert.CertificateFactory cf = java.security.cert.CertificateFactory.getInstance("X.509"); + try (java.io.FileInputStream fis = new java.io.FileInputStream(caCertPath)) { + int i = 0; + for (java.security.cert.Certificate c : cf.generateCertificates(fis)) { + ts.setCertificateEntry("custom-ca-" + i++, c); + } + } + tmf.init(ts); + } else { + tmf.init((java.security.KeyStore) null); + } + + if (allKeyManagers.isEmpty() && (caCertPath == null || caCertPath.isEmpty())) { + return null; + } + + javax.net.ssl.SSLContext sc = javax.net.ssl.SSLContext.getInstance("TLS"); + javax.net.ssl.KeyManager[] kms = allKeyManagers.isEmpty() + ? null + : allKeyManagers.toArray(new javax.net.ssl.KeyManager[0]); + sc.init(kms, tmf.getTrustManagers(), new java.security.SecureRandom()); + return sc; + } catch (Exception ex) { + System.err.println("Failed to build SSLContext: " + ex.getMessage()); + return null; + } + } + + private static java.security.PrivateKey loadPemPrivateKey(String path) throws Exception { + String pem = java.nio.file.Files.readString(java.nio.file.Path.of(path)); + String base64 = pem.replaceAll("-----[A-Z ]+-----", "").replaceAll("\\s", ""); + byte[] der = java.util.Base64.getDecoder().decode(base64); + java.security.spec.PKCS8EncodedKeySpec spec = new java.security.spec.PKCS8EncodedKeySpec(der); + for (String algo : new String[]{"RSA", "EC", "Ed25519"}) { + try { + return java.security.KeyFactory.getInstance(algo).generatePrivate(spec); + } catch (java.security.spec.InvalidKeySpecException ignored) { + } + } + throw new java.security.spec.InvalidKeySpecException("Unsupported private key type in " + path); } private void dispose() { diff --git a/src/main/java/gg/effront/anarcry/gui/AppSettings.java b/src/main/java/gg/effront/anarcry/gui/AppSettings.java new file mode 100644 index 0000000..845c1d5 --- /dev/null +++ b/src/main/java/gg/effront/anarcry/gui/AppSettings.java @@ -0,0 +1,97 @@ +package gg.effront.anarcry.gui; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +public final class AppSettings { + + private static final Path SETTINGS_DIR = Path.of(System.getProperty("user.home"), ".anarcry"); + private static final Path SETTINGS_FILE = SETTINGS_DIR.resolve("settings.json"); + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + private static AppSettings instance; + + // -- General -- + public boolean sslVerification; + public boolean storeCookies = true; + public boolean sendCookies = true; + public int requestTimeoutMs = 30000; + public int parallelism = 4; + public int scanTimeoutMinutes = 10; + public String collectionsDir = System.getProperty("user.home"); + public boolean autoSave; + + // -- Themes -- + public String theme = "Dark"; + + // -- Display -- + public int fontSize = 13; + public String resolution = "1920x1080"; + + // -- Proxy -- + public boolean proxyEnabled; + public String proxyProtocol = "http"; + public String proxyHost = ""; + public int proxyPort = 8080; + public String proxyUser = ""; + public String proxyPass = ""; + + // -- Certificates -- + public boolean keepDefaultCa = true; + public String caCertPath = ""; + public List clientCerts = new ArrayList<>(); + + // -- Notifications -- + public boolean notifyScanComplete = true; + public boolean notifyCriticalFinding = true; + + public static final class CertData { + + public boolean enabled = true; + public String domain = ""; + public String type = "PEM"; + public String certFile = ""; + public String keyFile = ""; + public String passphrase = ""; + } + + private AppSettings() { + } + + public static synchronized AppSettings get() { + if (instance == null) { + instance = load(); + } + return instance; + } + + public synchronized void save() { + try { + Files.createDirectories(SETTINGS_DIR); + Files.writeString(SETTINGS_FILE, GSON.toJson(this)); + } catch (IOException e) { + System.err.println("Failed to save settings: " + e.getMessage()); + } + } + + private static AppSettings load() { + if (Files.exists(SETTINGS_FILE)) { + try { + String json = Files.readString(SETTINGS_FILE); + AppSettings loaded = GSON.fromJson(json, AppSettings.class); + if (loaded != null) { + return loaded; + } + } catch (Exception e) { + System.err.println("Failed to load settings, using defaults: " + e.getMessage()); + } + } + return new AppSettings(); + } +} diff --git a/src/main/java/gg/effront/anarcry/gui/SplashScreen.java b/src/main/java/gg/effront/anarcry/gui/SplashScreen.java new file mode 100644 index 0000000..f31b55d --- /dev/null +++ b/src/main/java/gg/effront/anarcry/gui/SplashScreen.java @@ -0,0 +1,88 @@ +package gg.effront.anarcry.gui; + +import gg.effront.anarcry.Version; + +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.RenderingHints; + +import javax.swing.BorderFactory; +import javax.swing.ImageIcon; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JWindow; +import javax.swing.SwingConstants; +import javax.swing.SwingUtilities; + +public final class SplashScreen { + + private static final int WIDTH = 480; + private static final int HEIGHT = 520; + + private JWindow window; + + public void show() { + SwingUtilities.invokeLater(this::buildAndShow); + } + + public void dismiss() { + SwingUtilities.invokeLater(() -> { + if (window != null) { + window.dispose(); + window = null; + } + }); + } + + private void buildAndShow() { + window = new JWindow(); + + JPanel content = new JPanel(new BorderLayout(0, 0)) { + + @Override + protected void paintComponent(Graphics g) { + Graphics2D g2 = (Graphics2D) g.create(); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2.setColor(new Color(0x12, 0x12, 0x1A)); + g2.fillRoundRect(0, 0, getWidth(), getHeight(), 16, 16); + g2.dispose(); + } + }; + content.setOpaque(false); + content.setBorder( + BorderFactory.createCompoundBorder(BorderFactory.createLineBorder(new Color(0x3A, 0x3A, 0x4A), 1), + BorderFactory.createEmptyBorder(24, 24, 20, 24))); + + java.net.URL logoUrl = getClass().getResource("/icons/splash-logo.png"); + if (logoUrl != null) { + JLabel logo = new JLabel(new ImageIcon(logoUrl)); + logo.setHorizontalAlignment(SwingConstants.CENTER); + content.add(logo, BorderLayout.CENTER); + } + + JPanel bottom = new JPanel(new BorderLayout(0, 4)); + bottom.setOpaque(false); + + JLabel version = new JLabel("v" + Version.get(), SwingConstants.CENTER); + version.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 13)); + version.setForeground(new Color(0x88, 0x88, 0x99)); + bottom.add(version, BorderLayout.NORTH); + + JLabel loading = new JLabel("Loading...", SwingConstants.CENTER); + loading.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 11)); + loading.setForeground(new Color(0x58, 0xA6, 0xFF)); + bottom.add(loading, BorderLayout.SOUTH); + + content.add(bottom, BorderLayout.SOUTH); + + window.setContentPane(content); + window.setSize(new Dimension(WIDTH, HEIGHT)); + window.setLocationRelativeTo(null); + window.setBackground(new Color(0, 0, 0, 0)); + window.setVisible(true); + } +} diff --git a/src/main/java/gg/effront/anarcry/gui/components/GuiModulesPanel.java b/src/main/java/gg/effront/anarcry/gui/components/GuiModulesPanel.java index 8719954..ae2729c 100644 --- a/src/main/java/gg/effront/anarcry/gui/components/GuiModulesPanel.java +++ b/src/main/java/gg/effront/anarcry/gui/components/GuiModulesPanel.java @@ -38,7 +38,9 @@ public class GuiModulesPanel extends JPanel { private static final Color COLOR_CRYPTO = new Color(0x53, 0x84, 0xB2); private static final Color COLOR_WEB = new Color(0xFF, 0x9E, 0x7A); private static final Color ACCENT = new Color(0xD8, 0x66, 0x33); - private static final Color SECTION_LINE = new Color(0x49, 0x49, 0x49); + private static Color sectionLine() { + return gg.effront.anarcry.gui.theme.AnarCryLaf.sectionLine(); + } private final Map moduleChecks = new LinkedHashMap<>(); @@ -70,7 +72,7 @@ public GuiModulesPanel() { super(new BorderLayout()); setPreferredSize(new Dimension(PREFERRED_WIDTH, 0)); setMinimumSize(new Dimension(PREFERRED_WIDTH, 0)); - setBorder(BorderFactory.createMatteBorder(0, 0, 0, 1, SECTION_LINE)); + setBorder(BorderFactory.createMatteBorder(0, 0, 0, 1, sectionLine())); // -- Scrollable module checkboxes (CENTER) -- JPanel checksPanel = new JPanel(); @@ -209,6 +211,7 @@ public GuiModulesPanel() { stopButton = new JButton("Parar"); stopButton.setEnabled(false); stopButton.setPreferredSize(new Dimension(80, 32)); + stopButton.setFont(stopButton.getFont().deriveFont(Font.BOLD, 13f)); stopButton.addActionListener(e -> onStop.run()); buttonPanel.add(startButton); @@ -429,7 +432,7 @@ private static JLabel fieldLabel(String text) { private static JSeparator separator() { JSeparator sep = new JSeparator(SwingConstants.HORIZONTAL); - sep.setForeground(SECTION_LINE); + sep.setForeground(sectionLine()); sep.setMaximumSize(new Dimension(Integer.MAX_VALUE, 1)); sep.setAlignmentX(Component.LEFT_ALIGNMENT); return sep; diff --git a/src/main/java/gg/effront/anarcry/gui/components/GuiStatusBar.java b/src/main/java/gg/effront/anarcry/gui/components/GuiStatusBar.java index 446ae15..09a2dfc 100644 --- a/src/main/java/gg/effront/anarcry/gui/components/GuiStatusBar.java +++ b/src/main/java/gg/effront/anarcry/gui/components/GuiStatusBar.java @@ -23,6 +23,7 @@ public class GuiStatusBar extends JPanel { private static final Color COLOR_HIGH = new Color(0xD2, 0x99, 0x22); private static final Color COLOR_MEDIUM = new Color(0xE3, 0xB3, 0x41); private static final Color COLOR_LOW = new Color(0x58, 0xA6, 0xFF); + private static final Color COLOR_INFO = new Color(0x8B, 0x94, 0x9E); private static final Color COLOR_SEPARATOR = new Color(0x8B, 0x94, 0x9E); private static final Color COLOR_PRIVACY_ON = new Color(0x3F, 0xB9, 0x50); private static final Color COLOR_PROXY_ON = new Color(0x3F, 0xB9, 0x50); @@ -32,6 +33,7 @@ public class GuiStatusBar extends JPanel { private final JLabel highLabel; private final JLabel mediumLabel; private final JLabel lowLabel; + private final JLabel infoLabel; private final JLabel modulesLabel; private final JLabel targetLabel; private final JProgressBar progressBar; @@ -43,8 +45,13 @@ public class GuiStatusBar extends JPanel { private final JLabel versionLabel; private Runnable onSearchClick; + private Runnable onCookiesClick; private Runnable onProxyClick; private Runnable onSettingsClick; + private Runnable onThemeClick; + private Runnable onGithubClick; + private Runnable onLicenseClick; + private Runnable onKeybindingsClick; private String rawTarget = ""; private int modulesCompleted = 0; @@ -61,6 +68,7 @@ public GuiStatusBar() { highLabel = coloredLabel("H:0", COLOR_HIGH); mediumLabel = coloredLabel("M:0", COLOR_MEDIUM); lowLabel = coloredLabel("L:0", COLOR_LOW); + infoLabel = coloredLabel("I:0", COLOR_INFO); modulesLabel = new JLabel("Modules: 0/0"); targetLabel = new JLabel(""); @@ -69,9 +77,8 @@ public GuiStatusBar() { progressBar.setStringPainted(true); progressBar.setVisible(false); - JLabel settingsGear = clickableLabel("âš™"); - settingsGear.setFont(settingsGear.getFont().deriveFont(16f)); - settingsGear.setToolTipText("Preferences"); + JLabel settingsGear = iconButton(StatusBarIcons.settings()); + settingsGear.setToolTipText("Preferences (Ctrl+,)"); settingsGear.addMouseListener(new MouseAdapter() { @Override @@ -82,12 +89,52 @@ public void mouseClicked(MouseEvent e) { } }); + JLabel themeLabel = iconButton(StatusBarIcons.palette()); + themeLabel.setToolTipText("Toggle Theme (Ctrl+Shift+T)"); + themeLabel.addMouseListener(new MouseAdapter() { + + @Override + public void mouseClicked(MouseEvent e) { + if (onThemeClick != null) { + onThemeClick.run(); + } + } + }); + + JLabel githubLabel = iconButton(StatusBarIcons.github()); + githubLabel.setToolTipText("Open GitHub Repository"); + githubLabel.addMouseListener(new MouseAdapter() { + + @Override + public void mouseClicked(MouseEvent e) { + if (onGithubClick != null) { + onGithubClick.run(); + } + } + }); + + JLabel licenseLabel = iconButton(StatusBarIcons.license()); + licenseLabel.setToolTipText("License Info"); + licenseLabel.addMouseListener(new MouseAdapter() { + + @Override + public void mouseClicked(MouseEvent e) { + if (onLicenseClick != null) { + onLicenseClick.run(); + } + } + }); + left.add(settingsGear); + left.add(themeLabel); + left.add(githubLabel); + left.add(licenseLabel); left.add(separator()); left.add(criticalLabel); left.add(highLabel); left.add(mediumLabel); left.add(lowLabel); + left.add(infoLabel); left.add(separator()); left.add(modulesLabel); left.add(separator()); @@ -97,7 +144,8 @@ public void mouseClicked(MouseEvent e) { JPanel right = new JPanel(new FlowLayout(FlowLayout.RIGHT, 6, 2)); right.setOpaque(false); - searchLabel = clickableLabel("Search"); + searchLabel = iconButton(StatusBarIcons.search()); + searchLabel.setToolTipText("Global Search (Ctrl+K)"); searchLabel.addMouseListener(new MouseAdapter() { @Override @@ -108,8 +156,23 @@ public void mouseClicked(MouseEvent e) { } }); - cookiesLabel = clickableLabel("Cookies(0)"); - proxyLabel = clickableLabel("Proxy: OFF"); + cookiesLabel = new JLabel("Cookies(0)", StatusBarIcons.cookie(), JLabel.LEFT); + cookiesLabel.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + cookiesLabel.setIconTextGap(4); + cookiesLabel.setToolTipText("Cookie Jar"); + cookiesLabel.addMouseListener(new MouseAdapter() { + + @Override + public void mouseClicked(MouseEvent e) { + if (onCookiesClick != null) { + onCookiesClick.run(); + } + } + }); + proxyLabel = new JLabel("Proxy: OFF", StatusBarIcons.proxy(), JLabel.LEFT); + proxyLabel.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + proxyLabel.setIconTextGap(4); + proxyLabel.setToolTipText("Proxy Settings"); proxyLabel.addMouseListener(new MouseAdapter() { @Override @@ -151,6 +214,10 @@ public void setOnSearchClick(Runnable action) { this.onSearchClick = action; } + public void setOnCookiesClick(Runnable action) { + this.onCookiesClick = action; + } + public void setOnProxyClick(Runnable action) { this.onProxyClick = action; } @@ -159,6 +226,22 @@ public void setOnSettingsClick(Runnable action) { this.onSettingsClick = action; } + public void setOnThemeClick(Runnable action) { + this.onThemeClick = action; + } + + public void setOnGithubClick(Runnable action) { + this.onGithubClick = action; + } + + public void setOnLicenseClick(Runnable action) { + this.onLicenseClick = action; + } + + public void setOnKeybindingsClick(Runnable action) { + this.onKeybindingsClick = action; + } + public void setProxyEnabled(boolean on) { SwingUtilities.invokeLater(() -> { proxyLabel.setText(on ? "Proxy: ON" : "Proxy: OFF"); @@ -170,12 +253,13 @@ public void setCookieCount(int count) { SwingUtilities.invokeLater(() -> cookiesLabel.setText("Cookies(" + count + ")")); } - public void setSeverityCounts(int c, int h, int m, int l) { + public void setSeverityCounts(int c, int h, int m, int l, int i) { SwingUtilities.invokeLater(() -> { criticalLabel.setText("C:" + c); highLabel.setText("H:" + h); mediumLabel.setText("M:" + m); lowLabel.setText("L:" + l); + infoLabel.setText("I:" + i); }); } @@ -260,6 +344,12 @@ private static JLabel clickableLabel(String text) { return label; } + private static JLabel iconButton(javax.swing.Icon icon) { + JLabel label = new JLabel(icon); + label.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + return label; + } + private static JLabel separator() { JLabel sep = new JLabel("|"); sep.setForeground(COLOR_SEPARATOR); diff --git a/src/main/java/gg/effront/anarcry/gui/components/KvTableEditor.java b/src/main/java/gg/effront/anarcry/gui/components/KvTableEditor.java new file mode 100644 index 0000000..b1d9e1f --- /dev/null +++ b/src/main/java/gg/effront/anarcry/gui/components/KvTableEditor.java @@ -0,0 +1,141 @@ +package gg.effront.anarcry.gui.components; + +import java.awt.BorderLayout; +import java.awt.Component; +import java.awt.FlowLayout; +import java.util.ArrayList; +import java.util.List; + +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTable; +import javax.swing.ListSelectionModel; +import javax.swing.UIManager; +import javax.swing.table.DefaultTableModel; +import javax.swing.table.TableCellRenderer; + +public class KvTableEditor extends JPanel { + + public record Entry(String key, String value, boolean enabled, String description) { + + public Entry(String key, String value, boolean enabled) { + this(key, value, enabled, ""); + } + } + + private final DefaultTableModel model; + private final JTable table; + + public KvTableEditor() { + super(new BorderLayout()); + + model = new DefaultTableModel(new String[]{"", "Key", "Value", "Description"}, 0) { + @Override + public Class getColumnClass(int column) { + if (column == 0) { + return Boolean.class; + } + return String.class; + } + + @Override + public boolean isCellEditable(int row, int column) { + return true; + } + }; + + table = new JTable(model); + table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + table.setRowHeight(24); + table.putClientProperty("terminateEditOnFocusLost", Boolean.TRUE); + table.setShowGrid(true); + table.setGridColor(UIManager.getColor("Separator.foreground") != null + ? UIManager.getColor("Separator.foreground") + : gg.effront.anarcry.gui.theme.AnarCryLaf.border()); + table.getTableHeader().setReorderingAllowed(false); + + table.getColumnModel().getColumn(0).setMaxWidth(32); + table.getColumnModel().getColumn(0).setMinWidth(32); + table.getColumnModel().getColumn(0).setPreferredWidth(32); + table.getColumnModel().getColumn(1).setPreferredWidth(160); + table.getColumnModel().getColumn(2).setPreferredWidth(240); + table.getColumnModel().getColumn(3).setPreferredWidth(180); + + table.getColumnModel().getColumn(0).setHeaderRenderer(new CheckHeaderRenderer()); + + JScrollPane scroll = new JScrollPane(table); + scroll.setBorder(BorderFactory.createEmptyBorder()); + add(scroll, BorderLayout.CENTER); + + JPanel buttons = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 2)); + JButton addBtn = new JButton("+"); + addBtn.setToolTipText("Add row"); + addBtn.addActionListener(e -> addRow("", "", true)); + + JButton removeBtn = new JButton("-"); + removeBtn.setToolTipText("Remove selected row"); + removeBtn.addActionListener(e -> { + int row = table.getSelectedRow(); + if (row >= 0) { + if (table.isEditing()) { + table.getCellEditor().stopCellEditing(); + } + model.removeRow(row); + } + }); + + buttons.add(addBtn); + buttons.add(removeBtn); + add(buttons, BorderLayout.SOUTH); + } + + public void addRow(String key, String value, boolean enabled) { + addRow(key, value, enabled, ""); + } + + public void addRow(String key, String value, boolean enabled, String description) { + model.addRow(new Object[]{enabled, key, value, description}); + } + + public List getEntries() { + if (table.isEditing()) { + table.getCellEditor().stopCellEditing(); + } + List entries = new ArrayList<>(); + for (int i = 0; i < model.getRowCount(); i++) { + boolean enabled = Boolean.TRUE.equals(model.getValueAt(i, 0)); + String key = String.valueOf(model.getValueAt(i, 1)).trim(); + String value = String.valueOf(model.getValueAt(i, 2)).trim(); + String desc = String.valueOf(model.getValueAt(i, 3)).trim(); + if (!key.isEmpty()) { + entries.add(new Entry(key, value, enabled, desc)); + } + } + return entries; + } + + public void setEntries(List entries) { + model.setRowCount(0); + for (Entry e : entries) { + model.addRow(new Object[]{e.enabled(), e.key(), e.value(), e.description()}); + } + } + + public void clear() { + model.setRowCount(0); + } + + private static class CheckHeaderRenderer implements TableCellRenderer { + @Override + public Component getTableCellRendererComponent(JTable t, Object value, boolean isSel, boolean hasFocus, int row, + int col) { + javax.swing.JCheckBox cb = new javax.swing.JCheckBox(); + cb.setHorizontalAlignment(javax.swing.SwingConstants.CENTER); + cb.setOpaque(true); + cb.setBackground(t.getTableHeader().getBackground()); + return cb; + } + } +} diff --git a/src/main/java/gg/effront/anarcry/gui/components/LogPane.java b/src/main/java/gg/effront/anarcry/gui/components/LogPane.java index 2386d56..2ddf86e 100644 --- a/src/main/java/gg/effront/anarcry/gui/components/LogPane.java +++ b/src/main/java/gg/effront/anarcry/gui/components/LogPane.java @@ -58,10 +58,21 @@ public class LogPane extends JPanel { private static final Color COLOR_GREEN = new Color(0x3F, 0xB9, 0x50); private static final Color COLOR_BLUE = new Color(0x58, 0xA6, 0xFF); private static final Color COLOR_GRAY = new Color(0x8B, 0x94, 0x9E); - private static final Color BG_LOG = new Color(0x2B, 0x2B, 0x2B); - private static final Color BTN_BG = new Color(0x3C, 0x3C, 0x3C); - private static final Color BTN_BG_ON = new Color(0x2A, 0x4A, 0x2A); - private static final Color BTN_BORDER = new Color(0x55, 0x55, 0x55); + private static Color bgLog() { + return gg.effront.anarcry.gui.theme.AnarCryLaf.bgDark(); + } + + private static Color btnBg() { + return gg.effront.anarcry.gui.theme.AnarCryLaf.buttonBg(); + } + + private static Color btnBgOn() { + return gg.effront.anarcry.gui.theme.AnarCryLaf.buttonBgOn(); + } + + private static Color btnBorder() { + return gg.effront.anarcry.gui.theme.AnarCryLaf.border(); + } private final JScrollPane scrollPane; private final JTextPane textPane; @@ -75,8 +86,8 @@ public LogPane() { textPane.setEditable(false); ((DefaultCaret) textPane.getCaret()).setUpdatePolicy(DefaultCaret.NEVER_UPDATE); textPane.setFont(resolveMonoFont()); - textPane.setBackground(BG_LOG); - textPane.setForeground(new Color(0xCE, 0xCE, 0xCE)); + textPane.setBackground(bgLog()); + textPane.setForeground(gg.effront.anarcry.gui.theme.AnarCryLaf.fg()); scrollPane = new JScrollPane(textPane); scrollPane.setVerticalScrollBarPolicy(JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED); @@ -89,6 +100,13 @@ public LogPane() { setComponentZOrder(debugToggle, 0); PrivacyFilter.addListener(() -> SwingUtilities.invokeLater(this::rerender)); + gg.effront.anarcry.gui.theme.AnarCryLaf.addThemeListener(() -> { + textPane.setBackground(bgLog()); + textPane.setForeground(gg.effront.anarcry.gui.theme.AnarCryLaf.fg()); + debugToggle.setBackground(debugToggle.isSelected() ? btnBgOn() : btnBg()); + debugToggle.setBorder(BorderFactory.createLineBorder(btnBorder())); + rerender(); + }); installContextMenu(); } @@ -113,10 +131,10 @@ private JToggleButton createDebugToggle() { btn.setPreferredSize(new Dimension(BTN_SIZE, BTN_SIZE)); btn.setFocusPainted(false); btn.setOpaque(true); - btn.setBackground(BTN_BG); - btn.setBorder(BorderFactory.createLineBorder(BTN_BORDER)); + btn.setBackground(btnBg()); + btn.setBorder(BorderFactory.createLineBorder(btnBorder())); btn.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); - btn.addChangeListener(e -> btn.setBackground(btn.isSelected() ? BTN_BG_ON : BTN_BG)); + btn.addChangeListener(e -> btn.setBackground(btn.isSelected() ? btnBgOn() : btnBg())); return btn; } @@ -147,7 +165,7 @@ public void paintIcon(Component c, Graphics g, int x, int y) { g2.fillRoundRect(cx - bw, bTop, bw * 2, bH, 4, 4); // Wing seam - g2.setColor(c != null && c.getBackground() != null ? c.getBackground() : BTN_BG); + g2.setColor(c != null && c.getBackground() != null ? c.getBackground() : btnBg()); g2.setStroke(new BasicStroke(1.0f)); g2.drawLine(cx, bTop + 1, cx, bTop + bH - 2); diff --git a/src/main/java/gg/effront/anarcry/gui/components/StatusBarIcons.java b/src/main/java/gg/effront/anarcry/gui/components/StatusBarIcons.java new file mode 100644 index 0000000..2006b04 --- /dev/null +++ b/src/main/java/gg/effront/anarcry/gui/components/StatusBarIcons.java @@ -0,0 +1,56 @@ +package gg.effront.anarcry.gui.components; + +import java.net.URL; + +import javax.swing.Icon; +import javax.swing.ImageIcon; + +public final class StatusBarIcons { + + private StatusBarIcons() { + } + + public static Icon settings() { + return load("settings.png"); + } + + public static Icon palette() { + return load("palette.png"); + } + + public static Icon github() { + return load("brand-github.png"); + } + + public static Icon search() { + return load("search.png"); + } + + public static Icon cookie() { + return load("cookie.png"); + } + + public static Icon keyboard() { + return load("keyboard.png"); + } + + public static Icon license() { + return load("license.png"); + } + + public static Icon proxy() { + return load("arrows-right-left.png"); + } + + public static Icon privacy() { + return load("shield-lock.png"); + } + + private static Icon load(String name) { + URL url = StatusBarIcons.class.getResource("/icons/" + name); + if (url != null) { + return new ImageIcon(url); + } + return new ImageIcon(); + } +} diff --git a/src/main/java/gg/effront/anarcry/gui/theme/AnarCryLaf.java b/src/main/java/gg/effront/anarcry/gui/theme/AnarCryLaf.java index 866ccbe..da8a04d 100644 --- a/src/main/java/gg/effront/anarcry/gui/theme/AnarCryLaf.java +++ b/src/main/java/gg/effront/anarcry/gui/theme/AnarCryLaf.java @@ -5,14 +5,22 @@ import com.formdev.flatlaf.FlatLaf; import java.awt.Color; +import java.awt.Component; +import java.awt.Container; import java.awt.Dimension; import java.awt.Insets; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import javax.swing.JComponent; import javax.swing.UIManager; +import javax.swing.plaf.ColorUIResource; /** FlatLaf theme setup utility for the AnarCry GUI. */ public final class AnarCryLaf { + private static final List THEME_LISTENERS = new CopyOnWriteArrayList<>(); + // Burp-inspired palette static final Color BG = new Color(0x32, 0x33, 0x34); static final Color BG_DARK = new Color(0x2B, 0x2B, 0x2B); @@ -30,6 +38,34 @@ public final class AnarCryLaf { private AnarCryLaf() { } + public static Color bg() { + return isDark() ? BG : new Color(0xF5, 0xF5, 0xF5); + } + + public static Color bgDark() { + return isDark() ? BG_DARK : Color.WHITE; + } + + public static Color fg() { + return isDark() ? new Color(0xCE, 0xCE, 0xCE) : new Color(0x1A, 0x1A, 0x1A); + } + + public static Color border() { + return isDark() ? BORDER : new Color(0xC0, 0xC0, 0xC0); + } + + public static Color buttonBg() { + return isDark() ? new Color(0x4C, 0x50, 0x52) : new Color(0xE0, 0xE0, 0xE0); + } + + public static Color buttonBgOn() { + return isDark() ? new Color(0x2A, 0x4A, 0x2A) : new Color(0xC8, 0xE6, 0xC8); + } + + public static Color sectionLine() { + return isDark() ? new Color(0x49, 0x49, 0x49) : new Color(0xC0, 0xC0, 0xC0); + } + /** Installs the dark theme (FlatDarkLaf) with AnarCry UI customizations. */ public static void install() { FlatDarkLaf.setup(); @@ -41,7 +77,7 @@ public static void install() { */ public static void installLight() { FlatIntelliJLaf.setup(); - applySharedCustomizations(); + applyLightCustomizations(); } /** Returns {@code true} if the current Look and Feel is a dark theme. */ @@ -58,9 +94,63 @@ public static void toggle() { } } + /** Registers a callback invoked after every theme change. */ + public static void addThemeListener(Runnable listener) { + THEME_LISTENERS.add(listener); + } + + public static void removeThemeListener(Runnable listener) { + THEME_LISTENERS.remove(listener); + } + + /** + * Resets non-UIResource backgrounds/foregrounds on all descendants so that + * components whose colors were explicitly set in a previous theme get the + * current UIManager defaults, then notifies registered listeners for custom + * components (RSyntaxTextArea, JFreeChart, etc.). + * + *

+ * Must be called AFTER {@code SwingUtilities.updateComponentTreeUI()}. + */ + public static void fireThemeChanged(Container root) { + if (root != null) { + resetColors(root); + } + for (Runnable r : THEME_LISTENERS) { + r.run(); + } + } + + /** Overload for callers without a root container (listeners-only). */ + public static void fireThemeChanged() { + for (Runnable r : THEME_LISTENERS) { + r.run(); + } + } + + private static void resetColors(Container container) { + if (container instanceof JComponent jc) { + Color bg = jc.getBackground(); + if (bg != null && !(bg instanceof ColorUIResource)) { + jc.setBackground(null); + } + Color fg = jc.getForeground(); + if (fg != null && !(fg instanceof ColorUIResource)) { + jc.setForeground(null); + } + } + for (Component child : container.getComponents()) { + if (child instanceof Container c) { + resetColors(c); + } + } + } + private static void applyDarkCustomizations() { applySharedCustomizations(); + Color darkFg = new Color(0xCE, 0xCE, 0xCE); + // TabbedPane -- orange underline like Burp, taller tabs UIManager.put("TabbedPane.underlineColor", ACCENT); UIManager.put("TabbedPane.selectedBackground", BG); @@ -74,16 +164,20 @@ private static void applyDarkCustomizations() { // Table -- dark rows with alternation UIManager.put("Table.background", BG_DARK); + UIManager.put("Table.foreground", darkFg); UIManager.put("Table.alternateRowColor", BG); UIManager.put("Table.selectionBackground", SELECTION); + UIManager.put("Table.selectionForeground", darkFg); UIManager.put("Table.gridColor", GRID); // TableHeader UIManager.put("TableHeader.background", HEADER_BG); + UIManager.put("TableHeader.foreground", darkFg); UIManager.put("TableHeader.separatorColor", new Color(0x4D, 0x4D, 0x4D)); UIManager.put("TableHeader.bottomSeparatorColor", new Color(0x4D, 0x4D, 0x4D)); // CheckBox -- blue selected like Burp + UIManager.put("CheckBox.foreground", darkFg); UIManager.put("CheckBox.icon.selectedBorderColor", BLUE); UIManager.put("CheckBox.icon.selectedBackground", BLUE); UIManager.put("CheckBox.icon.focusedBorderColor", BLUE); @@ -100,6 +194,7 @@ private static void applyDarkCustomizations() { // Buttons UIManager.put("Button.background", new Color(0x4C, 0x50, 0x52)); + UIManager.put("Button.foreground", darkFg); // Separator UIManager.put("Separator.foreground", SEPARATOR); @@ -110,15 +205,118 @@ private static void applyDarkCustomizations() { // Tooltip UIManager.put("ToolTip.background", new Color(0x1E, 0x21, 0x23)); + UIManager.put("ToolTip.foreground", darkFg); // Tree UIManager.put("Tree.hash", new Color(0x50, 0x53, 0x55)); UIManager.put("Tree.selectionBackground", SELECTION); + UIManager.put("Tree.selectionForeground", darkFg); + UIManager.put("Tree.foreground", darkFg); + + // List + UIManager.put("List.foreground", darkFg); + UIManager.put("List.selectionBackground", SELECTION); + UIManager.put("List.selectionForeground", darkFg); + + // Text components + UIManager.put("TextArea.background", BG_DARK); + UIManager.put("TextArea.foreground", darkFg); + UIManager.put("TextField.background", BG_DARK); + UIManager.put("TextField.foreground", darkFg); + UIManager.put("TextPane.background", BG_DARK); + UIManager.put("TextPane.foreground", darkFg); + + // Panel / Label + UIManager.put("Panel.background", BG); + UIManager.put("Panel.foreground", darkFg); + UIManager.put("Label.foreground", darkFg); + + // ScrollPane + UIManager.put("ScrollPane.background", BG); // SplitPane UIManager.put("SplitPaneDivider.draggingColor", new Color(0x64, 0x64, 0x64)); } + private static void applyLightCustomizations() { + applySharedCustomizations(); + + Color lightBg = new Color(0xF5, 0xF5, 0xF5); + Color lightBgAlt = new Color(0xEB, 0xEB, 0xEB); + Color lightFg = new Color(0x1A, 0x1A, 0x1A); + Color lightBorder = new Color(0xC0, 0xC0, 0xC0); + Color lightSelection = new Color(0xCC, 0xDE, 0xF5); + Color lightGrid = new Color(0xD8, 0xD8, 0xD8); + Color lightHeader = new Color(0xE8, 0xE8, 0xE8); + Color lightAccent = new Color(0xC0, 0x50, 0x20); + + UIManager.put("TabbedPane.underlineColor", lightAccent); + UIManager.put("TabbedPane.selectedBackground", Color.WHITE); + UIManager.put("TabbedPane.hoverColor", lightBgAlt); + UIManager.put("TabbedPane.focusColor", Color.WHITE); + UIManager.put("TabbedPane.tabHeight", 28); + UIManager.put("TabbedPane.tabInsets", new Insets(2, 12, 4, 12)); + UIManager.put("TabbedPane.tabSelectionHeight", 2); + UIManager.put("TabbedPane.showTabSeparators", false); + UIManager.put("TabbedPane.contentSeparatorHeight", 1); + + UIManager.put("Table.background", Color.WHITE); + UIManager.put("Table.alternateRowColor", lightBg); + UIManager.put("Table.selectionBackground", lightSelection); + UIManager.put("Table.selectionForeground", lightFg); + UIManager.put("Table.foreground", lightFg); + UIManager.put("Table.gridColor", lightGrid); + + UIManager.put("TableHeader.background", lightHeader); + UIManager.put("TableHeader.foreground", lightFg); + UIManager.put("TableHeader.separatorColor", lightBorder); + UIManager.put("TableHeader.bottomSeparatorColor", lightBorder); + + UIManager.put("Tree.hash", lightBorder); + UIManager.put("Tree.selectionBackground", lightSelection); + UIManager.put("Tree.selectionForeground", lightFg); + UIManager.put("Tree.foreground", lightFg); + + UIManager.put("List.foreground", lightFg); + UIManager.put("List.selectionBackground", lightSelection); + UIManager.put("List.selectionForeground", lightFg); + + UIManager.put("TextArea.background", Color.WHITE); + UIManager.put("TextArea.foreground", lightFg); + UIManager.put("TextField.background", Color.WHITE); + UIManager.put("TextField.foreground", lightFg); + UIManager.put("TextPane.background", Color.WHITE); + UIManager.put("TextPane.foreground", lightFg); + + UIManager.put("Panel.background", lightBg); + UIManager.put("Panel.foreground", lightFg); + UIManager.put("Label.foreground", lightFg); + + UIManager.put("Component.borderColor", lightBorder); + UIManager.put("Component.focusedBorderColor", new Color(0x00, 0x66, 0xCC)); + + UIManager.put("CheckBox.foreground", lightFg); + UIManager.put("CheckBox.icon.selectedBorderColor", new Color(0x26, 0x64, 0x9D)); + UIManager.put("CheckBox.icon.selectedBackground", new Color(0x26, 0x64, 0x9D)); + + UIManager.put("Button.background", new Color(0xE0, 0xE0, 0xE0)); + UIManager.put("Button.foreground", lightFg); + + UIManager.put("Separator.foreground", lightBorder); + UIManager.put("MenuBar.borderColor", lightBorder); + UIManager.put("PopupMenu.borderColor", lightBorder); + + UIManager.put("ProgressBar.foreground", lightAccent); + UIManager.put("ProgressBar.arc", 999); + + UIManager.put("ToolTip.background", new Color(0xF0, 0xF0, 0xF0)); + UIManager.put("ToolTip.foreground", lightFg); + + UIManager.put("SplitPaneDivider.draggingColor", lightBorder); + + UIManager.put("ScrollPane.background", lightBg); + } + private static void applySharedCustomizations() { UIManager.put("flatlaf.menuBarEmbedded", false); UIManager.put("flatlaf.useWindowDecorations", false); diff --git a/src/main/java/gg/effront/anarcry/gui/views/ApiClientView.java b/src/main/java/gg/effront/anarcry/gui/views/ApiClientView.java index 1a904df..a27cfce 100644 --- a/src/main/java/gg/effront/anarcry/gui/views/ApiClientView.java +++ b/src/main/java/gg/effront/anarcry/gui/views/ApiClientView.java @@ -3,32 +3,46 @@ import gg.effront.anarcry.apiclient.auth.AuthInjectorRegistry; import gg.effront.anarcry.apiclient.io.BruImporter; import gg.effront.anarcry.apiclient.io.CollectionLoader; +import gg.effront.anarcry.gui.PrivacyFilter; import gg.effront.anarcry.apiclient.io.CollectionWriter; +import gg.effront.anarcry.apiclient.io.CurlGenerator; import gg.effront.anarcry.apiclient.io.CurlImporter; +import gg.effront.anarcry.apiclient.io.InsomniaImporter; import gg.effront.anarcry.apiclient.io.OpenApiImporter; import gg.effront.anarcry.apiclient.io.PostmanImporter; +import gg.effront.anarcry.apiclient.model.AssertOp; import gg.effront.anarcry.apiclient.model.Assertion; import gg.effront.anarcry.apiclient.model.AssertionResult; import gg.effront.anarcry.apiclient.model.AuthConfig; import gg.effront.anarcry.apiclient.model.BodyConfig; import gg.effront.anarcry.apiclient.model.Collection; import gg.effront.anarcry.apiclient.model.CollectionItem; +import gg.effront.anarcry.apiclient.model.EnvVariable; import gg.effront.anarcry.apiclient.model.Environment; import gg.effront.anarcry.apiclient.model.Folder; import gg.effront.anarcry.apiclient.model.Header; import gg.effront.anarcry.apiclient.model.HttpRequestDef; import gg.effront.anarcry.apiclient.model.Param; +import gg.effront.anarcry.apiclient.model.ParamsDef; import gg.effront.anarcry.apiclient.model.RequestItem; +import gg.effront.anarcry.apiclient.model.RequestMeta; import gg.effront.anarcry.apiclient.model.RequestResult; import gg.effront.anarcry.apiclient.model.RequestSettings; import gg.effront.anarcry.apiclient.model.RunResult; +import gg.effront.anarcry.apiclient.model.RunSummary; +import gg.effront.anarcry.apiclient.model.ScriptDef; import gg.effront.anarcry.apiclient.model.VarsDef; +import gg.effront.anarcry.apiclient.script.ScriptResult; import gg.effront.anarcry.apiclient.runner.AssertionEngine; import gg.effront.anarcry.apiclient.runner.CollectionRunner; import gg.effront.anarcry.apiclient.runner.RequestExecutor; +import gg.effront.anarcry.apiclient.runner.RunContext; import gg.effront.anarcry.apiclient.vars.Interpolator; +import gg.effront.anarcry.apiclient.vars.VariableStore; +import gg.effront.anarcry.gui.components.KvTableEditor; import java.awt.BorderLayout; +import java.awt.CardLayout; import java.awt.Color; import java.awt.Component; import java.awt.Cursor; @@ -46,17 +60,24 @@ import java.nio.file.Path; import java.time.Duration; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.function.Consumer; import javax.swing.BorderFactory; import javax.swing.DropMode; import javax.swing.JButton; +import javax.swing.DefaultListModel; +import javax.swing.ActionMap; +import javax.swing.InputMap; import javax.swing.JCheckBox; import javax.swing.JComboBox; import javax.swing.JComponent; import javax.swing.JFileChooser; import javax.swing.JFrame; import javax.swing.JLabel; +import javax.swing.JList; import javax.swing.JMenuItem; import javax.swing.JOptionPane; import javax.swing.JPanel; @@ -88,7 +109,7 @@ public class ApiClientView extends JPanel { - private static final int SIDEBAR_WIDTH = 250; + private static final int SIDEBAR_WIDTH = 320; private static final int REQUEST_TIMEOUT_SECONDS = 30; private static final int MAX_OPEN_TABS = 10; @@ -97,8 +118,29 @@ public class ApiClientView extends JPanel { private static final Color METHOD_PUT = new Color(0x58, 0xA6, 0xFF); private static final Color METHOD_DELETE = new Color(0xF8, 0x51, 0x49); private static final Color METHOD_PATCH = new Color(0xBB, 0x6B, 0xD9); - private static final Color TAB_ACTIVE_BG = new Color(0x3A, 0x3A, 0x4E); - private static final Color TAB_INACTIVE_BG = new Color(0x24, 0x24, 0x34); + private static Color tabActiveBg() { + return gg.effront.anarcry.gui.theme.AnarCryLaf.isDark() + ? new Color(0x3A, 0x3A, 0x4E) + : new Color(0xDE, 0xDE, 0xE8); + } + + private static Color tabInactiveBg() { + return gg.effront.anarcry.gui.theme.AnarCryLaf.isDark() + ? new Color(0x24, 0x24, 0x34) + : new Color(0xEA, 0xEA, 0xEA); + } + + private static Color panelBorder() { + return gg.effront.anarcry.gui.theme.AnarCryLaf.isDark() + ? new Color(0x44, 0x44, 0x4F) + : new Color(0xC0, 0xC0, 0xC0); + } + + private static Color badgeBg() { + return gg.effront.anarcry.gui.theme.AnarCryLaf.isDark() + ? new Color(0x2A, 0x2A, 0x3A) + : new Color(0xE0, 0xE0, 0xEC); + } private JTree collectionTree; private DefaultMutableTreeNode treeRoot; @@ -109,11 +151,26 @@ public class ApiClientView extends JPanel { private JButton sendButton; private JComboBox envCombo; private JComboBox bodyTypeCombo; - private JTextArea headersArea; + private KvTableEditor headersEditor; private RSyntaxTextArea bodyArea; - private JTextArea paramsArea; + private KvTableEditor formBodyEditor; + private CardLayout bodyCardLayout; + private JPanel bodyCardPanel; + private KvTableEditor queryParamsEditor; + private KvTableEditor pathParamsEditor; private JComboBox authTypeCombo; - private JTextArea authFieldsArea; + private CardLayout authCardLayout; + private JPanel authCards; + private JTextField authBearerToken; + private JTextField authBasicUser; + private JTextField authBasicPass; + private JTextField authApiKeyKey; + private JTextField authApiKeyValue; + private JComboBox authApiKeyPlacement; + private JTextField authDigestUser; + private JTextField authDigestPass; + private JTextField authWsseUser; + private JTextField authWssePass; private JTextArea varsPreArea; private JTextArea varsPostArea; private DefaultTableModel assertTableModel; @@ -121,28 +178,135 @@ public class ApiClientView extends JPanel { private JCheckBox settingsFollowRedirects; private JSpinner settingsTimeout; private JCheckBox settingsEncodeUrl; - private JLabel responseStatus; + private JLabel responseStatusLabel; + private JLabel responseTimeLabel; + private JLabel responseSizeLabel; private RSyntaxTextArea responseBody; + private JTabbedPane requestEditorTabs; private JTabbedPane responseTabPane; + private JComboBox responseFormatCombo; + private CardLayout rightCardLayout; + private JPanel rightCardPanel; private JTextArea responseHeaders; private JTextArea assertionsResultArea; + private DefaultTableModel cookiesTableModel; + private DefaultTableModel variablesTableModel; private JPanel requestTabsPanel; + private JButton overflowButton; + + private JTextField authOAuth2GrantType; + private JTextField authOAuth2TokenUrl; + private JTextField authOAuth2ClientId; + private JTextField authOAuth2ClientSecret; + private JTextField authOAuth2Scope; + private JTextField authOAuth2CallbackUrl; + private JTextField authOAuth2AuthUrl; + private JCheckBox authOAuth2Pkce; + private JTextField authNtlmUser; + private JTextField authNtlmPass; + private JTextField authNtlmDomain; + private JTextField authAwsAccessKey; + private JTextField authAwsSecretKey; + private JTextField authAwsSession; + private JTextField authAwsService; + private JTextField authAwsRegion; + private JTextField authOAuth1ConsumerKey; + private JTextField authOAuth1ConsumerSecret; + private JTextField authOAuth1AccessToken; + private JTextField authOAuth1TokenSecret; + private JTextField authEdgeAccessToken; + private JTextField authEdgeClientToken; + private JTextField authEdgeClientSecret; + + private RSyntaxTextArea preRequestScriptArea; + private RSyntaxTextArea postResponseScriptArea; + private RSyntaxTextArea testsScriptArea; + private JTextArea scriptConsoleArea; + private Consumer proxyStatusCallback; + private java.net.ProxySelector currentProxy; + private javax.net.ssl.SSLContext currentSslContext; private Collection currentCollection; private Path collectionPath; private RequestItem activeRequest; private final List openRequests = new ArrayList<>(); - private final HttpClient httpClient; + private final java.util.Map tabStateCache = new java.util.HashMap<>(); + private final java.util.Map tabResponseCache = new java.util.HashMap<>(); + private final java.net.CookieManager cookieManager; + private HttpClient httpClient; private final List runLog = new ArrayList<>(); + private RunContext sessionContext; + private DefaultListModel historyModel; + private JList historyList; + + private final Map privacyRawValues = new LinkedHashMap<>(); + private final List privacyManagedFields = new ArrayList<>(); + + public void setProxyStatusCallback(Consumer callback) { + this.proxyStatusCallback = callback; + } + + public List getRequestNames() { + if (currentCollection == null) { + return List.of(); + } + List names = new ArrayList<>(); + collectRequestNames(currentCollection.items(), names); + return names; + } + + private void collectRequestNames(List items, List out) { + for (CollectionItem item : items) { + if (item instanceof RequestItem ri) { + out.add(ri.request().method() + " " + ri.name() + " - " + ri.request().url()); + } else if (item instanceof Folder f) { + collectRequestNames(f.items(), out); + } + } + } public ApiClientView() { super(new BorderLayout()); - httpClient = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL) - .connectTimeout(Duration.ofSeconds(REQUEST_TIMEOUT_SECONDS)).build(); + cookieManager = new java.net.CookieManager(null, java.net.CookiePolicy.ACCEPT_ALL); + httpClient = buildHttpClient(); JSplitPane mainSplit = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, buildSidebar(), buildRightPanel()); mainSplit.setDividerLocation(SIDEBAR_WIDTH); add(mainSplit, BorderLayout.CENTER); + + registerPrivacyFields(); + PrivacyFilter.addListener(() -> SwingUtilities.invokeLater(this::onPrivacyChanged)); + gg.effront.anarcry.gui.theme.AnarCryLaf.addThemeListener(this::onThemeChanged); + } + + private void onThemeChanged() { + applySyntaxTheme(bodyArea); + applySyntaxTheme(responseBody); + applySyntaxTheme(preRequestScriptArea); + applySyntaxTheme(postResponseScriptArea); + applySyntaxTheme(testsScriptArea); + refreshRequestTabs(); + } + + private HttpClient buildHttpClient() { + HttpClient.Builder builder = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL) + .cookieHandler(cookieManager).connectTimeout(Duration.ofSeconds(REQUEST_TIMEOUT_SECONDS)); + if (currentProxy != null) { + builder.proxy(currentProxy); + } + if (currentSslContext != null) { + builder.sslContext(currentSslContext); + } + return builder.build(); + } + + public void setNetworkConfig(java.net.ProxySelector proxy, javax.net.ssl.SSLContext sslCtx, boolean proxyActive) { + this.currentProxy = proxy; + this.currentSslContext = sslCtx; + httpClient = buildHttpClient(); + if (proxyStatusCallback != null) { + proxyStatusCallback.accept(proxyActive); + } } // ==================== SIDEBAR ==================== @@ -153,9 +317,9 @@ private JPanel buildSidebar() { sidebarSearchField = new JTextField(); sidebarSearchField.putClientProperty("JTextField.placeholderText", "Filter requests..."); - sidebarSearchField.setBorder(BorderFactory.createCompoundBorder( - BorderFactory.createMatteBorder(0, 0, 1, 0, new Color(0x55, 0x55, 0x55)), - BorderFactory.createEmptyBorder(4, 6, 4, 6))); + sidebarSearchField.setBorder( + BorderFactory.createCompoundBorder(BorderFactory.createMatteBorder(0, 0, 1, 0, panelBorder()), + BorderFactory.createEmptyBorder(4, 6, 4, 6))); sidebarSearchField.getDocument().addDocumentListener(new DocumentListener() { @Override @@ -177,36 +341,41 @@ public void changedUpdate(DocumentEvent e) { topBar.add(sidebarSearchField, BorderLayout.CENTER); JPanel sortBar = new JPanel(new FlowLayout(FlowLayout.LEFT, 2, 1)); - sortBar.setBorder(BorderFactory.createMatteBorder(0, 0, 1, 0, new Color(0x44, 0x44, 0x44))); + sortBar.setBorder(BorderFactory.createMatteBorder(0, 0, 1, 0, panelBorder())); JButton sortAz = new JButton("A-Z"); sortAz.setFont(sortAz.getFont().deriveFont(10f)); sortAz.setMargin(new java.awt.Insets(1, 4, 1, 4)); sortAz.setToolTipText("Sort by name"); sortAz.addActionListener(e -> sortCollection(SortMode.NAME)); + sortBar.add(sortAz); JButton sortMethod = new JButton("MTD"); sortMethod.setFont(sortMethod.getFont().deriveFont(10f)); sortMethod.setMargin(new java.awt.Insets(1, 4, 1, 4)); sortMethod.setToolTipText("Sort by method"); sortMethod.addActionListener(e -> sortCollection(SortMode.METHOD)); + sortBar.add(sortMethod); JButton sortType = new JButton("TYP"); sortType.setFont(sortType.getFont().deriveFont(10f)); sortType.setMargin(new java.awt.Insets(1, 4, 1, 4)); sortType.setToolTipText("Sort by type (folders first)"); sortType.addActionListener(e -> sortCollection(SortMode.TYPE)); + sortBar.add(sortType); JButton expandAll = new JButton("+"); expandAll.setFont(expandAll.getFont().deriveFont(10f)); expandAll.setMargin(new java.awt.Insets(1, 6, 1, 6)); expandAll.setToolTipText("Expand all"); expandAll.addActionListener(e -> expandAllNodes()); + sortBar.add(expandAll); JButton collapseAll = new JButton("-"); collapseAll.setFont(collapseAll.getFont().deriveFont(10f)); collapseAll.setMargin(new java.awt.Insets(1, 6, 1, 6)); collapseAll.setToolTipText("Collapse all"); collapseAll.addActionListener(e -> collapseAllNodes()); + sortBar.add(collapseAll); topBar.add(sortBar, BorderLayout.SOUTH); @@ -221,51 +390,129 @@ public void changedUpdate(DocumentEvent e) { collectionTree.setDropMode(DropMode.ON_OR_INSERT); collectionTree.setTransferHandler(new TreeDragHandler()); installTreeContextMenu(); - - collectionTree.setCellRenderer(new DefaultTreeCellRenderer() { + javax.swing.ToolTipManager.sharedInstance().registerComponent(collectionTree); + + collectionTree.setCellRenderer(new javax.swing.tree.TreeCellRenderer() { + + private final JPanel panel = new JPanel(new BorderLayout(6, 0)); + private final JLabel methodLabel = new JLabel(); + private final JLabel nameLabel = new JLabel(); + private final DefaultTreeCellRenderer fallback = new DefaultTreeCellRenderer(); + + { + methodLabel.setFont(new Font(Font.MONOSPACED, Font.BOLD, 10)); + methodLabel.setHorizontalAlignment(javax.swing.SwingConstants.CENTER); + methodLabel.setPreferredSize(new Dimension(52, 16)); + methodLabel.setMinimumSize(new Dimension(52, 16)); + methodLabel.setMaximumSize(new Dimension(52, 16)); + methodLabel.setOpaque(true); + nameLabel.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 12)); + panel.setOpaque(true); + panel.setBorder(BorderFactory.createEmptyBorder(2, 2, 2, 0)); + panel.add(methodLabel, BorderLayout.WEST); + panel.add(nameLabel, BorderLayout.CENTER); + } @Override public Component getTreeCellRendererComponent(JTree tree, Object value, boolean sel, boolean expanded, boolean leaf, int row, boolean hasFocus) { - super.getTreeCellRendererComponent(tree, value, sel, expanded, leaf, row, hasFocus); if (value instanceof DefaultMutableTreeNode node && node.getUserObject() instanceof RequestItem ri) { - setIcon(null); String method = ri.request().method(); - String hex = String.format("%06x", methodColor(method).getRGB() & 0xFFFFFF); - setText("" + method + " " + escapeHtml(ri.name()) - + ""); - } else if (value instanceof DefaultMutableTreeNode node - && !(node.getUserObject() instanceof RequestItem)) { - setIcon(expanded ? getOpenIcon() : getClosedIcon()); - setText(String.valueOf(node.getUserObject())); + methodLabel.setText(method); + Color mc = methodColor(method); + methodLabel.setForeground(mc); + methodLabel.setBackground(methodBadgeBg(mc)); + nameLabel.setText(" " + ri.name()); + Color bg = sel ? tabActiveBg() : tree.getBackground(); + Color fg = sel ? gg.effront.anarcry.gui.theme.AnarCryLaf.fg() : tree.getForeground(); + panel.setBackground(bg); + nameLabel.setForeground(fg); + panel.setToolTipText(method + " " + ri.name() + " - " + pf(ri.request().url())); + return panel; + } + Component c = fallback.getTreeCellRendererComponent(tree, value, sel, expanded, leaf, row, hasFocus); + if (value instanceof DefaultMutableTreeNode node) { + fallback.setToolTipText(node.getUserObject().toString()); } + return c; + } + }); + + historyModel = new DefaultListModel<>(); + historyList = new JList<>(historyModel); + historyList.setFont(monoFont()); + historyList.setCellRenderer(new javax.swing.DefaultListCellRenderer() { + @Override + public Component getListCellRendererComponent(JList list, Object value, int index, boolean isSelected, + boolean cellHasFocus) { + super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); + if (value instanceof RequestResult rr) { + String method = rr.interpolatedRequest() != null ? rr.interpolatedRequest().method() : "?"; + int status = rr.response() != null ? rr.response().status() : 0; + long timeMs = rr.response() != null ? rr.response().responseTimeMs() : 0; + String url = rr.interpolatedRequest() != null ? rr.interpolatedRequest().url() : ""; + String timeStr = timeMs > 0 ? " " + formatDuration(timeMs) : ""; + setText(method + " " + status + timeStr + " " + pf(url)); + setForeground(statusColor(status)); + } + setBorder(BorderFactory.createEmptyBorder(2, 4, 2, 4)); return this; } }); + historyList.addListSelectionListener(e -> { + if (!e.getValueIsAdjusting()) { + RequestResult sel = historyList.getSelectedValue(); + if (sel != null) { + displayResult(sel); + } + } + }); - sidebar.add(new JScrollPane(collectionTree), BorderLayout.CENTER); + JPanel historyPanel = new JPanel(new BorderLayout()); + JLabel historyLabel = new JLabel(" History"); + historyLabel.setFont(historyLabel.getFont().deriveFont(Font.BOLD, 11f)); + JButton clearHistBtn = new JButton("Clear"); + clearHistBtn.setFont(clearHistBtn.getFont().deriveFont(10f)); + clearHistBtn.setMargin(new java.awt.Insets(1, 4, 1, 4)); + clearHistBtn.addActionListener(e -> historyModel.clear()); + + JPanel histTop = new JPanel(new BorderLayout()); + histTop.add(historyLabel, BorderLayout.WEST); + histTop.add(clearHistBtn, BorderLayout.EAST); + historyPanel.add(histTop, BorderLayout.NORTH); + historyPanel.add(new JScrollPane(historyList), BorderLayout.CENTER); + + JSplitPane sidebarSplit = new JSplitPane(JSplitPane.VERTICAL_SPLIT, new JScrollPane(collectionTree), + historyPanel); + sidebarSplit.setResizeWeight(0.75); + sidebar.add(sidebarSplit, BorderLayout.CENTER); JPanel buttons = new JPanel(new java.awt.GridLayout(5, 1, 2, 2)); buttons.setBorder(BorderFactory.createEmptyBorder(4, 4, 4, 4)); JButton loadBtn = new JButton("Load Collection"); loadBtn.addActionListener(e -> chooseAndLoadCollection()); + buttons.add(loadBtn); JButton importBtn = new JButton("Import..."); importBtn.addActionListener(e -> showImportMenu(importBtn)); + buttons.add(importBtn); JButton newReqBtn = new JButton("New Request"); newReqBtn.addActionListener(e -> addNewRequest()); + buttons.add(newReqBtn); JButton saveBtn = new JButton("Save"); saveBtn.addActionListener(e -> saveCollection()); + buttons.add(saveBtn); JButton runBtn = new JButton("Run All"); runBtn.addActionListener(e -> runCollection()); + buttons.add(runBtn); sidebar.add(buttons, BorderLayout.SOUTH); @@ -377,24 +624,136 @@ private JPanel buildRightPanel() { requestTabsPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 2, 2)); requestTabsPanel.setBorder(BorderFactory.createEmptyBorder(0, 4, 2, 4)); - JScrollPane tabsScroll = new JScrollPane(requestTabsPanel, JScrollPane.VERTICAL_SCROLLBAR_NEVER, - JScrollPane.HORIZONTAL_SCROLLBAR_AS_NEEDED); - tabsScroll.setPreferredSize(new Dimension(0, 30)); - tabsScroll.setBorder(null); - topSection.add(tabsScroll, BorderLayout.SOUTH); + + overflowButton = new JButton(">>"); + overflowButton.setFont(overflowButton.getFont().deriveFont(11f)); + overflowButton.setMargin(new java.awt.Insets(2, 6, 2, 6)); + overflowButton.setFocusPainted(false); + overflowButton.setVisible(false); + overflowButton.setToolTipText("More tabs"); + + JPanel tabsRow = new JPanel(new BorderLayout()); + tabsRow.add(requestTabsPanel, BorderLayout.CENTER); + tabsRow.add(overflowButton, BorderLayout.EAST); + tabsRow.setPreferredSize(new Dimension(0, 30)); + topSection.add(tabsRow, BorderLayout.SOUTH); right.add(topSection, BorderLayout.NORTH); JSplitPane split = new JSplitPane(JSplitPane.VERTICAL_SPLIT, buildRequestEditor(), buildResponseViewer()); split.setResizeWeight(0.45); - right.add(split, BorderLayout.CENTER); - KeyStroke ctrlEnter = KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, InputEvent.CTRL_DOWN_MASK); - right.registerKeyboardAction(e -> sendRequest(), ctrlEnter, JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT); + rightCardLayout = new CardLayout(); + rightCardPanel = new JPanel(rightCardLayout); + rightCardPanel.add(buildEmptyStatePanel(), "empty"); + rightCardPanel.add(split, "editor"); + rightCardLayout.show(rightCardPanel, "empty"); + + right.add(rightCardPanel, BorderLayout.CENTER); + + InputMap im = right.getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT); + ActionMap am = right.getActionMap(); + + im.put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, InputEvent.CTRL_DOWN_MASK), "send"); + am.put("send", new javax.swing.AbstractAction() { + @Override + public void actionPerformed(java.awt.event.ActionEvent e) { + sendRequest(); + } + }); + + im.put(KeyStroke.getKeyStroke(KeyEvent.VK_N, InputEvent.CTRL_DOWN_MASK), "newRequest"); + am.put("newRequest", new javax.swing.AbstractAction() { + @Override + public void actionPerformed(java.awt.event.ActionEvent e) { + addNewRequest(); + } + }); + + im.put(KeyStroke.getKeyStroke(KeyEvent.VK_E, InputEvent.CTRL_DOWN_MASK), "editEnv"); + am.put("editEnv", new javax.swing.AbstractAction() { + @Override + public void actionPerformed(java.awt.event.ActionEvent e) { + openEnvEditor(); + } + }); + + im.put(KeyStroke.getKeyStroke(KeyEvent.VK_L, InputEvent.CTRL_DOWN_MASK), "focusUrl"); + am.put("focusUrl", new javax.swing.AbstractAction() { + @Override + public void actionPerformed(java.awt.event.ActionEvent e) { + urlField.requestFocusInWindow(); + urlField.selectAll(); + } + }); + + im.put(KeyStroke.getKeyStroke(KeyEvent.VK_S, InputEvent.CTRL_DOWN_MASK), "saveRequest"); + am.put("saveRequest", new javax.swing.AbstractAction() { + @Override + public void actionPerformed(java.awt.event.ActionEvent e) { + saveCurrentRequest(); + } + }); return right; } + private JPanel buildEmptyStatePanel() { + JPanel panel = new JPanel(new java.awt.GridBagLayout()); + java.awt.GridBagConstraints gbc = new java.awt.GridBagConstraints(); + gbc.gridx = 0; + gbc.gridy = java.awt.GridBagConstraints.RELATIVE; + gbc.anchor = java.awt.GridBagConstraints.CENTER; + gbc.insets = new java.awt.Insets(4, 0, 4, 0); + + Color hintColor = new Color(0x6B, 0x73, 0x7C); + Color shortcutColor = new Color(0x8B, 0x94, 0x9E); + Color accentColor = new Color(0x3F, 0xB9, 0x50, 0x40); + + JLabel titleLabel = new JLabel("Select a request or create a new one"); + titleLabel.setFont(titleLabel.getFont().deriveFont(Font.PLAIN, 16f)); + titleLabel.setForeground(hintColor); + gbc.insets = new java.awt.Insets(0, 0, 20, 0); + panel.add(titleLabel, gbc); + + gbc.insets = new java.awt.Insets(4, 0, 4, 0); + java.util.List shortcuts = java.util.List.of(new String[]{"Send Request", "Ctrl+Enter"}, + new String[]{"New Request", "Ctrl+N"}, new String[]{"Edit Environments", "Ctrl+E"}, + new String[]{"Global Search", "Ctrl+Shift+F"}); + + for (String[] shortcut : shortcuts) { + JPanel row = new JPanel(new BorderLayout(0, 0)); + row.setOpaque(false); + row.setPreferredSize(new Dimension(280, 28)); + + JLabel actionLabel = new JLabel(shortcut[0]); + actionLabel.setForeground(hintColor); + actionLabel.setFont(actionLabel.getFont().deriveFont(13f)); + + JLabel keyLabel = new JLabel(shortcut[1]); + keyLabel.setForeground(shortcutColor); + keyLabel.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12)); + keyLabel.setOpaque(true); + keyLabel.setBackground(accentColor); + keyLabel.setBorder(BorderFactory.createCompoundBorder(BorderFactory.createLineBorder(panelBorder(), 1), + BorderFactory.createEmptyBorder(2, 6, 2, 6))); + keyLabel.setHorizontalAlignment(javax.swing.SwingConstants.CENTER); + + row.add(actionLabel, BorderLayout.WEST); + row.add(keyLabel, BorderLayout.EAST); + panel.add(row, gbc); + } + return panel; + } + + private void showEditorCard() { + rightCardLayout.show(rightCardPanel, "editor"); + } + + private void showEmptyCard() { + rightCardLayout.show(rightCardPanel, "empty"); + } + private JPanel buildTopBar() { JPanel bar = new JPanel(new BorderLayout(4, 0)); bar.setBorder(BorderFactory.createEmptyBorder(4, 4, 4, 4)); @@ -404,6 +763,10 @@ private JPanel buildTopBar() { sendButton = new JButton("Send"); sendButton.addActionListener(e -> sendRequest()); + JButton copyCurlBtn = new JButton("cURL"); + copyCurlBtn.setToolTipText("Copy as cURL command"); + copyCurlBtn.addActionListener(e -> copyAsCurl()); + envCombo = new JComboBox<>(); envCombo.addItem("(no env)"); envCombo.setPreferredSize(new Dimension(140, 28)); @@ -421,9 +784,13 @@ private JPanel buildTopBar() { envPanel.add(envCombo, BorderLayout.CENTER); envPanel.add(envConfigBtn, BorderLayout.EAST); + JPanel sendPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 4, 0)); + sendPanel.add(copyCurlBtn); + sendPanel.add(sendButton); + JPanel rightBar = new JPanel(new BorderLayout(4, 0)); rightBar.add(envPanel, BorderLayout.CENTER); - rightBar.add(sendButton, BorderLayout.EAST); + rightBar.add(sendPanel, BorderLayout.EAST); bar.add(left, BorderLayout.CENTER); bar.add(rightBar, BorderLayout.EAST); @@ -434,11 +801,12 @@ private JPanel buildTopBar() { private JTabbedPane buildRequestEditor() { JTabbedPane tabs = new JTabbedPane(); + requestEditorTabs = tabs; - headersArea = new JTextArea(8, 40); - headersArea.setFont(monoFont()); - headersArea.setText("User-Agent: " + gg.effront.anarcry.Version.userAgent() + "\nAccept: */*"); - tabs.addTab("Headers", new JScrollPane(headersArea)); + headersEditor = new KvTableEditor(); + headersEditor.addRow("User-Agent", gg.effront.anarcry.Version.userAgent(), true); + headersEditor.addRow("Accept", "*/*", true); + tabs.addTab("Headers", headersEditor); JPanel bodyPanel = new JPanel(new BorderLayout()); bodyTypeCombo = new JComboBox<>(new String[]{"JSON", "Text", "XML", "FormUrlEncoded", "GraphQL", "None"}); @@ -447,22 +815,70 @@ private JTabbedPane buildRequestEditor() { bodyTopBar.add(new JLabel("Type:")); bodyTopBar.add(bodyTypeCombo); bodyPanel.add(bodyTopBar, BorderLayout.NORTH); + bodyCardLayout = new CardLayout(); + bodyCardPanel = new JPanel(bodyCardLayout); bodyArea = createSyntaxArea(false); bodyArea.setSyntaxEditingStyle(SyntaxConstants.SYNTAX_STYLE_JSON); - bodyPanel.add(new RTextScrollPane(bodyArea), BorderLayout.CENTER); + bodyCardPanel.add(new RTextScrollPane(bodyArea), "text"); + formBodyEditor = new KvTableEditor(); + bodyCardPanel.add(formBodyEditor, "form"); + bodyPanel.add(bodyCardPanel, BorderLayout.CENTER); tabs.addTab("Body", bodyPanel); - paramsArea = new JTextArea(8, 40); - paramsArea.setFont(monoFont()); - tabs.addTab("Params", new JScrollPane(paramsArea)); + JPanel paramsPanel = new JPanel(new BorderLayout(0, 0)); + JSplitPane paramsSplit = new JSplitPane(JSplitPane.VERTICAL_SPLIT); + paramsSplit.setResizeWeight(0.5); + paramsSplit.setBorder(BorderFactory.createEmptyBorder()); + + JPanel querySection = new JPanel(new BorderLayout()); + JLabel queryLabel = new JLabel(" Query"); + queryLabel.setFont(queryLabel.getFont().deriveFont(java.awt.Font.BOLD)); + queryLabel.setBorder(BorderFactory.createEmptyBorder(4, 0, 2, 0)); + querySection.add(queryLabel, BorderLayout.NORTH); + queryParamsEditor = new KvTableEditor(); + querySection.add(queryParamsEditor, BorderLayout.CENTER); + + JPanel pathSection = new JPanel(new BorderLayout()); + JLabel pathLabel = new JLabel(" Path"); + pathLabel.setFont(pathLabel.getFont().deriveFont(java.awt.Font.BOLD)); + pathLabel.setBorder(BorderFactory.createEmptyBorder(4, 0, 2, 0)); + pathSection.add(pathLabel, BorderLayout.NORTH); + pathParamsEditor = new KvTableEditor(); + pathSection.add(pathParamsEditor, BorderLayout.CENTER); + + paramsSplit.setTopComponent(querySection); + paramsSplit.setBottomComponent(pathSection); + paramsPanel.add(paramsSplit, BorderLayout.CENTER); + tabs.addTab("Params", paramsPanel); JPanel authPanel = new JPanel(new BorderLayout(4, 4)); authPanel.setBorder(BorderFactory.createEmptyBorder(4, 4, 4, 4)); - authTypeCombo = new JComboBox<>(new String[]{"none", "bearer", "basic", "apikey", "digest", "oauth2", "wsse"}); - authFieldsArea = new JTextArea(6, 40); - authFieldsArea.setFont(monoFont()); + authTypeCombo = new JComboBox<>(new String[]{"none", "inherit", "bearer", "basic", "apikey", "digest", "oauth2", + "wsse", "ntlm", "awsv4", "oauth1", "akamai-edgegrid"}); + authTypeCombo.addActionListener(e -> { + String sel = (String) authTypeCombo.getSelectedItem(); + if (sel != null) { + authCardLayout.show(authCards, sel); + } + }); + + authCardLayout = new CardLayout(); + authCards = new JPanel(authCardLayout); + authCards.add(new JLabel("No authentication"), "none"); + authCards.add(new JLabel("Inherits from parent folder / collection"), "inherit"); + authCards.add(buildBearerCard(), "bearer"); + authCards.add(buildBasicCard(), "basic"); + authCards.add(buildApiKeyCard(), "apikey"); + authCards.add(buildDigestCard(), "digest"); + authCards.add(buildOAuth2Card(), "oauth2"); + authCards.add(buildWsseCard(), "wsse"); + authCards.add(buildNtlmCard(), "ntlm"); + authCards.add(buildAwsV4Card(), "awsv4"); + authCards.add(buildOAuth1Card(), "oauth1"); + authCards.add(buildEdgeGridCard(), "akamai-edgegrid"); + authPanel.add(authTypeCombo, BorderLayout.NORTH); - authPanel.add(new JScrollPane(authFieldsArea), BorderLayout.CENTER); + authPanel.add(authCards, BorderLayout.CENTER); tabs.addTab("Auth", authPanel); JPanel varsPanel = new JPanel(new BorderLayout(0, 4)); @@ -520,6 +936,8 @@ public boolean isCellEditable(int row, int col) { docsArea.setWrapStyleWord(true); tabs.addTab("Docs", new JScrollPane(docsArea)); + tabs.addTab("Script", buildScriptPanel()); + JPanel settingsPanel = new JPanel(new java.awt.GridBagLayout()); settingsPanel.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); java.awt.GridBagConstraints gbc = new java.awt.GridBagConstraints(); @@ -546,14 +964,39 @@ public boolean isCellEditable(int row, int col) { return tabs; } + private JPanel buildScriptPanel() { + JPanel panel = new JPanel(new BorderLayout()); + JTabbedPane scriptTabs = new JTabbedPane(); + + preRequestScriptArea = createSyntaxArea(false); + preRequestScriptArea.setSyntaxEditingStyle(SyntaxConstants.SYNTAX_STYLE_JAVASCRIPT); + scriptTabs.addTab("Pre-request", new RTextScrollPane(preRequestScriptArea)); + + postResponseScriptArea = createSyntaxArea(false); + postResponseScriptArea.setSyntaxEditingStyle(SyntaxConstants.SYNTAX_STYLE_JAVASCRIPT); + scriptTabs.addTab("Post-response", new RTextScrollPane(postResponseScriptArea)); + + testsScriptArea = createSyntaxArea(false); + testsScriptArea.setSyntaxEditingStyle(SyntaxConstants.SYNTAX_STYLE_JAVASCRIPT); + scriptTabs.addTab("Tests", new RTextScrollPane(testsScriptArea)); + + panel.add(scriptTabs, BorderLayout.CENTER); + return panel; + } + private void updateBodySyntax() { String type = (String) bodyTypeCombo.getSelectedItem(); - bodyArea.setSyntaxEditingStyle(switch (type) { - case "JSON" -> SyntaxConstants.SYNTAX_STYLE_JSON; - case "XML" -> SyntaxConstants.SYNTAX_STYLE_XML; - case "GraphQL" -> SyntaxConstants.SYNTAX_STYLE_JAVASCRIPT; - default -> SyntaxConstants.SYNTAX_STYLE_NONE; - }); + if ("FormUrlEncoded".equals(type)) { + bodyCardLayout.show(bodyCardPanel, "form"); + } else { + bodyCardLayout.show(bodyCardPanel, "text"); + bodyArea.setSyntaxEditingStyle(switch (type) { + case "JSON" -> SyntaxConstants.SYNTAX_STYLE_JSON; + case "XML" -> SyntaxConstants.SYNTAX_STYLE_XML; + case "GraphQL" -> SyntaxConstants.SYNTAX_STYLE_JAVASCRIPT; + default -> SyntaxConstants.SYNTAX_STYLE_NONE; + }); + } } // ==================== RESPONSE VIEWER ==================== @@ -561,9 +1004,40 @@ private void updateBodySyntax() { private JPanel buildResponseViewer() { JPanel panel = new JPanel(new BorderLayout()); - JPanel statusBar = new JPanel(new BorderLayout()); - responseStatus = new JLabel(""); + JPanel statusBar = new JPanel(new BorderLayout(4, 0)); + statusBar.setBorder(BorderFactory.createEmptyBorder(2, 4, 2, 4)); + + JPanel badges = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 1)); + badges.setOpaque(false); + + responseStatusLabel = createBadge(""); + responseTimeLabel = createBadge(""); + responseSizeLabel = createBadge(""); + responseStatusLabel.setVisible(false); + responseTimeLabel.setVisible(false); + responseSizeLabel.setVisible(false); + badges.add(responseStatusLabel); + badges.add(responseTimeLabel); + badges.add(responseSizeLabel); + JPanel respButtons = new JPanel(new FlowLayout(FlowLayout.RIGHT, 4, 0)); + respButtons.setOpaque(false); + + responseFormatCombo = new JComboBox<>(new String[]{"JSON", "HTML", "XML", "Raw"}); + responseFormatCombo.setFont(responseFormatCombo.getFont().deriveFont(11f)); + responseFormatCombo.setPreferredSize(new Dimension(70, 22)); + responseFormatCombo.addActionListener(e -> { + String fmt = (String) responseFormatCombo.getSelectedItem(); + if (fmt != null) { + responseBody.setSyntaxEditingStyle(switch (fmt) { + case "JSON" -> SyntaxConstants.SYNTAX_STYLE_JSON; + case "HTML" -> SyntaxConstants.SYNTAX_STYLE_HTML; + case "XML" -> SyntaxConstants.SYNTAX_STYLE_XML; + default -> SyntaxConstants.SYNTAX_STYLE_NONE; + }); + } + }); + JButton copyBtn = new JButton("Copy"); copyBtn.setFont(copyBtn.getFont().deriveFont(11f)); copyBtn.addActionListener(e -> { @@ -576,9 +1050,18 @@ private JPanel buildResponseViewer() { JButton prettifyBtn = new JButton("Prettify"); prettifyBtn.setFont(prettifyBtn.getFont().deriveFont(11f)); prettifyBtn.addActionListener(e -> prettifyResponse()); + JButton saveRespBtn = new JButton("Save"); + saveRespBtn.setFont(saveRespBtn.getFont().deriveFont(11f)); + saveRespBtn.addActionListener(e -> saveResponse()); + JButton clearRespBtn = new JButton("Clear"); + clearRespBtn.setFont(clearRespBtn.getFont().deriveFont(11f)); + clearRespBtn.addActionListener(e -> clearResponsePanel()); + respButtons.add(responseFormatCombo); respButtons.add(copyBtn); respButtons.add(prettifyBtn); - statusBar.add(responseStatus, BorderLayout.CENTER); + respButtons.add(saveRespBtn); + respButtons.add(clearRespBtn); + statusBar.add(badges, BorderLayout.CENTER); statusBar.add(respButtons, BorderLayout.EAST); panel.add(statusBar, BorderLayout.NORTH); @@ -597,6 +1080,37 @@ private JPanel buildResponseViewer() { assertionsResultArea.setFont(monoFont()); responseTabPane.addTab("Assertions", new JScrollPane(assertionsResultArea)); + cookiesTableModel = new DefaultTableModel( + new String[]{"Name", "Value", "Domain", "Path", "Expires", "HttpOnly", "Secure"}, 0) { + @Override + public boolean isCellEditable(int row, int column) { + return false; + } + }; + JTable cookiesTable = new JTable(cookiesTableModel); + cookiesTable.setFont(monoFont()); + responseTabPane.addTab("Cookies", new JScrollPane(cookiesTable)); + + variablesTableModel = new DefaultTableModel(new String[]{"Scope", "Key", "Value"}, 0) { + @Override + public boolean isCellEditable(int row, int column) { + return false; + } + }; + JTable variablesTable = new JTable(variablesTableModel); + variablesTable.setFont(monoFont()); + variablesTable.setRowHeight(22); + variablesTable.getColumnModel().getColumn(0).setPreferredWidth(80); + variablesTable.getColumnModel().getColumn(0).setMaxWidth(100); + variablesTable.getColumnModel().getColumn(1).setPreferredWidth(150); + variablesTable.getColumnModel().getColumn(2).setPreferredWidth(300); + responseTabPane.addTab("Variables", new JScrollPane(variablesTable)); + + scriptConsoleArea = new JTextArea(6, 40); + scriptConsoleArea.setEditable(false); + scriptConsoleArea.setFont(monoFont()); + responseTabPane.addTab("Console", new JScrollPane(scriptConsoleArea)); + panel.add(responseTabPane, BorderLayout.CENTER); return panel; } @@ -608,13 +1122,43 @@ private void prettifyResponse() { } try { com.google.gson.JsonElement el = com.google.gson.JsonParser.parseString(text); - responseBody.setText(new com.google.gson.GsonBuilder().setPrettyPrinting().create().toJson(el)); + String pretty = new com.google.gson.GsonBuilder().setPrettyPrinting().create().toJson(el); + responseBody.setText(pf(pretty)); responseBody.setCaretPosition(0); } catch (Exception ex) { // not JSON, ignore } } + private void saveResponse() { + String text = responseBody.getText(); + if (text.isBlank()) { + responseStatusLabel.setText("No response to save"); + responseStatusLabel.setForeground(Color.GRAY); + responseStatusLabel.setVisible(true); + return; + } + JFileChooser chooser = new JFileChooser(); + chooser.setDialogTitle("Save Response"); + String ext = responseBody.getSyntaxEditingStyle().contains("json") + ? ".json" + : responseBody.getSyntaxEditingStyle().contains("html") + ? ".html" + : responseBody.getSyntaxEditingStyle().contains("xml") ? ".xml" : ".txt"; + chooser.setSelectedFile(new java.io.File("response" + ext)); + if (chooser.showSaveDialog(this) == JFileChooser.APPROVE_OPTION) { + try { + java.nio.file.Files.writeString(chooser.getSelectedFile().toPath(), text); + responseStatusLabel.setText("Saved to " + chooser.getSelectedFile().getName()); + responseStatusLabel.setForeground(METHOD_GET); + responseStatusLabel.setVisible(true); + } catch (Exception ex) { + JOptionPane.showMessageDialog(this, "Save failed: " + ex.getMessage(), "Error", + JOptionPane.ERROR_MESSAGE); + } + } + } + // ==================== REQUEST TABS ==================== private void openRequestTab(RequestItem item) { @@ -626,46 +1170,123 @@ private void openRequestTab(RequestItem item) { } activeRequest = item; refreshRequestTabs(); + showEditorCard(); } private void closeRequestTab(RequestItem item) { openRequests.remove(item); + tabStateCache.remove(item.uid()); + tabResponseCache.remove(item.uid()); if (activeRequest == item) { activeRequest = openRequests.isEmpty() ? null : openRequests.getLast(); } refreshRequestTabs(); if (activeRequest != null) { loadRequestIntoEditor(activeRequest); + } else { + showEmptyCard(); } } private void refreshRequestTabs() { requestTabsPanel.removeAll(); + + List allTabs = new ArrayList<>(); for (RequestItem req : openRequests) { - requestTabsPanel.add(createRequestTabButton(req)); + allTabs.add(createRequestTabButton(req)); + } + + // Measure available width and decide which tabs fit. + // Active tab is always visible -- if it would overflow, swap with the last + // visible tab. + int availableWidth = requestTabsPanel.getParent() != null + ? requestTabsPanel.getParent().getWidth() - 50 + : Integer.MAX_VALUE; + int usedWidth = 8; + List visibleIndices = new ArrayList<>(); + List overflowItems = new ArrayList<>(); + for (int i = 0; i < allTabs.size(); i++) { + int tabWidth = allTabs.get(i).getPreferredSize().width + 4; + if (usedWidth + tabWidth <= availableWidth || visibleIndices.isEmpty()) { + visibleIndices.add(i); + usedWidth += tabWidth; + } else { + overflowItems.add(openRequests.get(i)); + } + } + // Ensure active tab is visible + int activeIdx = openRequests.indexOf(activeRequest); + if (activeIdx >= 0 && !visibleIndices.contains(activeIdx) && !visibleIndices.isEmpty()) { + int lastVisible = visibleIndices.getLast(); + overflowItems.remove(openRequests.get(activeIdx)); + overflowItems.add(openRequests.get(lastVisible)); + visibleIndices.set(visibleIndices.size() - 1, activeIdx); + } + for (int idx : visibleIndices) { + requestTabsPanel.add(allTabs.get(idx)); + } + + if (!overflowItems.isEmpty()) { + overflowButton.setVisible(true); + for (var al : overflowButton.getActionListeners()) { + overflowButton.removeActionListener(al); + } + overflowButton.addActionListener(e -> { + JPopupMenu popup = new JPopupMenu(); + for (RequestItem oi : overflowItems) { + String method = oi.request().method(); + JMenuItem mi = new JMenuItem(method + " " + oi.name()); + mi.setForeground(methodColor(method)); + mi.addActionListener(ev -> { + activeRequest = oi; + loadRequestIntoEditor(oi); + refreshRequestTabs(); + }); + popup.add(mi); + } + popup.show(overflowButton, 0, overflowButton.getHeight()); + }); + } else { + overflowButton.setVisible(false); } + requestTabsPanel.revalidate(); requestTabsPanel.repaint(); } private JPanel createRequestTabButton(RequestItem item) { String method = item.request().method(); - String label = method + " " + item.name(); - if (label.length() > 25) { - label = label.substring(0, 22) + "..."; + String name = item.name(); + if (name.length() > 20) { + name = name.substring(0, 17) + "..."; } JPanel tab = new JPanel(new BorderLayout(4, 0)); tab.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); - tab.setBackground(item == activeRequest ? TAB_ACTIVE_BG : TAB_INACTIVE_BG); - tab.setBorder(BorderFactory.createCompoundBorder( - BorderFactory.createLineBorder(item == activeRequest ? methodColor(method) : TAB_INACTIVE_BG, 1), - BorderFactory.createEmptyBorder(2, 6, 2, 2))); - - JLabel textLabel = new JLabel(label); - textLabel.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 11)); - textLabel.setForeground(methodColor(method)); - tab.add(textLabel, BorderLayout.CENTER); + boolean isActive = item == activeRequest; + tab.setBackground(isActive ? tabActiveBg() : tabInactiveBg()); + Color mc = methodColor(method); + tab.setBorder(BorderFactory.createCompoundBorder(BorderFactory.createMatteBorder(0, 0, isActive ? 2 : 0, 0, mc), + BorderFactory.createEmptyBorder(2, 4, 2, 2))); + + String shortMethod = method.length() > 3 ? method.substring(0, 3) : method; + JLabel methodTag = new JLabel(shortMethod); + methodTag.setFont(new Font(Font.MONOSPACED, Font.BOLD, 10)); + methodTag.setForeground(mc); + methodTag.setOpaque(true); + methodTag.setBackground(methodBadgeBg(mc)); + methodTag.setHorizontalAlignment(javax.swing.SwingConstants.CENTER); + methodTag.setBorder(BorderFactory.createEmptyBorder(1, 3, 1, 3)); + + JLabel nameLabel = new JLabel(name); + nameLabel.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 11)); + nameLabel.setForeground(isActive ? gg.effront.anarcry.gui.theme.AnarCryLaf.fg() : new Color(0x8B, 0x94, 0x9E)); + + JPanel labelPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 0)); + labelPanel.setOpaque(false); + labelPanel.add(methodTag); + labelPanel.add(nameLabel); + tab.add(labelPanel, BorderLayout.CENTER); JLabel closeLabel = new JLabel("×"); closeLabel.setFont(closeLabel.getFont().deriveFont(14f)); @@ -681,7 +1302,7 @@ public void mouseClicked(MouseEvent e) { @Override public void mouseEntered(MouseEvent e) { - closeLabel.setForeground(Color.WHITE); + closeLabel.setForeground(gg.effront.anarcry.gui.theme.AnarCryLaf.fg()); } @Override @@ -701,6 +1322,7 @@ public void mouseClicked(MouseEvent e) { if (SwingUtilities.isMiddleMouseButton(e)) { closeRequestTab(item); } else { + saveEditorState(); activeRequest = item; loadRequestIntoEditor(item); refreshRequestTabs(); @@ -721,8 +1343,11 @@ private static Color methodColor(String method) { }; } - private static String escapeHtml(String s) { - return s.replace("&", "&").replace("<", "<").replace(">", ">"); + private static Color methodBadgeBg(Color mc) { + if (gg.effront.anarcry.gui.theme.AnarCryLaf.isDark()) { + return new Color(mc.getRed() / 6, mc.getGreen() / 6, mc.getBlue() / 6); + } + return new Color(mc.getRed() / 3 + 170, mc.getGreen() / 3 + 170, mc.getBlue() / 3 + 170); } // ==================== TREE CONTEXT MENU ==================== @@ -780,9 +1405,16 @@ private void showTreePopup(MouseEvent e) { menu.add(moveDown); } - if (node.getUserObject() instanceof RequestItem) { + if (node.getUserObject() instanceof RequestItem reqItem) { menu.addSeparator(); + JMenuItem copyCurl = new JMenuItem("Copy as cURL"); + copyCurl.addActionListener(ev -> { + String curl = pf(CurlGenerator.generate(reqItem.request())); + copyToClipboard(curl); + }); + menu.add(copyCurl); + JMenuItem duplicate = new JMenuItem("Duplicate"); duplicate.addActionListener(ev -> duplicateRequest(node)); menu.add(duplicate); @@ -1020,39 +1652,66 @@ private void onTreeSelection() { if (node == null || !(node.getUserObject() instanceof RequestItem item)) { return; } + saveEditorState(); openRequestTab(item); loadRequestIntoEditor(item); } + private void saveEditorState() { + if (activeRequest == null) { + return; + } + RequestItem snapshot = buildCurrentRequestItem(); + tabStateCache.put(activeRequest.uid(), snapshot); + } + + private RequestItem resolveTabState(RequestItem item) { + RequestItem cached = tabStateCache.get(item.uid()); + return cached != null ? cached : item; + } + private void loadRequestIntoEditor(RequestItem item) { + item = resolveTabState(item); + RequestResult cachedResponse = tabResponseCache.get(item.uid()); + if (cachedResponse != null) { + displayResult(cachedResponse); + } else { + clearResponsePanel(); + } HttpRequestDef req = item.request(); methodCombo.setSelectedItem(req.method()); - urlField.setText(req.url()); + setFieldWithPrivacy(urlField, req.url()); - StringBuilder hdr = new StringBuilder(); + List headerEntries = new ArrayList<>(); for (Header h : req.headers()) { - if (h.enabled()) { - hdr.append(h.name()).append(": ").append(h.value()).append("\n"); - } + headerEntries.add(new KvTableEditor.Entry(h.name(), h.value(), h.enabled())); } - headersArea.setText(hdr.toString()); + headersEditor.setEntries(headerEntries); - StringBuilder params = new StringBuilder(); + List queryEntries = new ArrayList<>(); + List pathEntries = new ArrayList<>(); if (req.params() != null) { for (Param p : req.params().query()) { - if (p.enabled()) { - params.append(p.name()).append("=").append(p.value()).append("\n"); - } + queryEntries.add(new KvTableEditor.Entry(p.name(), p.value(), p.enabled())); } for (Param p : req.params().path()) { - if (p.enabled()) { - params.append(":").append(p.name()).append("=").append(p.value()).append("\n"); - } + pathEntries.add(new KvTableEditor.Entry(p.name(), p.value(), p.enabled())); } } - paramsArea.setText(params.toString()); + queryParamsEditor.setEntries(queryEntries); + pathParamsEditor.setEntries(pathEntries); - bodyArea.setText(extractBodyText(req.body())); + if (req.body() instanceof BodyConfig.FormUrlEncoded f) { + List formEntries = new ArrayList<>(); + for (Param p : f.params()) { + formEntries.add(new KvTableEditor.Entry(p.name(), p.value(), p.enabled())); + } + formBodyEditor.setEntries(formEntries); + bodyArea.setText(""); + } else { + formBodyEditor.clear(); + bodyArea.setText(extractBodyText(req.body())); + } selectBodyType(req.body()); populateAuth(req.auth()); @@ -1085,6 +1744,80 @@ private void loadRequestIntoEditor(RequestItem item) { settingsFollowRedirects.setSelected(settings.followRedirects()); settingsTimeout.setValue(settings.timeoutMs()); settingsEncodeUrl.setSelected(settings.encodeUrl()); + + if (item.scripts() != null) { + preRequestScriptArea.setText(item.scripts().preRequest()); + postResponseScriptArea.setText(item.scripts().postResponse()); + testsScriptArea.setText(item.scripts().tests()); + } else { + preRequestScriptArea.setText(""); + postResponseScriptArea.setText(""); + testsScriptArea.setText(""); + } + + autoSelectEditorTab(req); + updateTabBadges(req, item); + } + + private static final int TAB_HEADERS = 0; + private static final int TAB_BODY = 1; + private static final int TAB_PARAMS = 2; + private static final int TAB_AUTH = 3; + private static final int TAB_VARS = 4; + private static final int TAB_ASSERT = 5; + private static final int TAB_DOCS = 6; + private static final int TAB_SCRIPT = 7; + private static final int TAB_SETTINGS = 8; + + private void autoSelectEditorTab(HttpRequestDef req) { + boolean hasBody = !(req.body() instanceof BodyConfig.None); + boolean hasParams = req.params() != null && (!req.params().query().isEmpty() || !req.params().path().isEmpty()); + boolean hasAuth = req.auth() != null && !(req.auth() instanceof AuthConfig.None) + && !(req.auth() instanceof AuthConfig.Inherit); + boolean hasHeaders = !req.headers().isEmpty(); + + if (hasBody) { + requestEditorTabs.setSelectedIndex(TAB_BODY); + } else if (hasParams) { + requestEditorTabs.setSelectedIndex(TAB_PARAMS); + } else if (hasAuth) { + requestEditorTabs.setSelectedIndex(TAB_AUTH); + } else if (hasHeaders) { + requestEditorTabs.setSelectedIndex(TAB_HEADERS); + } else { + requestEditorTabs.setSelectedIndex(TAB_PARAMS); + } + } + + private void updateTabBadges(HttpRequestDef req, RequestItem item) { + int headerCount = req.headers().size(); + requestEditorTabs.setTitleAt(TAB_HEADERS, headerCount > 0 ? "Headers " + headerCount : "Headers"); + + boolean hasBody = !(req.body() instanceof BodyConfig.None); + requestEditorTabs.setTitleAt(TAB_BODY, hasBody ? "Body *" : "Body"); + + int paramCount = 0; + if (req.params() != null) { + paramCount = req.params().query().size() + req.params().path().size(); + } + requestEditorTabs.setTitleAt(TAB_PARAMS, paramCount > 0 ? "Params " + paramCount : "Params"); + + boolean hasAuth = req.auth() != null && !(req.auth() instanceof AuthConfig.None) + && !(req.auth() instanceof AuthConfig.Inherit); + requestEditorTabs.setTitleAt(TAB_AUTH, hasAuth ? "Auth *" : "Auth"); + + boolean hasVars = item.vars() != null && (!item.vars().pre().isEmpty() || !item.vars().post().isEmpty()); + requestEditorTabs.setTitleAt(TAB_VARS, hasVars ? "Vars *" : "Vars"); + + int assertCount = item.assertions() != null ? item.assertions().size() : 0; + requestEditorTabs.setTitleAt(TAB_ASSERT, assertCount > 0 ? "Assert " + assertCount : "Assert"); + + boolean hasDocs = item.docs() != null && !item.docs().isBlank(); + requestEditorTabs.setTitleAt(TAB_DOCS, hasDocs ? "Docs *" : "Docs"); + + boolean hasScript = item.scripts() != null && (!item.scripts().preRequest().isBlank() + || !item.scripts().postResponse().isBlank() || !item.scripts().tests().isBlank()); + requestEditorTabs.setTitleAt(TAB_SCRIPT, hasScript ? "Script *" : "Script"); } private String extractBodyText(BodyConfig body) { @@ -1109,16 +1842,524 @@ private void selectBodyType(BodyConfig body) { bodyTypeCombo.setSelectedItem(type); } + private JPanel buildBearerCard() { + JPanel p = authFormPanel(); + java.awt.GridBagConstraints g = authFormGbc(); + p.add(new JLabel("Token:"), g); + g.gridx = 1; + g.fill = java.awt.GridBagConstraints.HORIZONTAL; + g.weightx = 1.0; + authBearerToken = new JTextField(30); + authBearerToken.setFont(monoFont()); + p.add(authBearerToken, g); + g.gridy++; + g.weighty = 1.0; + p.add(new JLabel(), g); + return p; + } + + private JPanel buildBasicCard() { + JPanel p = authFormPanel(); + java.awt.GridBagConstraints g = authFormGbc(); + p.add(new JLabel("Username:"), g); + g.gridx = 1; + g.fill = java.awt.GridBagConstraints.HORIZONTAL; + g.weightx = 1.0; + authBasicUser = new JTextField(30); + authBasicUser.setFont(monoFont()); + p.add(authBasicUser, g); + g.gridx = 0; + g.gridy = 1; + g.fill = java.awt.GridBagConstraints.NONE; + g.weightx = 0; + p.add(new JLabel("Password:"), g); + g.gridx = 1; + g.fill = java.awt.GridBagConstraints.HORIZONTAL; + g.weightx = 1.0; + authBasicPass = new JTextField(30); + authBasicPass.setFont(monoFont()); + p.add(authBasicPass, g); + g.gridy++; + g.weighty = 1.0; + p.add(new JLabel(), g); + return p; + } + + private JPanel buildApiKeyCard() { + JPanel p = authFormPanel(); + java.awt.GridBagConstraints g = authFormGbc(); + p.add(new JLabel("Key:"), g); + g.gridx = 1; + g.fill = java.awt.GridBagConstraints.HORIZONTAL; + g.weightx = 1.0; + authApiKeyKey = new JTextField(30); + authApiKeyKey.setFont(monoFont()); + p.add(authApiKeyKey, g); + g.gridx = 0; + g.gridy = 1; + g.fill = java.awt.GridBagConstraints.NONE; + g.weightx = 0; + p.add(new JLabel("Value:"), g); + g.gridx = 1; + g.fill = java.awt.GridBagConstraints.HORIZONTAL; + g.weightx = 1.0; + authApiKeyValue = new JTextField(30); + authApiKeyValue.setFont(monoFont()); + p.add(authApiKeyValue, g); + g.gridx = 0; + g.gridy = 2; + g.fill = java.awt.GridBagConstraints.NONE; + g.weightx = 0; + p.add(new JLabel("Add to:"), g); + g.gridx = 1; + g.fill = java.awt.GridBagConstraints.HORIZONTAL; + g.weightx = 1.0; + authApiKeyPlacement = new JComboBox<>(new String[]{"header", "queryparams"}); + p.add(authApiKeyPlacement, g); + g.gridy++; + g.weighty = 1.0; + p.add(new JLabel(), g); + return p; + } + + private JPanel buildDigestCard() { + JPanel p = authFormPanel(); + java.awt.GridBagConstraints g = authFormGbc(); + p.add(new JLabel("Username:"), g); + g.gridx = 1; + g.fill = java.awt.GridBagConstraints.HORIZONTAL; + g.weightx = 1.0; + authDigestUser = new JTextField(30); + authDigestUser.setFont(monoFont()); + p.add(authDigestUser, g); + g.gridx = 0; + g.gridy = 1; + g.fill = java.awt.GridBagConstraints.NONE; + g.weightx = 0; + p.add(new JLabel("Password:"), g); + g.gridx = 1; + g.fill = java.awt.GridBagConstraints.HORIZONTAL; + g.weightx = 1.0; + authDigestPass = new JTextField(30); + authDigestPass.setFont(monoFont()); + p.add(authDigestPass, g); + g.gridy++; + g.weighty = 1.0; + p.add(new JLabel(), g); + return p; + } + + private JPanel buildWsseCard() { + JPanel p = authFormPanel(); + java.awt.GridBagConstraints g = authFormGbc(); + p.add(new JLabel("Username:"), g); + g.gridx = 1; + g.fill = java.awt.GridBagConstraints.HORIZONTAL; + g.weightx = 1.0; + authWsseUser = new JTextField(30); + authWsseUser.setFont(monoFont()); + p.add(authWsseUser, g); + g.gridx = 0; + g.gridy = 1; + g.fill = java.awt.GridBagConstraints.NONE; + g.weightx = 0; + p.add(new JLabel("Password:"), g); + g.gridx = 1; + g.fill = java.awt.GridBagConstraints.HORIZONTAL; + g.weightx = 1.0; + authWssePass = new JTextField(30); + authWssePass.setFont(monoFont()); + p.add(authWssePass, g); + g.gridy++; + g.weighty = 1.0; + p.add(new JLabel(), g); + return p; + } + + private JPanel buildOAuth2Card() { + JPanel p = authFormPanel(); + java.awt.GridBagConstraints g = authFormGbc(); + p.add(new JLabel("Grant Type:"), g); + g.gridx = 1; + g.fill = java.awt.GridBagConstraints.HORIZONTAL; + g.weightx = 1.0; + authOAuth2GrantType = new JTextField("client_credentials", 30); + authOAuth2GrantType.setFont(monoFont()); + p.add(authOAuth2GrantType, g); + g.gridx = 0; + g.gridy++; + g.fill = java.awt.GridBagConstraints.NONE; + g.weightx = 0; + p.add(new JLabel("Access Token URL:"), g); + g.gridx = 1; + g.fill = java.awt.GridBagConstraints.HORIZONTAL; + g.weightx = 1.0; + authOAuth2TokenUrl = new JTextField(30); + authOAuth2TokenUrl.setFont(monoFont()); + p.add(authOAuth2TokenUrl, g); + g.gridx = 0; + g.gridy++; + g.fill = java.awt.GridBagConstraints.NONE; + g.weightx = 0; + p.add(new JLabel("Client ID:"), g); + g.gridx = 1; + g.fill = java.awt.GridBagConstraints.HORIZONTAL; + g.weightx = 1.0; + authOAuth2ClientId = new JTextField(30); + authOAuth2ClientId.setFont(monoFont()); + p.add(authOAuth2ClientId, g); + g.gridx = 0; + g.gridy++; + g.fill = java.awt.GridBagConstraints.NONE; + g.weightx = 0; + p.add(new JLabel("Client Secret:"), g); + g.gridx = 1; + g.fill = java.awt.GridBagConstraints.HORIZONTAL; + g.weightx = 1.0; + authOAuth2ClientSecret = new JTextField(30); + authOAuth2ClientSecret.setFont(monoFont()); + p.add(authOAuth2ClientSecret, g); + g.gridx = 0; + g.gridy++; + g.fill = java.awt.GridBagConstraints.NONE; + g.weightx = 0; + p.add(new JLabel("Scope:"), g); + g.gridx = 1; + g.fill = java.awt.GridBagConstraints.HORIZONTAL; + g.weightx = 1.0; + authOAuth2Scope = new JTextField(30); + authOAuth2Scope.setFont(monoFont()); + p.add(authOAuth2Scope, g); + g.gridx = 0; + g.gridy++; + g.fill = java.awt.GridBagConstraints.NONE; + g.weightx = 0; + p.add(new JLabel("Authorization URL:"), g); + g.gridx = 1; + g.fill = java.awt.GridBagConstraints.HORIZONTAL; + g.weightx = 1.0; + authOAuth2AuthUrl = new JTextField(30); + authOAuth2AuthUrl.setFont(monoFont()); + p.add(authOAuth2AuthUrl, g); + g.gridx = 0; + g.gridy++; + g.fill = java.awt.GridBagConstraints.NONE; + g.weightx = 0; + p.add(new JLabel("Callback URL:"), g); + g.gridx = 1; + g.fill = java.awt.GridBagConstraints.HORIZONTAL; + g.weightx = 1.0; + authOAuth2CallbackUrl = new JTextField(30); + authOAuth2CallbackUrl.setFont(monoFont()); + p.add(authOAuth2CallbackUrl, g); + g.gridx = 0; + g.gridy++; + g.fill = java.awt.GridBagConstraints.NONE; + g.weightx = 0; + authOAuth2Pkce = new JCheckBox("PKCE"); + p.add(authOAuth2Pkce, g); + g.gridy++; + g.gridx = 1; + g.weighty = 1.0; + p.add(new JLabel(""), g); + return p; + } + + private JPanel buildNtlmCard() { + JPanel p = authFormPanel(); + java.awt.GridBagConstraints g = authFormGbc(); + p.add(new JLabel("Username:"), g); + g.gridx = 1; + g.fill = java.awt.GridBagConstraints.HORIZONTAL; + g.weightx = 1.0; + authNtlmUser = new JTextField(30); + authNtlmUser.setFont(monoFont()); + p.add(authNtlmUser, g); + g.gridx = 0; + g.gridy++; + g.fill = java.awt.GridBagConstraints.NONE; + g.weightx = 0; + p.add(new JLabel("Password:"), g); + g.gridx = 1; + g.fill = java.awt.GridBagConstraints.HORIZONTAL; + g.weightx = 1.0; + authNtlmPass = new JTextField(30); + authNtlmPass.setFont(monoFont()); + p.add(authNtlmPass, g); + g.gridx = 0; + g.gridy++; + g.fill = java.awt.GridBagConstraints.NONE; + g.weightx = 0; + p.add(new JLabel("Domain:"), g); + g.gridx = 1; + g.fill = java.awt.GridBagConstraints.HORIZONTAL; + g.weightx = 1.0; + authNtlmDomain = new JTextField(30); + authNtlmDomain.setFont(monoFont()); + p.add(authNtlmDomain, g); + g.gridy++; + g.weighty = 1.0; + p.add(new JLabel(""), g); + return p; + } + + private JPanel buildAwsV4Card() { + JPanel p = authFormPanel(); + java.awt.GridBagConstraints g = authFormGbc(); + p.add(new JLabel("Access Key ID:"), g); + g.gridx = 1; + g.fill = java.awt.GridBagConstraints.HORIZONTAL; + g.weightx = 1.0; + authAwsAccessKey = new JTextField(30); + authAwsAccessKey.setFont(monoFont()); + p.add(authAwsAccessKey, g); + g.gridx = 0; + g.gridy++; + g.fill = java.awt.GridBagConstraints.NONE; + g.weightx = 0; + p.add(new JLabel("Secret Access Key:"), g); + g.gridx = 1; + g.fill = java.awt.GridBagConstraints.HORIZONTAL; + g.weightx = 1.0; + authAwsSecretKey = new JTextField(30); + authAwsSecretKey.setFont(monoFont()); + p.add(authAwsSecretKey, g); + g.gridx = 0; + g.gridy++; + g.fill = java.awt.GridBagConstraints.NONE; + g.weightx = 0; + p.add(new JLabel("Session Token:"), g); + g.gridx = 1; + g.fill = java.awt.GridBagConstraints.HORIZONTAL; + g.weightx = 1.0; + authAwsSession = new JTextField(30); + authAwsSession.setFont(monoFont()); + p.add(authAwsSession, g); + g.gridx = 0; + g.gridy++; + g.fill = java.awt.GridBagConstraints.NONE; + g.weightx = 0; + p.add(new JLabel("Service:"), g); + g.gridx = 1; + g.fill = java.awt.GridBagConstraints.HORIZONTAL; + g.weightx = 1.0; + authAwsService = new JTextField(30); + authAwsService.setFont(monoFont()); + p.add(authAwsService, g); + g.gridx = 0; + g.gridy++; + g.fill = java.awt.GridBagConstraints.NONE; + g.weightx = 0; + p.add(new JLabel("Region:"), g); + g.gridx = 1; + g.fill = java.awt.GridBagConstraints.HORIZONTAL; + g.weightx = 1.0; + authAwsRegion = new JTextField(30); + authAwsRegion.setFont(monoFont()); + p.add(authAwsRegion, g); + g.gridy++; + g.weighty = 1.0; + p.add(new JLabel(""), g); + return p; + } + + private JPanel buildOAuth1Card() { + JPanel p = authFormPanel(); + java.awt.GridBagConstraints g = authFormGbc(); + p.add(new JLabel("Consumer Key:"), g); + g.gridx = 1; + g.fill = java.awt.GridBagConstraints.HORIZONTAL; + g.weightx = 1.0; + authOAuth1ConsumerKey = new JTextField(30); + authOAuth1ConsumerKey.setFont(monoFont()); + p.add(authOAuth1ConsumerKey, g); + g.gridx = 0; + g.gridy++; + g.fill = java.awt.GridBagConstraints.NONE; + g.weightx = 0; + p.add(new JLabel("Consumer Secret:"), g); + g.gridx = 1; + g.fill = java.awt.GridBagConstraints.HORIZONTAL; + g.weightx = 1.0; + authOAuth1ConsumerSecret = new JTextField(30); + authOAuth1ConsumerSecret.setFont(monoFont()); + p.add(authOAuth1ConsumerSecret, g); + g.gridx = 0; + g.gridy++; + g.fill = java.awt.GridBagConstraints.NONE; + g.weightx = 0; + p.add(new JLabel("Access Token:"), g); + g.gridx = 1; + g.fill = java.awt.GridBagConstraints.HORIZONTAL; + g.weightx = 1.0; + authOAuth1AccessToken = new JTextField(30); + authOAuth1AccessToken.setFont(monoFont()); + p.add(authOAuth1AccessToken, g); + g.gridx = 0; + g.gridy++; + g.fill = java.awt.GridBagConstraints.NONE; + g.weightx = 0; + p.add(new JLabel("Token Secret:"), g); + g.gridx = 1; + g.fill = java.awt.GridBagConstraints.HORIZONTAL; + g.weightx = 1.0; + authOAuth1TokenSecret = new JTextField(30); + authOAuth1TokenSecret.setFont(monoFont()); + p.add(authOAuth1TokenSecret, g); + g.gridy++; + g.weighty = 1.0; + p.add(new JLabel(""), g); + return p; + } + + private JPanel buildEdgeGridCard() { + JPanel p = authFormPanel(); + java.awt.GridBagConstraints g = authFormGbc(); + p.add(new JLabel("Access Token:"), g); + g.gridx = 1; + g.fill = java.awt.GridBagConstraints.HORIZONTAL; + g.weightx = 1.0; + authEdgeAccessToken = new JTextField(30); + authEdgeAccessToken.setFont(monoFont()); + p.add(authEdgeAccessToken, g); + g.gridx = 0; + g.gridy++; + g.fill = java.awt.GridBagConstraints.NONE; + g.weightx = 0; + p.add(new JLabel("Client Token:"), g); + g.gridx = 1; + g.fill = java.awt.GridBagConstraints.HORIZONTAL; + g.weightx = 1.0; + authEdgeClientToken = new JTextField(30); + authEdgeClientToken.setFont(monoFont()); + p.add(authEdgeClientToken, g); + g.gridx = 0; + g.gridy++; + g.fill = java.awt.GridBagConstraints.NONE; + g.weightx = 0; + p.add(new JLabel("Client Secret:"), g); + g.gridx = 1; + g.fill = java.awt.GridBagConstraints.HORIZONTAL; + g.weightx = 1.0; + authEdgeClientSecret = new JTextField(30); + authEdgeClientSecret.setFont(monoFont()); + p.add(authEdgeClientSecret, g); + g.gridy++; + g.weighty = 1.0; + p.add(new JLabel(""), g); + return p; + } + + private static JPanel authFormPanel() { + JPanel p = new JPanel(new java.awt.GridBagLayout()); + p.setBorder(BorderFactory.createEmptyBorder(4, 0, 4, 0)); + return p; + } + + private static java.awt.GridBagConstraints authFormGbc() { + java.awt.GridBagConstraints g = new java.awt.GridBagConstraints(); + g.insets = new java.awt.Insets(2, 4, 2, 4); + g.anchor = java.awt.GridBagConstraints.WEST; + g.gridx = 0; + g.gridy = 0; + return g; + } + private void populateAuth(AuthConfig auth) { authTypeCombo.setSelectedItem(auth.mode()); - String fields = switch (auth) { - case AuthConfig.Bearer b -> "token=" + b.token(); - case AuthConfig.Basic b -> "username=" + b.username() + "\npassword=" + b.password(); - case AuthConfig.ApiKey a -> "key=" + a.key() + "\nvalue=" + a.value() + "\nplacement=" + a.placement(); - case AuthConfig.Digest d -> "username=" + d.username() + "\npassword=" + d.password(); - default -> ""; - }; - authFieldsArea.setText(fields); + clearAuthCards(); + switch (auth) { + case AuthConfig.Bearer b -> setFieldWithPrivacy(authBearerToken, b.token()); + case AuthConfig.Basic b -> { + setFieldWithPrivacy(authBasicUser, b.username()); + setFieldWithPrivacy(authBasicPass, b.password()); + } + case AuthConfig.ApiKey a -> { + setFieldWithPrivacy(authApiKeyKey, a.key()); + setFieldWithPrivacy(authApiKeyValue, a.value()); + authApiKeyPlacement.setSelectedItem(a.placement()); + } + case AuthConfig.Digest d -> { + setFieldWithPrivacy(authDigestUser, d.username()); + setFieldWithPrivacy(authDigestPass, d.password()); + } + case AuthConfig.Wsse w -> { + setFieldWithPrivacy(authWsseUser, w.username()); + setFieldWithPrivacy(authWssePass, w.password()); + } + case AuthConfig.OAuth2 o -> { + setFieldWithPrivacy(authOAuth2GrantType, o.grantType()); + setFieldWithPrivacy(authOAuth2TokenUrl, o.accessTokenUrl()); + setFieldWithPrivacy(authOAuth2ClientId, o.clientId()); + setFieldWithPrivacy(authOAuth2ClientSecret, o.clientSecret()); + setFieldWithPrivacy(authOAuth2Scope, o.scope()); + setFieldWithPrivacy(authOAuth2AuthUrl, o.authorizationUrl() != null ? o.authorizationUrl() : ""); + setFieldWithPrivacy(authOAuth2CallbackUrl, o.callbackUrl() != null ? o.callbackUrl() : ""); + authOAuth2Pkce.setSelected(o.pkce()); + } + case AuthConfig.Ntlm n -> { + setFieldWithPrivacy(authNtlmUser, n.username()); + setFieldWithPrivacy(authNtlmPass, n.password()); + setFieldWithPrivacy(authNtlmDomain, n.domain()); + } + case AuthConfig.AwsV4 a -> { + setFieldWithPrivacy(authAwsAccessKey, a.accessKeyId()); + setFieldWithPrivacy(authAwsSecretKey, a.secretAccessKey()); + setFieldWithPrivacy(authAwsSession, a.sessionToken() != null ? a.sessionToken() : ""); + setFieldWithPrivacy(authAwsService, a.service()); + setFieldWithPrivacy(authAwsRegion, a.region()); + } + case AuthConfig.OAuth1 o -> { + setFieldWithPrivacy(authOAuth1ConsumerKey, o.consumerKey()); + setFieldWithPrivacy(authOAuth1ConsumerSecret, o.consumerSecret()); + setFieldWithPrivacy(authOAuth1AccessToken, o.accessToken()); + setFieldWithPrivacy(authOAuth1TokenSecret, o.tokenSecret()); + } + case AuthConfig.EdgeGrid eg -> { + setFieldWithPrivacy(authEdgeAccessToken, eg.accessToken()); + setFieldWithPrivacy(authEdgeClientToken, eg.clientToken()); + setFieldWithPrivacy(authEdgeClientSecret, eg.clientSecret()); + } + default -> { + } + } + authCardLayout.show(authCards, auth.mode()); + } + + private void clearAuthCards() { + authBearerToken.setText(""); + authBasicUser.setText(""); + authBasicPass.setText(""); + authApiKeyKey.setText(""); + authApiKeyValue.setText(""); + authApiKeyPlacement.setSelectedIndex(0); + authDigestUser.setText(""); + authDigestPass.setText(""); + authWsseUser.setText(""); + authWssePass.setText(""); + authOAuth2GrantType.setText("client_credentials"); + authOAuth2TokenUrl.setText(""); + authOAuth2ClientId.setText(""); + authOAuth2ClientSecret.setText(""); + authOAuth2Scope.setText(""); + authOAuth2AuthUrl.setText(""); + authOAuth2CallbackUrl.setText(""); + authOAuth2Pkce.setSelected(false); + authNtlmUser.setText(""); + authNtlmPass.setText(""); + authNtlmDomain.setText(""); + authAwsAccessKey.setText(""); + authAwsSecretKey.setText(""); + authAwsSession.setText(""); + authAwsService.setText(""); + authAwsRegion.setText(""); + authOAuth1ConsumerKey.setText(""); + authOAuth1ConsumerSecret.setText(""); + authOAuth1AccessToken.setText(""); + authOAuth1TokenSecret.setText(""); + authEdgeAccessToken.setText(""); + authEdgeClientToken.setText(""); + authEdgeClientSecret.setText(""); } // ==================== ENV EDITOR ==================== @@ -1190,18 +2431,43 @@ private void saveCollection() { } } + private void saveCurrentRequest() { + saveEditorState(); + saveCollection(); + } + // ==================== SEND / RUN ==================== + private void copyAsCurl() { + RequestItem item = buildCurrentRequestItem(); + String curl = pf(CurlGenerator.generate(item.request())); + copyToClipboard(curl); + responseStatusLabel.setText("cURL copied to clipboard"); + responseStatusLabel.setForeground(METHOD_GET); + responseStatusLabel.setVisible(true); + } + + private void copyToClipboard(String text) { + java.awt.Toolkit.getDefaultToolkit().getSystemClipboard() + .setContents(new java.awt.datatransfer.StringSelection(text), null); + } + private void sendRequest() { String method = (String) methodCombo.getSelectedItem(); String url = urlField.getText().trim(); if (url.isBlank() || "https://".equals(url)) { - responseStatus.setText(" URL required"); + responseStatusLabel.setText("URL required"); + responseStatusLabel.setForeground(METHOD_DELETE); + responseStatusLabel.setVisible(true); return; } sendButton.setEnabled(false); - responseStatus.setText(" Sending..."); + responseStatusLabel.setText("Sending..."); + responseStatusLabel.setForeground(new Color(0x8B, 0x94, 0x9E)); + responseStatusLabel.setVisible(true); + responseTimeLabel.setVisible(false); + responseSizeLabel.setVisible(false); responseBody.setText(""); responseHeaders.setText(""); assertionsResultArea.setText(""); @@ -1214,16 +2480,25 @@ private void sendRequest() { : new Collection(null, "adhoc", null, null, List.of(item), null, null); Environment env = getSelectedEnvironment(); + ensureSessionContext(coll, env); Interpolator interpolator = new Interpolator(); RequestExecutor executor = new RequestExecutor(httpClient, interpolator, new AuthInjectorRegistry(), new AssertionEngine(interpolator)); - CollectionRunner runner = new CollectionRunner(executor); - RequestResult result = runner.runSingle(item, coll, env, msg -> { + RequestResult result = executor.execute(item, sessionContext, coll, msg -> { }); - SwingUtilities.invokeLater(() -> displayResult(result)); + autoExtractToken(result); + + SwingUtilities.invokeLater(() -> { + displayResult(result); + addToHistory(result); + }); } catch (Exception ex) { - SwingUtilities.invokeLater(() -> responseStatus.setText(" Error: " + ex.getMessage())); + SwingUtilities.invokeLater(() -> { + responseStatusLabel.setText("Error: " + ex.getMessage()); + responseStatusLabel.setForeground(METHOD_DELETE); + responseStatusLabel.setVisible(true); + }); } finally { SwingUtilities.invokeLater(() -> sendButton.setEnabled(true)); } @@ -1232,28 +2507,77 @@ private void sendRequest() { private RequestItem buildCurrentRequestItem() { String method = (String) methodCombo.getSelectedItem(); - String url = urlField.getText().trim(); + String url = rawFieldValue(urlField).trim(); List

headers = new ArrayList<>(); - for (String line : headersArea.getText().split("\n")) { - int sep = line.indexOf(':'); + for (KvTableEditor.Entry e : headersEditor.getEntries()) { + headers.add(new Header(e.key(), e.value(), e.enabled())); + } + + List queryParams = new ArrayList<>(); + for (KvTableEditor.Entry e : queryParamsEditor.getEntries()) { + queryParams.add(new Param(e.key(), e.value(), Param.ParamType.QUERY, e.enabled())); + } + List pathParams = new ArrayList<>(); + for (KvTableEditor.Entry e : pathParamsEditor.getEntries()) { + pathParams.add(new Param(e.key(), e.value(), Param.ParamType.PATH, e.enabled())); + } + ParamsDef params = new ParamsDef(queryParams, pathParams); + + List preVars = new ArrayList<>(); + for (String line : varsPreArea.getText().split("\n")) { + int sep = line.indexOf('='); + if (sep > 0) { + preVars.add(new VarsDef.VarEntry(line.substring(0, sep).trim(), line.substring(sep + 1).trim())); + } + } + List postVars = new ArrayList<>(); + for (String line : varsPostArea.getText().split("\n")) { + int sep = line.indexOf('='); if (sep > 0) { - headers.add(Header.of(line.substring(0, sep).trim(), line.substring(sep + 1).trim())); + postVars.add(new VarsDef.VarEntry(line.substring(0, sep).trim(), line.substring(sep + 1).trim())); + } + } + VarsDef vars = new VarsDef(preVars, postVars); + + List assertions = new ArrayList<>(); + for (int i = 0; i < assertTableModel.getRowCount(); i++) { + String expr = String.valueOf(assertTableModel.getValueAt(i, 0)); + String op = String.valueOf(assertTableModel.getValueAt(i, 1)); + String expected = String.valueOf(assertTableModel.getValueAt(i, 2)); + boolean enabled = Boolean.TRUE.equals(assertTableModel.getValueAt(i, 3)); + try { + assertions.add(new Assertion(expr, AssertOp.fromSymbol(op), expected, enabled)); + } catch (IllegalArgumentException ignored) { } } BodyConfig body = buildBody(); AuthConfig auth = buildAuth(); - HttpRequestDef req = new HttpRequestDef(method, url, null, headers, auth, body, null); + HttpRequestDef req = new HttpRequestDef(method, url, params, headers, auth, body, null); String name = activeRequest != null ? activeRequest.name() : "Manual Request"; - return new RequestItem(null, name, null, req, null, List.of(), null); + int seq = activeRequest != null && activeRequest.meta() != null ? activeRequest.meta().seq() : 0; + RequestMeta meta = RequestMeta.http(name, seq); + ScriptDef scripts = new ScriptDef(preRequestScriptArea.getText(), postResponseScriptArea.getText(), + testsScriptArea.getText()); + return new RequestItem(null, name, meta, req, vars, assertions, null, scripts); } private BodyConfig buildBody() { - String text = bodyArea.getText(); String type = (String) bodyTypeCombo.getSelectedItem(); - if (text.isBlank() || "None".equals(type)) { + if ("None".equals(type)) { + return new BodyConfig.None(); + } + if ("FormUrlEncoded".equals(type)) { + List formParams = new ArrayList<>(); + for (KvTableEditor.Entry e : formBodyEditor.getEntries()) { + formParams.add(new Param(e.key(), e.value(), Param.ParamType.QUERY, e.enabled())); + } + return new BodyConfig.FormUrlEncoded(formParams); + } + String text = bodyArea.getText(); + if (text.isBlank()) { return new BodyConfig.None(); } return switch (type) { @@ -1266,38 +2590,139 @@ private BodyConfig buildBody() { private AuthConfig buildAuth() { String mode = (String) authTypeCombo.getSelectedItem(); - String fields = authFieldsArea.getText(); - java.util.Map map = new java.util.LinkedHashMap<>(); - for (String line : fields.split("\n")) { - int sep = line.indexOf('='); - if (sep > 0) { - map.put(line.substring(0, sep).trim(), line.substring(sep + 1).trim()); - } - } return switch (mode) { - case "bearer" -> new AuthConfig.Bearer(map.getOrDefault("token", "")); - case "basic" -> new AuthConfig.Basic(map.getOrDefault("username", ""), map.getOrDefault("password", "")); - case "apikey" -> new AuthConfig.ApiKey(map.getOrDefault("key", ""), map.getOrDefault("value", ""), - map.getOrDefault("placement", "header")); - case "digest" -> new AuthConfig.Digest(map.getOrDefault("username", ""), map.getOrDefault("password", "")); + case "inherit" -> new AuthConfig.Inherit(); + case "bearer" -> new AuthConfig.Bearer(rawFieldValue(authBearerToken)); + case "basic" -> new AuthConfig.Basic(rawFieldValue(authBasicUser), rawFieldValue(authBasicPass)); + case "apikey" -> new AuthConfig.ApiKey(rawFieldValue(authApiKeyKey), rawFieldValue(authApiKeyValue), + (String) authApiKeyPlacement.getSelectedItem()); + case "digest" -> new AuthConfig.Digest(rawFieldValue(authDigestUser), rawFieldValue(authDigestPass)); + case "wsse" -> new AuthConfig.Wsse(rawFieldValue(authWsseUser), rawFieldValue(authWssePass)); + case "oauth2" -> new AuthConfig.OAuth2(rawFieldValue(authOAuth2GrantType), + rawFieldValue(authOAuth2TokenUrl), "", rawFieldValue(authOAuth2ClientId), + rawFieldValue(authOAuth2ClientSecret), rawFieldValue(authOAuth2Scope), + rawFieldValue(authOAuth2CallbackUrl), rawFieldValue(authOAuth2AuthUrl), authOAuth2Pkce.isSelected(), + "body", "header", false, false, java.util.Map.of()); + case "ntlm" -> new AuthConfig.Ntlm(rawFieldValue(authNtlmUser), rawFieldValue(authNtlmPass), + rawFieldValue(authNtlmDomain)); + case "awsv4" -> new AuthConfig.AwsV4(rawFieldValue(authAwsAccessKey), rawFieldValue(authAwsSecretKey), + rawFieldValue(authAwsSession), rawFieldValue(authAwsService), rawFieldValue(authAwsRegion)); + case "oauth1" -> new AuthConfig.OAuth1(rawFieldValue(authOAuth1ConsumerKey), + rawFieldValue(authOAuth1ConsumerSecret), rawFieldValue(authOAuth1AccessToken), + rawFieldValue(authOAuth1TokenSecret), "HMAC-SHA1", "header"); + case "akamai-edgegrid" -> new AuthConfig.EdgeGrid(rawFieldValue(authEdgeAccessToken), + rawFieldValue(authEdgeClientToken), rawFieldValue(authEdgeClientSecret)); default -> new AuthConfig.None(); }; } + private void ensureSessionContext(Collection coll, Environment env) { + if (sessionContext == null) { + VariableStore vars = new VariableStore(); + vars.loadCollection(coll.root()); + if (env != null) { + vars.loadEnv(env); + } + sessionContext = new RunContext(vars); + } else if (env != null) { + sessionContext.vars().loadEnv(env); + } + } + + private void resetSessionContext() { + sessionContext = null; + } + + private static final int MAX_HISTORY = 50; + + private void addToHistory(RequestResult result) { + historyModel.add(0, result); + if (historyModel.size() > MAX_HISTORY) { + historyModel.remove(historyModel.size() - 1); + } + } + + private static final String[] TOKEN_FIELDS = {"token", "accessToken", "access_token", "jwt"}; + + private void autoExtractToken(RequestResult result) { + if (result.response() == null || result.response().status() >= 300) { + return; + } + String body = result.response().body(); + if (body == null || body.isBlank() || !body.trim().startsWith("{")) { + return; + } + try { + com.google.gson.JsonObject obj = com.google.gson.JsonParser.parseString(body).getAsJsonObject(); + String token = findTokenField(obj); + if (token == null && obj.has("data") && obj.get("data").isJsonObject()) { + token = findTokenField(obj.getAsJsonObject("data")); + } + if (token != null) { + sessionContext.vars().setRuntime("token", token); + } + } catch (Exception ignored) { + } + } + + private static String findTokenField(com.google.gson.JsonObject obj) { + for (String field : TOKEN_FIELDS) { + if (obj.has(field) && obj.get(field).isJsonPrimitive()) { + String value = obj.get(field).getAsString(); + if (value != null && !value.isBlank()) { + return value; + } + } + } + return null; + } + + private void clearResponsePanel() { + responseStatusLabel.setVisible(false); + responseTimeLabel.setVisible(false); + responseSizeLabel.setVisible(false); + responseBody.setText(""); + responseHeaders.setText(""); + assertionsResultArea.setText(""); + cookiesTableModel.setRowCount(0); + responseTabPane.setTitleAt(1, "Headers"); + responseTabPane.setTitleAt(3, "Cookies"); + scriptConsoleArea.setText(""); + } + private void displayResult(RequestResult result) { + if (activeRequest != null) { + tabResponseCache.put(activeRequest.uid(), result); + } + cookiesTableModel.setRowCount(0); if (result.response() != null) { var resp = result.response(); String statusText = resp.statusText() != null && !resp.statusText().isEmpty() ? resp.statusText() - : String.valueOf(resp.status()); - responseStatus.setText(String.format(" %d %s %dms %s", resp.status(), statusText, resp.responseTimeMs(), - formatBytes(resp.body().length()))); + : httpReasonPhrase(resp.status()); + + Color sc = statusColor(resp.status()); + responseStatusLabel.setText(resp.status() + " " + statusText); + responseStatusLabel.setForeground(sc); + responseStatusLabel.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(sc.darker(), 1), BorderFactory.createEmptyBorder(1, 6, 1, 6))); + responseStatusLabel.setVisible(true); + + responseTimeLabel.setText(formatDuration(resp.responseTimeMs())); + responseTimeLabel.setForeground(new Color(0x8B, 0x94, 0x9E)); + responseTimeLabel.setVisible(true); - responseBody.setText(resp.body()); + responseSizeLabel.setText(formatBytes(resp.body().length())); + responseSizeLabel.setForeground(new Color(0x8B, 0x94, 0x9E)); + responseSizeLabel.setVisible(true); + + responseBody.setText(pf(resp.body())); responseBody.setCaretPosition(0); String ct = resp.header("content-type"); - responseBody.setSyntaxEditingStyle(detectSyntax(ct != null ? ct : "")); + String syntax = detectSyntax(ct != null ? ct : ""); + responseBody.setSyntaxEditingStyle(syntax); + selectResponseFormat(syntax); StringBuilder hdr = new StringBuilder(); int headerCount = 0; @@ -1307,10 +2732,18 @@ private void displayResult(RequestResult result) { headerCount++; } } - responseHeaders.setText(hdr.toString()); + responseHeaders.setText(pf(hdr.toString())); responseTabPane.setTitleAt(1, "Headers " + headerCount); + + parseCookies(resp.headers()); + int cookieCount = cookiesTableModel.getRowCount(); + responseTabPane.setTitleAt(3, cookieCount > 0 ? "Cookies " + cookieCount : "Cookies"); } else { - responseStatus.setText(" No response"); + responseStatusLabel.setText("No response"); + responseStatusLabel.setForeground(Color.GRAY); + responseStatusLabel.setVisible(true); + responseTimeLabel.setVisible(false); + responseSizeLabel.setVisible(false); } if (result.assertions() != null && !result.assertions().isEmpty()) { @@ -1324,15 +2757,200 @@ private void displayResult(RequestResult result) { } sb.append("\n"); } - assertionsResultArea.setText(sb.toString()); + assertionsResultArea.setText(pf(sb.toString())); } + + populateVariablesTab(); + populateScriptConsole(); + } + + private void populateScriptConsole() { + scriptConsoleArea.setText(""); + if (sessionContext == null) { + return; + } + ScriptResult sr = sessionContext.lastScriptResult(); + if (sr == null) { + return; + } + StringBuilder sb = new StringBuilder(); + for (String line : sr.consoleLog()) { + sb.append(line).append("\n"); + } + if (sr.hasError()) { + sb.append("[ERROR] ").append(sr.error()).append("\n"); + } + if (!sr.testResults().isEmpty()) { + sb.append("\n--- Test Results ---\n"); + int passed = 0; + int failed = 0; + for (ScriptResult.TestResult t : sr.testResults()) { + String status = t.passed() ? "PASS" : "FAIL"; + sb.append("[").append(status).append("] ").append(t.name()); + if (!t.message().isEmpty()) { + sb.append(" -- ").append(t.message()); + } + sb.append("\n"); + if (t.passed()) { + passed++; + } else { + failed++; + } + } + sb.append("\n").append(passed).append(" passed, ").append(failed).append(" failed\n"); + } + scriptConsoleArea.setText(pf(sb.toString())); + int consoleIdx = responseTabPane.indexOfTab("Console"); + if (consoleIdx >= 0 && !sr.testResults().isEmpty()) { + int total = sr.testResults().size(); + int failCount = sr.testsFailed(); + String label = failCount > 0 ? "Console (" + failCount + " FAIL)" : "Console (" + total + " PASS)"; + responseTabPane.setTitleAt(consoleIdx, label); + } else if (consoleIdx >= 0) { + responseTabPane.setTitleAt(consoleIdx, "Console"); + } + } + + private void populateVariablesTab() { + variablesTableModel.setRowCount(0); + if (sessionContext == null) { + return; + } + VariableStore vars = sessionContext.vars(); + vars.envVars().forEach( + (k, v) -> variablesTableModel.addRow(new Object[]{"env", k, vars.isSecret(k) ? "********" : pf(v)})); + vars.collectionVars().forEach((k, v) -> variablesTableModel.addRow(new Object[]{"collection", k, pf(v)})); + vars.folderVars().forEach((k, v) -> variablesTableModel.addRow(new Object[]{"folder", k, pf(v)})); + vars.runtimeVars().forEach((k, v) -> variablesTableModel.addRow(new Object[]{"runtime", k, pf(v)})); + vars.requestVars().forEach((k, v) -> variablesTableModel.addRow(new Object[]{"request", k, pf(v)})); + } + + private void selectResponseFormat(String syntax) { + String fmt = switch (syntax) { + case SyntaxConstants.SYNTAX_STYLE_JSON -> "JSON"; + case SyntaxConstants.SYNTAX_STYLE_HTML -> "HTML"; + case SyntaxConstants.SYNTAX_STYLE_XML -> "XML"; + default -> "Raw"; + }; + responseFormatCombo.setSelectedItem(fmt); + } + + private static JLabel createBadge(String text) { + JLabel badge = new JLabel(text); + badge.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 11)); + badge.setBorder(BorderFactory.createCompoundBorder(BorderFactory.createLineBorder(panelBorder(), 1), + BorderFactory.createEmptyBorder(1, 6, 1, 6))); + badge.setOpaque(true); + badge.setBackground(badgeBg()); + return badge; + } + + private static String formatDuration(long ms) { + if (ms < 1000) { + return ms + "ms"; + } + return String.format("%.2fs", ms / 1000.0); } private static String formatBytes(int bytes) { if (bytes < 1024) { - return bytes + "B"; + return bytes + " B"; + } + if (bytes < 1024 * 1024) { + return String.format("%.1f KB", bytes / 1024.0); + } + return String.format("%.1f MB", bytes / (1024.0 * 1024.0)); + } + + private static Color statusColor(int code) { + if (code >= 200 && code < 300) { + return METHOD_GET; + } + if (code >= 300 && code < 400) { + return METHOD_PUT; + } + if (code >= 400 && code < 500) { + return METHOD_POST; + } + if (code >= 500) { + return METHOD_DELETE; + } + return Color.GRAY; + } + + @SuppressWarnings("checkstyle:MagicNumber") + private static String httpReasonPhrase(int code) { + return switch (code) { + case 200 -> "OK"; + case 201 -> "Created"; + case 202 -> "Accepted"; + case 204 -> "No Content"; + case 301 -> "Moved Permanently"; + case 302 -> "Found"; + case 304 -> "Not Modified"; + case 307 -> "Temporary Redirect"; + case 308 -> "Permanent Redirect"; + case 400 -> "Bad Request"; + case 401 -> "Unauthorized"; + case 403 -> "Forbidden"; + case 404 -> "Not Found"; + case 405 -> "Method Not Allowed"; + case 409 -> "Conflict"; + case 413 -> "Payload Too Large"; + case 415 -> "Unsupported Media Type"; + case 422 -> "Unprocessable Entity"; + case 429 -> "Too Many Requests"; + case 500 -> "Internal Server Error"; + case 502 -> "Bad Gateway"; + case 503 -> "Service Unavailable"; + case 504 -> "Gateway Timeout"; + default -> String.valueOf(code); + }; + } + + private void parseCookies(java.util.Map> headers) { + for (var entry : headers.entrySet()) { + if (!"set-cookie".equalsIgnoreCase(entry.getKey())) { + continue; + } + for (String raw : entry.getValue()) { + String name = ""; + String value = ""; + String domain = ""; + String path = ""; + String expires = ""; + String httpOnly = ""; + String secure = ""; + + String[] parts = raw.split(";"); + for (int i = 0; i < parts.length; i++) { + String part = parts[i].trim(); + if (i == 0) { + int eq = part.indexOf('='); + if (eq > 0) { + name = part.substring(0, eq).trim(); + value = part.substring(eq + 1).trim(); + } + } else { + String lower = part.toLowerCase(java.util.Locale.ROOT); + if (lower.startsWith("domain=")) { + domain = part.substring(7).trim(); + } else if (lower.startsWith("path=")) { + path = part.substring(5).trim(); + } else if (lower.startsWith("expires=")) { + expires = part.substring(8).trim(); + } else if (lower.startsWith("max-age=")) { + expires = "max-age=" + part.substring(8).trim(); + } else if ("httponly".equals(lower)) { + httpOnly = "Yes"; + } else if ("secure".equals(lower)) { + secure = "Yes"; + } + } + } + cookiesTableModel.addRow(new Object[]{name, pf(value), pf(domain), path, expires, httpOnly, secure}); + } } - return String.format("%.1fKB", bytes / 1024.0); } // ==================== LOAD / IMPORT ==================== @@ -1362,7 +2980,11 @@ public void loadCollection(Path dir) { } collectionPath = dir; openRequests.clear(); + tabStateCache.clear(); + tabResponseCache.clear(); activeRequest = null; + resetSessionContext(); + loadDotEnv(dir); populateTree(); populateEnvCombo(); refreshRequestTabs(); @@ -1372,6 +2994,39 @@ public void loadCollection(Path dir) { } } + private void loadDotEnv(Path dir) { + Path dotEnv = dir.resolve(".env"); + if (!java.nio.file.Files.isRegularFile(dotEnv)) { + return; + } + try { + List vars = new ArrayList<>(); + for (String line : java.nio.file.Files.readAllLines(dotEnv)) { + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#")) { + continue; + } + int eq = trimmed.indexOf('='); + if (eq > 0) { + String key = trimmed.substring(0, eq).trim(); + String value = trimmed.substring(eq + 1).trim(); + if (value.startsWith("\"") && value.endsWith("\"") && value.length() >= 2) { + value = value.substring(1, value.length() - 1); + } + vars.add(EnvVariable.of(key, value)); + } + } + if (!vars.isEmpty()) { + List envs = new ArrayList<>(currentCollection.environments()); + envs.add(0, new Environment(null, ".env", null, vars, null)); + currentCollection = new Collection(currentCollection.uid(), currentCollection.name(), + currentCollection.version(), envs, currentCollection.items(), currentCollection.root(), + currentCollection.settings()); + } + } catch (Exception ignored) { + } + } + private boolean hasBruFiles(Path dir) { try (var stream = java.nio.file.Files.walk(dir, 3)) { return stream.anyMatch(p -> p.toString().endsWith(".bru")); @@ -1438,6 +3093,10 @@ private void showImportMenu(JButton anchor) { bruno.addActionListener(e -> importBruno()); menu.add(bruno); + JMenuItem insomnia = new JMenuItem("Insomnia Export..."); + insomnia.addActionListener(e -> importInsomnia()); + menu.add(insomnia); + JMenuItem curl = new JMenuItem("cURL Command..."); curl.addActionListener(e -> importCurl()); menu.add(curl); @@ -1452,6 +3111,7 @@ private void importPostman() { try { currentCollection = new PostmanImporter().importFile(chooser.getSelectedFile().toPath()); collectionPath = null; + resetSessionContext(); populateTree(); populateEnvCombo(); } catch (Exception ex) { @@ -1468,6 +3128,7 @@ private void importOpenApi() { try { currentCollection = new OpenApiImporter().importFile(chooser.getSelectedFile().toPath()); collectionPath = null; + resetSessionContext(); populateTree(); populateEnvCombo(); } catch (Exception ex) { @@ -1485,6 +3146,24 @@ private void importBruno() { try { currentCollection = new BruImporter().importDirectory(chooser.getSelectedFile().toPath()); collectionPath = null; + resetSessionContext(); + populateTree(); + populateEnvCombo(); + } catch (Exception ex) { + JOptionPane.showMessageDialog(this, "Import failed: " + ex.getMessage(), "Error", + JOptionPane.ERROR_MESSAGE); + } + } + } + + private void importInsomnia() { + JFileChooser chooser = new JFileChooser(); + chooser.setDialogTitle("Select Insomnia Export JSON"); + if (chooser.showOpenDialog(this) == JFileChooser.APPROVE_OPTION) { + try { + currentCollection = new InsomniaImporter().importFile(chooser.getSelectedFile().toPath()); + collectionPath = null; + resetSessionContext(); populateTree(); populateEnvCombo(); } catch (Exception ex) { @@ -1501,11 +3180,12 @@ private void importCurl() { RequestItem item = new CurlImporter().importCurl(cmd); currentCollection = new Collection(null, "cURL Import", null, null, List.of(item), null, null); collectionPath = null; + resetSessionContext(); populateTree(); populateEnvCombo(); onTreeSelection(); methodCombo.setSelectedItem(item.request().method()); - urlField.setText(item.request().url()); + setFieldWithPrivacy(urlField, item.request().url()); } catch (Exception ex) { JOptionPane.showMessageDialog(this, "Import failed: " + ex.getMessage(), "Error", JOptionPane.ERROR_MESSAGE); @@ -1539,19 +3219,176 @@ private void runCollection() { } private void showRunResult(RunResult result) { - StringBuilder sb = new StringBuilder(); - sb.append("Collection: ").append(currentCollection.name()).append("\n"); - sb.append("Total: ").append(result.summary().total()).append(" | Passed: ").append(result.summary().passed()) - .append(" | Failed: ").append(result.summary().failed()).append("\n"); - sb.append("Duration: ").append(result.durationMs()).append("ms\n\n"); - runLog.forEach(l -> sb.append(l).append("\n")); + removeRunResultsTab(); + + JPanel panel = new JPanel(new BorderLayout(0, 4)); + + RunSummary summary = result.summary(); + JPanel summaryBar = new JPanel(new FlowLayout(FlowLayout.LEFT, 12, 4)); + summaryBar.add(summaryLabel("Total: " + summary.total(), Color.GRAY)); + summaryBar.add(summaryLabel("Passed: " + summary.passed(), METHOD_GET)); + summaryBar.add(summaryLabel("Failed: " + summary.failed(), METHOD_DELETE)); + if (summary.skipped() > 0) { + summaryBar.add(summaryLabel("Skipped: " + summary.skipped(), METHOD_PUT)); + } + summaryBar.add(summaryLabel(result.durationMs() + "ms", Color.GRAY)); + JButton closeBtn = new JButton("X"); + closeBtn.setFont(closeBtn.getFont().deriveFont(Font.BOLD, 10f)); + closeBtn.setMargin(new java.awt.Insets(0, 4, 0, 4)); + closeBtn.addActionListener(e -> removeRunResultsTab()); + summaryBar.add(closeBtn); + panel.add(summaryBar, BorderLayout.NORTH); + + String[] columns = {"#", "Name", "Method", "URL", "Status", "Time", "Asserts", "Result"}; + DefaultTableModel model = new DefaultTableModel(columns, 0) { + @Override + public boolean isCellEditable(int row, int column) { + return false; + } + }; - JTextArea logArea = new JTextArea(sb.toString()); - logArea.setEditable(false); - logArea.setFont(monoFont()); - JScrollPane scroll = new JScrollPane(logArea); - scroll.setPreferredSize(new Dimension(600, 400)); - JOptionPane.showMessageDialog(this, scroll, "Collection Run Result", JOptionPane.INFORMATION_MESSAGE); + List results = result.results(); + for (int i = 0; i < results.size(); i++) { + RequestResult rr = results.get(i); + String name = rr.item() != null ? rr.item().name() : "?"; + String method = rr.interpolatedRequest() != null ? rr.interpolatedRequest().method() : "?"; + String url = rr.interpolatedRequest() != null ? rr.interpolatedRequest().url() : ""; + int status = rr.response() != null ? rr.response().status() : 0; + long time = rr.durationMs(); + int assertCount = rr.assertions() != null ? rr.assertions().size() : 0; + long assertPassed = rr.assertions() != null + ? rr.assertions().stream().filter(AssertionResult::passed).count() + : 0; + String assertText = assertCount > 0 ? assertPassed + "/" + assertCount : "-"; + String passText = rr.passed() ? "PASS" : "FAIL"; + + model.addRow(new Object[]{i + 1, name, method, pf(url), status, time + "ms", assertText, passText}); + } + + JTable table = new JTable(model); + table.setFont(monoFont()); + table.setRowHeight(22); + table.getColumnModel().getColumn(0).setMaxWidth(35); + table.getColumnModel().getColumn(2).setMaxWidth(70); + table.getColumnModel().getColumn(4).setMaxWidth(60); + table.getColumnModel().getColumn(5).setMaxWidth(70); + table.getColumnModel().getColumn(6).setMaxWidth(60); + table.getColumnModel().getColumn(7).setMaxWidth(50); + table.setDefaultRenderer(Object.class, new javax.swing.table.DefaultTableCellRenderer() { + @Override + public java.awt.Component getTableCellRendererComponent(JTable t, Object value, boolean isSelected, + boolean hasFocus, int row, int column) { + super.getTableCellRendererComponent(t, value, isSelected, hasFocus, row, column); + if (!isSelected) { + if (column == 4 && value instanceof Integer code) { + setForeground(statusColor(code)); + } else if (column == 7) { + setForeground("PASS".equals(value) ? METHOD_GET : METHOD_DELETE); + } else { + setForeground(t.getForeground()); + } + } + return this; + } + }); + + table.getSelectionModel().addListSelectionListener(e -> { + if (!e.getValueIsAdjusting()) { + int row = table.getSelectedRow(); + if (row >= 0 && row < results.size()) { + displayResult(results.get(row)); + } + } + }); + + panel.add(new JScrollPane(table), BorderLayout.CENTER); + + responseTabPane.addTab("Run Results", panel); + responseTabPane.setSelectedComponent(panel); + } + + private void removeRunResultsTab() { + for (int i = responseTabPane.getTabCount() - 1; i >= 0; i--) { + if ("Run Results".equals(responseTabPane.getTitleAt(i))) { + responseTabPane.removeTabAt(i); + } + } + } + + private static JLabel summaryLabel(String text, Color color) { + JLabel label = new JLabel(text); + label.setForeground(color); + label.setFont(label.getFont().deriveFont(Font.BOLD, 12f)); + return label; + } + + // ==================== PRIVACY ==================== + + private void registerPrivacyFields() { + privacyManagedFields.addAll(List.of(urlField, authBearerToken, authBasicUser, authBasicPass, authApiKeyKey, + authApiKeyValue, authDigestUser, authDigestPass, authWsseUser, authWssePass, authOAuth2GrantType, + authOAuth2TokenUrl, authOAuth2ClientId, authOAuth2ClientSecret, authOAuth2Scope, authOAuth2CallbackUrl, + authOAuth2AuthUrl, authNtlmUser, authNtlmPass, authNtlmDomain, authAwsAccessKey, authAwsSecretKey, + authAwsSession, authAwsService, authAwsRegion, authOAuth1ConsumerKey, authOAuth1ConsumerSecret, + authOAuth1AccessToken, authOAuth1TokenSecret, authEdgeAccessToken, authEdgeClientToken, + authEdgeClientSecret)); + } + + private void onPrivacyChanged() { + if (PrivacyFilter.isEnabled()) { + for (javax.swing.text.JTextComponent field : privacyManagedFields) { + String raw = field.getText(); + privacyRawValues.put(field, raw); + field.setText(PrivacyFilter.apply(raw)); + field.setEditable(false); + } + } else { + for (javax.swing.text.JTextComponent field : privacyManagedFields) { + String raw = privacyRawValues.getOrDefault(field, field.getText()); + field.setText(raw); + field.setEditable(true); + } + privacyRawValues.clear(); + } + + refreshResponsePrivacy(); + collectionTree.repaint(); + historyList.repaint(); + } + + private void refreshResponsePrivacy() { + if (activeRequest != null) { + RequestResult cached = tabResponseCache.get(activeRequest.uid()); + if (cached != null) { + displayResult(cached); + return; + } + } + responseBody.setText(PrivacyFilter.apply(responseBody.getText())); + responseHeaders.setText(PrivacyFilter.apply(responseHeaders.getText())); + assertionsResultArea.setText(PrivacyFilter.apply(assertionsResultArea.getText())); + scriptConsoleArea.setText(PrivacyFilter.apply(scriptConsoleArea.getText())); + } + + private static String pf(String text) { + return PrivacyFilter.apply(text); + } + + private void setFieldWithPrivacy(javax.swing.text.JTextComponent field, String rawValue) { + if (PrivacyFilter.isEnabled()) { + privacyRawValues.put(field, rawValue); + field.setText(PrivacyFilter.apply(rawValue)); + field.setEditable(false); + } else { + field.setText(rawValue); + } + } + + String rawFieldValue(javax.swing.text.JTextComponent field) { + if (PrivacyFilter.isEnabled()) { + return privacyRawValues.getOrDefault(field, field.getText()); + } + return field.getText(); } // ==================== UTIL ==================== @@ -1562,15 +3399,29 @@ private RSyntaxTextArea createSyntaxArea(boolean readOnly) { area.setCodeFoldingEnabled(true); area.setAntiAliasingEnabled(true); area.setTabSize(2); + applySyntaxTheme(area); + return area; + } + + private void applySyntaxTheme(RSyntaxTextArea area) { + boolean dark = gg.effront.anarcry.gui.theme.AnarCryLaf.isDark(); + String themePath = dark + ? "/org/fife/ui/rsyntaxtextarea/themes/dark.xml" + : "/org/fife/ui/rsyntaxtextarea/themes/default.xml"; try { - Theme theme = Theme.load(getClass().getResourceAsStream("/org/fife/ui/rsyntaxtextarea/themes/dark.xml")); + Theme theme = Theme.load(getClass().getResourceAsStream(themePath)); theme.apply(area); } catch (Exception e) { - area.setBackground(new Color(0x1E, 0x1E, 0x2E)); - area.setForeground(Color.WHITE); - area.setCaretColor(Color.WHITE); + if (dark) { + area.setBackground(new Color(0x1E, 0x1E, 0x2E)); + area.setForeground(Color.WHITE); + area.setCaretColor(Color.WHITE); + } else { + area.setBackground(Color.WHITE); + area.setForeground(new Color(0x1A, 0x1A, 0x1A)); + area.setCaretColor(new Color(0x1A, 0x1A, 0x1A)); + } } - return area; } private static String detectSyntax(String contentType) { diff --git a/src/main/java/gg/effront/anarcry/gui/views/AssistantView.java b/src/main/java/gg/effront/anarcry/gui/views/AssistantView.java index 9333cb4..f1fb11a 100644 --- a/src/main/java/gg/effront/anarcry/gui/views/AssistantView.java +++ b/src/main/java/gg/effront/anarcry/gui/views/AssistantView.java @@ -49,12 +49,31 @@ public class AssistantView extends JPanel { - private static final Color DARK_BG = new Color(0x1E, 0x1E, 0x2E); + private static Color darkBg() { + return gg.effront.anarcry.gui.theme.AnarCryLaf.isDark() + ? new Color(0x1E, 0x1E, 0x2E) + : new Color(0xF5, 0xF5, 0xF5); + } + private static final Color USER_COLOR = new Color(0x4E, 0xC9, 0xB0); - private static final Color ASSISTANT_COLOR = new Color(0xD4, 0xD4, 0xD4); + + private static Color assistantColor() { + return gg.effront.anarcry.gui.theme.AnarCryLaf.isDark() + ? new Color(0xD4, 0xD4, 0xD4) + : new Color(0x2A, 0x2A, 0x2A); + } + private static final Color SYSTEM_COLOR = new Color(0xE5, 0xC0, 0x7B); - private static final Color INPUT_BG = new Color(0x2D, 0x2D, 0x3D); - private static final Color LABEL_COLOR = new Color(0x88, 0x88, 0x99); + + private static Color inputBg() { + return gg.effront.anarcry.gui.theme.AnarCryLaf.isDark() ? new Color(0x2D, 0x2D, 0x3D) : Color.WHITE; + } + + private static Color labelColor() { + return gg.effront.anarcry.gui.theme.AnarCryLaf.isDark() + ? new Color(0x88, 0x88, 0x99) + : new Color(0x66, 0x66, 0x77); + } private static final Font CHAT_FONT = new Font(Font.MONOSPACED, Font.PLAIN, 13); private static final Font INPUT_FONT = new Font(Font.MONOSPACED, Font.PLAIN, 14); @@ -84,11 +103,11 @@ public class AssistantView extends JPanel { public AssistantView() { super(new BorderLayout(0, 4)); - setBackground(DARK_BG); + setBackground(darkBg()); providerCombo = new JComboBox<>(); - providerCombo.setBackground(INPUT_BG); - providerCombo.setForeground(ASSISTANT_COLOR); + providerCombo.setBackground(inputBg()); + providerCombo.setForeground(assistantColor()); providerCombo.setFont(CHAT_FONT); providerCombo.setPreferredSize(new Dimension(140, 26)); providerCombo.addItemListener(e -> { @@ -99,12 +118,12 @@ public AssistantView() { modelCombo = new JComboBox<>(); modelCombo.setEditable(true); - modelCombo.setBackground(INPUT_BG); - modelCombo.setForeground(ASSISTANT_COLOR); + modelCombo.setBackground(inputBg()); + modelCombo.setForeground(assistantColor()); modelCombo.setFont(CHAT_FONT); modelCombo.setPreferredSize(new Dimension(250, 26)); - modelCombo.getEditor().getEditorComponent().setBackground(INPUT_BG); - modelCombo.getEditor().getEditorComponent().setForeground(ASSISTANT_COLOR); + modelCombo.getEditor().getEditorComponent().setBackground(inputBg()); + modelCombo.getEditor().getEditorComponent().setForeground(assistantColor()); modelCombo.addItemListener(e -> { if (!suppressComboEvents && e.getStateChange() == ItemEvent.SELECTED) { onModelChanged(); @@ -112,15 +131,15 @@ public AssistantView() { }); JLabel providerLabel = new JLabel(" Provider:"); - providerLabel.setForeground(LABEL_COLOR); + providerLabel.setForeground(labelColor()); providerLabel.setFont(CHAT_FONT); JLabel modelLabel = new JLabel(" Model:"); - modelLabel.setForeground(LABEL_COLOR); + modelLabel.setForeground(labelColor()); modelLabel.setFont(CHAT_FONT); JPanel selectorBar = new JPanel(new FlowLayout(FlowLayout.LEFT, 2, 4)); - selectorBar.setBackground(DARK_BG); + selectorBar.setBackground(darkBg()); selectorBar.add(providerLabel); selectorBar.add(providerCombo); selectorBar.add(modelLabel); @@ -128,7 +147,7 @@ public AssistantView() { chatPane = new JTextPane(); chatPane.setEditable(false); - chatPane.setBackground(DARK_BG); + chatPane.setBackground(darkBg()); chatPane.setFont(CHAT_FONT); initStyles(); @@ -136,27 +155,27 @@ public AssistantView() { scrollPane.setBorder(null); JPanel centerPanel = new JPanel(new BorderLayout()); - centerPanel.setBackground(DARK_BG); + centerPanel.setBackground(darkBg()); centerPanel.add(selectorBar, BorderLayout.NORTH); centerPanel.add(scrollPane, BorderLayout.CENTER); add(centerPanel, BorderLayout.CENTER); JPanel bottomPanel = new JPanel(new BorderLayout(4, 0)); - bottomPanel.setBackground(DARK_BG); + bottomPanel.setBackground(darkBg()); JLabel hintLabel = new JLabel(" /findings /explain /remediate /plan /kb /report /clear"); - hintLabel.setForeground(LABEL_COLOR); + hintLabel.setForeground(labelColor()); hintLabel.setFont(CHAT_FONT); inputField = new JTextField(); - inputField.setBackground(INPUT_BG); - inputField.setForeground(ASSISTANT_COLOR); - inputField.setCaretColor(ASSISTANT_COLOR); + inputField.setBackground(inputBg()); + inputField.setForeground(assistantColor()); + inputField.setCaretColor(assistantColor()); inputField.setFont(INPUT_FONT); inputField.setPreferredSize(new Dimension(0, 38)); - inputField.setBorder( - BorderFactory.createCompoundBorder(BorderFactory.createLineBorder(new Color(0x44, 0x44, 0x55)), - BorderFactory.createEmptyBorder(8, 8, 8, 8))); + inputField.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(gg.effront.anarcry.gui.theme.AnarCryLaf.border()), + BorderFactory.createEmptyBorder(8, 8, 8, 8))); inputField.addKeyListener(new KeyAdapter() { @Override @@ -172,16 +191,16 @@ public void keyPressed(KeyEvent e) { sendBtn.addActionListener(e -> handleSubmit()); usageLabel = new JLabel(""); - usageLabel.setForeground(LABEL_COLOR); + usageLabel.setForeground(labelColor()); usageLabel.setFont(CHAT_FONT); JPanel inputRow = new JPanel(new BorderLayout(4, 0)); - inputRow.setBackground(DARK_BG); + inputRow.setBackground(darkBg()); inputRow.add(inputField, BorderLayout.CENTER); inputRow.add(sendBtn, BorderLayout.EAST); JPanel usageRow = new JPanel(new BorderLayout()); - usageRow.setBackground(DARK_BG); + usageRow.setBackground(darkBg()); usageRow.add(hintLabel, BorderLayout.WEST); usageRow.add(usageLabel, BorderLayout.EAST); @@ -189,6 +208,25 @@ public void keyPressed(KeyEvent e) { bottomPanel.add(inputRow, BorderLayout.SOUTH); add(bottomPanel, BorderLayout.SOUTH); + + gg.effront.anarcry.gui.theme.AnarCryLaf.addThemeListener(this::onThemeChanged); + } + + private void onThemeChanged() { + setBackground(darkBg()); + chatPane.setBackground(darkBg()); + inputField.setBackground(inputBg()); + inputField.setForeground(assistantColor()); + inputField.setCaretColor(assistantColor()); + providerCombo.setBackground(inputBg()); + providerCombo.setForeground(assistantColor()); + modelCombo.setBackground(inputBg()); + modelCombo.setForeground(assistantColor()); + modelCombo.getEditor().getEditorComponent().setBackground(inputBg()); + modelCombo.getEditor().getEditorComponent().setForeground(assistantColor()); + usageLabel.setForeground(labelColor()); + initStyles(); + repaint(); } public void configure(Map envMap) { @@ -451,7 +489,7 @@ private void initStyles() { StyleConstants.setFontSize(userStyle, 13); Style assistantStyle = doc.addStyle("assistant", null); - StyleConstants.setForeground(assistantStyle, ASSISTANT_COLOR); + StyleConstants.setForeground(assistantStyle, assistantColor()); StyleConstants.setFontFamily(assistantStyle, Font.MONOSPACED); StyleConstants.setFontSize(assistantStyle, 13); diff --git a/src/main/java/gg/effront/anarcry/gui/views/DashboardView.java b/src/main/java/gg/effront/anarcry/gui/views/DashboardView.java index 151489e..355b342 100644 --- a/src/main/java/gg/effront/anarcry/gui/views/DashboardView.java +++ b/src/main/java/gg/effront/anarcry/gui/views/DashboardView.java @@ -39,8 +39,27 @@ */ public class DashboardView extends JPanel { - private static final Color BG_DARK = new Color(0x1E, 0x1E, 0x2E); - private static final Color FG_WHITE = Color.WHITE; + private static Color dashBg() { + return gg.effront.anarcry.gui.theme.AnarCryLaf.isDark() + ? new Color(0x1E, 0x1E, 0x2E) + : new Color(0xF0, 0xF0, 0xF0); + } + + private static Color dashFg() { + return gg.effront.anarcry.gui.theme.AnarCryLaf.fg(); + } + + private static Color dashGrid() { + return gg.effront.anarcry.gui.theme.AnarCryLaf.isDark() + ? new Color(0x44, 0x44, 0x55) + : new Color(0xC8, 0xC8, 0xC8); + } + + private static Color dashBorder() { + return gg.effront.anarcry.gui.theme.AnarCryLaf.isDark() + ? new Color(0x44, 0x44, 0x55) + : new Color(0xC0, 0xC0, 0xC0); + } private static final Color COLOR_CRITICAL = new Color(0xF8, 0x51, 0x49); private static final Color COLOR_HIGH = new Color(0xD2, 0x99, 0x22); @@ -53,6 +72,8 @@ public class DashboardView extends JPanel { private final JLabel highCount; private final JLabel mediumCount; private final JLabel lowCount; + private final JLabel infoCount; + private final JLabel summaryLabel; @SuppressWarnings("rawtypes") private final DefaultPieDataset pieDataset; @@ -63,7 +84,7 @@ public class DashboardView extends JPanel { @SuppressWarnings({"unchecked", "rawtypes"}) public DashboardView() { super(new BorderLayout(0, 8)); - setBackground(BG_DARK); + setBackground(dashBg()); setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12)); // -- Stat cards -- @@ -71,13 +92,15 @@ public DashboardView() { highCount = statValue("0"); mediumCount = statValue("0"); lowCount = statValue("0"); + infoCount = statValue("0"); - JPanel cards = new JPanel(new GridLayout(1, 4, 12, 0)); + JPanel cards = new JPanel(new GridLayout(1, 5, 12, 0)); cards.setOpaque(false); cards.add(statCard("CRITICAL", criticalCount, COLOR_CRITICAL)); cards.add(statCard("HIGH", highCount, COLOR_HIGH)); cards.add(statCard("MEDIUM", mediumCount, COLOR_MEDIUM)); cards.add(statCard("LOW", lowCount, COLOR_LOW)); + cards.add(statCard("INFO", infoCount, COLOR_INFO)); cards.setMaximumSize(new Dimension(Integer.MAX_VALUE, 90)); // -- Pie chart -- @@ -100,8 +123,38 @@ public DashboardView() { chartSplit.setDividerLocation(0.5); chartSplit.setOpaque(false); + summaryLabel = new JLabel("No findings yet."); + summaryLabel.setForeground(COLOR_INFO); + summaryLabel.setFont(summaryLabel.getFont().deriveFont(Font.PLAIN, 13f)); + JPanel summaryPanel = new JPanel(new BorderLayout()); + summaryPanel.setBackground(dashBg()); + summaryPanel + .setBorder(BorderFactory.createCompoundBorder(BorderFactory.createMatteBorder(1, 0, 0, 0, dashBorder()), + BorderFactory.createEmptyBorder(8, 4, 4, 4))); + summaryPanel.add(summaryLabel, BorderLayout.CENTER); + add(cards, BorderLayout.NORTH); add(chartSplit, BorderLayout.CENTER); + add(summaryPanel, BorderLayout.SOUTH); + + gg.effront.anarcry.gui.theme.AnarCryLaf.addThemeListener(() -> { + setBackground(dashBg()); + summaryPanel.setBackground(dashBg()); + summaryPanel.setBorder( + BorderFactory.createCompoundBorder(BorderFactory.createMatteBorder(1, 0, 0, 0, dashBorder()), + BorderFactory.createEmptyBorder(8, 4, 4, 4))); + for (java.awt.Component c : cards.getComponents()) { + c.setBackground(dashBg()); + } + criticalCount.setForeground(dashFg()); + highCount.setForeground(dashFg()); + mediumCount.setForeground(dashFg()); + lowCount.setForeground(dashFg()); + infoCount.setForeground(dashFg()); + stylePieChart(pieChart); + styleBarChart(barChart); + repaint(); + }); } /** @@ -128,10 +181,32 @@ private void rebuild(List findings) { moduleCounts.merge(f.module(), 1, Integer::sum); } - criticalCount.setText(String.valueOf(sevCounts.get(Severity.CRITICAL))); - highCount.setText(String.valueOf(sevCounts.get(Severity.HIGH))); - mediumCount.setText(String.valueOf(sevCounts.get(Severity.MEDIUM))); - lowCount.setText(String.valueOf(sevCounts.get(Severity.LOW))); + int crit = sevCounts.get(Severity.CRITICAL); + int high = sevCounts.get(Severity.HIGH); + int med = sevCounts.get(Severity.MEDIUM); + int low = sevCounts.get(Severity.LOW); + int info = sevCounts.get(Severity.INFO); + int total = findings.size(); + + criticalCount.setText(String.valueOf(crit)); + highCount.setText(String.valueOf(high)); + mediumCount.setText(String.valueOf(med)); + lowCount.setText(String.valueOf(low)); + infoCount.setText(String.valueOf(info)); + + if (total == 0) { + summaryLabel.setText("No findings yet."); + } else { + StringBuilder topModules = new StringBuilder(); + moduleCounts.entrySet().stream().sorted(Map.Entry.comparingByValue(Comparator.reverseOrder())).limit(5) + .forEach(e -> { + if (!topModules.isEmpty()) { + topModules.append(", "); + } + topModules.append(e.getKey()).append(" (").append(e.getValue()).append(")"); + }); + summaryLabel.setText("Total: " + total + " findings -- Top modules: " + topModules); + } pieDataset.clear(); for (Severity s : Severity.values()) { @@ -152,16 +227,16 @@ private void rebuild(List findings) { @SuppressWarnings({"unchecked", "rawtypes"}) private static void stylePieChart(JFreeChart chart) { - chart.setBackgroundPaint(BG_DARK); - chart.getTitle().setPaint(FG_WHITE); + chart.setBackgroundPaint(dashBg()); + chart.getTitle().setPaint(dashFg()); if (chart.getLegend() != null) { - chart.getLegend().setBackgroundPaint(BG_DARK); - chart.getLegend().setItemPaint(FG_WHITE); + chart.getLegend().setBackgroundPaint(dashBg()); + chart.getLegend().setItemPaint(dashFg()); } PiePlot plot = (PiePlot) chart.getPlot(); - plot.setBackgroundPaint(BG_DARK); + plot.setBackgroundPaint(dashBg()); plot.setOutlineVisible(false); - plot.setLabelPaint(FG_WHITE); + plot.setLabelPaint(dashFg()); plot.setLabelBackgroundPaint(null); plot.setLabelOutlinePaint(null); plot.setLabelShadowPaint(null); @@ -173,16 +248,16 @@ private static void stylePieChart(JFreeChart chart) { } private static void styleBarChart(JFreeChart chart) { - chart.setBackgroundPaint(BG_DARK); - chart.getTitle().setPaint(FG_WHITE); + chart.setBackgroundPaint(dashBg()); + chart.getTitle().setPaint(dashFg()); CategoryPlot plot = chart.getCategoryPlot(); - plot.setBackgroundPaint(BG_DARK); + plot.setBackgroundPaint(dashBg()); plot.setOutlineVisible(false); - plot.setRangeGridlinePaint(new Color(0x44, 0x44, 0x55)); - plot.getDomainAxis().setTickLabelPaint(FG_WHITE); - plot.getDomainAxis().setLabelPaint(FG_WHITE); - plot.getRangeAxis().setTickLabelPaint(FG_WHITE); - plot.getRangeAxis().setLabelPaint(FG_WHITE); + plot.setRangeGridlinePaint(dashGrid()); + plot.getDomainAxis().setTickLabelPaint(dashFg()); + plot.getDomainAxis().setLabelPaint(dashFg()); + plot.getRangeAxis().setTickLabelPaint(dashFg()); + plot.getRangeAxis().setLabelPaint(dashFg()); BarRenderer renderer = (BarRenderer) plot.getRenderer(); renderer.setSeriesPaint(0, COLOR_BAR); renderer.setBarPainter(new StandardBarPainter()); @@ -192,7 +267,7 @@ private static void styleBarChart(JFreeChart chart) { private static JPanel statCard(String title, JLabel valueLabel, Color accentColor) { JPanel card = new JPanel(); card.setLayout(new BoxLayout(card, BoxLayout.Y_AXIS)); - card.setBackground(BG_DARK); + card.setBackground(dashBg()); card.setBorder(BorderFactory.createCompoundBorder(BorderFactory.createLineBorder(accentColor, 2), BorderFactory.createEmptyBorder(8, 12, 8, 12))); @@ -202,7 +277,7 @@ private static JPanel statCard(String title, JLabel valueLabel, Color accentColo titleLabel.setAlignmentX(CENTER_ALIGNMENT); valueLabel.setAlignmentX(CENTER_ALIGNMENT); - valueLabel.setForeground(FG_WHITE); + valueLabel.setForeground(dashFg()); card.add(titleLabel); card.add(Box.createVerticalStrut(4)); @@ -213,7 +288,7 @@ private static JPanel statCard(String title, JLabel valueLabel, Color accentColo private static JLabel statValue(String text) { JLabel label = new JLabel(text, SwingConstants.CENTER); label.setFont(label.getFont().deriveFont(Font.BOLD, 28f)); - label.setForeground(FG_WHITE); + label.setForeground(dashFg()); return label; } } diff --git a/src/main/java/gg/effront/anarcry/gui/views/GlobalSearchDialog.java b/src/main/java/gg/effront/anarcry/gui/views/GlobalSearchDialog.java index 69cac5f..f28c852 100644 --- a/src/main/java/gg/effront/anarcry/gui/views/GlobalSearchDialog.java +++ b/src/main/java/gg/effront/anarcry/gui/views/GlobalSearchDialog.java @@ -44,14 +44,15 @@ public GlobalSearchDialog(JFrame parent) { setLocationRelativeTo(parent); JPanel root = new JPanel(new BorderLayout()); - root.setBorder(BorderFactory.createCompoundBorder(BorderFactory.createLineBorder(new Color(0x55, 0x55, 0x55)), + root.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(gg.effront.anarcry.gui.theme.AnarCryLaf.border()), BorderFactory.createEmptyBorder(8, 8, 8, 8))); searchField = new JTextField(); searchField.setFont(searchField.getFont().deriveFont(14f)); searchField.putClientProperty("JTextField.placeholderText", "Search findings, endpoints, requests..."); searchField.setBorder(BorderFactory.createCompoundBorder( - BorderFactory.createMatteBorder(0, 0, 1, 0, new Color(0x55, 0x55, 0x55)), + BorderFactory.createMatteBorder(0, 0, 1, 0, gg.effront.anarcry.gui.theme.AnarCryLaf.border()), BorderFactory.createEmptyBorder(6, 6, 6, 6))); searchField.getDocument().addDocumentListener(new DocumentListener() { @@ -80,16 +81,14 @@ public void keyPressed(KeyEvent e) { dispose(); } else if (e.getKeyCode() == KeyEvent.VK_DOWN) { int idx = resultList.getSelectedIndex(); - if (idx < listModel.size() - 1) { - resultList.setSelectedIndex(idx + 1); - resultList.ensureIndexIsVisible(idx + 1); - } + int next = (idx + 1) % Math.max(1, listModel.size()); + resultList.setSelectedIndex(next); + resultList.ensureIndexIsVisible(next); } else if (e.getKeyCode() == KeyEvent.VK_UP) { int idx = resultList.getSelectedIndex(); - if (idx > 0) { - resultList.setSelectedIndex(idx - 1); - resultList.ensureIndexIsVisible(idx - 1); - } + int prev = idx <= 0 ? listModel.size() - 1 : idx - 1; + resultList.setSelectedIndex(prev); + resultList.ensureIndexIsVisible(prev); } else if (e.getKeyCode() == KeyEvent.VK_ENTER) { selectCurrent(); } @@ -103,9 +102,7 @@ public void keyPressed(KeyEvent e) { @Override public void mouseClicked(MouseEvent e) { - if (e.getClickCount() == 2) { - selectCurrent(); - } + selectCurrent(); } }); @@ -113,6 +110,18 @@ public void mouseClicked(MouseEvent e) { root.add(new JScrollPane(resultList), BorderLayout.CENTER); setContentPane(root); + + addWindowFocusListener(new java.awt.event.WindowFocusListener() { + + @Override + public void windowGainedFocus(java.awt.event.WindowEvent e) { + } + + @Override + public void windowLostFocus(java.awt.event.WindowEvent e) { + dispose(); + } + }); } public void setFindingsSupplier(Supplier> supplier) { @@ -134,8 +143,22 @@ public void setOnSelect(Consumer consumer) { public void showSearch() { searchField.setText(""); listModel.clear(); + setLocationRelativeTo(getOwner()); setVisible(true); - SwingUtilities.invokeLater(searchField::requestFocusInWindow); + SwingUtilities.invokeLater(() -> { + searchField.requestFocusInWindow(); + populateDefaults(); + }); + } + + private void populateDefaults() { + listModel.clear(); + addApiRequests(""); + addFindings(""); + addSiteMapPaths(""); + if (!listModel.isEmpty()) { + resultList.setSelectedIndex(0); + } } private void search() { @@ -143,42 +166,53 @@ private void search() { listModel.clear(); if (query.isEmpty()) { + populateDefaults(); return; } + addApiRequests(query); + addFindings(query); + addSiteMapPaths(query); + + if (!listModel.isEmpty()) { + resultList.setSelectedIndex(0); + } + } + + private void addFindings(String query) { List findings = findingsSupplier.get(); for (Finding f : findings) { - String text = f.title() + " " + f.severity() + " " + f.module(); - if (text.toLowerCase().contains(query)) { - listModel.addElement(new SearchResult(Category.FINDING, f.severity() + " - " + f.title(), f.module())); - } if (listModel.size() >= 50) { break; } + String text = (f.title() + " " + f.severity() + " " + f.module() + " " + f.endpoint()).toLowerCase(); + if (query.isEmpty() || text.contains(query)) { + listModel.addElement(new SearchResult(Category.FINDING, f.severity() + " - " + f.title(), f.module())); + } } + } + private void addSiteMapPaths(String query) { List paths = siteMapSupplier.get(); for (String path : paths) { - if (path.toLowerCase().contains(query)) { - listModel.addElement(new SearchResult(Category.SITEMAP, path, "SiteMap")); - } if (listModel.size() >= 50) { break; } + if (query.isEmpty() || path.toLowerCase().contains(query)) { + listModel.addElement(new SearchResult(Category.SITEMAP, path, "SiteMap")); + } } + } + private void addApiRequests(String query) { List requests = apiRequestsSupplier.get(); for (String req : requests) { - if (req.toLowerCase().contains(query)) { - listModel.addElement(new SearchResult(Category.API_REQUEST, req, "API Client")); - } if (listModel.size() >= 50) { break; } - } - - if (!listModel.isEmpty()) { - resultList.setSelectedIndex(0); + if (query.isEmpty() || req.toLowerCase().contains(query)) { + listModel.addElement(new SearchResult(Category.API_REQUEST, req, "API Client")); + } } } @@ -186,8 +220,8 @@ private void selectCurrent() { SearchResult selected = resultList.getSelectedValue(); if (selected != null && onSelect != null) { onSelect.accept(selected); + dispose(); } - dispose(); } public enum Category { diff --git a/src/main/java/gg/effront/anarcry/gui/views/PreferencesDialog.java b/src/main/java/gg/effront/anarcry/gui/views/PreferencesDialog.java index e6053e7..f42ef14 100644 --- a/src/main/java/gg/effront/anarcry/gui/views/PreferencesDialog.java +++ b/src/main/java/gg/effront/anarcry/gui/views/PreferencesDialog.java @@ -1,6 +1,7 @@ package gg.effront.anarcry.gui.views; import gg.effront.anarcry.Version; +import gg.effront.anarcry.gui.AppSettings; import gg.effront.anarcry.gui.theme.AnarCryLaf; import java.awt.BorderLayout; @@ -35,12 +36,16 @@ public class PreferencesDialog extends JDialog { - private static final String[] CATEGORIES = {"General", "Themes", "Display", "Proxy", "Certificates", "License", - "Notifications", "Keybindings", "Support", "About"}; + public record CertEntry(boolean enabled, String domain, String type, String certFile, String keyFile, + String passphrase) { + } + + private static final String[] CATEGORIES = {"General", "Themes", "Display", "Proxy", "Certificates", + "Notifications", "Keybindings", "License", "Support", "About"}; private static final Color[] CATEGORY_COLORS = {new Color(0x8B, 0x94, 0x9E), new Color(0xA8, 0x5C, 0xF0), new Color(0x58, 0xA6, 0xFF), new Color(0x3F, 0xB9, 0x50), new Color(0xD2, 0x99, 0x22), - new Color(0xF8, 0x51, 0x49), new Color(0xE3, 0xB3, 0x41), new Color(0x6C, 0x9E, 0xD6), + new Color(0xE3, 0xB3, 0x41), new Color(0x6C, 0x9E, 0xD6), new Color(0xF8, 0x51, 0x49), new Color(0xCC, 0x66, 0x99), new Color(0x8B, 0x94, 0x9E)}; private JList categoryList; @@ -56,11 +61,15 @@ public class PreferencesDialog extends JDialog { private final JTextField collectionsDir = new JTextField(System.getProperty("user.home")); private final JCheckBox autoSave = new JCheckBox("Enable Auto Save"); + private static int currentFontSize = 13; + private final JComboBox themeCombo = new JComboBox<>(new String[]{"Dark", "Light"}); - private final JSpinner fontSizeSpinner = new JSpinner(new SpinnerNumberModel(13, 10, 24, 1)); + private final JSpinner fontSizeSpinner = new JSpinner(new SpinnerNumberModel(currentFontSize, 10, 24, 1)); + private final JComboBox resolutionCombo = new JComboBox<>( + new String[]{"1920x1080", "1280x720", "1400x950", "1600x1000", "Maximized"}); private final JCheckBox proxyEnabled = new JCheckBox("Enable Proxy"); - private final JComboBox proxyProtocol = new JComboBox<>(new String[]{"HTTP", "HTTPS", "SOCKS5"}); + private final JComboBox proxyProtocol = new JComboBox<>(new String[]{"http", "https", "socks5"}); private final JTextField proxyHost = new JTextField(); private final JSpinner proxyPort = new JSpinner(new SpinnerNumberModel(8080, 1, 65535, 1)); private final JTextField proxyUser = new JTextField(); @@ -68,10 +77,30 @@ public class PreferencesDialog extends JDialog { private final JCheckBox keepDefaultCa = new JCheckBox("Keep Default CA Certificates", true); private final DefaultTableModel certTableModel = new DefaultTableModel( - new String[]{"Domain", "Cert File", "Key File"}, 0); + new String[]{"", "Domain", "Type", "Cert/PFX File", "Key File", "Passphrase"}, 0) { + + @Override + public Class getColumnClass(int column) { + return column == 0 ? Boolean.class : String.class; + } + + @Override + public boolean isCellEditable(int row, int column) { + if (column == 4) { + Object type = getValueAt(row, 2); + return !"PKCS12".equals(type); + } + return true; + } + }; + private final JTextField caCertPath = new JTextField(); + + private final JCheckBox notifyScanComplete = new JCheckBox("Notify on scan complete", true); + private final JCheckBox notifyCriticalFinding = new JCheckBox("Notify on critical finding", true); private int parallelism = 4; private int timeoutMinutes = 10; + private Runnable onApply; public PreferencesDialog(JFrame parent) { this(parent, "General"); @@ -79,11 +108,11 @@ public PreferencesDialog(JFrame parent) { public PreferencesDialog(JFrame parent, String initialCategory) { super(parent, "Preferences", true); - setSize(750, 540); + setSize(900, 680); setLocationRelativeTo(parent); setLayout(new BorderLayout()); - themeCombo.setSelectedItem(AnarCryLaf.isDark() ? "Dark" : "Light"); + loadFromSettings(); cards = new CardLayout(); cardPanel = new JPanel(cards); @@ -94,9 +123,9 @@ public PreferencesDialog(JFrame parent, String initialCategory) { cardPanel.add(buildDisplayPanel(), "Display"); cardPanel.add(buildProxyPanel(), "Proxy"); cardPanel.add(buildCertificatesPanel(), "Certificates"); - cardPanel.add(buildLicensePanel(), "License"); cardPanel.add(buildNotificationsPanel(), "Notifications"); cardPanel.add(buildKeybindingsPanel(), "Keybindings"); + cardPanel.add(buildLicensePanel(), "License"); cardPanel.add(buildSupportPanel(), "Support"); cardPanel.add(buildAboutPanel(), "About"); @@ -132,10 +161,23 @@ public Component getListCellRendererComponent(JList list, Object value, int i }); JScrollPane listScroll = new JScrollPane(categoryList); - listScroll.setPreferredSize(new Dimension(190, 0)); - listScroll.setBorder(BorderFactory.createMatteBorder(0, 0, 0, 1, getBackground().darker())); - - add(listScroll, BorderLayout.WEST); + listScroll.setBorder(BorderFactory.createEmptyBorder()); + + JButton checkUpdatesBtn = new JButton("Check for Updates"); + checkUpdatesBtn.addActionListener(e -> doCheckForUpdates()); + JPanel updatePanel = new JPanel(new BorderLayout()); + updatePanel.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createMatteBorder(1, 0, 0, 0, getBackground().darker()), + BorderFactory.createEmptyBorder(8, 12, 8, 12))); + updatePanel.add(checkUpdatesBtn, BorderLayout.CENTER); + + JPanel sidebar = new JPanel(new BorderLayout()); + sidebar.setPreferredSize(new Dimension(190, 0)); + sidebar.setBorder(BorderFactory.createMatteBorder(0, 0, 0, 1, getBackground().darker())); + sidebar.add(listScroll, BorderLayout.CENTER); + sidebar.add(updatePanel, BorderLayout.SOUTH); + + add(sidebar, BorderLayout.WEST); add(cardPanel, BorderLayout.CENTER); if (initialIdx > 0) { @@ -254,6 +296,39 @@ private JPanel buildDisplayPanel() { gbc.weightx = 1.0; p.add(fontSizeSpinner, gbc); + gbc.gridx = 0; + gbc.gridy++; + gbc.fill = GridBagConstraints.NONE; + gbc.weightx = 0; + p.add(new JLabel("Window resolution:"), gbc); + gbc.gridx = 1; + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.weightx = 1.0; + java.awt.Window owner = getOwner(); + if (owner != null) { + int w = owner.getWidth(); + int h = owner.getHeight(); + boolean maximized = owner instanceof JFrame + && (((JFrame) owner).getExtendedState() & JFrame.MAXIMIZED_BOTH) != 0; + if (maximized) { + resolutionCombo.setSelectedItem("Maximized"); + } else { + String current = w + "x" + h; + boolean found = false; + for (int i = 0; i < resolutionCombo.getItemCount(); i++) { + if (resolutionCombo.getItemAt(i).equals(current)) { + found = true; + break; + } + } + if (!found) { + resolutionCombo.insertItemAt(current, 0); + } + resolutionCombo.setSelectedItem(current); + } + } + p.add(resolutionCombo, gbc); + addFiller(p, gbc); return p; } @@ -334,17 +409,31 @@ private JPanel buildCertificatesPanel() { p.setBorder(BorderFactory.createEmptyBorder(4, 0, 0, 0)); JLabel title = new JLabel("CLIENT CERTIFICATES"); + title.setFont(title.getFont().deriveFont(11f)); + title.setForeground(title.getForeground().darker()); title.setBorder(BorderFactory.createEmptyBorder(0, 0, 8, 0)); p.add(title, BorderLayout.NORTH); JTable table = new JTable(certTableModel); table.setFillsViewportHeight(true); + table.setRowHeight(24); + table.getColumnModel().getColumn(0).setMaxWidth(40); + table.getColumnModel().getColumn(0).setMinWidth(40); + table.getColumnModel().getColumn(1).setPreferredWidth(120); + table.getColumnModel().getColumn(2).setPreferredWidth(70); + table.getColumnModel().getColumn(2).setMaxWidth(80); + table.getColumnModel().getColumn(3).setPreferredWidth(150); + table.getColumnModel().getColumn(4).setPreferredWidth(150); + table.getColumnModel().getColumn(5).setPreferredWidth(100); + table.getColumnModel().getColumn(2) + .setCellEditor(new javax.swing.DefaultCellEditor(new JComboBox<>(new String[]{"PEM", "PKCS12"}))); p.add(new JScrollPane(table), BorderLayout.CENTER); - JPanel bottom = new JPanel(new BorderLayout(0, 6)); + JPanel bottom = new JPanel(new BorderLayout(0, 8)); + JPanel btns = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 0)); JButton addBtn = new JButton("Add"); - addBtn.addActionListener(e -> certTableModel.addRow(new String[]{"", "", ""})); + addBtn.addActionListener(e -> certTableModel.addRow(new Object[]{Boolean.TRUE, "", "PEM", "", "", ""})); btns.add(addBtn); JButton removeBtn = new JButton("Remove"); removeBtn.addActionListener(e -> { @@ -354,13 +443,53 @@ private JPanel buildCertificatesPanel() { } }); btns.add(removeBtn); + JButton browseCert = new JButton("Browse Cert/PFX..."); + browseCert.addActionListener(e -> browseFileToColumn(table, 3, "pem", "crt", "p12", "pfx")); + btns.add(browseCert); + JButton browseKey = new JButton("Browse Key..."); + browseKey.addActionListener(e -> browseFileToColumn(table, 4, "pem", "key")); + btns.add(browseKey); bottom.add(btns, BorderLayout.NORTH); - bottom.add(keepDefaultCa, BorderLayout.SOUTH); + + JPanel caPanel = new JPanel(new BorderLayout(0, 4)); + JLabel caTitle = new JLabel("CUSTOM CA CERTIFICATE"); + caTitle.setFont(caTitle.getFont().deriveFont(11f)); + caTitle.setForeground(caTitle.getForeground().darker()); + caPanel.add(caTitle, BorderLayout.NORTH); + JPanel caRow = new JPanel(new BorderLayout(4, 0)); + caRow.add(caCertPath, BorderLayout.CENTER); + JButton browseCa = new JButton("Browse"); + browseCa.addActionListener(e -> { + JFileChooser fc = new JFileChooser(); + fc.setFileFilter(new javax.swing.filechooser.FileNameExtensionFilter( + "CA Certificates (*.pem, *.crt, *.der)", "pem", "crt", "der")); + if (fc.showOpenDialog(this) == JFileChooser.APPROVE_OPTION) { + caCertPath.setText(fc.getSelectedFile().getAbsolutePath()); + } + }); + caRow.add(browseCa, BorderLayout.EAST); + caPanel.add(caRow, BorderLayout.CENTER); + caPanel.add(keepDefaultCa, BorderLayout.SOUTH); + + bottom.add(caPanel, BorderLayout.SOUTH); p.add(bottom, BorderLayout.SOUTH); return p; } + private void browseFileToColumn(JTable table, int column, String... extensions) { + int row = table.getSelectedRow(); + if (row < 0) { + return; + } + JFileChooser fc = new JFileChooser(); + fc.setFileFilter( + new javax.swing.filechooser.FileNameExtensionFilter(String.join(", ", extensions), extensions)); + if (fc.showOpenDialog(this) == JFileChooser.APPROVE_OPTION) { + certTableModel.setValueAt(fc.getSelectedFile().getAbsolutePath(), row, column); + } + } + private JPanel buildKeybindingsPanel() { JPanel p = new JPanel(new BorderLayout(0, 8)); p.setBorder(BorderFactory.createEmptyBorder(4, 0, 0, 0)); @@ -399,8 +528,16 @@ private JPanel buildAboutPanel() { gbc.gridx = 0; gbc.gridy = 0; gbc.anchor = GridBagConstraints.CENTER; - gbc.insets = new Insets(12, 0, 6, 0); + gbc.insets = new Insets(8, 0, 4, 0); + java.net.URL logoUrl = getClass().getResource("/icons/logo-about.png"); + if (logoUrl != null) { + JLabel logoLabel = new JLabel(new javax.swing.ImageIcon(logoUrl)); + p.add(logoLabel, gbc); + gbc.gridy++; + } + + gbc.insets = new Insets(8, 0, 4, 0); JLabel version = new JLabel(Version.label()); version.setFont(version.getFont().deriveFont(18f)); p.add(version, gbc); @@ -423,15 +560,16 @@ private JPanel buildLicensePanel() { gbc.gridx = 0; gbc.gridy = 0; gbc.anchor = GridBagConstraints.CENTER; - gbc.insets = new Insets(12, 0, 6, 0); + gbc.insets = new Insets(8, 0, 4, 0); - JLabel title = new JLabel("LICENSE"); - title.setFont(title.getFont().deriveFont(11f)); - title.setForeground(title.getForeground().darker()); - p.add(title, gbc); + java.net.URL artUrl = getClass().getResource("/icons/license-art.png"); + if (artUrl != null) { + JLabel artLabel = new JLabel(new javax.swing.ImageIcon(artUrl)); + p.add(artLabel, gbc); + gbc.gridy++; + } - gbc.gridy++; - gbc.insets = new Insets(16, 0, 4, 0); + gbc.insets = new Insets(12, 0, 4, 0); p.add(new JLabel("AnarCry - Proprietary License"), gbc); gbc.gridy++; @@ -441,7 +579,7 @@ private JPanel buildLicensePanel() { p.add(notice, gbc); gbc.gridy++; - gbc.insets = new Insets(16, 0, 4, 0); + gbc.insets = new Insets(12, 0, 4, 0); p.add(new JLabel("Effront - All rights reserved"), gbc); return p; @@ -455,16 +593,10 @@ private JPanel buildNotificationsPanel() { gbc.gridy++; gbc.gridwidth = 2; - JCheckBox scanComplete = new JCheckBox("Notify on scan complete", true); - p.add(scanComplete, gbc); - - gbc.gridy++; - JCheckBox criticalFinding = new JCheckBox("Notify on critical finding", true); - p.add(criticalFinding, gbc); + p.add(notifyScanComplete, gbc); gbc.gridy++; - JCheckBox updateAvailable = new JCheckBox("Check for updates"); - p.add(updateAvailable, gbc); + p.add(notifyCriticalFinding, gbc); addFiller(p, gbc); return p; @@ -476,8 +608,16 @@ private JPanel buildSupportPanel() { gbc.gridx = 0; gbc.gridy = 0; gbc.anchor = GridBagConstraints.CENTER; - gbc.insets = new Insets(12, 0, 6, 0); + gbc.insets = new Insets(8, 0, 6, 0); + + java.net.URL supportUrl = getClass().getResource("/icons/support-art.png"); + if (supportUrl != null) { + JLabel artLabel = new JLabel(new javax.swing.ImageIcon(supportUrl)); + p.add(artLabel, gbc); + gbc.gridy++; + } + gbc.insets = new Insets(12, 0, 6, 0); JLabel title = new JLabel("SUPPORT"); title.setFont(title.getFont().deriveFont(11f)); title.setForeground(title.getForeground().darker()); @@ -489,14 +629,12 @@ private JPanel buildSupportPanel() { gbc.gridy++; gbc.insets = new Insets(12, 0, 4, 0); - JLabel docs = new JLabel("Documentation: docs.effront.gg/anarcry"); - docs.setForeground(new Color(0x58, 0xA6, 0xFF)); + JLabel docs = hyperlink("Documentation: docs.effront.gg/anarcry", "https://docs.effront.gg/anarcry"); p.add(docs, gbc); gbc.gridy++; gbc.insets = new Insets(4, 0, 4, 0); - JLabel repo = new JLabel("Repository: github.com/effront/anarcry"); - repo.setForeground(new Color(0x58, 0xA6, 0xFF)); + JLabel repo = hyperlink("Repository: github.com/effront/anarcry", "https://github.com/effront/anarcry"); p.add(repo, gbc); return p; @@ -566,7 +704,36 @@ public boolean isKeepDefaultCa() { return keepDefaultCa.isSelected(); } + public String getCustomCaCertPath() { + return caCertPath.getText().trim(); + } + + public java.util.List getCertEntries() { + java.util.List entries = new java.util.ArrayList<>(); + for (int i = 0; i < certTableModel.getRowCount(); i++) { + boolean enabled = Boolean.TRUE.equals(certTableModel.getValueAt(i, 0)); + String domain = stringVal(certTableModel.getValueAt(i, 1)); + String type = stringVal(certTableModel.getValueAt(i, 2)); + String certFile = stringVal(certTableModel.getValueAt(i, 3)); + String keyFile = stringVal(certTableModel.getValueAt(i, 4)); + String passphrase = stringVal(certTableModel.getValueAt(i, 5)); + if (!domain.isEmpty() && !certFile.isEmpty()) { + entries.add(new CertEntry(enabled, domain, type, certFile, keyFile, passphrase)); + } + } + return entries; + } + + private static String stringVal(Object obj) { + return obj == null ? "" : obj.toString().trim(); + } + + public void setOnApply(Runnable callback) { + this.onApply = callback; + } + private void applySettings() { + saveToSettings(); parallelism = (Integer) parallelismSpinner.getValue(); timeoutMinutes = (Integer) scanTimeoutSpinner.getValue(); @@ -577,13 +744,107 @@ private void applySettings() { } else { AnarCryLaf.installLight(); } - SwingUtilities.updateComponentTreeUI(getOwner()); - SwingUtilities.updateComponentTreeUI(this); } + String resolution = (String) resolutionCombo.getSelectedItem(); + java.awt.Window owner = getOwner(); + if (owner instanceof JFrame ownerFrame) { + if ("Maximized".equals(resolution)) { + ownerFrame.setExtendedState(ownerFrame.getExtendedState() | JFrame.MAXIMIZED_BOTH); + } else if (resolution != null && resolution.contains("x")) { + ownerFrame.setExtendedState(JFrame.NORMAL); + String[] parts = resolution.split("x"); + int w = Integer.parseInt(parts[0].trim()); + int h = Integer.parseInt(parts[1].trim()); + ownerFrame.setSize(w, h); + ownerFrame.setLocationRelativeTo(null); + } + } + + SwingUtilities.updateComponentTreeUI(getOwner()); + + currentFontSize = (Integer) fontSizeSpinner.getValue(); + applyGlobalFontSize(currentFontSize); + SwingUtilities.updateComponentTreeUI(getOwner()); + gg.effront.anarcry.gui.theme.AnarCryLaf.fireThemeChanged(owner); + + if (onApply != null) { + onApply.run(); + } dispose(); } + private void loadFromSettings() { + AppSettings s = AppSettings.get(); + sslVerification.setSelected(s.sslVerification); + storeCookies.setSelected(s.storeCookies); + sendCookies.setSelected(s.sendCookies); + requestTimeoutSpinner.setValue(s.requestTimeoutMs); + parallelismSpinner.setValue(s.parallelism); + scanTimeoutSpinner.setValue(s.scanTimeoutMinutes); + collectionsDir.setText(s.collectionsDir); + autoSave.setSelected(s.autoSave); + themeCombo.setSelectedItem(s.theme); + fontSizeSpinner.setValue(s.fontSize); + resolutionCombo.setSelectedItem(s.resolution); + proxyEnabled.setSelected(s.proxyEnabled); + proxyProtocol.setSelectedItem(s.proxyProtocol); + proxyHost.setText(s.proxyHost); + proxyPort.setValue(s.proxyPort); + proxyUser.setText(s.proxyUser); + proxyPass.setText(s.proxyPass); + keepDefaultCa.setSelected(s.keepDefaultCa); + caCertPath.setText(s.caCertPath); + notifyScanComplete.setSelected(s.notifyScanComplete); + notifyCriticalFinding.setSelected(s.notifyCriticalFinding); + parallelism = s.parallelism; + timeoutMinutes = s.scanTimeoutMinutes; + + certTableModel.setRowCount(0); + for (AppSettings.CertData cd : s.clientCerts) { + certTableModel.addRow(new Object[]{cd.enabled, cd.domain, cd.type, cd.certFile, cd.keyFile, cd.passphrase}); + } + } + + private void saveToSettings() { + AppSettings s = AppSettings.get(); + s.sslVerification = sslVerification.isSelected(); + s.storeCookies = storeCookies.isSelected(); + s.sendCookies = sendCookies.isSelected(); + s.requestTimeoutMs = (Integer) requestTimeoutSpinner.getValue(); + s.parallelism = (Integer) parallelismSpinner.getValue(); + s.scanTimeoutMinutes = (Integer) scanTimeoutSpinner.getValue(); + s.collectionsDir = collectionsDir.getText(); + s.autoSave = autoSave.isSelected(); + s.theme = (String) themeCombo.getSelectedItem(); + s.fontSize = (Integer) fontSizeSpinner.getValue(); + s.resolution = (String) resolutionCombo.getSelectedItem(); + s.proxyEnabled = proxyEnabled.isSelected(); + s.proxyProtocol = (String) proxyProtocol.getSelectedItem(); + s.proxyHost = proxyHost.getText(); + s.proxyPort = (Integer) proxyPort.getValue(); + s.proxyUser = proxyUser.getText(); + s.proxyPass = new String(proxyPass.getPassword()); + s.keepDefaultCa = keepDefaultCa.isSelected(); + s.caCertPath = caCertPath.getText().trim(); + s.notifyScanComplete = notifyScanComplete.isSelected(); + s.notifyCriticalFinding = notifyCriticalFinding.isSelected(); + + s.clientCerts.clear(); + for (int i = 0; i < certTableModel.getRowCount(); i++) { + AppSettings.CertData cd = new AppSettings.CertData(); + cd.enabled = Boolean.TRUE.equals(certTableModel.getValueAt(i, 0)); + cd.domain = stringVal(certTableModel.getValueAt(i, 1)); + cd.type = stringVal(certTableModel.getValueAt(i, 2)); + cd.certFile = stringVal(certTableModel.getValueAt(i, 3)); + cd.keyFile = stringVal(certTableModel.getValueAt(i, 4)); + cd.passphrase = stringVal(certTableModel.getValueAt(i, 5)); + s.clientCerts.add(cd); + } + + s.save(); + } + private void browseCollectionsDir() { JFileChooser chooser = new JFileChooser(collectionsDir.getText()); chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); @@ -592,6 +853,144 @@ private void browseCollectionsDir() { } } + private void doCheckForUpdates() { + JButton src = findCheckButton(); + if (src != null) { + src.setEnabled(false); + src.setText("Checking..."); + } + new Thread(() -> { + try { + java.net.http.HttpClient client = java.net.http.HttpClient.newBuilder() + .connectTimeout(java.time.Duration.ofSeconds(10)).build(); + java.net.http.HttpRequest req = java.net.http.HttpRequest.newBuilder() + .uri(java.net.URI.create("https://api.github.com/repos/effront/anarcry/releases/latest")) + .header("Accept", "application/vnd.github.v3+json") + .header("User-Agent", "AnarCry/" + Version.get()).GET().build(); + java.net.http.HttpResponse resp = client.send(req, + java.net.http.HttpResponse.BodyHandlers.ofString()); + String body = resp.body(); + String latest = extractJsonField(body, "tag_name").replaceFirst("^v", ""); + String url = extractJsonField(body, "html_url"); + if (url.isEmpty()) { + url = "https://github.com/effront/anarcry/releases"; + } + String current = Version.get(); + boolean hasUpdate = !latest.isEmpty() && !latest.equals(current); + String releaseUrl = url; + SwingUtilities.invokeLater(() -> { + if (src != null) { + src.setEnabled(true); + src.setText("Check for Updates"); + } + if (hasUpdate) { + int choice = javax.swing.JOptionPane.showOptionDialog(this, + "Update available: v" + latest + " (current: v" + current + ")\n" + + "Open release page?", + "Update Available", javax.swing.JOptionPane.YES_NO_OPTION, + javax.swing.JOptionPane.INFORMATION_MESSAGE, null, new String[]{"Open", "Later"}, + "Open"); + if (choice == 0) { + try { + java.awt.Desktop.getDesktop().browse(java.net.URI.create(releaseUrl)); + } catch (Exception ignored) { + } + } + } else if (latest.isEmpty()) { + javax.swing.JOptionPane.showMessageDialog(this, "Could not determine latest version.", + "Update Check", javax.swing.JOptionPane.WARNING_MESSAGE); + } else { + javax.swing.JOptionPane.showMessageDialog(this, + "You are running the latest version (v" + current + ").", "Up to Date", + javax.swing.JOptionPane.INFORMATION_MESSAGE); + } + }); + } catch (Exception ex) { + SwingUtilities.invokeLater(() -> { + if (src != null) { + src.setEnabled(true); + src.setText("Check for Updates"); + } + javax.swing.JOptionPane.showMessageDialog(this, "Failed to check for updates:\n" + ex.getMessage(), + "Update Error", javax.swing.JOptionPane.ERROR_MESSAGE); + }); + } + }, "update-check").start(); + } + + private JButton findCheckButton() { + java.awt.Container parent = getContentPane(); + return findButtonRecursive(parent, "Checking...", "Check for Updates"); + } + + private static JButton findButtonRecursive(java.awt.Container c, String... texts) { + for (java.awt.Component comp : c.getComponents()) { + if (comp instanceof JButton btn) { + for (String t : texts) { + if (t.equals(btn.getText())) { + return btn; + } + } + } + if (comp instanceof java.awt.Container sub) { + JButton found = findButtonRecursive(sub, texts); + if (found != null) { + return found; + } + } + } + return null; + } + + private static String extractJsonField(String json, String field) { + String key = "\"" + field + "\""; + int idx = json.indexOf(key); + if (idx < 0) { + return ""; + } + int colon = json.indexOf(':', idx + key.length()); + if (colon < 0) { + return ""; + } + int start = json.indexOf('"', colon + 1); + if (start < 0) { + return ""; + } + int end = json.indexOf('"', start + 1); + if (end < 0) { + return ""; + } + return json.substring(start + 1, end); + } + + private static void applyGlobalFontSize(int size) { + java.util.Enumeration keys = javax.swing.UIManager.getDefaults().keys(); + while (keys.hasMoreElements()) { + Object key = keys.nextElement(); + Object val = javax.swing.UIManager.get(key); + if (val instanceof javax.swing.plaf.FontUIResource orig) { + javax.swing.UIManager.put(key, new javax.swing.plaf.FontUIResource(orig.deriveFont((float) size))); + } + } + } + + private static JLabel hyperlink(String text, String url) { + JLabel label = new JLabel("" + text + ""); + label.setForeground(new Color(0x58, 0xA6, 0xFF)); + label.setCursor(java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.HAND_CURSOR)); + label.addMouseListener(new java.awt.event.MouseAdapter() { + + @Override + public void mouseClicked(java.awt.event.MouseEvent e) { + try { + java.awt.Desktop.getDesktop().browse(java.net.URI.create(url)); + } catch (Exception ignored) { + } + } + }); + return label; + } + private static GridBagConstraints gbc() { GridBagConstraints gbc = new GridBagConstraints(); gbc.gridx = 0; diff --git a/src/main/java/gg/effront/anarcry/gui/views/RepeaterView.java b/src/main/java/gg/effront/anarcry/gui/views/RepeaterView.java index 5f57089..f661516 100644 --- a/src/main/java/gg/effront/anarcry/gui/views/RepeaterView.java +++ b/src/main/java/gg/effront/anarcry/gui/views/RepeaterView.java @@ -59,6 +59,13 @@ public RepeaterView() { JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, buildRequestPanel(), buildResponsePanel()); split.setResizeWeight(0.5); add(split, BorderLayout.CENTER); + + gg.effront.anarcry.gui.theme.AnarCryLaf.addThemeListener(this::onThemeChanged); + } + + private void onThemeChanged() { + applySyntaxTheme(requestBody); + applySyntaxTheme(responseBody); } public void setTargetUrl(String url) { @@ -257,19 +264,29 @@ private RSyntaxTextArea createSyntaxArea(boolean readOnly) { area.setCodeFoldingEnabled(true); area.setAntiAliasingEnabled(true); area.setTabSize(2); - applyDarkTheme(area); + applySyntaxTheme(area); return area; } - private void applyDarkTheme(RSyntaxTextArea area) { + private void applySyntaxTheme(RSyntaxTextArea area) { + boolean dark = gg.effront.anarcry.gui.theme.AnarCryLaf.isDark(); + String themePath = dark + ? "/org/fife/ui/rsyntaxtextarea/themes/dark.xml" + : "/org/fife/ui/rsyntaxtextarea/themes/default.xml"; try { - Theme theme = Theme.load(getClass().getResourceAsStream("/org/fife/ui/rsyntaxtextarea/themes/dark.xml")); + Theme theme = Theme.load(getClass().getResourceAsStream(themePath)); theme.apply(area); } catch (Exception e) { - area.setBackground(DARK_BG); - area.setForeground(Color.WHITE); - area.setCaretColor(Color.WHITE); - area.setCurrentLineHighlightColor(new Color(0x2A, 0x2A, 0x3E)); + if (dark) { + area.setBackground(DARK_BG); + area.setForeground(Color.WHITE); + area.setCaretColor(Color.WHITE); + area.setCurrentLineHighlightColor(new Color(0x2A, 0x2A, 0x3E)); + } else { + area.setBackground(Color.WHITE); + area.setForeground(new Color(0x1A, 0x1A, 0x1A)); + area.setCaretColor(new Color(0x1A, 0x1A, 0x1A)); + } } } diff --git a/src/main/resources/icons/app-icon-128.png b/src/main/resources/icons/app-icon-128.png new file mode 100644 index 0000000..52f8f61 Binary files /dev/null and b/src/main/resources/icons/app-icon-128.png differ diff --git a/src/main/resources/icons/app-icon-256.png b/src/main/resources/icons/app-icon-256.png new file mode 100644 index 0000000..4d7e5f3 Binary files /dev/null and b/src/main/resources/icons/app-icon-256.png differ diff --git a/src/main/resources/icons/app-icon-64.png b/src/main/resources/icons/app-icon-64.png new file mode 100644 index 0000000..3b97ae0 Binary files /dev/null and b/src/main/resources/icons/app-icon-64.png differ diff --git a/src/main/resources/icons/arrows-right-left.png b/src/main/resources/icons/arrows-right-left.png new file mode 100644 index 0000000..9cc71d0 Binary files /dev/null and b/src/main/resources/icons/arrows-right-left.png differ diff --git a/src/main/resources/icons/arrows-right-left.svg b/src/main/resources/icons/arrows-right-left.svg new file mode 100644 index 0000000..3b431b7 --- /dev/null +++ b/src/main/resources/icons/arrows-right-left.svg @@ -0,0 +1,22 @@ + + + + + + + diff --git a/src/main/resources/icons/brand-github.png b/src/main/resources/icons/brand-github.png new file mode 100644 index 0000000..7ff183a Binary files /dev/null and b/src/main/resources/icons/brand-github.png differ diff --git a/src/main/resources/icons/brand-github.svg b/src/main/resources/icons/brand-github.svg new file mode 100644 index 0000000..2084b5c --- /dev/null +++ b/src/main/resources/icons/brand-github.svg @@ -0,0 +1,19 @@ + + + + diff --git a/src/main/resources/icons/cookie.png b/src/main/resources/icons/cookie.png new file mode 100644 index 0000000..d4555f4 Binary files /dev/null and b/src/main/resources/icons/cookie.png differ diff --git a/src/main/resources/icons/cookie.svg b/src/main/resources/icons/cookie.svg new file mode 100644 index 0000000..e36f212 --- /dev/null +++ b/src/main/resources/icons/cookie.svg @@ -0,0 +1,24 @@ + + + + + + + + + diff --git a/src/main/resources/icons/keyboard.png b/src/main/resources/icons/keyboard.png new file mode 100644 index 0000000..d93c586 Binary files /dev/null and b/src/main/resources/icons/keyboard.png differ diff --git a/src/main/resources/icons/keyboard.svg b/src/main/resources/icons/keyboard.svg new file mode 100644 index 0000000..243127b --- /dev/null +++ b/src/main/resources/icons/keyboard.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + diff --git a/src/main/resources/icons/license-art.png b/src/main/resources/icons/license-art.png new file mode 100644 index 0000000..dc5f1f2 Binary files /dev/null and b/src/main/resources/icons/license-art.png differ diff --git a/src/main/resources/icons/license.png b/src/main/resources/icons/license.png new file mode 100644 index 0000000..b06f411 Binary files /dev/null and b/src/main/resources/icons/license.png differ diff --git a/src/main/resources/icons/license.svg b/src/main/resources/icons/license.svg new file mode 100644 index 0000000..7633a82 --- /dev/null +++ b/src/main/resources/icons/license.svg @@ -0,0 +1,21 @@ + + + + + + diff --git a/src/main/resources/icons/logo-about.png b/src/main/resources/icons/logo-about.png new file mode 100644 index 0000000..64a4040 Binary files /dev/null and b/src/main/resources/icons/logo-about.png differ diff --git a/src/main/resources/icons/palette.png b/src/main/resources/icons/palette.png new file mode 100644 index 0000000..98f0a4a Binary files /dev/null and b/src/main/resources/icons/palette.png differ diff --git a/src/main/resources/icons/palette.svg b/src/main/resources/icons/palette.svg new file mode 100644 index 0000000..a107014 --- /dev/null +++ b/src/main/resources/icons/palette.svg @@ -0,0 +1,22 @@ + + + + + + + diff --git a/src/main/resources/icons/search.png b/src/main/resources/icons/search.png new file mode 100644 index 0000000..272a77f Binary files /dev/null and b/src/main/resources/icons/search.png differ diff --git a/src/main/resources/icons/search.svg b/src/main/resources/icons/search.svg new file mode 100644 index 0000000..e894f96 --- /dev/null +++ b/src/main/resources/icons/search.svg @@ -0,0 +1,20 @@ + + + + + diff --git a/src/main/resources/icons/settings.png b/src/main/resources/icons/settings.png new file mode 100644 index 0000000..b898050 Binary files /dev/null and b/src/main/resources/icons/settings.png differ diff --git a/src/main/resources/icons/settings.svg b/src/main/resources/icons/settings.svg new file mode 100644 index 0000000..c948914 --- /dev/null +++ b/src/main/resources/icons/settings.svg @@ -0,0 +1,20 @@ + + + + + diff --git a/src/main/resources/icons/shield-lock.png b/src/main/resources/icons/shield-lock.png new file mode 100644 index 0000000..2f5a577 Binary files /dev/null and b/src/main/resources/icons/shield-lock.png differ diff --git a/src/main/resources/icons/shield-lock.svg b/src/main/resources/icons/shield-lock.svg new file mode 100644 index 0000000..0c189bd --- /dev/null +++ b/src/main/resources/icons/shield-lock.svg @@ -0,0 +1,21 @@ + + + + + + diff --git a/src/main/resources/icons/splash-logo.png b/src/main/resources/icons/splash-logo.png new file mode 100644 index 0000000..bc36ff7 Binary files /dev/null and b/src/main/resources/icons/splash-logo.png differ diff --git a/src/main/resources/icons/support-art.png b/src/main/resources/icons/support-art.png new file mode 100644 index 0000000..bec8ce7 Binary files /dev/null and b/src/main/resources/icons/support-art.png differ