vscode extensions
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
// Command vscode-bridge exposes the Nexus backend to VS Code extensions over a
|
||||
// localhost-only JSON-RPC and event stream.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/nexus/internal/api"
|
||||
"github.com/andydixon/nexus/internal/audit"
|
||||
"github.com/andydixon/nexus/internal/config"
|
||||
"github.com/andydixon/nexus/internal/preferences"
|
||||
"github.com/andydixon/nexus/internal/recording"
|
||||
"github.com/andydixon/nexus/internal/secrets"
|
||||
"github.com/andydixon/nexus/internal/sessions"
|
||||
"github.com/andydixon/nexus/internal/storage"
|
||||
)
|
||||
|
||||
type rpcRequest struct {
|
||||
Method string `json:"method"`
|
||||
Args []json.RawMessage `json:"args"`
|
||||
}
|
||||
|
||||
type rpcResponse struct {
|
||||
Result any `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type runtimeEventRequest struct {
|
||||
Name string `json:"name"`
|
||||
Payload map[string]any `json:"payload"`
|
||||
}
|
||||
|
||||
type eventBroker struct {
|
||||
mu sync.Mutex
|
||||
clients map[chan []byte]struct{}
|
||||
}
|
||||
|
||||
func newEventBroker() *eventBroker {
|
||||
return &eventBroker{clients: make(map[chan []byte]struct{})}
|
||||
}
|
||||
|
||||
func (b *eventBroker) publish(name string, payload any) {
|
||||
msg, err := json.Marshal(map[string]any{"name": name, "payload": payload})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
for ch := range b.clients {
|
||||
select {
|
||||
case ch <- msg:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *eventBroker) subscribe() chan []byte {
|
||||
ch := make(chan []byte, 64)
|
||||
b.mu.Lock()
|
||||
b.clients[ch] = struct{}{}
|
||||
b.mu.Unlock()
|
||||
return ch
|
||||
}
|
||||
|
||||
func (b *eventBroker) unsubscribe(ch chan []byte) {
|
||||
b.mu.Lock()
|
||||
delete(b.clients, ch)
|
||||
close(ch)
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
type bridgeApp struct {
|
||||
service *api.Service
|
||||
manager *sessions.Manager
|
||||
store *storage.Store
|
||||
secrets *secrets.SecretStore
|
||||
events *eventBroker
|
||||
}
|
||||
|
||||
func main() {
|
||||
listenAddr := flag.String("listen", "127.0.0.1:0", "address for the localhost bridge")
|
||||
flag.Parse()
|
||||
|
||||
bridge, err := newBridgeApp()
|
||||
if err != nil {
|
||||
log.Fatalf("initialise bridge: %v", err)
|
||||
}
|
||||
defer bridge.close()
|
||||
|
||||
token, err := randomToken()
|
||||
if err != nil {
|
||||
log.Fatalf("create auth token: %v", err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/health", bridge.auth(token, bridge.handleHealth))
|
||||
mux.HandleFunc("/rpc", bridge.auth(token, bridge.handleRPC))
|
||||
mux.HandleFunc("/events", bridge.auth(token, bridge.handleEvents))
|
||||
mux.HandleFunc("/runtime/event", bridge.auth(token, bridge.handleRuntimeEvent))
|
||||
|
||||
server := &http.Server{Handler: mux}
|
||||
listener, err := net.Listen("tcp", *listenAddr)
|
||||
if err != nil {
|
||||
log.Fatalf("listen: %v", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stdout, "{\"addr\":%q,\"token\":%q}\n", listener.Addr().String(), token)
|
||||
|
||||
go func() {
|
||||
if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Printf("server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||
<-sigCh
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = server.Shutdown(ctx)
|
||||
}
|
||||
|
||||
func newBridgeApp() (*bridgeApp, error) {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cfg.EnsureDirectories(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store, err := storage.NewStore(cfg.DBPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
secretStore := secrets.NewSecretStore(cfg.SecretPath)
|
||||
keychainStore := secrets.NewKeychainStore("NexusTerminal")
|
||||
auditLog := audit.NewLogger(store)
|
||||
prefs := preferences.NewManager(store)
|
||||
if err := prefs.Load(); err != nil {
|
||||
log.Printf("warning: failed to load preferences: %v", err)
|
||||
}
|
||||
|
||||
recordStore := recording.NewStore(filepath.Join(cfg.DataDir, "recordings"))
|
||||
manager := sessions.NewManager(store, secretStore, keychainStore, auditLog, recordStore)
|
||||
service := api.NewService(store, secretStore, keychainStore, manager, auditLog, prefs)
|
||||
events := newEventBroker()
|
||||
manager.SetEventSink(events.publish)
|
||||
service.SetEventSink(events.publish)
|
||||
|
||||
return &bridgeApp{
|
||||
service: service,
|
||||
manager: manager,
|
||||
store: store,
|
||||
secrets: secretStore,
|
||||
events: events,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *bridgeApp) close() {
|
||||
if a.manager != nil {
|
||||
a.manager.CloseAll()
|
||||
}
|
||||
if a.store != nil {
|
||||
_ = a.store.Close()
|
||||
}
|
||||
if a.secrets != nil {
|
||||
a.secrets.Lock()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *bridgeApp) auth(token string, next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer "+token {
|
||||
http.Error(w, "unauthorised", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *bridgeApp) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *bridgeApp) handleRPC(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req rpcRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, rpcResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
result, err := callService(a.service, req.Method, req.Args)
|
||||
if err != nil {
|
||||
writeJSON(w, rpcResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, rpcResponse{Result: result})
|
||||
}
|
||||
|
||||
func (a *bridgeApp) handleRuntimeEvent(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req runtimeEventRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, rpcResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
switch req.Name {
|
||||
case "session:secret:response":
|
||||
requestID, _ := req.Payload["requestID"].(string)
|
||||
value, _ := req.Payload["value"].(string)
|
||||
cancelled, _ := req.Payload["cancelled"].(bool)
|
||||
a.service.RespondSecret(requestID, value, cancelled)
|
||||
a.manager.RespondSecret(requestID, value)
|
||||
case "session:hostkey:response":
|
||||
requestID, _ := req.Payload["requestID"].(string)
|
||||
accepted, _ := req.Payload["accepted"].(bool)
|
||||
a.manager.RespondHostKey(requestID, accepted)
|
||||
default:
|
||||
writeJSON(w, rpcResponse{Error: "unsupported runtime event"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, rpcResponse{Result: true})
|
||||
}
|
||||
|
||||
func (a *bridgeApp) handleEvents(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
ch := a.events.subscribe()
|
||||
defer a.events.unsubscribe(ch)
|
||||
fmt.Fprint(w, ": connected\n\n")
|
||||
flusher.Flush()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case msg := <-ch:
|
||||
fmt.Fprintf(w, "data: %s\n\n", msg)
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func callService(service *api.Service, methodName string, rawArgs []json.RawMessage) (any, error) {
|
||||
if strings.TrimSpace(methodName) == "" {
|
||||
return nil, errors.New("method is required")
|
||||
}
|
||||
method := reflect.ValueOf(service).MethodByName(methodName)
|
||||
if !method.IsValid() {
|
||||
return nil, fmt.Errorf("unknown service method %q", methodName)
|
||||
}
|
||||
mt := method.Type()
|
||||
if len(rawArgs) != mt.NumIn() {
|
||||
return nil, fmt.Errorf("%s expects %d args, got %d", methodName, mt.NumIn(), len(rawArgs))
|
||||
}
|
||||
|
||||
args := make([]reflect.Value, mt.NumIn())
|
||||
for i := 0; i < mt.NumIn(); i++ {
|
||||
target := reflect.New(mt.In(i))
|
||||
if err := json.Unmarshal(rawArgs[i], target.Interface()); err != nil {
|
||||
return nil, fmt.Errorf("arg %d: %w", i, err)
|
||||
}
|
||||
args[i] = target.Elem()
|
||||
}
|
||||
|
||||
values := method.Call(args)
|
||||
if len(values) == 0 {
|
||||
return true, nil
|
||||
}
|
||||
last := values[len(values)-1]
|
||||
if last.Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) && !last.IsNil() {
|
||||
return nil, last.Interface().(error)
|
||||
}
|
||||
if len(values) == 1 {
|
||||
if last.Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) {
|
||||
return true, nil
|
||||
}
|
||||
return last.Interface(), nil
|
||||
}
|
||||
return values[0].Interface(), nil
|
||||
}
|
||||
|
||||
func randomToken() (string, error) {
|
||||
var b [32]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b[:]), nil
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
+85
-2
@@ -61,6 +61,7 @@ type Service struct {
|
||||
auditLog *audit.Logger
|
||||
prefs *preferences.Manager
|
||||
ctx context.Context
|
||||
eventSink func(string, interface{})
|
||||
|
||||
// secretChans maps request IDs to channels awaiting user secret input.
|
||||
secretMu sync.Mutex
|
||||
@@ -107,6 +108,12 @@ func (s *Service) SetContext(ctx context.Context) {
|
||||
s.RegisterSecretResponseListener()
|
||||
}
|
||||
|
||||
// SetEventSink registers an alternate UI event sink for non-Wails hosts such
|
||||
// as the VS Code bridge.
|
||||
func (s *Service) SetEventSink(sink func(string, interface{})) {
|
||||
s.eventSink = sink
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profile operations
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1026,6 +1033,26 @@ func (s *Service) DownloadGitHubRepoArchive(sessionID string, fullName string, r
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// DownloadGitHubRepoArchiveToPath downloads a repository zip archive to an
|
||||
// explicit local destination. Non-Wails hosts provide the save path through
|
||||
// their own file picker.
|
||||
func (s *Service) DownloadGitHubRepoArchiveToPath(sessionID string, fullName string, ref string, destPath string) (string, error) {
|
||||
if strings.TrimSpace(fullName) == "" {
|
||||
return "", fmt.Errorf("repository name cannot be empty")
|
||||
}
|
||||
destPath = strings.TrimSpace(destPath)
|
||||
if destPath == "" {
|
||||
return "", fmt.Errorf("destination path cannot be empty")
|
||||
}
|
||||
if filepath.Ext(destPath) != ".zip" {
|
||||
destPath += ".zip"
|
||||
}
|
||||
if err := s.sessions.DownloadGitHubArchive(sessionID, fullName, ref, destPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return destPath, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VNC
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1410,6 +1437,19 @@ func (s *Service) SFTPDownload(sessionID string, remotePath string) error {
|
||||
return s.sessions.SFTPDownload(sessionID, remotePath, localPath)
|
||||
}
|
||||
|
||||
// SFTPDownloadToPath downloads a remote file to an explicit local path. This
|
||||
// is used by non-Wails hosts, where the UI owns the save dialog.
|
||||
func (s *Service) SFTPDownloadToPath(sessionID string, remotePath string, localPath string) error {
|
||||
if strings.TrimSpace(remotePath) == "" {
|
||||
return fmt.Errorf("remote path cannot be empty")
|
||||
}
|
||||
localPath = strings.TrimSpace(localPath)
|
||||
if localPath == "" {
|
||||
return fmt.Errorf("local path cannot be empty")
|
||||
}
|
||||
return s.sessions.SFTPDownload(sessionID, remotePath, localPath)
|
||||
}
|
||||
|
||||
// SFTPUpload uploads a local file to a remote directory. Opens a native file
|
||||
// dialog to select the local file.
|
||||
func (s *Service) SFTPUpload(sessionID string, remotePath string) error {
|
||||
@@ -1431,6 +1471,29 @@ func (s *Service) SFTPUpload(sessionID string, remotePath string) error {
|
||||
return s.sessions.SFTPUpload(sessionID, localPath, destPath)
|
||||
}
|
||||
|
||||
// SFTPUploadPath uploads an explicit local file path to a remote directory.
|
||||
// The remote filename is derived from the local basename and sanitised through
|
||||
// the same upload-name validation used by browser uploads.
|
||||
func (s *Service) SFTPUploadPath(sessionID string, remoteDir string, localPath string) error {
|
||||
localPath = strings.TrimSpace(localPath)
|
||||
if localPath == "" {
|
||||
return fmt.Errorf("local path cannot be empty")
|
||||
}
|
||||
info, err := os.Stat(localPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading local file metadata: %w", err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return fmt.Errorf("local path is a directory")
|
||||
}
|
||||
safeName, err := safeSFTPUploadName(filepath.Base(localPath))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
destPath := path.Join(remoteDir, safeName)
|
||||
return s.sessions.SFTPUpload(sessionID, localPath, destPath)
|
||||
}
|
||||
|
||||
// SFTPUploadData uploads raw file data to a remote directory.
|
||||
// Data is expected as base64-encoded bytes from the frontend.
|
||||
func (s *Service) SFTPUploadData(sessionID string, remoteDir string, fileName string, base64Data string) error {
|
||||
@@ -1676,7 +1739,7 @@ func (s *Service) TestConnection(host string, port int) (map[string]interface{},
|
||||
// This bridges the synchronous Go SSH auth callback with the asynchronous
|
||||
// frontend prompt UI.
|
||||
func (s *Service) promptSecret(prompt string) (string, error) {
|
||||
if s.ctx == nil {
|
||||
if s.ctx == nil && s.eventSink == nil {
|
||||
return "", fmt.Errorf("application context not available")
|
||||
}
|
||||
|
||||
@@ -1693,7 +1756,7 @@ func (s *Service) promptSecret(prompt string) (string, error) {
|
||||
s.secretMu.Unlock()
|
||||
|
||||
// Emit the prompt event to the frontend.
|
||||
wailsRuntime.EventsEmit(s.ctx, "session:secret:prompt", map[string]interface{}{
|
||||
s.emitEvent("session:secret:prompt", map[string]interface{}{
|
||||
"requestID": requestID,
|
||||
"message": prompt,
|
||||
"title": "Credentials Required",
|
||||
@@ -1751,6 +1814,26 @@ func (s *Service) RegisterSecretResponseListener() {
|
||||
})
|
||||
}
|
||||
|
||||
// RespondSecret supplies a credential prompt response for non-Wails hosts.
|
||||
func (s *Service) RespondSecret(requestID string, value string, cancelled bool) {
|
||||
s.secretMu.Lock()
|
||||
ch, exists := s.secretChans[requestID]
|
||||
s.secretMu.Unlock()
|
||||
|
||||
if exists {
|
||||
ch <- secretResponse{value: value, cancelled: cancelled}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) emitEvent(eventName string, data interface{}) {
|
||||
if s.eventSink != nil {
|
||||
s.eventSink(eventName, data)
|
||||
}
|
||||
if s.ctx != nil {
|
||||
wailsRuntime.EventsEmit(s.ctx, eventName, data)
|
||||
}
|
||||
}
|
||||
|
||||
// derefProfiles converts a slice of profile pointers to values.
|
||||
func derefProfiles(ptrs []*models.Profile) []models.Profile {
|
||||
result := make([]models.Profile, len(ptrs))
|
||||
|
||||
@@ -218,6 +218,23 @@ func (s *Service) LeviathanSaveWorking() (string, error) {
|
||||
return path, writeLeviathanDocumentFile(path, doc)
|
||||
}
|
||||
|
||||
// LeviathanSaveWorkingToPath writes the patched bytes to an explicit path.
|
||||
// This is used by non-Wails hosts, where file picking is owned by the host UI.
|
||||
func (s *Service) LeviathanSaveWorkingToPath(path string) (string, error) {
|
||||
engine := s.leviathanEngine()
|
||||
engine.mu.Lock()
|
||||
if engine.doc == nil {
|
||||
engine.mu.Unlock()
|
||||
return "", errors.New("open a file first")
|
||||
}
|
||||
doc := engine.doc
|
||||
engine.mu.Unlock()
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return "", errors.New("save path is required")
|
||||
}
|
||||
return path, writeLeviathanDocumentFile(path, doc)
|
||||
}
|
||||
|
||||
// LeviathanExportJSON writes a tool result to disk.
|
||||
func (s *Service) LeviathanExportJSON(name string, value map[string]any) (string, error) {
|
||||
if s.ctx == nil {
|
||||
|
||||
@@ -70,9 +70,12 @@ type Manager struct {
|
||||
auditLog *audit.Logger
|
||||
recordStore *recording.Store
|
||||
ctx context.Context // Wails app context for event emission
|
||||
eventSink func(string, interface{})
|
||||
hostKeyMu sync.Mutex
|
||||
hostKeyChans map[string]chan bool
|
||||
hostKeyListenerRegistered bool
|
||||
secretMu sync.Mutex
|
||||
secretChans map[string]chan string
|
||||
}
|
||||
|
||||
// NewManager creates a Manager wired to the given backing services.
|
||||
@@ -93,6 +96,16 @@ func (m *Manager) SetContext(ctx context.Context) {
|
||||
m.registerHostKeyResponseListener()
|
||||
}
|
||||
|
||||
// SetEventSink registers an alternate UI event sink for non-Wails hosts.
|
||||
// Wails remains supported through SetContext; this hook is used by the VS Code
|
||||
// bridge so existing protocol/session code can be reused without a webview
|
||||
// runtime context.
|
||||
func (m *Manager) SetEventSink(sink func(string, interface{})) {
|
||||
m.mu.Lock()
|
||||
m.eventSink = sink
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// Connect creates a new session from a profile and connects it.
|
||||
// The getSecret callback is used to prompt the user for credentials when
|
||||
// required. Returns the session ID on success.
|
||||
@@ -1706,7 +1719,7 @@ func (m *Manager) hostKeyCallback(sessionID string, policy models.HostKeyPolicy)
|
||||
}
|
||||
|
||||
func (m *Manager) promptHostKey(sessionID string, host string, keyType string, fingerprint string) (bool, error) {
|
||||
if m.ctx == nil {
|
||||
if m.ctx == nil && m.eventSink == nil {
|
||||
return false, fmt.Errorf("host key prompt unavailable")
|
||||
}
|
||||
|
||||
@@ -1784,6 +1797,20 @@ func (m *Manager) handleHostKeyResponse(optionalData ...interface{}) {
|
||||
}
|
||||
}
|
||||
|
||||
// RespondHostKey supplies a host-key prompt response for non-Wails hosts.
|
||||
func (m *Manager) RespondHostKey(requestID string, accepted bool) {
|
||||
m.hostKeyMu.Lock()
|
||||
ch, exists := m.hostKeyChans[requestID]
|
||||
m.hostKeyMu.Unlock()
|
||||
|
||||
if exists {
|
||||
select {
|
||||
case ch <- accepted:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// buildSecretCallback returns a func() (string, error) that resolves a
|
||||
// credential according to the profile's SecretMode:
|
||||
// - "saved": check keychain/vault first, prompt if missing, then persist
|
||||
@@ -2240,25 +2267,30 @@ func (m *Manager) scheduleReconnect(sess *Session, sessionID string) {
|
||||
// original callback is no longer available.
|
||||
func (m *Manager) buildInteractiveSecretCallback(sessionID string) func(prompt string) (string, error) {
|
||||
return func(prompt string) (string, error) {
|
||||
// Use the same event-based flow as the initial connection.
|
||||
requestID := uuid.New().String()
|
||||
|
||||
ch := make(chan string, 1)
|
||||
m.mu.Lock()
|
||||
// Store a temporary secret channel keyed by requestID.
|
||||
if m.ctx != nil {
|
||||
wailsRuntime.EventsEmit(m.ctx, "session:secret:prompt", map[string]interface{}{
|
||||
"requestID": requestID,
|
||||
"sessionID": sessionID,
|
||||
"title": "Reconnection Credentials",
|
||||
"message": prompt,
|
||||
})
|
||||
m.secretMu.Lock()
|
||||
if m.secretChans == nil {
|
||||
m.secretChans = make(map[string]chan string)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
m.secretChans[requestID] = ch
|
||||
m.secretMu.Unlock()
|
||||
defer func() {
|
||||
m.secretMu.Lock()
|
||||
delete(m.secretChans, requestID)
|
||||
m.secretMu.Unlock()
|
||||
}()
|
||||
|
||||
m.emitEvent("session:secret:prompt", map[string]interface{}{
|
||||
"requestID": requestID,
|
||||
"sessionID": sessionID,
|
||||
"title": "Reconnection Credentials",
|
||||
"message": prompt,
|
||||
})
|
||||
|
||||
// Wait for the response with a timeout.
|
||||
var response string
|
||||
cancel := make(chan struct{})
|
||||
|
||||
if m.ctx != nil {
|
||||
wailsRuntime.EventsOn(m.ctx, "session:secret:response", func(optionalData ...interface{}) {
|
||||
@@ -2282,7 +2314,6 @@ func (m *Manager) buildInteractiveSecretCallback(sessionID string) func(prompt s
|
||||
select {
|
||||
case response = <-ch:
|
||||
case <-time.After(60 * time.Second):
|
||||
close(cancel)
|
||||
return "", fmt.Errorf("credential prompt timed out during reconnect")
|
||||
}
|
||||
|
||||
@@ -2290,6 +2321,21 @@ func (m *Manager) buildInteractiveSecretCallback(sessionID string) func(prompt s
|
||||
}
|
||||
}
|
||||
|
||||
// RespondSecret supplies a credential prompt response for reconnect prompts in
|
||||
// non-Wails hosts.
|
||||
func (m *Manager) RespondSecret(requestID string, value string) {
|
||||
m.secretMu.Lock()
|
||||
ch, exists := m.secretChans[requestID]
|
||||
m.secretMu.Unlock()
|
||||
|
||||
if exists {
|
||||
select {
|
||||
case ch <- value:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CancelReconnect cancels any in-progress auto-reconnect for a session.
|
||||
func (m *Manager) CancelReconnect(sessionID string) {
|
||||
m.mu.Lock()
|
||||
@@ -2324,6 +2370,12 @@ func (m *Manager) createTabName(profile *models.Profile) string {
|
||||
|
||||
// emitEvent sends an event to the Wails frontend via the runtime context.
|
||||
func (m *Manager) emitEvent(eventName string, data interface{}) {
|
||||
m.mu.RLock()
|
||||
sink := m.eventSink
|
||||
m.mu.RUnlock()
|
||||
if sink != nil {
|
||||
sink(eventName, data)
|
||||
}
|
||||
if m.ctx == nil {
|
||||
return
|
||||
}
|
||||
|
||||
Executable
BIN
Binary file not shown.
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
VSCODE_DIR="$ROOT_DIR/vscode"
|
||||
OUT_DIR="$VSCODE_DIR/dist"
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
echo "[vscode] Building frontend assets"
|
||||
if [ ! -d "$ROOT_DIR/frontend/node_modules" ]; then
|
||||
npm --prefix "$ROOT_DIR/frontend" install
|
||||
fi
|
||||
npm --prefix "$ROOT_DIR/frontend" run build
|
||||
|
||||
echo "[vscode] Building Go bridge"
|
||||
mkdir -p "$VSCODE_DIR/bin"
|
||||
BRIDGE_NAME="nexus-vscode-bridge"
|
||||
if [ "$(go env GOOS)" = "windows" ]; then
|
||||
BRIDGE_NAME="nexus-vscode-bridge.exe"
|
||||
fi
|
||||
go build -trimpath -o "$VSCODE_DIR/bin/$BRIDGE_NAME" ./cmd/vscode-bridge
|
||||
|
||||
echo "[vscode] Staging extension assets"
|
||||
for EXT in nexus leviathan; do
|
||||
mkdir -p "$VSCODE_DIR/$EXT/bin"
|
||||
cp "$VSCODE_DIR/bin/$BRIDGE_NAME" "$VSCODE_DIR/$EXT/bin/$BRIDGE_NAME"
|
||||
done
|
||||
|
||||
cp "$ROOT_DIR/frontend/dist/src/js/lib/xterm.min.js" "$VSCODE_DIR/nexus/media/xterm.min.js"
|
||||
cp "$ROOT_DIR/frontend/dist/src/js/lib/xterm-addon-fit.min.js" "$VSCODE_DIR/nexus/media/xterm-addon-fit.min.js"
|
||||
cp "$ROOT_DIR/frontend/dist/src/css/lib/xterm.css" "$VSCODE_DIR/nexus/media/xterm.css"
|
||||
cp "$ROOT_DIR/frontend/dist/src/js/lib/novnc.min.js" "$VSCODE_DIR/nexus/media/novnc.min.js"
|
||||
|
||||
echo "[vscode] Packaging VSIX-compatible archives"
|
||||
rm -rf "$OUT_DIR"
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
package_extension() {
|
||||
local ext="$1"
|
||||
local out="$2"
|
||||
if command -v vsce >/dev/null 2>&1; then
|
||||
vsce package --no-dependencies --allow-missing-repository -o "$out"
|
||||
else
|
||||
npx --yes @vscode/vsce package --no-dependencies --allow-missing-repository -o "$out"
|
||||
fi
|
||||
}
|
||||
|
||||
for EXT in nexus leviathan; do
|
||||
(
|
||||
cd "$VSCODE_DIR/$EXT"
|
||||
package_extension "$EXT" "$OUT_DIR/$EXT-0.1.0.vsix"
|
||||
)
|
||||
echo "[vscode] Wrote $OUT_DIR/$EXT-0.1.0.vsix"
|
||||
done
|
||||
|
||||
echo "[vscode] Build complete"
|
||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,948 @@
|
||||
'use strict';
|
||||
|
||||
const vscode = require('vscode');
|
||||
const cp = require('child_process');
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const path = require('path');
|
||||
|
||||
const TOOLS = [
|
||||
['Editor', 'hex', 'Hex Editor', 'Browse and patch bytes in the working copy.'],
|
||||
['Triage', 'overview', 'Overview', 'Fast first-pass summary across hashes, strings, entropy and IOCs.'],
|
||||
['Triage', 'hashes', 'Hashes', 'CRC32, MD5, SHA-1, SHA-256 and SHA-512.'],
|
||||
['Triage', 'entropy', 'Entropy', 'Windowed entropy for packed or encrypted regions.'],
|
||||
['Triage', 'strings', 'Strings', 'ASCII and UTF-16 string extraction.'],
|
||||
['Triage', 'iocs', 'IOCs', 'URLs, domains, hashes, paths and indicators.'],
|
||||
['Find', 'search', 'Search', 'Text, hex or regular-expression search.'],
|
||||
['Find', 'multi-search', 'Multi-search', 'Batch text, hex and regex searches.'],
|
||||
['Structure', 'parse', 'Structures', 'PE, ELF, Mach-O, ZIP, images, RIFF, ASN.1 and certificates.'],
|
||||
['Structure', 'disasm', 'Disasm', 'Heuristic disassembly for common CPU families.'],
|
||||
['Structure', 'pdf', 'PDF', 'Objects, streams, xrefs and suspicious PDF surface.'],
|
||||
['Structure', 'squashfs', 'SquashFS', 'Superblock and directory metadata.'],
|
||||
['Audio', 'spectrogram-encode', 'Spectrogram Encode', 'Convert images into WAV spectrogram payloads.'],
|
||||
['Audio', 'spectrogram-view', 'Spectrogram View', 'Render audio files as spectrogram images.'],
|
||||
['Compare', 'diff', 'Diff', 'Changed byte regions against a compare file.'],
|
||||
['Recover', 'carve', 'Carve', 'Embedded file carving candidates.'],
|
||||
['Recover', 'keys', 'Keys', 'PEM, DER, OpenSSH and PKCS#12 artefact discovery.'],
|
||||
['Recover', 'timestamps', 'Timestamps', 'UNIX, FILETIME, WebKit and OLE timestamp heuristics.'],
|
||||
['Recover', 'stego', 'Stego', 'Appended payloads and embedded trailer formats.'],
|
||||
['Recover', 'metadata', 'Metadata', 'PNG, JPEG and GIF textual metadata extraction.'],
|
||||
['Advanced', 'binsnoop', 'Binsnoop', 'Deep signature scanning through Binsnoop.'],
|
||||
['Advanced', 'rules', 'Rules', 'YARA-like rule scans over the current document.'],
|
||||
['Edit', 'patch-plan', 'Patch Plan', 'Validate patch scripts without modifying bytes.'],
|
||||
['Edit', 'patch-apply', 'Patch Apply', 'Apply a patch script to the working copy.'],
|
||||
['Edit', 'transform', 'Transforms', 'Hex, Base64, URL, XOR, inflate and cipher pipelines.'],
|
||||
['Case', 'case', 'Case Export', 'Build a case archive payload from the working copy.']
|
||||
].map(([group, id, name, desc]) => ({ group, id, name, desc }));
|
||||
|
||||
const DEFAULTS = {
|
||||
search: { mode: 'text', query: 'MZ', max: 1000 },
|
||||
'multi-search': { spec: 'text MZ\nhex 7f454c46\nregex https?://', max: 1000 },
|
||||
parse: { kind: 'auto' },
|
||||
disasm: { arch: 'x86_64', start: 0, length: 4096, max: 1000 },
|
||||
entropy: { window: 4096, step: 4096 },
|
||||
strings: { min: 4, max: 5000 },
|
||||
iocs: { min: 5 },
|
||||
carve: { max: 256 },
|
||||
keys: { max: 200 },
|
||||
timestamps: { max: 1000 },
|
||||
rules: { source: 'rule mz { strings: $a = "MZ" condition: $a }', max: 1000 },
|
||||
'patch-plan': { source: 'set 0x00 90 90' },
|
||||
'patch-apply': { source: 'set 0x00 90 90' },
|
||||
transform: { pipeline: 'hex.encode', start: 0, length: 1024 },
|
||||
'spectrogram-view': { fftSize: 2048, height: 1024, maxWidth: 1400, aspectRatio: 1, minHz: 0, maxHz: 0, brightness: 1, contrast: 1 },
|
||||
hex: { start: 0, bytes: 2048, width: 16 }
|
||||
};
|
||||
|
||||
class Bridge {
|
||||
constructor(context) {
|
||||
this.context = context;
|
||||
this.proc = null;
|
||||
this.addr = null;
|
||||
this.token = null;
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.addr) return;
|
||||
const exe = process.platform === 'win32' ? 'nexus-vscode-bridge.exe' : 'nexus-vscode-bridge';
|
||||
const bridgePath = path.join(this.context.extensionPath, 'bin', exe);
|
||||
if (!fs.existsSync(bridgePath)) {
|
||||
throw new Error(`Bridge executable not found: ${bridgePath}. Run vscode/build.sh first.`);
|
||||
}
|
||||
this.proc = cp.spawn(bridgePath, ['-listen', '127.0.0.1:0'], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
});
|
||||
this.context.subscriptions.push({ dispose: () => this.stop() });
|
||||
const firstLine = await new Promise((resolve, reject) => {
|
||||
let buf = '';
|
||||
const timer = setTimeout(() => reject(new Error('Timed out waiting for bridge startup')), 10000);
|
||||
this.proc.stdout.on('data', chunk => {
|
||||
buf += chunk.toString('utf8');
|
||||
const idx = buf.indexOf('\n');
|
||||
if (idx >= 0) {
|
||||
clearTimeout(timer);
|
||||
resolve(buf.slice(0, idx));
|
||||
}
|
||||
});
|
||||
this.proc.once('error', reject);
|
||||
this.proc.once('exit', code => reject(new Error(`Bridge exited during startup (${code})`)));
|
||||
});
|
||||
const meta = JSON.parse(firstLine);
|
||||
this.addr = meta.addr;
|
||||
this.token = meta.token;
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.proc && !this.proc.killed) this.proc.kill();
|
||||
this.proc = null;
|
||||
}
|
||||
|
||||
async rpc(method, args = []) {
|
||||
await this.start();
|
||||
const payload = Buffer.from(JSON.stringify({ method, args }));
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request({
|
||||
hostname: this.addr.split(':')[0],
|
||||
port: Number(this.addr.split(':')[1]),
|
||||
path: '/rpc',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': payload.length
|
||||
}
|
||||
}, res => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => {
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
reject(new Error(`Bridge HTTP ${res.statusCode}: ${data}`));
|
||||
return;
|
||||
}
|
||||
const decoded = JSON.parse(data || '{}');
|
||||
if (decoded.error) reject(new Error(decoded.error));
|
||||
else resolve(decoded.result);
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class ToolsView {
|
||||
constructor(context, bridge) {
|
||||
this.context = context;
|
||||
this.bridge = bridge;
|
||||
this.view = null;
|
||||
this.state = null;
|
||||
}
|
||||
|
||||
resolveWebviewView(view) {
|
||||
this.view = view;
|
||||
view.webview.options = { enableScripts: true };
|
||||
view.webview.html = this.html(view.webview);
|
||||
view.webview.onDidReceiveMessage(async msg => {
|
||||
try {
|
||||
if (msg.type === 'load') await loadFile(this.bridge, false, this);
|
||||
if (msg.type === 'compare') await loadFile(this.bridge, true, this);
|
||||
if (msg.type === 'save') await saveFile(this.bridge, this);
|
||||
if (msg.type === 'refresh') await this.refreshState();
|
||||
if (msg.type === 'tool') await openTool(this.context, this.bridge, this, msg.tool);
|
||||
} catch (err) {
|
||||
this.postError(err);
|
||||
}
|
||||
});
|
||||
this.refreshState();
|
||||
}
|
||||
|
||||
async refreshState() {
|
||||
try {
|
||||
this.state = await this.bridge.rpc('LeviathanState');
|
||||
} catch (_) {
|
||||
this.state = null;
|
||||
}
|
||||
this.postState();
|
||||
}
|
||||
|
||||
postState() {
|
||||
if (this.view) this.view.webview.postMessage({ type: 'state', state: this.state });
|
||||
}
|
||||
|
||||
postError(err) {
|
||||
if (this.view) this.view.webview.postMessage({ type: 'error', error: err.message || String(err) });
|
||||
else vscode.window.showErrorMessage(err.message || String(err));
|
||||
}
|
||||
|
||||
html(webview) {
|
||||
const nonce = String(Date.now());
|
||||
const toolsJson = JSON.stringify(TOOLS);
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}';">
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--vscode-foreground);
|
||||
background: var(--vscode-sideBar-background);
|
||||
font: var(--vscode-font-size) var(--vscode-font-family);
|
||||
}
|
||||
.shell { display: grid; gap: 10px; padding: 10px; }
|
||||
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
|
||||
button {
|
||||
border: 1px solid var(--vscode-button-border, transparent);
|
||||
background: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
padding: 5px 8px;
|
||||
min-height: 26px;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { filter: brightness(1.08); }
|
||||
button.primary { background: var(--vscode-button-background); color: var(--vscode-button-foreground); }
|
||||
.state {
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
background: var(--vscode-editorWidget-background);
|
||||
padding: 8px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.state strong { display: block; line-height: 18px; }
|
||||
.state span { color: var(--vscode-descriptionForeground); font-size: 11px; }
|
||||
.search {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--vscode-input-border, transparent);
|
||||
background: var(--vscode-input-background);
|
||||
color: var(--vscode-input-foreground);
|
||||
padding: 6px 8px;
|
||||
outline: none;
|
||||
}
|
||||
.search:focus { border-color: var(--vscode-focusBorder); }
|
||||
.segments { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
.seg {
|
||||
min-height: 23px;
|
||||
padding: 3px 7px;
|
||||
background: transparent;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
font-size: 11px;
|
||||
}
|
||||
.seg.active {
|
||||
background: var(--vscode-button-background);
|
||||
color: var(--vscode-button-foreground);
|
||||
border-color: transparent;
|
||||
}
|
||||
.group {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 10px 0 5px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
.tool {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 8px;
|
||||
text-align: left;
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
background: var(--vscode-editor-background);
|
||||
color: var(--vscode-foreground);
|
||||
padding: 8px;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
.tool:hover { border-color: var(--vscode-focusBorder); background: var(--vscode-list-hoverBackground); }
|
||||
.mark {
|
||||
width: 30px;
|
||||
height: 28px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--vscode-badge-background);
|
||||
color: var(--vscode-badge-foreground);
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.name { font-weight: 600; line-height: 17px; }
|
||||
.desc { color: var(--vscode-descriptionForeground); font-size: 11px; line-height: 15px; }
|
||||
.empty {
|
||||
border: 1px dashed var(--vscode-panel-border);
|
||||
color: var(--vscode-descriptionForeground);
|
||||
text-align: center;
|
||||
padding: 16px 10px;
|
||||
}
|
||||
.status { color: var(--vscode-descriptionForeground); font-size: 11px; min-height: 15px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<div class="actions">
|
||||
<button class="primary" id="load">Load File</button>
|
||||
<button id="compare">Compare</button>
|
||||
<button id="save">Save Copy</button>
|
||||
<button id="refresh">Refresh</button>
|
||||
</div>
|
||||
<div class="state" id="state"><strong>No file loaded</strong><span>Load a binary or right-click a file in Explorer.</span></div>
|
||||
<input id="search" class="search" placeholder="Search tools">
|
||||
<div id="segments" class="segments"></div>
|
||||
<div id="tools"></div>
|
||||
<div id="status" class="status"></div>
|
||||
</div>
|
||||
<script nonce="${nonce}">
|
||||
const vscode = acquireVsCodeApi();
|
||||
const tools = ${toolsJson};
|
||||
let state = null;
|
||||
let filter = 'all';
|
||||
const groups = ['all', ...Array.from(new Set(tools.map(t => t.group)))];
|
||||
const search = document.getElementById('search');
|
||||
document.getElementById('load').onclick = () => vscode.postMessage({ type: 'load' });
|
||||
document.getElementById('compare').onclick = () => vscode.postMessage({ type: 'compare' });
|
||||
document.getElementById('save').onclick = () => vscode.postMessage({ type: 'save' });
|
||||
document.getElementById('refresh').onclick = () => vscode.postMessage({ type: 'refresh' });
|
||||
function esc(value) {
|
||||
return String(value == null ? '' : value).replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]));
|
||||
}
|
||||
function mark(tool) {
|
||||
return tool.name.split(/\\s+/).map(part => part[0]).join('').slice(0, 3).toUpperCase();
|
||||
}
|
||||
function renderState() {
|
||||
const el = document.getElementById('state');
|
||||
if (!state || !state.name) {
|
||||
el.innerHTML = '<strong>No file loaded</strong><span>Load a binary or right-click a file in Explorer.</span>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = '<strong>' + esc(state.name) + '</strong><span>' + Number(state.size || 0).toLocaleString() + ' bytes' + (state.dirty ? ' • modified' : '') + (state.compare ? ' • compare loaded' : '') + '</span>';
|
||||
}
|
||||
function renderSegments() {
|
||||
const root = document.getElementById('segments');
|
||||
root.textContent = '';
|
||||
for (const group of groups) {
|
||||
const count = group === 'all' ? tools.length : tools.filter(t => t.group === group).length;
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'seg' + (filter === group ? ' active' : '');
|
||||
btn.textContent = group === 'all' ? 'All ' + count : group + ' ' + count;
|
||||
btn.onclick = () => { filter = group; renderTools(); renderSegments(); };
|
||||
root.appendChild(btn);
|
||||
}
|
||||
}
|
||||
function visibleTools() {
|
||||
const q = search.value.trim().toLowerCase();
|
||||
return tools.filter(tool => {
|
||||
if (filter !== 'all' && tool.group !== filter) return false;
|
||||
if (!q) return true;
|
||||
return (tool.name + ' ' + tool.group + ' ' + tool.desc).toLowerCase().includes(q);
|
||||
});
|
||||
}
|
||||
function renderTools() {
|
||||
const root = document.getElementById('tools');
|
||||
const visible = visibleTools();
|
||||
if (!visible.length) {
|
||||
root.innerHTML = '<div class="empty">No matching tools</div>';
|
||||
return;
|
||||
}
|
||||
const byGroup = new Map();
|
||||
for (const tool of visible) {
|
||||
if (!byGroup.has(tool.group)) byGroup.set(tool.group, []);
|
||||
byGroup.get(tool.group).push(tool);
|
||||
}
|
||||
root.innerHTML = [...byGroup.entries()].map(([group, items]) =>
|
||||
'<div class="group"><span>' + esc(group) + '</span><span>' + items.length + '</span></div>' +
|
||||
items.map(tool => '<button class="tool" data-tool="' + esc(tool.id) + '"><span class="mark">' + esc(mark(tool)) + '</span><span><span class="name">' + esc(tool.name) + '</span><br><span class="desc">' + esc(tool.desc) + '</span></span></button>').join('')
|
||||
).join('');
|
||||
}
|
||||
document.getElementById('tools').addEventListener('click', event => {
|
||||
const btn = event.target.closest('[data-tool]');
|
||||
if (btn) vscode.postMessage({ type: 'tool', tool: btn.dataset.tool });
|
||||
});
|
||||
search.addEventListener('input', renderTools);
|
||||
renderSegments();
|
||||
renderTools();
|
||||
window.addEventListener('message', event => {
|
||||
const msg = event.data || {};
|
||||
if (msg.type === 'state') {
|
||||
state = msg.state;
|
||||
renderState();
|
||||
}
|
||||
if (msg.type === 'error') document.getElementById('status').textContent = msg.error || '';
|
||||
});
|
||||
vscode.postMessage({ type: 'refresh' });
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
|
||||
class ToolPanel {
|
||||
constructor(context, bridge, tool, options = {}) {
|
||||
this.context = context;
|
||||
this.bridge = bridge;
|
||||
this.tool = tool;
|
||||
this.fileState = options.fileState || null;
|
||||
const meta = TOOLS.find(t => t.id === tool) || { name: tool };
|
||||
const title = this.fileState?.name ? `${meta.name}: ${this.fileState.name}` : meta.name;
|
||||
this.panel = vscode.window.createWebviewPanel('leviathanTool', title, vscode.ViewColumn.Active, { enableScripts: true, retainContextWhenHidden: true });
|
||||
this.panel.webview.html = this.tool === 'hex' ? this.hexHtml(meta) : this.html(meta);
|
||||
this.panel.webview.onDidReceiveMessage(msg => {
|
||||
if (msg.type === 'run') this.run(msg.params);
|
||||
if (msg.type === 'hexLoad') this.loadHex(msg);
|
||||
if (msg.type === 'hexSet') this.setHexByte(msg);
|
||||
if (msg.type === 'hexSearch') this.searchHex(msg);
|
||||
if (msg.type === 'undo') this.stateAction('LeviathanUndo', msg);
|
||||
if (msg.type === 'redo') this.stateAction('LeviathanRedo', msg);
|
||||
if (msg.type === 'revert') this.stateAction('LeviathanRevert', msg);
|
||||
});
|
||||
this.panel.webview.postMessage({ type: 'fileState', state: this.fileState });
|
||||
this.refreshState();
|
||||
if (options.autoRun) {
|
||||
if (this.tool === 'hex') {
|
||||
setTimeout(() => this.loadHex(DEFAULTS.hex), 150);
|
||||
} else {
|
||||
setTimeout(() => this.run(JSON.stringify(DEFAULTS[this.tool] || {})), 150);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async refreshState() {
|
||||
try {
|
||||
const state = await this.bridge.rpc('LeviathanState');
|
||||
this.fileState = state;
|
||||
this.panel.webview.postMessage({ type: 'fileState', state });
|
||||
} catch (err) {
|
||||
this.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async run(paramsText) {
|
||||
try {
|
||||
const params = JSON.parse(paramsText || '{}');
|
||||
const result = await this.bridge.rpc('LeviathanRunTool', [this.tool, params]);
|
||||
this.panel.webview.postMessage({ type: 'result', result });
|
||||
} catch (err) {
|
||||
this.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
error(err) {
|
||||
this.panel.webview.postMessage({ type: 'error', error: err.message || String(err) });
|
||||
}
|
||||
|
||||
async loadHex(params = {}) {
|
||||
try {
|
||||
const start = Number(params.start || 0);
|
||||
const bytes = Number(params.bytes || 2048);
|
||||
const width = Number(params.width || 16);
|
||||
const result = await this.bridge.rpc('LeviathanRunTool', ['hex', { start, bytes, width }]);
|
||||
this.panel.webview.postMessage({ type: 'hexRows', rows: result.rows || [], start, bytes, width });
|
||||
await this.refreshState();
|
||||
} catch (err) {
|
||||
this.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async setHexByte(msg) {
|
||||
try {
|
||||
const offset = Number(msg.offset);
|
||||
const value = String(msg.value || '').trim();
|
||||
const payload = await this.bridge.rpc('LeviathanSetByte', [offset, value]);
|
||||
if (payload && payload.state) {
|
||||
this.fileState = payload.state;
|
||||
this.panel.webview.postMessage({ type: 'fileState', state: payload.state });
|
||||
}
|
||||
await this.loadHex({ start: msg.start, bytes: msg.bytes, width: msg.width });
|
||||
} catch (err) {
|
||||
this.panel.webview.postMessage({ type: 'hexEditError', offset: msg.offset, error: err.message || String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
async searchHex(msg) {
|
||||
try {
|
||||
const mode = msg.mode === 'hex' ? 'hex' : 'text';
|
||||
const query = String(msg.query || '');
|
||||
const result = await this.bridge.rpc('LeviathanRunTool', ['search', { mode, query, max: 2000 }]);
|
||||
this.panel.webview.postMessage({ type: 'hexSearchResults', hits: result.hits || [], mode, query });
|
||||
} catch (err) {
|
||||
this.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async stateAction(method, msg = {}) {
|
||||
try {
|
||||
const state = await this.bridge.rpc(method);
|
||||
this.panel.webview.postMessage({ type: 'state', state });
|
||||
this.panel.webview.postMessage({ type: 'fileState', state });
|
||||
if (this.tool === 'hex') {
|
||||
await this.loadHex(msg.start === undefined ? DEFAULTS.hex : msg);
|
||||
}
|
||||
} catch (err) {
|
||||
this.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
hexHtml(meta) {
|
||||
const nonce = String(Date.now());
|
||||
const initialFileState = JSON.stringify(this.fileState || null);
|
||||
const name = escapeHtml(meta.name);
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}';">
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--vscode-foreground);
|
||||
background: var(--vscode-editor-background);
|
||||
font: var(--vscode-font-size) var(--vscode-font-family);
|
||||
overflow: hidden;
|
||||
}
|
||||
header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 8px;
|
||||
padding: 9px 10px;
|
||||
border-bottom: 1px solid var(--vscode-panel-border);
|
||||
}
|
||||
h1 { margin: 0; font-size: 15px; line-height: 19px; }
|
||||
.file-state { color: var(--vscode-descriptionForeground); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto auto auto 1fr auto auto;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--vscode-panel-border);
|
||||
background: var(--vscode-sideBar-background);
|
||||
}
|
||||
.searchbar {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto auto auto;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--vscode-panel-border);
|
||||
background: var(--vscode-sideBar-background);
|
||||
}
|
||||
input, select {
|
||||
min-width: 0;
|
||||
color: var(--vscode-input-foreground);
|
||||
background: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-input-border, transparent);
|
||||
padding: 5px 7px;
|
||||
font: 12px Menlo, Monaco, Consolas, "Courier New", monospace;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
input:focus, select:focus { outline: 1px solid var(--vscode-focusBorder); }
|
||||
button {
|
||||
border: 1px solid var(--vscode-button-border, transparent);
|
||||
background: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
padding: 5px 9px;
|
||||
min-height: 26px;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
button.primary { background: var(--vscode-button-background); color: var(--vscode-button-foreground); }
|
||||
.seg { display: inline-flex; border: 1px solid var(--vscode-panel-border); }
|
||||
.seg button { border: 0; border-right: 1px solid var(--vscode-panel-border); background: transparent; }
|
||||
.seg button:last-child { border-right: 0; }
|
||||
.seg button.active { background: var(--vscode-button-background); color: var(--vscode-button-foreground); }
|
||||
main { height: calc(100vh - 132px); overflow: auto; }
|
||||
table { width: 100%; border-collapse: collapse; font: 12px Menlo, Monaco, Consolas, "Courier New", monospace; }
|
||||
th, td { border-bottom: 1px solid var(--vscode-panel-border); padding: 3px 6px; vertical-align: top; }
|
||||
th { position: sticky; top: 0; background: var(--vscode-editor-background); z-index: 1; text-align: left; color: var(--vscode-descriptionForeground); }
|
||||
.offset { width: 88px; color: var(--vscode-descriptionForeground); user-select: text; }
|
||||
.hex-cell {
|
||||
width: 2.35ch;
|
||||
margin-right: .2ch;
|
||||
padding: 1px 2px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--vscode-foreground);
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
font: 12px Menlo, Monaco, Consolas, "Courier New", monospace;
|
||||
}
|
||||
.hex-cell:focus { border-color: var(--vscode-focusBorder); background: var(--vscode-input-background); outline: none; }
|
||||
.hex-cell.dirty { color: var(--vscode-terminal-ansiYellow); }
|
||||
.hex-cell.hit, .ascii-char.hit { background: var(--vscode-editor-findMatchHighlightBackground); }
|
||||
.ascii {
|
||||
white-space: pre;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
letter-spacing: .08em;
|
||||
min-width: 34ch;
|
||||
}
|
||||
.ascii-char { display: inline-block; min-width: 1ch; }
|
||||
.status { color: var(--vscode-descriptionForeground); font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div><h1>${name}</h1><div id="fileState" class="file-state">Checking loaded file...</div></div>
|
||||
<div><button id="undo">Undo</button><button id="redo">Redo</button><button id="revert">Revert</button></div>
|
||||
</header>
|
||||
<section class="toolbar">
|
||||
<label>Offset <input id="start" value="0" size="10"></label>
|
||||
<label>Bytes <input id="bytes" value="2048" size="8"></label>
|
||||
<span class="seg"><button id="width16" class="active">16</button><button id="width32">32</button></span>
|
||||
<button id="prev">Prev</button>
|
||||
<button id="next">Next</button>
|
||||
<button id="load" class="primary">Load</button>
|
||||
<span id="status" class="status"></span>
|
||||
</section>
|
||||
<section class="searchbar">
|
||||
<select id="searchMode"><option value="hex">Hex</option><option value="ascii">ASCII</option></select>
|
||||
<input id="searchQuery" placeholder="Search hex bytes or ASCII text">
|
||||
<button id="search">Search</button>
|
||||
<button id="searchPrev">Prev Hit</button>
|
||||
<button id="searchNext">Next Hit</button>
|
||||
</section>
|
||||
<main>
|
||||
<table>
|
||||
<thead><tr><th class="offset">Offset</th><th>Hex</th><th>ASCII</th></tr></thead>
|
||||
<tbody id="rows"></tbody>
|
||||
</table>
|
||||
</main>
|
||||
<script nonce="${nonce}">
|
||||
const vscode = acquireVsCodeApi();
|
||||
const initialFileState = ${initialFileState};
|
||||
let width = 16;
|
||||
let rows = [];
|
||||
let hits = [];
|
||||
let hitIndex = -1;
|
||||
const changed = new Map();
|
||||
function esc(value) {
|
||||
return String(value == null ? '' : value).replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]));
|
||||
}
|
||||
function parseNum(value, fallback) {
|
||||
const text = String(value || '').trim();
|
||||
if (!text) return fallback;
|
||||
const n = Number(text.startsWith('0x') || text.startsWith('0X') ? parseInt(text, 16) : parseInt(text, 10));
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
function printable(hex) {
|
||||
const n = parseInt(hex, 16);
|
||||
return n >= 32 && n < 127 ? String.fromCharCode(n) : '.';
|
||||
}
|
||||
function currentParams() {
|
||||
return {
|
||||
start: parseNum(document.getElementById('start').value, 0),
|
||||
bytes: parseNum(document.getElementById('bytes').value, 2048),
|
||||
width
|
||||
};
|
||||
}
|
||||
function renderFileState(s) {
|
||||
document.getElementById('fileState').textContent = s && s.name ? s.name + ' • ' + Number(s.size || 0).toLocaleString() + ' bytes' + (s.dirty ? ' • modified' : '') : 'No file loaded';
|
||||
}
|
||||
function hitOffsets() {
|
||||
const set = new Set();
|
||||
for (const hit of hits) {
|
||||
const start = Number(hit.offset || hit.start || 0);
|
||||
const length = Math.max(1, Number(hit.length || 1));
|
||||
for (let i = 0; i < length; i++) set.add(start + i);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
function renderRows() {
|
||||
const hitSet = hitOffsets();
|
||||
const body = document.getElementById('rows');
|
||||
body.textContent = '';
|
||||
for (const row of rows) {
|
||||
const tr = document.createElement('tr');
|
||||
const off = Number(row.offset || 0);
|
||||
const bytes = Array.isArray(row.bytes) ? row.bytes : String(row.hex || '').split(/\\s+/).filter(Boolean);
|
||||
const hexTd = document.createElement('td');
|
||||
const asciiTd = document.createElement('td');
|
||||
asciiTd.className = 'ascii';
|
||||
bytes.forEach((byte, idx) => {
|
||||
const absolute = off + idx;
|
||||
const input = document.createElement('input');
|
||||
input.className = 'hex-cell' + (changed.has(absolute) ? ' dirty' : '') + (hitSet.has(absolute) ? ' hit' : '');
|
||||
input.maxLength = 2;
|
||||
input.value = String(changed.get(absolute) || byte).toUpperCase();
|
||||
input.dataset.offset = String(absolute);
|
||||
input.addEventListener('input', () => {
|
||||
input.value = input.value.replace(/[^0-9a-fA-F]/g, '').slice(0, 2).toUpperCase();
|
||||
updateAsciiFromInputs(tr);
|
||||
});
|
||||
input.addEventListener('change', () => commitByte(input));
|
||||
input.addEventListener('keydown', event => {
|
||||
if (event.key === 'Enter') { event.preventDefault(); input.blur(); }
|
||||
});
|
||||
hexTd.appendChild(input);
|
||||
});
|
||||
tr.innerHTML = '<td class="offset">0x' + off.toString(16).padStart(8, '0') + '</td>';
|
||||
tr.appendChild(hexTd);
|
||||
tr.appendChild(asciiTd);
|
||||
body.appendChild(tr);
|
||||
updateAsciiFromInputs(tr);
|
||||
}
|
||||
}
|
||||
function updateAsciiFromInputs(tr) {
|
||||
const asciiTd = tr.querySelector('.ascii');
|
||||
asciiTd.textContent = '';
|
||||
for (const input of tr.querySelectorAll('.hex-cell')) {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'ascii-char' + (input.classList.contains('hit') ? ' hit' : '');
|
||||
span.textContent = /^[0-9A-Fa-f]{2}$/.test(input.value) ? printable(input.value) : '.';
|
||||
asciiTd.appendChild(span);
|
||||
}
|
||||
}
|
||||
function commitByte(input) {
|
||||
if (!/^[0-9A-Fa-f]{2}$/.test(input.value)) {
|
||||
document.getElementById('status').textContent = 'Byte must be two hex digits';
|
||||
return;
|
||||
}
|
||||
const offset = Number(input.dataset.offset);
|
||||
changed.set(offset, input.value.toUpperCase());
|
||||
input.classList.add('dirty');
|
||||
vscode.postMessage({ type: 'hexSet', offset, value: input.value, ...currentParams() });
|
||||
}
|
||||
function load() {
|
||||
document.getElementById('status').textContent = 'Loading...';
|
||||
vscode.postMessage({ type: 'hexLoad', ...currentParams() });
|
||||
}
|
||||
function gotoHit(delta) {
|
||||
if (!hits.length) return;
|
||||
hitIndex = (hitIndex + delta + hits.length) % hits.length;
|
||||
const offset = Number(hits[hitIndex].offset || hits[hitIndex].start || 0);
|
||||
const aligned = Math.max(0, Math.floor(offset / width) * width);
|
||||
document.getElementById('start').value = String(aligned);
|
||||
load();
|
||||
}
|
||||
document.getElementById('width16').onclick = () => { width = 16; document.getElementById('width16').classList.add('active'); document.getElementById('width32').classList.remove('active'); load(); };
|
||||
document.getElementById('width32').onclick = () => { width = 32; document.getElementById('width32').classList.add('active'); document.getElementById('width16').classList.remove('active'); load(); };
|
||||
document.getElementById('load').onclick = load;
|
||||
document.getElementById('prev').onclick = () => { const p = currentParams(); document.getElementById('start').value = String(Math.max(0, p.start - p.bytes)); load(); };
|
||||
document.getElementById('next').onclick = () => { const p = currentParams(); document.getElementById('start').value = String(p.start + p.bytes); load(); };
|
||||
document.getElementById('search').onclick = () => {
|
||||
const mode = document.getElementById('searchMode').value === 'hex' ? 'hex' : 'ascii';
|
||||
vscode.postMessage({ type: 'hexSearch', mode, query: document.getElementById('searchQuery').value });
|
||||
};
|
||||
document.getElementById('searchPrev').onclick = () => gotoHit(-1);
|
||||
document.getElementById('searchNext').onclick = () => gotoHit(1);
|
||||
document.getElementById('undo').onclick = () => vscode.postMessage({ type: 'undo', ...currentParams() });
|
||||
document.getElementById('redo').onclick = () => vscode.postMessage({ type: 'redo', ...currentParams() });
|
||||
document.getElementById('revert').onclick = () => vscode.postMessage({ type: 'revert', ...currentParams() });
|
||||
window.addEventListener('message', event => {
|
||||
const msg = event.data || {};
|
||||
if (msg.type === 'fileState') renderFileState(msg.state);
|
||||
if (msg.type === 'hexRows') {
|
||||
rows = msg.rows || [];
|
||||
width = Number(msg.width || width);
|
||||
renderRows();
|
||||
document.getElementById('status').textContent = rows.length ? rows.length + ' rows' : 'No bytes in range';
|
||||
}
|
||||
if (msg.type === 'hexSearchResults') {
|
||||
hits = msg.hits || [];
|
||||
hitIndex = hits.length ? 0 : -1;
|
||||
document.getElementById('status').textContent = hits.length + ' hit' + (hits.length === 1 ? '' : 's');
|
||||
if (hits.length) {
|
||||
const offset = Number(hits[0].offset || hits[0].start || 0);
|
||||
document.getElementById('start').value = String(Math.max(0, Math.floor(offset / width) * width));
|
||||
load();
|
||||
} else {
|
||||
renderRows();
|
||||
}
|
||||
}
|
||||
if (msg.type === 'hexEditError') document.getElementById('status').textContent = msg.error || 'Edit failed';
|
||||
if (msg.type === 'state') renderFileState(msg.state);
|
||||
if (msg.type === 'error') document.getElementById('status').textContent = msg.error || '';
|
||||
});
|
||||
renderFileState(initialFileState);
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
html(meta) {
|
||||
const nonce = String(Date.now());
|
||||
const defaults = JSON.stringify(DEFAULTS[this.tool] || {});
|
||||
const initialFileState = JSON.stringify(this.fileState || null);
|
||||
const name = escapeHtml(meta.name);
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}';">
|
||||
<style>
|
||||
body { margin: 0; color: var(--vscode-foreground); background: var(--vscode-editor-background); font: var(--vscode-font-size) var(--vscode-font-family); }
|
||||
header { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--vscode-panel-border); }
|
||||
h1 { margin: 0; font-size: 15px; }
|
||||
.title-wrap { min-width: 0; }
|
||||
.file-state { color: var(--vscode-descriptionForeground); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
main { display: grid; grid-template-columns: minmax(260px, 360px) 1fr; height: calc(100vh - 58px); }
|
||||
textarea { width: calc(100% - 24px); height: calc(100% - 66px); margin: 12px; resize: none; color: var(--vscode-input-foreground); background: var(--vscode-input-background); border: 1px solid var(--vscode-input-border); font-family: var(--vscode-editor-font-family); }
|
||||
.left { border-right: 1px solid var(--vscode-panel-border); }
|
||||
.actions { display: flex; gap: 6px; padding: 0 12px 12px; }
|
||||
button { padding: 5px 10px; }
|
||||
#out { margin: 0; padding: 12px; overflow: auto; height: calc(100% - 24px); box-sizing: border-box; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 0 0 12px; }
|
||||
th, td { border: 1px solid var(--vscode-panel-border); padding: 4px 6px; text-align: left; vertical-align: top; font-family: var(--vscode-editor-font-family); font-size: 12px; }
|
||||
th { background: var(--vscode-editorWidget-background); position: sticky; top: 0; }
|
||||
pre { white-space: pre-wrap; word-break: break-word; }
|
||||
img { max-width: 100%; height: auto; border: 1px solid var(--vscode-panel-border); }
|
||||
h2 { font-size: 14px; margin: 12px 0 6px; }
|
||||
.hex-table td, .hex-table th { font-family: Menlo, Monaco, Consolas, "Courier New", monospace; }
|
||||
.hex-cell { display: inline-block; min-width: 2.2ch; margin-right: .35ch; }
|
||||
.ascii { color: var(--vscode-descriptionForeground); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="title-wrap"><h1>${name}</h1><div id="fileState" class="file-state">Checking loaded file...</div></div>
|
||||
<div><button id="undo">Undo</button><button id="redo">Redo</button><button id="revert">Revert</button></div>
|
||||
</header>
|
||||
<main>
|
||||
<section class="left">
|
||||
<textarea id="params" spellcheck="false">${escapeHtml(JSON.stringify(JSON.parse(defaults), null, 2))}</textarea>
|
||||
<div class="actions"><button id="run">Run</button></div>
|
||||
</section>
|
||||
<section id="out">Run the tool to see results.</section>
|
||||
</main>
|
||||
<script nonce="${nonce}">
|
||||
const vscode = acquireVsCodeApi();
|
||||
const initialFileState = ${initialFileState};
|
||||
document.getElementById('run').onclick = () => vscode.postMessage({ type: 'run', params: document.getElementById('params').value });
|
||||
document.getElementById('undo').onclick = () => vscode.postMessage({ type: 'undo' });
|
||||
document.getElementById('redo').onclick = () => vscode.postMessage({ type: 'redo' });
|
||||
document.getElementById('revert').onclick = () => vscode.postMessage({ type: 'revert' });
|
||||
function esc(value) {
|
||||
return String(value == null ? '' : value).replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]));
|
||||
}
|
||||
function table(rows) {
|
||||
if (!Array.isArray(rows) || rows.length === 0 || typeof rows[0] !== 'object') return '<pre>' + esc(JSON.stringify(rows, null, 2)) + '</pre>';
|
||||
const keys = Array.from(rows.reduce((set, row) => {
|
||||
Object.keys(row || {}).slice(0, 12).forEach(key => set.add(key));
|
||||
return set;
|
||||
}, new Set()));
|
||||
return '<table><thead><tr>' + keys.map(k => '<th>' + esc(k) + '</th>').join('') + '</tr></thead><tbody>' +
|
||||
rows.slice(0, 1000).map(row => '<tr>' + keys.map(k => '<td>' + esc(typeof row[k] === 'object' ? JSON.stringify(row[k]) : row[k]) + '</td>').join('') + '</tr>').join('') +
|
||||
'</tbody></table>' + (rows.length > 1000 ? '<p>Showing first 1000 rows.</p>' : '');
|
||||
}
|
||||
function renderHexRows(rows) {
|
||||
return '<table class="hex-table"><thead><tr><th>Offset</th><th>Hex</th><th>ASCII</th></tr></thead><tbody>' +
|
||||
rows.map(row => {
|
||||
const cells = Array.isArray(row.bytes) ? row.bytes.map(cell => '<span class="hex-cell">' + esc(cell && cell.hex ? cell.hex : cell) + '</span>').join('') : esc(row.hex || '');
|
||||
return '<tr><td>' + esc(row.offset) + '</td><td>' + cells + '</td><td class="ascii">' + esc(row.ascii || '') + '</td></tr>';
|
||||
}).join('') + '</tbody></table>';
|
||||
}
|
||||
function render(value) {
|
||||
if (value && value.mime === 'image/png' && value.pngBase64) {
|
||||
const summary = value.summary ? '<h2>Summary</h2>' + render(value.summary) : '';
|
||||
return '<img src="data:image/png;base64,' + value.pngBase64 + '">' + summary;
|
||||
}
|
||||
if (Array.isArray(value)) return table(value);
|
||||
if (!value || typeof value !== 'object') return '<pre>' + esc(value) + '</pre>';
|
||||
if (Array.isArray(value.rows) && value.rows.length && Object.prototype.hasOwnProperty.call(value.rows[0], 'ascii')) {
|
||||
return renderHexRows(value.rows);
|
||||
}
|
||||
const preferred = ['rows', 'items', 'hits', 'results', 'entries', 'plan'];
|
||||
let html = '';
|
||||
for (const key of preferred) {
|
||||
if (Array.isArray(value[key])) html += '<h2>' + esc(key) + '</h2>' + table(value[key]);
|
||||
}
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
if (preferred.includes(key) && Array.isArray(val)) continue;
|
||||
html += '<h2>' + esc(key) + '</h2>' + (Array.isArray(val) ? table(val) : '<pre>' + esc(JSON.stringify(val, null, 2)) + '</pre>');
|
||||
}
|
||||
return html || '<pre>' + esc(JSON.stringify(value, null, 2)) + '</pre>';
|
||||
}
|
||||
function renderFileState(s) {
|
||||
document.getElementById('fileState').textContent = s && s.name ? s.name + ' • ' + Number(s.size || 0).toLocaleString() + ' bytes' + (s.dirty ? ' • modified' : '') : 'No file loaded';
|
||||
}
|
||||
window.addEventListener('message', event => {
|
||||
const msg = event.data || {};
|
||||
if (msg.type === 'result') document.getElementById('out').innerHTML = render(msg.result);
|
||||
if (msg.type === 'state') document.getElementById('out').innerHTML = render(msg.state);
|
||||
if (msg.type === 'fileState') renderFileState(msg.state);
|
||||
if (msg.type === 'error') document.getElementById('out').textContent = msg.error;
|
||||
});
|
||||
renderFileState(initialFileState);
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFile(bridge, compare, view) {
|
||||
const picked = await vscode.window.showOpenDialog({ canSelectFiles: true, canSelectFolders: false, canSelectMany: false });
|
||||
if (!picked || !picked.length) return null;
|
||||
await bridge.rpc('LeviathanLoadFile', [picked[0].fsPath, compare]);
|
||||
if (view) await view.refreshState();
|
||||
return bridge.rpc('LeviathanState');
|
||||
}
|
||||
|
||||
async function saveFile(bridge, view) {
|
||||
const state = await bridge.rpc('LeviathanState');
|
||||
const defaultName = state && state.name ? `${state.name}.patched` : 'working-copy.bin';
|
||||
const picked = await vscode.window.showSaveDialog({ defaultUri: vscode.Uri.file(defaultName) });
|
||||
if (!picked) return;
|
||||
await bridge.rpc('LeviathanSaveWorkingToPath', [picked.fsPath]);
|
||||
await view.refreshState();
|
||||
}
|
||||
|
||||
async function ensureLoadedFile(bridge, toolsView) {
|
||||
try {
|
||||
const state = await bridge.rpc('LeviathanState');
|
||||
if (state && state.name) return state;
|
||||
} catch (_) {
|
||||
// Fall through to picker.
|
||||
}
|
||||
const loaded = await loadFile(bridge, false, toolsView);
|
||||
if (!loaded || !loaded.name) {
|
||||
throw new Error('Load a file before opening a Leviathan tool.');
|
||||
}
|
||||
return loaded;
|
||||
}
|
||||
|
||||
async function openTool(context, bridge, toolsView, tool) {
|
||||
const state = await ensureLoadedFile(bridge, toolsView);
|
||||
const autoRun = !['case', 'patch-apply', 'spectrogram-encode'].includes(tool);
|
||||
new ToolPanel(context, bridge, tool, { autoRun, fileState: state });
|
||||
}
|
||||
|
||||
async function openSelectedTool(context, bridge, toolsView, tool, resource, selectedResources) {
|
||||
const target = Array.isArray(selectedResources) && selectedResources.length > 0
|
||||
? selectedResources[0]
|
||||
: resource;
|
||||
if (!target || target.scheme !== 'file') {
|
||||
vscode.window.showErrorMessage('Select a local file before opening it in Leviathan.');
|
||||
return;
|
||||
}
|
||||
await bridge.rpc('LeviathanLoadFile', [target.fsPath, false]);
|
||||
await toolsView.refreshState();
|
||||
const state = await bridge.rpc('LeviathanState');
|
||||
const autoRun = !['case', 'patch-apply', 'spectrogram-encode'].includes(tool);
|
||||
new ToolPanel(context, bridge, tool, { autoRun, fileState: state });
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value).replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]));
|
||||
}
|
||||
|
||||
async function activate(context) {
|
||||
const bridge = new Bridge(context);
|
||||
await bridge.start();
|
||||
const toolsView = new ToolsView(context, bridge);
|
||||
context.subscriptions.push(vscode.window.registerWebviewViewProvider('leviathan.tools', toolsView));
|
||||
context.subscriptions.push(vscode.commands.registerCommand('leviathan.loadFile', () => loadFile(bridge, false, toolsView)));
|
||||
context.subscriptions.push(vscode.commands.registerCommand('leviathan.loadCompare', () => loadFile(bridge, true, toolsView)));
|
||||
context.subscriptions.push(vscode.commands.registerCommand('leviathan.saveFile', () => saveFile(bridge, toolsView)));
|
||||
context.subscriptions.push(vscode.commands.registerCommand('leviathan.openTool', tool => openTool(context, bridge, toolsView, tool)));
|
||||
for (const tool of TOOLS) {
|
||||
context.subscriptions.push(vscode.commands.registerCommand(
|
||||
`leviathan.openSelected.${tool.id}`,
|
||||
(resource, selectedResources) => openSelectedTool(context, bridge, toolsView, tool.id, resource, selectedResources)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
function deactivate() {}
|
||||
|
||||
module.exports = { activate, deactivate };
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5.5 4.2h8.7l4.3 4.3v11.3H5.5V4.2Z"/>
|
||||
<path d="M14.2 4.2v4.3h4.3"/>
|
||||
<path d="M8.4 8.2h2.2"/>
|
||||
<path d="M8.4 11.4h4.9"/>
|
||||
<path d="M8.4 14.6h2.9"/>
|
||||
<path d="m15.1 14.3 1.5 1.5"/>
|
||||
<circle cx="13.9" cy="13.1" r="2.1"/>
|
||||
<path d="M8.4 17.8h6.8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 433 B |
@@ -0,0 +1,312 @@
|
||||
{
|
||||
"name": "leviathan",
|
||||
"displayName": "Leviathan",
|
||||
"description": "Leviathan binary analysis tools inside Visual Studio Code.",
|
||||
"version": "0.1.0",
|
||||
"publisher": "andydixon",
|
||||
"engines": {
|
||||
"vscode": "^1.90.0"
|
||||
},
|
||||
"categories": [
|
||||
"Other"
|
||||
],
|
||||
"files": [
|
||||
"extension.js",
|
||||
"package.json",
|
||||
"media/**",
|
||||
"bin/**"
|
||||
],
|
||||
"activationEvents": [
|
||||
"onView:leviathan.tools",
|
||||
"onCommand:leviathan.openSelected.hex",
|
||||
"onCommand:leviathan.openSelected.overview",
|
||||
"onCommand:leviathan.openSelected.hashes",
|
||||
"onCommand:leviathan.openSelected.entropy",
|
||||
"onCommand:leviathan.openSelected.strings",
|
||||
"onCommand:leviathan.openSelected.iocs",
|
||||
"onCommand:leviathan.openSelected.search",
|
||||
"onCommand:leviathan.openSelected.multi-search",
|
||||
"onCommand:leviathan.openSelected.parse",
|
||||
"onCommand:leviathan.openSelected.disasm",
|
||||
"onCommand:leviathan.openSelected.pdf",
|
||||
"onCommand:leviathan.openSelected.squashfs",
|
||||
"onCommand:leviathan.openSelected.spectrogram-encode",
|
||||
"onCommand:leviathan.openSelected.spectrogram-view",
|
||||
"onCommand:leviathan.openSelected.diff",
|
||||
"onCommand:leviathan.openSelected.carve",
|
||||
"onCommand:leviathan.openSelected.keys",
|
||||
"onCommand:leviathan.openSelected.timestamps",
|
||||
"onCommand:leviathan.openSelected.stego",
|
||||
"onCommand:leviathan.openSelected.metadata",
|
||||
"onCommand:leviathan.openSelected.binsnoop",
|
||||
"onCommand:leviathan.openSelected.rules",
|
||||
"onCommand:leviathan.openSelected.patch-plan",
|
||||
"onCommand:leviathan.openSelected.patch-apply",
|
||||
"onCommand:leviathan.openSelected.transform",
|
||||
"onCommand:leviathan.openSelected.case"
|
||||
],
|
||||
"main": "./extension.js",
|
||||
"contributes": {
|
||||
"commands": [
|
||||
{
|
||||
"command": "leviathan.loadFile",
|
||||
"title": "Load Binary"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.loadCompare",
|
||||
"title": "Load Compare Binary"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.saveFile",
|
||||
"title": "Save Working Copy"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openTool",
|
||||
"title": "Open Leviathan Tool"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.hex",
|
||||
"title": "Hex Editor"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.overview",
|
||||
"title": "Overview"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.hashes",
|
||||
"title": "Hashes"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.entropy",
|
||||
"title": "Entropy"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.strings",
|
||||
"title": "Strings"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.iocs",
|
||||
"title": "IOCs"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.search",
|
||||
"title": "Search"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.multi-search",
|
||||
"title": "Multi-search"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.parse",
|
||||
"title": "Structures"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.disasm",
|
||||
"title": "Disasm"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.pdf",
|
||||
"title": "PDF"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.squashfs",
|
||||
"title": "SquashFS"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.spectrogram-encode",
|
||||
"title": "Spectrogram Encode"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.spectrogram-view",
|
||||
"title": "Spectrogram View"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.diff",
|
||||
"title": "Diff"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.carve",
|
||||
"title": "Carve"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.keys",
|
||||
"title": "Keys"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.timestamps",
|
||||
"title": "Timestamps"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.stego",
|
||||
"title": "Stego"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.metadata",
|
||||
"title": "Metadata"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.binsnoop",
|
||||
"title": "Binsnoop"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.rules",
|
||||
"title": "Rules"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.patch-plan",
|
||||
"title": "Patch Plan"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.patch-apply",
|
||||
"title": "Patch Apply"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.transform",
|
||||
"title": "Transforms"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.case",
|
||||
"title": "Case Export"
|
||||
}
|
||||
],
|
||||
"submenus": [
|
||||
{
|
||||
"id": "leviathan.contextTools",
|
||||
"label": "Leviathan"
|
||||
}
|
||||
],
|
||||
"viewsContainers": {
|
||||
"activitybar": [
|
||||
{
|
||||
"id": "leviathan",
|
||||
"title": "Leviathan",
|
||||
"icon": "media/leviathan.svg"
|
||||
}
|
||||
]
|
||||
},
|
||||
"views": {
|
||||
"leviathan": [
|
||||
{
|
||||
"id": "leviathan.tools",
|
||||
"name": "Tools",
|
||||
"type": "webview"
|
||||
}
|
||||
]
|
||||
},
|
||||
"menus": {
|
||||
"explorer/context": [
|
||||
{
|
||||
"submenu": "leviathan.contextTools",
|
||||
"when": "resourceScheme == file && explorerResourceIsFolder == false",
|
||||
"group": "navigation"
|
||||
}
|
||||
],
|
||||
"leviathan.contextTools": [
|
||||
{
|
||||
"command": "leviathan.openSelected.hex",
|
||||
"group": "01_editor"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.overview",
|
||||
"group": "02_triage@1"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.hashes",
|
||||
"group": "02_triage@2"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.entropy",
|
||||
"group": "02_triage@3"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.strings",
|
||||
"group": "02_triage@4"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.iocs",
|
||||
"group": "02_triage@5"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.search",
|
||||
"group": "03_find@1"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.multi-search",
|
||||
"group": "03_find@2"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.parse",
|
||||
"group": "04_structure@1"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.disasm",
|
||||
"group": "04_structure@2"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.pdf",
|
||||
"group": "04_structure@3"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.squashfs",
|
||||
"group": "04_structure@4"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.spectrogram-encode",
|
||||
"group": "05_audio@1"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.spectrogram-view",
|
||||
"group": "05_audio@2"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.diff",
|
||||
"group": "06_compare"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.carve",
|
||||
"group": "07_recover@1"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.keys",
|
||||
"group": "07_recover@2"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.timestamps",
|
||||
"group": "07_recover@3"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.stego",
|
||||
"group": "07_recover@4"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.metadata",
|
||||
"group": "07_recover@5"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.binsnoop",
|
||||
"group": "08_advanced@1"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.rules",
|
||||
"group": "08_advanced@2"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.patch-plan",
|
||||
"group": "09_edit@1"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.patch-apply",
|
||||
"group": "09_edit@2"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.transform",
|
||||
"group": "09_edit@3"
|
||||
},
|
||||
{
|
||||
"command": "leviathan.openSelected.case",
|
||||
"group": "10_case"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
BIN
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 2.8 20.2 7.4v9.2L12 21.2l-8.2-4.6V7.4L12 2.8Z"/>
|
||||
<path d="M7.2 8.6h9.6a1.6 1.6 0 0 1 1.6 1.6v5.6a1.6 1.6 0 0 1-1.6 1.6H7.2a1.6 1.6 0 0 1-1.6-1.6v-5.6a1.6 1.6 0 0 1 1.6-1.6Z"/>
|
||||
<path d="m8.3 11 2 2-2 2"/>
|
||||
<path d="M12 15h3.7"/>
|
||||
<path d="M12 2.8v3.1"/>
|
||||
<path d="M4.1 7.6 6.8 9"/>
|
||||
<path d="m17.2 9 2.7-1.4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 501 B |
Vendored
+7
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(globalThis,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core._renderService.dimensions;if(0===e.css.cell.width||0===e.css.cell.height)return;const t=0===this._terminal.options.scrollback?0:this._terminal.options.overviewRuler?.width||14,r=window.getComputedStyle(this._terminal.element.parentElement),i=parseInt(r.getPropertyValue("height")),o=Math.max(0,parseInt(r.getPropertyValue("width"))),s=window.getComputedStyle(this._terminal.element),n=i-(parseInt(s.getPropertyValue("padding-top"))+parseInt(s.getPropertyValue("padding-bottom"))),l=o-(parseInt(s.getPropertyValue("padding-right"))+parseInt(s.getPropertyValue("padding-left")))-t;return{cols:Math.max(2,Math.floor(l/e.css.cell.width)),rows:Math.max(1,Math.floor(n/e.css.cell.height))}}}})(),e})()));
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
||||
* https://github.com/chjj/term.js
|
||||
* @license MIT
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*
|
||||
* Originally forked from (with the author's permission):
|
||||
* Fabrice Bellard's javascript vt100 for jslinux:
|
||||
* http://bellard.org/jslinux/
|
||||
* Copyright (c) 2011 Fabrice Bellard
|
||||
* The original design remains. The terminal itself
|
||||
* has been extended to include xterm CSI codes, among
|
||||
* other features.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default styles for xterm.js
|
||||
*/
|
||||
|
||||
.xterm {
|
||||
cursor: text;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.xterm.focus,
|
||||
.xterm:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-helpers {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
/**
|
||||
* The z-index of the helpers must be higher than the canvases in order for
|
||||
* IMEs to appear on top.
|
||||
*/
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.xterm .xterm-helper-textarea {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
left: -9999em;
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
z-index: -5;
|
||||
/** Prevent wrapping so the IME appears against the textarea at the correct position */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.xterm .composition-view {
|
||||
/* TODO: Composition position got messed up somewhere */
|
||||
background: #000;
|
||||
color: #FFF;
|
||||
display: none;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.xterm .composition-view.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport {
|
||||
/* On OS X this is required in order for the scroll bar to appear fully opaque */
|
||||
background-color: #000;
|
||||
overflow-y: scroll;
|
||||
cursor: default;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen canvas {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.xterm-char-measure-element {
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -9999em;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.xterm.enable-mouse-events {
|
||||
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.xterm.xterm-cursor-pointer,
|
||||
.xterm .xterm-cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xterm.column-select.focus {
|
||||
/* Column selection mode */
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility:not(.debug),
|
||||
.xterm .xterm-message {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
color: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility-tree {
|
||||
font-family: monospace;
|
||||
user-select: text;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility-tree > div {
|
||||
transform-origin: left;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.xterm .live-region {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xterm-dim {
|
||||
/* Dim should not apply to background, so the opacity of the foreground color is applied
|
||||
* explicitly in the generated class and reset to 1 here */
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.xterm-underline-1 { text-decoration: underline; }
|
||||
.xterm-underline-2 { text-decoration: double underline; }
|
||||
.xterm-underline-3 { text-decoration: wavy underline; }
|
||||
.xterm-underline-4 { text-decoration: dotted underline; }
|
||||
.xterm-underline-5 { text-decoration: dashed underline; }
|
||||
|
||||
.xterm-overline {
|
||||
text-decoration: overline;
|
||||
}
|
||||
|
||||
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
|
||||
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
|
||||
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
|
||||
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
|
||||
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
|
||||
|
||||
.xterm-strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration {
|
||||
z-index: 6;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
|
||||
z-index: 7;
|
||||
}
|
||||
|
||||
.xterm-decoration-overview-ruler {
|
||||
z-index: 8;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm-decoration-top {
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Derived from vs/base/browser/ui/scrollbar/media/scrollbar.css */
|
||||
|
||||
/* xterm.js customization: Override xterm's cursor style */
|
||||
.xterm .xterm-scrollable-element > .scrollbar {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Arrows */
|
||||
.xterm .xterm-scrollable-element > .scrollbar > .scra {
|
||||
cursor: pointer;
|
||||
font-size: 11px !important;
|
||||
}
|
||||
|
||||
.xterm .xterm-scrollable-element > .visible {
|
||||
opacity: 1;
|
||||
|
||||
/* Background rule added for IE9 - to allow clicks on dom node */
|
||||
background:rgba(0,0,0,0);
|
||||
|
||||
transition: opacity 100ms linear;
|
||||
/* In front of peek view */
|
||||
z-index: 11;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .invisible {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .invisible.fade {
|
||||
transition: opacity 800ms linear;
|
||||
}
|
||||
|
||||
/* Scrollable Content Inset Shadow */
|
||||
.xterm .xterm-scrollable-element > .shadow {
|
||||
position: absolute;
|
||||
display: none;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .shadow.top {
|
||||
display: block;
|
||||
top: 0;
|
||||
left: 3px;
|
||||
height: 3px;
|
||||
width: 100%;
|
||||
box-shadow: var(--vscode-scrollbar-shadow, #000) 0 6px 6px -6px inset;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .shadow.left {
|
||||
display: block;
|
||||
top: 3px;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 3px;
|
||||
box-shadow: var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .shadow.top-left-corner {
|
||||
display: block;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
width: 3px;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .shadow.top.left {
|
||||
box-shadow: var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset;
|
||||
}
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"name": "nexus",
|
||||
"displayName": "Nexus",
|
||||
"description": "Nexus terminal connections inside Visual Studio Code.",
|
||||
"version": "0.1.0",
|
||||
"publisher": "andydixon",
|
||||
"engines": {
|
||||
"vscode": "^1.90.0"
|
||||
},
|
||||
"categories": [
|
||||
"Other"
|
||||
],
|
||||
"files": [
|
||||
"extension.js",
|
||||
"package.json",
|
||||
"media/**",
|
||||
"bin/**"
|
||||
],
|
||||
"activationEvents": [
|
||||
"onView:nexus.connections",
|
||||
"onCommand:nexus.newProfile",
|
||||
"onCommand:nexus.openSettings"
|
||||
],
|
||||
"main": "./extension.js",
|
||||
"contributes": {
|
||||
"commands": [
|
||||
{
|
||||
"command": "nexus.refreshConnections",
|
||||
"title": "Refresh Connections"
|
||||
},
|
||||
{
|
||||
"command": "nexus.connectLocal",
|
||||
"title": "Connect Local Terminal"
|
||||
},
|
||||
{
|
||||
"command": "nexus.openConnection",
|
||||
"title": "Open Nexus Connection"
|
||||
},
|
||||
{
|
||||
"command": "nexus.newProfile",
|
||||
"title": "New Nexus Profile"
|
||||
},
|
||||
{
|
||||
"command": "nexus.editProfile",
|
||||
"title": "Edit Nexus Profile"
|
||||
},
|
||||
{
|
||||
"command": "nexus.duplicateProfile",
|
||||
"title": "Duplicate Nexus Profile"
|
||||
},
|
||||
{
|
||||
"command": "nexus.deleteProfile",
|
||||
"title": "Delete Nexus Profile"
|
||||
},
|
||||
{
|
||||
"command": "nexus.openSettings",
|
||||
"title": "Nexus Settings"
|
||||
},
|
||||
{
|
||||
"command": "nexus.openSftp",
|
||||
"title": "Open SFTP Browser"
|
||||
}
|
||||
],
|
||||
"viewsContainers": {
|
||||
"activitybar": [
|
||||
{
|
||||
"id": "nexus",
|
||||
"title": "Nexus",
|
||||
"icon": "media/nexus.svg"
|
||||
}
|
||||
]
|
||||
},
|
||||
"views": {
|
||||
"nexus": [
|
||||
{
|
||||
"id": "nexus.connections",
|
||||
"name": "Connections",
|
||||
"type": "webview"
|
||||
}
|
||||
]
|
||||
},
|
||||
"menus": {
|
||||
"view/item/context": [
|
||||
{
|
||||
"command": "nexus.openConnection",
|
||||
"when": "view == nexus.connections && viewItem == profile",
|
||||
"group": "inline"
|
||||
},
|
||||
{
|
||||
"command": "nexus.editProfile",
|
||||
"when": "view == nexus.connections && viewItem == profile",
|
||||
"group": "navigation@1"
|
||||
},
|
||||
{
|
||||
"command": "nexus.duplicateProfile",
|
||||
"when": "view == nexus.connections && viewItem == profile",
|
||||
"group": "navigation@2"
|
||||
},
|
||||
{
|
||||
"command": "nexus.deleteProfile",
|
||||
"when": "view == nexus.connections && viewItem == profile",
|
||||
"group": "navigation@3"
|
||||
},
|
||||
{
|
||||
"command": "nexus.openSftp",
|
||||
"when": "view == nexus.connections && viewItem == profile",
|
||||
"group": "navigation@4"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user