This commit is contained in:
@@ -190,18 +190,23 @@ type taskCard struct {
|
||||
}
|
||||
|
||||
type taskDTO struct {
|
||||
ID string `json:"id"`
|
||||
PublicSeed string `json:"public_seed"`
|
||||
RangeBits int `json:"range_bits"`
|
||||
NextSeq int64 `json:"next_seq"`
|
||||
ServerMinIntervalSec int `json:"server_min_interval_sec"`
|
||||
ClientSubmitIntervalSec int `json:"client_submit_interval_sec"`
|
||||
DefaultMaxNodes int `json:"default_max_nodes"`
|
||||
Paused bool `json:"paused"`
|
||||
Revision int64 `json:"revision"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
ParentTaskID *string `json:"parent_task_id"`
|
||||
ID string `json:"id"`
|
||||
PublicSeed string `json:"public_seed"`
|
||||
RangeBits int `json:"range_bits"`
|
||||
NextSeq int64 `json:"next_seq"`
|
||||
ServerMinIntervalSec int `json:"server_min_interval_sec"`
|
||||
ClientSubmitIntervalSec int `json:"client_submit_interval_sec"`
|
||||
DefaultMaxNodes int `json:"default_max_nodes"`
|
||||
GuessLotteryWindowSec int `json:"guess_lottery_window_sec"`
|
||||
GuessLotteryMaxAccepted int `json:"guess_lottery_max_accepted"`
|
||||
BeaconHuntEnabled int `json:"beacon_hunt_enabled"`
|
||||
BeaconBonusWeight int `json:"beacon_bonus_weight"`
|
||||
BeaconPaths []string `json:"beacon_paths"`
|
||||
Paused bool `json:"paused"`
|
||||
Revision int64 `json:"revision"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
ParentTaskID *string `json:"parent_task_id"`
|
||||
}
|
||||
|
||||
type point struct {
|
||||
@@ -243,6 +248,15 @@ type publicArtifact struct {
|
||||
PreviewURI string `json:"preview_uri"`
|
||||
}
|
||||
|
||||
type ownedArtifact struct {
|
||||
TaskID string `json:"task_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
RangeBits int `json:"range_bits"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
PreviewURI string `json:"preview_uri"`
|
||||
DownloadURI string `json:"download_uri"`
|
||||
}
|
||||
|
||||
func (c *apiClient) tasks(ctx context.Context) ([]taskCard, error) {
|
||||
var out []taskCard
|
||||
err := c.do(ctx, http.MethodGet, "/api/tasks", nil, &out)
|
||||
@@ -273,6 +287,18 @@ func (c *apiClient) me(ctx context.Context) (meDTO, error) {
|
||||
return out, err
|
||||
}
|
||||
|
||||
type hostedLinkCode struct {
|
||||
Code string `json:"code"`
|
||||
ClientID string `json:"client_id"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
func (c *apiClient) hostedLinkCode(ctx context.Context) (hostedLinkCode, error) {
|
||||
var out hostedLinkCode
|
||||
err := c.do(ctx, http.MethodPost, "/api/me/customer-link", map[string]any{}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *apiClient) leaderboard(ctx context.Context) ([]leader, error) {
|
||||
var out []leader
|
||||
err := c.do(ctx, http.MethodGet, "/api/leaderboard", nil, &out)
|
||||
@@ -285,14 +311,25 @@ func (c *apiClient) artifacts(ctx context.Context, limit int) ([]publicArtifact,
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *apiClient) guess(ctx context.Context, t taskDTO, seq int64) (bool, error) {
|
||||
func (c *apiClient) ownedArtifacts(ctx context.Context, limit int) ([]ownedArtifact, error) {
|
||||
var out []ownedArtifact
|
||||
err := c.do(ctx, http.MethodGet, "/api/me/artifacts?limit="+strconv.Itoa(limit), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *apiClient) guess(ctx context.Context, t taskDTO, seq int64, beaconPath string) (bool, error) {
|
||||
guess := expectedGuess(t.ID, t.PublicSeed, c.cid, seq, t.RangeBits)
|
||||
sig, err := signRaw(c.key, fmt.Sprintf("guess|%s|%d|%s", t.ID, seq, guess))
|
||||
msg := fmt.Sprintf("guess|%s|%d|%s", t.ID, seq, guess)
|
||||
if t.BeaconHuntEnabled == 1 && t.GuessLotteryMaxAccepted > 0 {
|
||||
beaconPath = strings.ToUpper(strings.TrimSpace(beaconPath))
|
||||
msg += "|" + beaconPath
|
||||
}
|
||||
sig, err := signRaw(c.key, msg)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
var correct bool
|
||||
err = c.do(ctx, http.MethodPost, "/api/tasks/"+url.PathEscape(t.ID)+"/guess", map[string]any{"seq": seq, "guess": guess, "signature": sig}, &correct)
|
||||
err = c.do(ctx, http.MethodPost, "/api/tasks/"+url.PathEscape(t.ID)+"/guess", map[string]any{"seq": seq, "guess": guess, "signature": sig, "beacon_path": beaconPath}, &correct)
|
||||
return correct, err
|
||||
}
|
||||
|
||||
@@ -348,6 +385,38 @@ func (c *apiClient) downloadPreview(ctx context.Context, taskID, dest string) er
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *apiClient) downloadOwnedArtifact(ctx context.Context, taskID, dest string) error {
|
||||
path := "/api/me/artifacts/" + url.PathEscape(taskID) + "/download"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return &apiError{Status: resp.StatusCode, Body: string(b)}
|
||||
}
|
||||
if dir := filepath.Dir(dest); dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
f, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, io.LimitReader(resp.Body, 64<<20))
|
||||
return err
|
||||
}
|
||||
|
||||
func expectedGuess(taskID, seed, clientID string, seq int64, bits int) string {
|
||||
// Keep this exactly aligned with internal/core.ExpectedGuess without importing
|
||||
// implementation details into the terminal UX layer.
|
||||
@@ -355,3 +424,81 @@ func expectedGuess(taskID, seed, clientID string, seq int64, bits int) string {
|
||||
}
|
||||
|
||||
var errSwitching = errors.New("task switch in progress")
|
||||
|
||||
// registerHostedWorker binds this worker's freshly authenticated cryptographic
|
||||
// identity to its Customer Service worker record. The endpoint is reachable
|
||||
// only on the private Docker network and additionally requires the per-worker
|
||||
// one-time bearer token injected by the Customer Service.
|
||||
func (c *apiClient) registerHostedWorker(ctx context.Context) error {
|
||||
registerURL := strings.TrimSpace(os.Getenv("NEURALHUNT_WORKER_REGISTER_URL"))
|
||||
workerID := strings.TrimSpace(os.Getenv("NEURALHUNT_WORKER_ID"))
|
||||
token := strings.TrimSpace(os.Getenv("NEURALHUNT_WORKER_REGISTER_TOKEN"))
|
||||
if registerURL == "" && workerID == "" && token == "" {
|
||||
return nil
|
||||
}
|
||||
if registerURL == "" || workerID == "" || token == "" {
|
||||
return errors.New("hosted worker registration requires NEURALHUNT_WORKER_REGISTER_URL, NEURALHUNT_WORKER_ID and NEURALHUNT_WORKER_REGISTER_TOKEN")
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"worker_id": workerID, "client_id": c.cid})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, registerURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
hc := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hosted worker register: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return fmt.Errorf("hosted worker register HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hostedWorkerLeaseLoop is a fail-closed control-plane lease. Billing remains
|
||||
// authoritative in Customer Service; this loop merely prevents a managed
|
||||
// worker from running indefinitely if the Customer Service/Docker controller
|
||||
// disappears. After three consecutive missed 20-second renewals it cancels the
|
||||
// client context and the managed container exits.
|
||||
func (c *apiClient) hostedWorkerLeaseLoop(ctx context.Context, cancel context.CancelFunc) {
|
||||
leaseURL := strings.TrimSpace(os.Getenv("NEURALHUNT_WORKER_LEASE_URL"))
|
||||
workerID := strings.TrimSpace(os.Getenv("NEURALHUNT_WORKER_ID"))
|
||||
token := strings.TrimSpace(os.Getenv("NEURALHUNT_WORKER_REGISTER_TOKEN"))
|
||||
if leaseURL == "" || workerID == "" || token == "" {
|
||||
return
|
||||
}
|
||||
t := time.NewTicker(20 * time.Second)
|
||||
defer t.Stop()
|
||||
failures := 0
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
body, _ := json.Marshal(map[string]string{"worker_id": workerID})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, leaseURL, bytes.NewReader(body))
|
||||
if err == nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, callErr := (&http.Client{Timeout: 8 * time.Second}).Do(req)
|
||||
if callErr == nil {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode/100 == 2 {
|
||||
failures = 0
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
failures++
|
||||
if failures >= 3 {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,8 +41,15 @@ type identityFile struct {
|
||||
PrivateJWK privateJWK `json:"privateJwk"`
|
||||
}
|
||||
|
||||
const identityKDFIterations = 250000
|
||||
|
||||
type encryptedIdentity struct {
|
||||
Version int `json:"version"`
|
||||
Format string `json:"format,omitempty"`
|
||||
ClientID string `json:"clientId,omitempty"`
|
||||
KDF string `json:"kdf,omitempty"`
|
||||
Iterations int `json:"iterations,omitempty"`
|
||||
Cipher string `json:"cipher,omitempty"`
|
||||
Salt string `json:"salt"`
|
||||
IV string `json:"iv"`
|
||||
Ciphertext string `json:"ciphertext"`
|
||||
@@ -161,6 +168,15 @@ func readIdentityImport(path, passphrase string) (identityFile, error) {
|
||||
if err := json.Unmarshal(b, &enc); err != nil {
|
||||
return identityFile{}, err
|
||||
}
|
||||
if enc.Format != "" && enc.Format != "neuralhunt-identity-export" {
|
||||
return identityFile{}, errors.New("unsupported identity export format")
|
||||
}
|
||||
if enc.KDF != "" && enc.KDF != "PBKDF2-HMAC-SHA256" {
|
||||
return identityFile{}, errors.New("unsupported identity KDF")
|
||||
}
|
||||
if enc.Cipher != "" && enc.Cipher != "AES-256-GCM" {
|
||||
return identityFile{}, errors.New("unsupported identity cipher")
|
||||
}
|
||||
salt, err := rawURL.DecodeString(enc.Salt)
|
||||
if err != nil {
|
||||
return identityFile{}, err
|
||||
@@ -173,7 +189,14 @@ func readIdentityImport(path, passphrase string) (identityFile, error) {
|
||||
if err != nil {
|
||||
return identityFile{}, err
|
||||
}
|
||||
key := pbkdf2SHA256([]byte(passphrase), salt, 250000, 32)
|
||||
iterations := enc.Iterations
|
||||
if iterations == 0 {
|
||||
iterations = identityKDFIterations // compatibility with v1 exports
|
||||
}
|
||||
if iterations < 100000 || iterations > 2000000 {
|
||||
return identityFile{}, errors.New("unsupported identity KDF iteration count")
|
||||
}
|
||||
key := pbkdf2SHA256([]byte(passphrase), salt, iterations, 32)
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return identityFile{}, err
|
||||
@@ -193,6 +216,15 @@ func readIdentityImport(path, passphrase string) (identityFile, error) {
|
||||
if _, err := privateKeyFromIdentity(id); err != nil {
|
||||
return identityFile{}, err
|
||||
}
|
||||
if enc.ClientID != "" {
|
||||
cid, err := auth.ClientID(id.PublicJWK)
|
||||
if err != nil {
|
||||
return identityFile{}, err
|
||||
}
|
||||
if cid != enc.ClientID {
|
||||
return identityFile{}, errors.New("identity export client ID mismatch")
|
||||
}
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
@@ -200,6 +232,9 @@ func exportBrowserIdentity(path, passphrase string, id identityFile) error {
|
||||
if passphrase == "" {
|
||||
return errors.New("export requires --passphrase or NEURALHUNT_IDENTITY_PASSPHRASE")
|
||||
}
|
||||
if len(passphrase) < 12 {
|
||||
return errors.New("identity export passphrase must be at least 12 characters")
|
||||
}
|
||||
plain, err := json.Marshal(id)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -212,7 +247,7 @@ func exportBrowserIdentity(path, passphrase string, id identityFile) error {
|
||||
if _, err := rand.Read(iv); err != nil {
|
||||
return err
|
||||
}
|
||||
key := pbkdf2SHA256([]byte(passphrase), salt, 250000, 32)
|
||||
key := pbkdf2SHA256([]byte(passphrase), salt, identityKDFIterations, 32)
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -222,7 +257,15 @@ func exportBrowserIdentity(path, passphrase string, id identityFile) error {
|
||||
return err
|
||||
}
|
||||
ct := gcm.Seal(nil, iv, plain, nil)
|
||||
enc := encryptedIdentity{Version: 1, Salt: rawURL.EncodeToString(salt), IV: rawURL.EncodeToString(iv), Ciphertext: rawURL.EncodeToString(ct)}
|
||||
cid, err := auth.ClientID(id.PublicJWK)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
enc := encryptedIdentity{
|
||||
Version: 1, Format: "neuralhunt-identity-export", ClientID: cid,
|
||||
KDF: "PBKDF2-HMAC-SHA256", Iterations: identityKDFIterations, Cipher: "AES-256-GCM",
|
||||
Salt: rawURL.EncodeToString(salt), IV: rawURL.EncodeToString(iv), Ciphertext: rawURL.EncodeToString(ct),
|
||||
}
|
||||
b, err := json.MarshalIndent(enc, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -24,6 +25,17 @@ func TestBrowserIdentityExportImportRoundTrip(t *testing.T) {
|
||||
if err := exportBrowserIdentity(path, "correct horse battery staple", id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var enc encryptedIdentity
|
||||
if err := json.Unmarshal(b, &enc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if enc.Format != "neuralhunt-identity-export" || enc.ClientID == "" || enc.Iterations != identityKDFIterations || enc.Cipher != "AES-256-GCM" {
|
||||
t.Fatalf("missing portable export metadata: %#v", enc)
|
||||
}
|
||||
got, err := readIdentityImport(path, "correct horse battery staple")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -36,6 +48,16 @@ func TestBrowserIdentityExportImportRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityExportRejectsWeakPassphrase(t *testing.T) {
|
||||
id, _, err := generateIdentity()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := exportBrowserIdentity(filepath.Join(t.TempDir(), "weak.json"), "too-short", id); err == nil {
|
||||
t.Fatal("expected short export passphrase to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawIdentityImport(t *testing.T) {
|
||||
id, _, err := generateIdentity()
|
||||
if err != nil {
|
||||
|
||||
@@ -18,6 +18,7 @@ func main() {
|
||||
maxNodes := flag.Int("max-nodes", 500, "maximum target-field working set")
|
||||
nonInteractive := flag.Bool("non-interactive", false, "run unattended without command prompt")
|
||||
quiet := flag.Bool("quiet", false, "suppress connection/status chatter")
|
||||
beaconPath := flag.String("beacon-path", envOr("NEURALHUNT_BEACON_PATH", "auto"), "Beacon Hunt path: auto, pulse, flux or orbit")
|
||||
importPath := flag.String("import", "", "import browser/terminal identity JSON before login")
|
||||
exportPath := flag.String("export", "", "export current identity in browser-compatible encrypted format and exit")
|
||||
passphrase := flag.String("passphrase", os.Getenv("NEURALHUNT_IDENTITY_PASSPHRASE"), "identity import/export passphrase (prefer environment variable)")
|
||||
@@ -52,15 +53,22 @@ func main() {
|
||||
if err := api.login(ctx); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := api.registerHostedWorker(ctx); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
go api.hostedWorkerLeaseLoop(ctx, stop)
|
||||
if created {
|
||||
fmt.Println("Neue Terminal-Identität erzeugt:", *identityPath)
|
||||
}
|
||||
fmt.Println("NEURAL HUNT SHELL")
|
||||
fmt.Println("Identity:", api.cid)
|
||||
fmt.Println("Server :", strings.TrimRight(*base, "/"))
|
||||
fmt.Println("Hinweis : Dieselbe Identity darf nicht gleichzeitig im Browser verbunden sein.")
|
||||
fmt.Println("Client-ID :", api.cid)
|
||||
fmt.Println("Identity-Datei:", *identityPath)
|
||||
fmt.Println("Server :", strings.TrimRight(*base, "/"))
|
||||
fmt.Println("Backup : identity export <datei> (verschlüsselt, im Browser importierbar)")
|
||||
fmt.Println("Hinweis : Dieselbe Identity darf nicht gleichzeitig im Browser verbunden sein.")
|
||||
|
||||
a := newApp(api, *identityPath, *passphrase, *maxNodes, *quiet, *nonInteractive)
|
||||
a.beaconPathMode = strings.ToLower(strings.TrimSpace(*beaconPath))
|
||||
initial, err := selectInitialTask(ctx, a, *taskSelector, !*nonInteractive)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
|
||||
102
cmd/client/ui.go
102
cmd/client/ui.go
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -31,18 +32,19 @@ type app struct {
|
||||
quiet bool
|
||||
unattended bool
|
||||
|
||||
mu sync.RWMutex
|
||||
task taskDTO
|
||||
seq int64
|
||||
points map[string]point
|
||||
me meDTO
|
||||
ws *websocket.Conn
|
||||
wsConnected bool
|
||||
sessionCancel context.CancelFunc
|
||||
switching bool
|
||||
leaderWatch bool
|
||||
lastGuessAt time.Time
|
||||
lastGuessOK bool
|
||||
mu sync.RWMutex
|
||||
task taskDTO
|
||||
seq int64
|
||||
points map[string]point
|
||||
me meDTO
|
||||
ws *websocket.Conn
|
||||
wsConnected bool
|
||||
sessionCancel context.CancelFunc
|
||||
switching bool
|
||||
leaderWatch bool
|
||||
lastGuessAt time.Time
|
||||
lastGuessOK bool
|
||||
beaconPathMode string
|
||||
}
|
||||
|
||||
func newApp(api *apiClient, identityPath, passphrase string, maxNodes int, quiet, unattended bool) *app {
|
||||
@@ -428,6 +430,16 @@ func (a *app) refreshTask(taskID string) {
|
||||
}
|
||||
}
|
||||
|
||||
func chooseBeaconPath(mode, clientID string, seq int64) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||
case "PULSE", "FLUX", "ORBIT":
|
||||
return strings.ToUpper(strings.TrimSpace(mode))
|
||||
}
|
||||
h := sha256.Sum256([]byte(fmt.Sprintf("nh-cli-beacon-auto|%s|%d", clientID, seq)))
|
||||
paths := []string{"PULSE", "FLUX", "ORBIT"}
|
||||
return paths[int(h[0])%len(paths)]
|
||||
}
|
||||
|
||||
func (a *app) guessLoop(ctx context.Context, taskID string) {
|
||||
for {
|
||||
a.mu.RLock()
|
||||
@@ -461,7 +473,14 @@ func (a *app) guessLoop(ctx context.Context, taskID string) {
|
||||
if t.Paused || !connected {
|
||||
continue
|
||||
}
|
||||
correct, err := a.api.guess(ctx, t, seq)
|
||||
path := ""
|
||||
if t.BeaconHuntEnabled == 1 && t.GuessLotteryMaxAccepted > 0 {
|
||||
path = chooseBeaconPath(a.beaconPathMode, a.api.cid, seq)
|
||||
if !a.quiet {
|
||||
fmt.Printf("[beacon] Pfad %s · Bonusgewicht bei Treffer ×%d\n", path, t.BeaconBonusWeight)
|
||||
}
|
||||
}
|
||||
correct, err := a.api.guess(ctx, t, seq, path)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
@@ -613,6 +632,31 @@ func (a *app) printNFTs(ctx context.Context, limit int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *app) printMyNFTs(ctx context.Context, limit int) error {
|
||||
if limit <= 0 {
|
||||
limit = 30
|
||||
}
|
||||
items, err := a.api.ownedArtifacts(ctx, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("\nMEINE NFTS / ORIGINAL-ARTEFAKTE")
|
||||
fmt.Println("──────────────────────────────────────────────────────────────────────────")
|
||||
for i, n := range items {
|
||||
name := strings.TrimSpace(n.DisplayName)
|
||||
if name == "" {
|
||||
name = shortID(n.TaskID)
|
||||
}
|
||||
fmt.Printf("%2d %-24s task=%-16s %3dbit %s\n", i+1, name, shortID(n.TaskID), n.RangeBits, n.CompletedAt.Local().Format("2006-01-02 15:04"))
|
||||
fmt.Printf(" Original: %s%s\n", a.api.base, n.DownloadURI)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
fmt.Println("(für diese Identität noch keine fertigen Gewinner-Artefakte)")
|
||||
}
|
||||
fmt.Println()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *app) watcher(ctx context.Context) {
|
||||
t := time.NewTicker(5 * time.Second)
|
||||
defer t.Stop()
|
||||
@@ -638,7 +682,7 @@ func (a *app) watcher(ctx context.Context) {
|
||||
|
||||
func (a *app) commandLoop(ctx context.Context, in io.Reader) error {
|
||||
s := bufio.NewScanner(in)
|
||||
fmt.Println("Befehle: help, tasks, use <nr|id|name>, status, map [n], leaderboard [n|watch|stop], nfts [n], nft get <task-id> <datei>, identity, identity export <datei>, quit")
|
||||
fmt.Println("Befehle: help, tasks, use <nr|id|name>, status, map [n], leaderboard [n|watch|stop], nfts [n], my-nfts [n], nft get <task-id> <datei>, nft original <task-id> <datei>, identity, identity export <datei>, hosted-code, quit")
|
||||
for {
|
||||
fmt.Print("neuralhunt> ")
|
||||
if !s.Scan() {
|
||||
@@ -658,10 +702,13 @@ func (a *app) commandLoop(ctx context.Context, in io.Reader) error {
|
||||
fmt.Println(" map [n] textuelles Target Field / Nähe")
|
||||
fmt.Println(" leaderboard [n] Live-Rangliste")
|
||||
fmt.Println(" leaderboard watch|stop Rangliste alle 5s ein/aus")
|
||||
fmt.Println(" nfts [n] Wasserzeichen-NFTs anzeigen")
|
||||
fmt.Println(" nfts [n] öffentliche Wasserzeichen-NFTs anzeigen")
|
||||
fmt.Println(" my-nfts [n] eigene Gewinner-Artefakte anzeigen")
|
||||
fmt.Println(" nft get <task-id> <datei> Wasserzeichen-Preview speichern")
|
||||
fmt.Println(" nft original <task-id> <datei> eigenes Original speichern")
|
||||
fmt.Println(" identity Client-ID und Identity-Datei")
|
||||
fmt.Println(" identity export <datei> browser-kompatiblen verschlüsselten Export schreiben")
|
||||
fmt.Println(" hosted-code 10-Minuten-Code zum Koppeln als Reward-Identität")
|
||||
fmt.Println(" quit beenden")
|
||||
case "tasks":
|
||||
if _, err := a.printTasks(ctx); err != nil {
|
||||
@@ -723,6 +770,14 @@ func (a *app) commandLoop(ctx context.Context, in io.Reader) error {
|
||||
if err := a.printNFTs(ctx, n); err != nil {
|
||||
fmt.Println("Fehler:", err)
|
||||
}
|
||||
case "my-nfts", "mine":
|
||||
n := 30
|
||||
if len(parts) > 1 {
|
||||
n, _ = strconv.Atoi(parts[1])
|
||||
}
|
||||
if err := a.printMyNFTs(ctx, n); err != nil {
|
||||
fmt.Println("Fehler:", err)
|
||||
}
|
||||
case "nft":
|
||||
if len(parts) == 4 && strings.EqualFold(parts[1], "get") {
|
||||
if err := a.api.downloadPreview(ctx, parts[2], parts[3]); err != nil {
|
||||
@@ -730,8 +785,14 @@ func (a *app) commandLoop(ctx context.Context, in io.Reader) error {
|
||||
} else {
|
||||
fmt.Println("Wasserzeichen-Preview gespeichert:", parts[3])
|
||||
}
|
||||
} else if len(parts) == 4 && (strings.EqualFold(parts[1], "original") || strings.EqualFold(parts[1], "download")) {
|
||||
if err := a.api.downloadOwnedArtifact(ctx, parts[2], parts[3]); err != nil {
|
||||
fmt.Println("Fehler:", err)
|
||||
} else {
|
||||
fmt.Println("Original-Artefakt gespeichert:", parts[3])
|
||||
}
|
||||
} else {
|
||||
fmt.Println("nft get <task-id> <datei>")
|
||||
fmt.Println("nft get <task-id> <datei> | nft original <task-id> <datei>")
|
||||
}
|
||||
case "identity", "id":
|
||||
if len(parts) >= 2 && strings.EqualFold(parts[1], "export") {
|
||||
@@ -748,6 +809,15 @@ func (a *app) commandLoop(ctx context.Context, in io.Reader) error {
|
||||
fmt.Println("Client-ID:", a.api.cid)
|
||||
fmt.Println("Identity :", a.identityPath)
|
||||
}
|
||||
case "hosted-code", "hosted-link":
|
||||
x, err := a.api.hostedLinkCode(ctx)
|
||||
if err != nil {
|
||||
fmt.Println("Fehler:", err)
|
||||
} else {
|
||||
fmt.Println("Hosted-Code:", x.Code)
|
||||
fmt.Println("Client-ID :", x.ClientID)
|
||||
fmt.Println("Gültig bis :", x.ExpiresAt.Local().Format(time.RFC3339))
|
||||
}
|
||||
case "quit", "exit", "q":
|
||||
return nil
|
||||
default:
|
||||
|
||||
Reference in New Issue
Block a user