feat: Add dapp.fm native desktop player (Wails)
- cmd/dapp-fm-app: Native desktop app with WebView (Wails) - cmd/dapp-fm: CLI binary for HTTP server mode - pkg/player: Shared player core with Go bindings Architecture: Go decrypts SMSG content, serves via asset handler. Frontend calls Go directly via Wails bindings for manifest/license checks.
This commit is contained in:
parent
727072e2e5
commit
ef3d6e9731
27 changed files with 5977 additions and 32 deletions
3
cmd/dapp-fm-app/.gitignore
vendored
Normal file
3
cmd/dapp-fm-app/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
build/
|
||||||
|
*.exe
|
||||||
|
dapp-fm-app
|
||||||
987
cmd/dapp-fm-app/frontend/index.html
Normal file
987
cmd/dapp-fm-app/frontend/index.html
Normal file
|
|
@ -0,0 +1,987 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>dapp.fm - Decentralized Music Player</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #0f0f1a 0%, #1a0a2e 50%, #0f1a2e 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo h1 {
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: 800;
|
||||||
|
background: linear-gradient(135deg, #ff006e 0%, #8338ec 50%, #3a86ff 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
letter-spacing: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo .tagline {
|
||||||
|
color: #888;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-text {
|
||||||
|
text-align: center;
|
||||||
|
margin: 2rem 0;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: rgba(255,255,255,0.03);
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-text p {
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-text strong {
|
||||||
|
color: #ff006e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 2rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 .icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea, input[type="password"], input[type="text"], input[type="url"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border: 2px solid rgba(255,255,255,0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(0,0,0,0.4);
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea:focus, input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #8338ec;
|
||||||
|
box-shadow: 0 0 0 4px rgba(131, 56, 236, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.encrypted {
|
||||||
|
min-height: 100px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
word-break: break-all;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unlock-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unlock-row .input-group {
|
||||||
|
flex: 1;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 1rem 2.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
font-size: 1rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: linear-gradient(135deg, #ff006e 0%, #8338ec 100%);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 4px 20px rgba(255, 0, 110, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 8px 30px rgba(255, 0, 110, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid rgba(255,255,255,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary:hover {
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator .dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.loading .dot {
|
||||||
|
background: #ffc107;
|
||||||
|
animation: pulse 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.ready .dot {
|
||||||
|
background: #00ff94;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.error .dot {
|
||||||
|
background: #ff5252;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.3; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
background: rgba(255, 82, 82, 0.15);
|
||||||
|
border: 1px solid rgba(255, 82, 82, 0.4);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: none;
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Media Player Styles */
|
||||||
|
.player-container {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-container.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-info {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-artwork {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
margin: 0 auto 1.5rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #2d1b4e 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 5rem;
|
||||||
|
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-artwork img, .track-artwork video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-artist {
|
||||||
|
color: #888;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-player-wrapper {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-player {
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
audio, video {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
video {
|
||||||
|
max-height: 500px;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-player-wrapper {
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-info {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: rgba(131, 56, 236, 0.1);
|
||||||
|
border: 1px solid rgba(131, 56, 236, 0.3);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-info h4 {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
color: #8338ec;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-info p {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #aaa;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-info .license-token {
|
||||||
|
font-family: 'Monaco', 'Menlo', monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
word-break: break-all;
|
||||||
|
color: #00ff94;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-section {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.1);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-section button {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-list-section {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-list-section h3 {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: rgba(0,0,0,0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-item:hover {
|
||||||
|
background: rgba(131, 56, 236, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-item.active {
|
||||||
|
background: rgba(255, 0, 110, 0.2);
|
||||||
|
border: 1px solid rgba(255, 0, 110, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-number {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #8338ec;
|
||||||
|
min-width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-name {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-type {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #888;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-time {
|
||||||
|
font-family: 'Monaco', 'Menlo', monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #00ff94;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input-wrapper input[type="file"] {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 2rem;
|
||||||
|
border: 2px dashed rgba(255,255,255,0.2);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(0,0,0,0.2);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input-label:hover {
|
||||||
|
border-color: #8338ec;
|
||||||
|
background: rgba(131, 56, 236, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input-label .icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.or-divider {
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
margin: 1rem 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.native-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
background: linear-gradient(135deg, #00ff94 0%, #00d4aa 100%);
|
||||||
|
color: #000;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="logo">
|
||||||
|
<h1>dapp.fm</h1>
|
||||||
|
<p class="tagline">Decentralized Music Distribution <span class="native-badge">Native App</span></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hero-text">
|
||||||
|
<p>
|
||||||
|
<strong>No middlemen. No platforms. No 70% cuts.</strong><br>
|
||||||
|
Artists encrypt their music with ChaCha20-Poly1305. Fans unlock with a license token.
|
||||||
|
Content lives on any CDN, IPFS, or artist's own server. The password IS the license.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="status" class="status-indicator ready">
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span>Native decryption ready (memory speed)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2><span class="icon">🔐</span> Unlock Licensed Content</h2>
|
||||||
|
|
||||||
|
<div class="file-input-wrapper">
|
||||||
|
<input type="file" id="file-input" accept=".smsg,.enc,.borg">
|
||||||
|
<label class="file-input-label">
|
||||||
|
<span class="icon">📁</span>
|
||||||
|
<span>Drop encrypted file here or click to browse</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="or-divider">- or paste encrypted content -</div>
|
||||||
|
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="encrypted-content">Encrypted Content (base64):</label>
|
||||||
|
<textarea id="encrypted-content" class="encrypted" placeholder="Paste the encrypted content from the artist..."></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="demo-banner" style="background: rgba(255, 0, 110, 0.1); border: 1px solid rgba(255, 0, 110, 0.3); border-radius: 12px; padding: 1rem; margin-bottom: 1rem;">
|
||||||
|
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 1rem;">
|
||||||
|
<div>
|
||||||
|
<strong style="color: #ff006e;">Try the Demo!</strong>
|
||||||
|
<span style="color: #888; font-size: 0.85rem; margin-left: 0.5rem;">Bundled sample video</span>
|
||||||
|
</div>
|
||||||
|
<button id="load-demo-btn" class="secondary" style="padding: 0.6rem 1.2rem; font-size: 0.85rem;">Load Demo Track</button>
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 0.8rem; color: #666; margin-top: 0.5rem;">
|
||||||
|
Password: <code style="background: rgba(0,0,0,0.3); padding: 0.2rem 0.5rem; border-radius: 4px; color: #00ff94;">dapp-fm-2024</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="error-banner" class="error-banner"></div>
|
||||||
|
|
||||||
|
<!-- Manifest preview (shown without decryption) -->
|
||||||
|
<div id="manifest-preview" style="display: none; background: rgba(131, 56, 236, 0.1); border: 1px solid rgba(131, 56, 236, 0.3); border-radius: 12px; padding: 1.25rem; margin-bottom: 1rem;"></div>
|
||||||
|
|
||||||
|
<div class="unlock-row">
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="license-token">License Token (Password):</label>
|
||||||
|
<input type="password" id="license-token" placeholder="Enter your license token from the artist">
|
||||||
|
</div>
|
||||||
|
<button id="unlock-btn" class="primary">Unlock</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Player appears after unlock -->
|
||||||
|
<div id="player-container" class="card player-container">
|
||||||
|
<h2><span class="icon">🎵</span> Now Playing</h2>
|
||||||
|
|
||||||
|
<div class="track-info">
|
||||||
|
<div class="track-artwork" id="track-artwork">🎶</div>
|
||||||
|
<div class="track-title" id="track-title">Track Title</div>
|
||||||
|
<div class="track-artist" id="track-artist">Artist Name</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="media-player-wrapper" id="media-player-wrapper">
|
||||||
|
<!-- Audio/Video player inserted here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="track-list-section" class="track-list-section" style="display: none;">
|
||||||
|
<h3><span>💿</span> Track List</h3>
|
||||||
|
<div id="track-list" class="track-list">
|
||||||
|
<!-- Tracks populated by JS -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="license-info">
|
||||||
|
<h4>🔓 Licensed Content</h4>
|
||||||
|
<p id="license-description">This content was unlocked with your personal license token.
|
||||||
|
Decryption powered by native Go - no servers, memory speed.</p>
|
||||||
|
<div class="license-token" id="license-display"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="download-section">
|
||||||
|
<button class="secondary" id="download-btn">Download Original</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Wails runtime - provides window.go bindings
|
||||||
|
let currentMediaBlob = null;
|
||||||
|
let currentMediaName = null;
|
||||||
|
let currentMediaMime = null;
|
||||||
|
let currentManifest = null;
|
||||||
|
|
||||||
|
// Check if Wails runtime is available
|
||||||
|
function isWailsReady() {
|
||||||
|
return typeof window.go !== 'undefined' &&
|
||||||
|
typeof window.go.player !== 'undefined' &&
|
||||||
|
typeof window.go.player.Player !== 'undefined';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for Wails runtime
|
||||||
|
function waitForWails() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (isWailsReady()) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Poll for Wails runtime
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (isWailsReady()) {
|
||||||
|
clearInterval(interval);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
const errorBanner = document.getElementById('error-banner');
|
||||||
|
errorBanner.textContent = msg;
|
||||||
|
errorBanner.classList.add('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideError() {
|
||||||
|
document.getElementById('error-banner').classList.remove('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle file input
|
||||||
|
document.getElementById('file-input').addEventListener('change', async (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await file.arrayBuffer();
|
||||||
|
const base64 = btoa(String.fromCharCode(...new Uint8Array(content)));
|
||||||
|
document.getElementById('encrypted-content').value = base64;
|
||||||
|
await showManifestPreview(base64);
|
||||||
|
} catch (err) {
|
||||||
|
showError('Failed to read file: ' + err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for content paste/input
|
||||||
|
let previewDebounce = null;
|
||||||
|
document.getElementById('encrypted-content').addEventListener('input', async (e) => {
|
||||||
|
const content = e.target.value.trim();
|
||||||
|
clearTimeout(previewDebounce);
|
||||||
|
previewDebounce = setTimeout(async () => {
|
||||||
|
if (content && content.length > 100) {
|
||||||
|
await showManifestPreview(content);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show manifest preview using Go bindings (NO WASM!)
|
||||||
|
async function showManifestPreview(encryptedB64) {
|
||||||
|
await waitForWails();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Direct Go call at memory speed!
|
||||||
|
const manifest = await window.go.player.Player.GetManifest(encryptedB64);
|
||||||
|
currentManifest = manifest;
|
||||||
|
|
||||||
|
const previewSection = document.getElementById('manifest-preview');
|
||||||
|
while (previewSection.firstChild) {
|
||||||
|
previewSection.removeChild(previewSection.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manifest && manifest.title) {
|
||||||
|
previewSection.style.display = 'block';
|
||||||
|
|
||||||
|
// Header with icon
|
||||||
|
const headerDiv = document.createElement('div');
|
||||||
|
headerDiv.style.cssText = 'display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem;';
|
||||||
|
|
||||||
|
const icon = document.createElement('span');
|
||||||
|
icon.style.fontSize = '2.5rem';
|
||||||
|
icon.textContent = manifest.release_type === 'djset' ? '🎧' :
|
||||||
|
manifest.release_type === 'live' ? '🎤' : '💿';
|
||||||
|
|
||||||
|
const titleDiv = document.createElement('div');
|
||||||
|
const titleEl = document.createElement('div');
|
||||||
|
titleEl.style.cssText = 'font-size: 1.2rem; font-weight: 700; color: #fff;';
|
||||||
|
titleEl.textContent = manifest.title || 'Untitled';
|
||||||
|
|
||||||
|
const artistEl = document.createElement('div');
|
||||||
|
artistEl.style.cssText = 'font-size: 0.9rem; color: #888;';
|
||||||
|
artistEl.textContent = manifest.artist || 'Unknown Artist';
|
||||||
|
|
||||||
|
titleDiv.appendChild(titleEl);
|
||||||
|
titleDiv.appendChild(artistEl);
|
||||||
|
headerDiv.appendChild(icon);
|
||||||
|
headerDiv.appendChild(titleDiv);
|
||||||
|
previewSection.appendChild(headerDiv);
|
||||||
|
|
||||||
|
// Track list
|
||||||
|
if (manifest.tracks && manifest.tracks.length > 0) {
|
||||||
|
const trackHeader = document.createElement('div');
|
||||||
|
trackHeader.style.cssText = 'font-size: 0.85rem; color: #8338ec; margin-bottom: 0.5rem;';
|
||||||
|
trackHeader.textContent = '💿 ' + manifest.tracks.length + ' track(s)';
|
||||||
|
previewSection.appendChild(trackHeader);
|
||||||
|
|
||||||
|
const trackList = document.createElement('div');
|
||||||
|
trackList.style.maxHeight = '150px';
|
||||||
|
trackList.style.overflowY = 'auto';
|
||||||
|
|
||||||
|
manifest.tracks.forEach((track, i) => {
|
||||||
|
const trackEl = document.createElement('div');
|
||||||
|
trackEl.style.cssText = 'display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem; background: rgba(0,0,0,0.2); border-radius: 6px; margin-bottom: 0.25rem; font-size: 0.85rem;';
|
||||||
|
|
||||||
|
const numEl = document.createElement('span');
|
||||||
|
numEl.style.cssText = 'color: #8338ec; font-weight: 600; min-width: 20px;';
|
||||||
|
numEl.textContent = (track.track_num || (i + 1)) + '.';
|
||||||
|
|
||||||
|
const nameEl = document.createElement('span');
|
||||||
|
nameEl.style.cssText = 'flex: 1; color: #ccc;';
|
||||||
|
nameEl.textContent = track.title || 'Track ' + (i + 1);
|
||||||
|
|
||||||
|
const timeEl = document.createElement('span');
|
||||||
|
timeEl.style.cssText = 'color: #00ff94; font-family: monospace; font-size: 0.8rem;';
|
||||||
|
timeEl.textContent = formatTime(track.start || 0);
|
||||||
|
|
||||||
|
trackEl.appendChild(numEl);
|
||||||
|
trackEl.appendChild(nameEl);
|
||||||
|
trackEl.appendChild(timeEl);
|
||||||
|
trackList.appendChild(trackEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
previewSection.appendChild(trackList);
|
||||||
|
}
|
||||||
|
|
||||||
|
// License status
|
||||||
|
if (manifest.is_expired !== undefined) {
|
||||||
|
const licenseDiv = document.createElement('div');
|
||||||
|
licenseDiv.style.cssText = 'margin-top: 1rem; padding: 0.75rem; border-radius: 8px;';
|
||||||
|
|
||||||
|
if (manifest.is_expired) {
|
||||||
|
licenseDiv.style.background = 'rgba(255, 82, 82, 0.2)';
|
||||||
|
licenseDiv.style.border = '1px solid rgba(255, 82, 82, 0.4)';
|
||||||
|
const label = document.createElement('div');
|
||||||
|
label.style.cssText = 'color: #ff5252; font-weight: 600;';
|
||||||
|
label.textContent = 'LICENSE EXPIRED';
|
||||||
|
licenseDiv.appendChild(label);
|
||||||
|
} else if (manifest.time_remaining) {
|
||||||
|
licenseDiv.style.background = 'rgba(0, 255, 148, 0.1)';
|
||||||
|
licenseDiv.style.border = '1px solid rgba(0, 255, 148, 0.3)';
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.style.cssText = 'color: #00ff94; font-weight: 600; font-size: 0.8rem;';
|
||||||
|
label.textContent = (manifest.license_type || 'LICENSE').toUpperCase();
|
||||||
|
const time = document.createElement('span');
|
||||||
|
time.style.cssText = 'color: #888; font-size: 0.8rem; margin-left: 0.5rem;';
|
||||||
|
time.textContent = manifest.time_remaining + ' remaining';
|
||||||
|
licenseDiv.appendChild(label);
|
||||||
|
licenseDiv.appendChild(time);
|
||||||
|
} else {
|
||||||
|
licenseDiv.style.background = 'rgba(0, 255, 148, 0.1)';
|
||||||
|
licenseDiv.style.border = '1px solid rgba(0, 255, 148, 0.3)';
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.style.cssText = 'color: #00ff94; font-weight: 600; font-size: 0.8rem;';
|
||||||
|
label.textContent = 'PERPETUAL LICENSE';
|
||||||
|
licenseDiv.appendChild(label);
|
||||||
|
}
|
||||||
|
previewSection.appendChild(licenseDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hint = document.createElement('div');
|
||||||
|
hint.style.cssText = 'margin-top: 1rem; font-size: 0.85rem; color: #888; text-align: center;';
|
||||||
|
hint.textContent = manifest.is_expired ?
|
||||||
|
'License expired. Contact artist for renewal.' :
|
||||||
|
'Enter license token to unlock and play';
|
||||||
|
previewSection.appendChild(hint);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
previewSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log('Could not read manifest:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock content using Go bindings (memory speed!)
|
||||||
|
async function unlockContent() {
|
||||||
|
hideError();
|
||||||
|
await waitForWails();
|
||||||
|
|
||||||
|
const encryptedB64 = document.getElementById('encrypted-content').value.trim();
|
||||||
|
const password = document.getElementById('license-token').value;
|
||||||
|
|
||||||
|
if (!encryptedB64) {
|
||||||
|
showError('Please provide encrypted content');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
showError('Please enter your license token');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check license validity (memory speed)
|
||||||
|
const isValid = await window.go.player.Player.IsLicenseValid(encryptedB64);
|
||||||
|
if (!isValid) {
|
||||||
|
showError('License has expired. Contact the artist for renewal.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt using Go bindings (memory speed - no HTTP/TCP!)
|
||||||
|
const result = await window.go.player.Player.Decrypt(encryptedB64, password);
|
||||||
|
displayMedia(result, password);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
showError('Unlock failed: ' + err.message);
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display decrypted media
|
||||||
|
function displayMedia(result, password) {
|
||||||
|
const playerContainer = document.getElementById('player-container');
|
||||||
|
const mediaWrapper = document.getElementById('media-player-wrapper');
|
||||||
|
const artworkEl = document.getElementById('track-artwork');
|
||||||
|
|
||||||
|
// Set track info
|
||||||
|
const title = (currentManifest && currentManifest.title) || result.subject || 'Untitled';
|
||||||
|
const artist = (currentManifest && currentManifest.artist) || result.from || 'Unknown Artist';
|
||||||
|
document.getElementById('track-title').textContent = title;
|
||||||
|
document.getElementById('track-artist').textContent = artist;
|
||||||
|
|
||||||
|
// Show masked license token
|
||||||
|
const masked = password.substring(0, 4) + '••••••••' + password.substring(password.length - 4);
|
||||||
|
document.getElementById('license-display').textContent = masked;
|
||||||
|
|
||||||
|
// Clear previous media
|
||||||
|
while (mediaWrapper.firstChild) mediaWrapper.removeChild(mediaWrapper.firstChild);
|
||||||
|
while (artworkEl.firstChild) artworkEl.removeChild(artworkEl.firstChild);
|
||||||
|
artworkEl.textContent = '🎶';
|
||||||
|
|
||||||
|
// Process attachments
|
||||||
|
if (result.attachments && result.attachments.length > 0) {
|
||||||
|
result.attachments.forEach((att) => {
|
||||||
|
const mime = att.mime_type || 'application/octet-stream';
|
||||||
|
|
||||||
|
// URL from Go - served through Wails asset handler
|
||||||
|
const url = att.url || att.file_path || att.stream_url || att.data_url;
|
||||||
|
|
||||||
|
// Store info for download
|
||||||
|
currentMediaName = att.name;
|
||||||
|
currentMediaMime = mime;
|
||||||
|
|
||||||
|
if (mime.startsWith('video/')) {
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'video-player-wrapper';
|
||||||
|
const video = document.createElement('video');
|
||||||
|
video.controls = true;
|
||||||
|
video.src = url;
|
||||||
|
video.style.width = '100%';
|
||||||
|
wrapper.appendChild(video);
|
||||||
|
mediaWrapper.appendChild(wrapper);
|
||||||
|
artworkEl.textContent = '🎬';
|
||||||
|
|
||||||
|
} else if (mime.startsWith('audio/')) {
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'audio-player';
|
||||||
|
const audio = document.createElement('audio');
|
||||||
|
audio.controls = true;
|
||||||
|
audio.src = url;
|
||||||
|
audio.style.width = '100%';
|
||||||
|
wrapper.appendChild(audio);
|
||||||
|
mediaWrapper.appendChild(wrapper);
|
||||||
|
artworkEl.textContent = '🎵';
|
||||||
|
|
||||||
|
} else if (mime.startsWith('image/')) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = url;
|
||||||
|
artworkEl.textContent = '';
|
||||||
|
artworkEl.appendChild(img);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build track list from manifest
|
||||||
|
const trackListSection = document.getElementById('track-list-section');
|
||||||
|
const trackListEl = document.getElementById('track-list');
|
||||||
|
while (trackListEl.firstChild) trackListEl.removeChild(trackListEl.firstChild);
|
||||||
|
|
||||||
|
if (currentManifest && currentManifest.tracks && currentManifest.tracks.length > 0) {
|
||||||
|
trackListSection.style.display = 'block';
|
||||||
|
|
||||||
|
currentManifest.tracks.forEach((track, index) => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'track-item';
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
const media = document.querySelector('audio, video');
|
||||||
|
if (media) {
|
||||||
|
media.currentTime = track.start || 0;
|
||||||
|
media.play();
|
||||||
|
document.querySelectorAll('.track-item').forEach(t => t.classList.remove('active'));
|
||||||
|
item.classList.add('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const num = document.createElement('span');
|
||||||
|
num.className = 'track-number';
|
||||||
|
num.textContent = track.track_num || (index + 1);
|
||||||
|
|
||||||
|
const info = document.createElement('div');
|
||||||
|
info.style.flex = '1';
|
||||||
|
const name = document.createElement('div');
|
||||||
|
name.className = 'track-name';
|
||||||
|
name.textContent = track.title || 'Track ' + (index + 1);
|
||||||
|
info.appendChild(name);
|
||||||
|
|
||||||
|
const time = document.createElement('span');
|
||||||
|
time.className = 'track-time';
|
||||||
|
time.textContent = formatTime(track.start || 0);
|
||||||
|
|
||||||
|
item.appendChild(num);
|
||||||
|
item.appendChild(info);
|
||||||
|
item.appendChild(time);
|
||||||
|
trackListEl.appendChild(item);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
trackListSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update license description
|
||||||
|
if (currentManifest && currentManifest.time_remaining) {
|
||||||
|
document.getElementById('license-description').textContent =
|
||||||
|
(currentManifest.license_type || 'Rental').toUpperCase() + ' license - ' +
|
||||||
|
currentManifest.time_remaining + ' remaining. Native Go decryption at memory speed.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide preview, show player
|
||||||
|
document.getElementById('manifest-preview').style.display = 'none';
|
||||||
|
playerContainer.classList.add('visible');
|
||||||
|
playerContainer.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(seconds) {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return mins + ':' + secs.toString().padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download handler
|
||||||
|
document.getElementById('download-btn').addEventListener('click', () => {
|
||||||
|
if (!currentMediaBlob) {
|
||||||
|
alert('No media to download');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = URL.createObjectURL(currentMediaBlob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = currentMediaName || 'media';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load bundled demo - DIRECT GO CALL, no HTTP!
|
||||||
|
async function loadDemo() {
|
||||||
|
const btn = document.getElementById('load-demo-btn');
|
||||||
|
const originalText = btn.textContent;
|
||||||
|
btn.textContent = 'Loading...';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await waitForWails();
|
||||||
|
|
||||||
|
// Get manifest first (direct Go call)
|
||||||
|
const manifest = await window.go.main.App.GetDemoManifest();
|
||||||
|
currentManifest = manifest;
|
||||||
|
|
||||||
|
// Decrypt demo directly in Go - NO fetch, NO base64 encoding!
|
||||||
|
// Go reads embedded bytes -> decrypts -> returns result
|
||||||
|
const result = await window.go.main.App.LoadDemo();
|
||||||
|
|
||||||
|
// Display the decrypted media
|
||||||
|
displayMedia(result, 'dapp-fm-2024');
|
||||||
|
|
||||||
|
btn.textContent = 'Loaded!';
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.textContent = originalText;
|
||||||
|
btn.disabled = false;
|
||||||
|
}, 2000);
|
||||||
|
} catch (err) {
|
||||||
|
showError('Failed to load demo: ' + err.message);
|
||||||
|
btn.textContent = originalText;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
document.getElementById('unlock-btn').addEventListener('click', unlockContent);
|
||||||
|
document.getElementById('license-token').addEventListener('keypress', (e) => {
|
||||||
|
if (e.key === 'Enter') unlockContent();
|
||||||
|
});
|
||||||
|
document.getElementById('load-demo-btn').addEventListener('click', loadDemo);
|
||||||
|
|
||||||
|
// Ready check
|
||||||
|
waitForWails().then(() => {
|
||||||
|
console.log('Wails bindings ready - memory speed decryption enabled');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
14
cmd/dapp-fm-app/frontend/wailsjs/go/main/App.d.ts
vendored
Executable file
14
cmd/dapp-fm-app/frontend/wailsjs/go/main/App.d.ts
vendored
Executable file
|
|
@ -0,0 +1,14 @@
|
||||||
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
|
// This file is automatically generated. DO NOT EDIT
|
||||||
|
import {main} from '../models';
|
||||||
|
import {player} from '../models';
|
||||||
|
|
||||||
|
export function DecryptAndServe(arg1:string,arg2:string):Promise<main.MediaResult>;
|
||||||
|
|
||||||
|
export function GetDemoManifest():Promise<player.ManifestInfo>;
|
||||||
|
|
||||||
|
export function GetManifest(arg1:string):Promise<player.ManifestInfo>;
|
||||||
|
|
||||||
|
export function IsLicenseValid(arg1:string):Promise<boolean>;
|
||||||
|
|
||||||
|
export function LoadDemo():Promise<main.MediaResult>;
|
||||||
23
cmd/dapp-fm-app/frontend/wailsjs/go/main/App.js
Executable file
23
cmd/dapp-fm-app/frontend/wailsjs/go/main/App.js
Executable file
|
|
@ -0,0 +1,23 @@
|
||||||
|
// @ts-check
|
||||||
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
|
// This file is automatically generated. DO NOT EDIT
|
||||||
|
|
||||||
|
export function DecryptAndServe(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['DecryptAndServe'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetDemoManifest() {
|
||||||
|
return window['go']['main']['App']['GetDemoManifest']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetManifest(arg1) {
|
||||||
|
return window['go']['main']['App']['GetManifest'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IsLicenseValid(arg1) {
|
||||||
|
return window['go']['main']['App']['IsLicenseValid'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadDemo() {
|
||||||
|
return window['go']['main']['App']['LoadDemo']();
|
||||||
|
}
|
||||||
140
cmd/dapp-fm-app/frontend/wailsjs/go/models.ts
Executable file
140
cmd/dapp-fm-app/frontend/wailsjs/go/models.ts
Executable file
|
|
@ -0,0 +1,140 @@
|
||||||
|
export namespace main {
|
||||||
|
|
||||||
|
export class MediaAttachment {
|
||||||
|
name: string;
|
||||||
|
mime_type: string;
|
||||||
|
size: number;
|
||||||
|
url: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new MediaAttachment(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.name = source["name"];
|
||||||
|
this.mime_type = source["mime_type"];
|
||||||
|
this.size = source["size"];
|
||||||
|
this.url = source["url"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class MediaResult {
|
||||||
|
body: string;
|
||||||
|
subject?: string;
|
||||||
|
from?: string;
|
||||||
|
attachments?: MediaAttachment[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new MediaResult(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.body = source["body"];
|
||||||
|
this.subject = source["subject"];
|
||||||
|
this.from = source["from"];
|
||||||
|
this.attachments = this.convertValues(source["attachments"], MediaAttachment);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace player {
|
||||||
|
|
||||||
|
export class TrackInfo {
|
||||||
|
title: string;
|
||||||
|
start: number;
|
||||||
|
end?: number;
|
||||||
|
type?: string;
|
||||||
|
track_num?: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new TrackInfo(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.title = source["title"];
|
||||||
|
this.start = source["start"];
|
||||||
|
this.end = source["end"];
|
||||||
|
this.type = source["type"];
|
||||||
|
this.track_num = source["track_num"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class ManifestInfo {
|
||||||
|
title?: string;
|
||||||
|
artist?: string;
|
||||||
|
album?: string;
|
||||||
|
genre?: string;
|
||||||
|
year?: number;
|
||||||
|
release_type?: string;
|
||||||
|
duration?: number;
|
||||||
|
format?: string;
|
||||||
|
expires_at?: number;
|
||||||
|
issued_at?: number;
|
||||||
|
license_type?: string;
|
||||||
|
tracks?: TrackInfo[];
|
||||||
|
is_expired: boolean;
|
||||||
|
time_remaining?: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new ManifestInfo(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.title = source["title"];
|
||||||
|
this.artist = source["artist"];
|
||||||
|
this.album = source["album"];
|
||||||
|
this.genre = source["genre"];
|
||||||
|
this.year = source["year"];
|
||||||
|
this.release_type = source["release_type"];
|
||||||
|
this.duration = source["duration"];
|
||||||
|
this.format = source["format"];
|
||||||
|
this.expires_at = source["expires_at"];
|
||||||
|
this.issued_at = source["issued_at"];
|
||||||
|
this.license_type = source["license_type"];
|
||||||
|
this.tracks = this.convertValues(source["tracks"], TrackInfo);
|
||||||
|
this.is_expired = source["is_expired"];
|
||||||
|
this.time_remaining = source["time_remaining"];
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
24
cmd/dapp-fm-app/frontend/wailsjs/runtime/package.json
Normal file
24
cmd/dapp-fm-app/frontend/wailsjs/runtime/package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"name": "@wailsapp/runtime",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"description": "Wails Javascript runtime library",
|
||||||
|
"main": "runtime.js",
|
||||||
|
"types": "runtime.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/wailsapp/wails.git"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"Wails",
|
||||||
|
"Javascript",
|
||||||
|
"Go"
|
||||||
|
],
|
||||||
|
"author": "Lea Anthony <lea.anthony@gmail.com>",
|
||||||
|
"license": "MIT",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/wailsapp/wails/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/wailsapp/wails#readme"
|
||||||
|
}
|
||||||
249
cmd/dapp-fm-app/frontend/wailsjs/runtime/runtime.d.ts
vendored
Normal file
249
cmd/dapp-fm-app/frontend/wailsjs/runtime/runtime.d.ts
vendored
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
/*
|
||||||
|
_ __ _ __
|
||||||
|
| | / /___ _(_) /____
|
||||||
|
| | /| / / __ `/ / / ___/
|
||||||
|
| |/ |/ / /_/ / / (__ )
|
||||||
|
|__/|__/\__,_/_/_/____/
|
||||||
|
The electron alternative for Go
|
||||||
|
(c) Lea Anthony 2019-present
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Position {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Size {
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Screen {
|
||||||
|
isCurrent: boolean;
|
||||||
|
isPrimary: boolean;
|
||||||
|
width : number
|
||||||
|
height : number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Environment information such as platform, buildtype, ...
|
||||||
|
export interface EnvironmentInfo {
|
||||||
|
buildType: string;
|
||||||
|
platform: string;
|
||||||
|
arch: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit)
|
||||||
|
// emits the given event. Optional data may be passed with the event.
|
||||||
|
// This will trigger any event listeners.
|
||||||
|
export function EventsEmit(eventName: string, ...data: any): void;
|
||||||
|
|
||||||
|
// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
|
||||||
|
export function EventsOn(eventName: string, callback: (...data: any) => void): () => void;
|
||||||
|
|
||||||
|
// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
|
||||||
|
// sets up a listener for the given event name, but will only trigger a given number times.
|
||||||
|
export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void;
|
||||||
|
|
||||||
|
// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
|
||||||
|
// sets up a listener for the given event name, but will only trigger once.
|
||||||
|
export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void;
|
||||||
|
|
||||||
|
// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff)
|
||||||
|
// unregisters the listener for the given event name.
|
||||||
|
export function EventsOff(eventName: string, ...additionalEventNames: string[]): void;
|
||||||
|
|
||||||
|
// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
|
||||||
|
// unregisters all listeners.
|
||||||
|
export function EventsOffAll(): void;
|
||||||
|
|
||||||
|
// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
|
||||||
|
// logs the given message as a raw message
|
||||||
|
export function LogPrint(message: string): void;
|
||||||
|
|
||||||
|
// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace)
|
||||||
|
// logs the given message at the `trace` log level.
|
||||||
|
export function LogTrace(message: string): void;
|
||||||
|
|
||||||
|
// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug)
|
||||||
|
// logs the given message at the `debug` log level.
|
||||||
|
export function LogDebug(message: string): void;
|
||||||
|
|
||||||
|
// [LogError](https://wails.io/docs/reference/runtime/log#logerror)
|
||||||
|
// logs the given message at the `error` log level.
|
||||||
|
export function LogError(message: string): void;
|
||||||
|
|
||||||
|
// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal)
|
||||||
|
// logs the given message at the `fatal` log level.
|
||||||
|
// The application will quit after calling this method.
|
||||||
|
export function LogFatal(message: string): void;
|
||||||
|
|
||||||
|
// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo)
|
||||||
|
// logs the given message at the `info` log level.
|
||||||
|
export function LogInfo(message: string): void;
|
||||||
|
|
||||||
|
// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning)
|
||||||
|
// logs the given message at the `warning` log level.
|
||||||
|
export function LogWarning(message: string): void;
|
||||||
|
|
||||||
|
// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload)
|
||||||
|
// Forces a reload by the main application as well as connected browsers.
|
||||||
|
export function WindowReload(): void;
|
||||||
|
|
||||||
|
// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp)
|
||||||
|
// Reloads the application frontend.
|
||||||
|
export function WindowReloadApp(): void;
|
||||||
|
|
||||||
|
// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop)
|
||||||
|
// Sets the window AlwaysOnTop or not on top.
|
||||||
|
export function WindowSetAlwaysOnTop(b: boolean): void;
|
||||||
|
|
||||||
|
// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme)
|
||||||
|
// *Windows only*
|
||||||
|
// Sets window theme to system default (dark/light).
|
||||||
|
export function WindowSetSystemDefaultTheme(): void;
|
||||||
|
|
||||||
|
// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme)
|
||||||
|
// *Windows only*
|
||||||
|
// Sets window to light theme.
|
||||||
|
export function WindowSetLightTheme(): void;
|
||||||
|
|
||||||
|
// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme)
|
||||||
|
// *Windows only*
|
||||||
|
// Sets window to dark theme.
|
||||||
|
export function WindowSetDarkTheme(): void;
|
||||||
|
|
||||||
|
// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter)
|
||||||
|
// Centers the window on the monitor the window is currently on.
|
||||||
|
export function WindowCenter(): void;
|
||||||
|
|
||||||
|
// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle)
|
||||||
|
// Sets the text in the window title bar.
|
||||||
|
export function WindowSetTitle(title: string): void;
|
||||||
|
|
||||||
|
// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen)
|
||||||
|
// Makes the window full screen.
|
||||||
|
export function WindowFullscreen(): void;
|
||||||
|
|
||||||
|
// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen)
|
||||||
|
// Restores the previous window dimensions and position prior to full screen.
|
||||||
|
export function WindowUnfullscreen(): void;
|
||||||
|
|
||||||
|
// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen)
|
||||||
|
// Returns the state of the window, i.e. whether the window is in full screen mode or not.
|
||||||
|
export function WindowIsFullscreen(): Promise<boolean>;
|
||||||
|
|
||||||
|
// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
|
||||||
|
// Sets the width and height of the window.
|
||||||
|
export function WindowSetSize(width: number, height: number): void;
|
||||||
|
|
||||||
|
// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
|
||||||
|
// Gets the width and height of the window.
|
||||||
|
export function WindowGetSize(): Promise<Size>;
|
||||||
|
|
||||||
|
// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize)
|
||||||
|
// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions.
|
||||||
|
// Setting a size of 0,0 will disable this constraint.
|
||||||
|
export function WindowSetMaxSize(width: number, height: number): void;
|
||||||
|
|
||||||
|
// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize)
|
||||||
|
// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions.
|
||||||
|
// Setting a size of 0,0 will disable this constraint.
|
||||||
|
export function WindowSetMinSize(width: number, height: number): void;
|
||||||
|
|
||||||
|
// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition)
|
||||||
|
// Sets the window position relative to the monitor the window is currently on.
|
||||||
|
export function WindowSetPosition(x: number, y: number): void;
|
||||||
|
|
||||||
|
// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition)
|
||||||
|
// Gets the window position relative to the monitor the window is currently on.
|
||||||
|
export function WindowGetPosition(): Promise<Position>;
|
||||||
|
|
||||||
|
// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide)
|
||||||
|
// Hides the window.
|
||||||
|
export function WindowHide(): void;
|
||||||
|
|
||||||
|
// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow)
|
||||||
|
// Shows the window, if it is currently hidden.
|
||||||
|
export function WindowShow(): void;
|
||||||
|
|
||||||
|
// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise)
|
||||||
|
// Maximises the window to fill the screen.
|
||||||
|
export function WindowMaximise(): void;
|
||||||
|
|
||||||
|
// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise)
|
||||||
|
// Toggles between Maximised and UnMaximised.
|
||||||
|
export function WindowToggleMaximise(): void;
|
||||||
|
|
||||||
|
// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise)
|
||||||
|
// Restores the window to the dimensions and position prior to maximising.
|
||||||
|
export function WindowUnmaximise(): void;
|
||||||
|
|
||||||
|
// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised)
|
||||||
|
// Returns the state of the window, i.e. whether the window is maximised or not.
|
||||||
|
export function WindowIsMaximised(): Promise<boolean>;
|
||||||
|
|
||||||
|
// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
|
||||||
|
// Minimises the window.
|
||||||
|
export function WindowMinimise(): void;
|
||||||
|
|
||||||
|
// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise)
|
||||||
|
// Restores the window to the dimensions and position prior to minimising.
|
||||||
|
export function WindowUnminimise(): void;
|
||||||
|
|
||||||
|
// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised)
|
||||||
|
// Returns the state of the window, i.e. whether the window is minimised or not.
|
||||||
|
export function WindowIsMinimised(): Promise<boolean>;
|
||||||
|
|
||||||
|
// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal)
|
||||||
|
// Returns the state of the window, i.e. whether the window is normal or not.
|
||||||
|
export function WindowIsNormal(): Promise<boolean>;
|
||||||
|
|
||||||
|
// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
|
||||||
|
// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
|
||||||
|
export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
|
||||||
|
|
||||||
|
// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall)
|
||||||
|
// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system.
|
||||||
|
export function ScreenGetAll(): Promise<Screen[]>;
|
||||||
|
|
||||||
|
// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl)
|
||||||
|
// Opens the given URL in the system browser.
|
||||||
|
export function BrowserOpenURL(url: string): void;
|
||||||
|
|
||||||
|
// [Environment](https://wails.io/docs/reference/runtime/intro#environment)
|
||||||
|
// Returns information about the environment
|
||||||
|
export function Environment(): Promise<EnvironmentInfo>;
|
||||||
|
|
||||||
|
// [Quit](https://wails.io/docs/reference/runtime/intro#quit)
|
||||||
|
// Quits the application.
|
||||||
|
export function Quit(): void;
|
||||||
|
|
||||||
|
// [Hide](https://wails.io/docs/reference/runtime/intro#hide)
|
||||||
|
// Hides the application.
|
||||||
|
export function Hide(): void;
|
||||||
|
|
||||||
|
// [Show](https://wails.io/docs/reference/runtime/intro#show)
|
||||||
|
// Shows the application.
|
||||||
|
export function Show(): void;
|
||||||
|
|
||||||
|
// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext)
|
||||||
|
// Returns the current text stored on clipboard
|
||||||
|
export function ClipboardGetText(): Promise<string>;
|
||||||
|
|
||||||
|
// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
|
||||||
|
// Sets a text on the clipboard
|
||||||
|
export function ClipboardSetText(text: string): Promise<boolean>;
|
||||||
|
|
||||||
|
// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop)
|
||||||
|
// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
|
||||||
|
export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void
|
||||||
|
|
||||||
|
// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff)
|
||||||
|
// OnFileDropOff removes the drag and drop listeners and handlers.
|
||||||
|
export function OnFileDropOff() :void
|
||||||
|
|
||||||
|
// Check if the file path resolver is available
|
||||||
|
export function CanResolveFilePaths(): boolean;
|
||||||
|
|
||||||
|
// Resolves file paths for an array of files
|
||||||
|
export function ResolveFilePaths(files: File[]): void
|
||||||
242
cmd/dapp-fm-app/frontend/wailsjs/runtime/runtime.js
Normal file
242
cmd/dapp-fm-app/frontend/wailsjs/runtime/runtime.js
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
/*
|
||||||
|
_ __ _ __
|
||||||
|
| | / /___ _(_) /____
|
||||||
|
| | /| / / __ `/ / / ___/
|
||||||
|
| |/ |/ / /_/ / / (__ )
|
||||||
|
|__/|__/\__,_/_/_/____/
|
||||||
|
The electron alternative for Go
|
||||||
|
(c) Lea Anthony 2019-present
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function LogPrint(message) {
|
||||||
|
window.runtime.LogPrint(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogTrace(message) {
|
||||||
|
window.runtime.LogTrace(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogDebug(message) {
|
||||||
|
window.runtime.LogDebug(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogInfo(message) {
|
||||||
|
window.runtime.LogInfo(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogWarning(message) {
|
||||||
|
window.runtime.LogWarning(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogError(message) {
|
||||||
|
window.runtime.LogError(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogFatal(message) {
|
||||||
|
window.runtime.LogFatal(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventsOnMultiple(eventName, callback, maxCallbacks) {
|
||||||
|
return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventsOn(eventName, callback) {
|
||||||
|
return EventsOnMultiple(eventName, callback, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventsOff(eventName, ...additionalEventNames) {
|
||||||
|
return window.runtime.EventsOff(eventName, ...additionalEventNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventsOffAll() {
|
||||||
|
return window.runtime.EventsOffAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventsOnce(eventName, callback) {
|
||||||
|
return EventsOnMultiple(eventName, callback, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventsEmit(eventName) {
|
||||||
|
let args = [eventName].slice.call(arguments);
|
||||||
|
return window.runtime.EventsEmit.apply(null, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowReload() {
|
||||||
|
window.runtime.WindowReload();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowReloadApp() {
|
||||||
|
window.runtime.WindowReloadApp();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetAlwaysOnTop(b) {
|
||||||
|
window.runtime.WindowSetAlwaysOnTop(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetSystemDefaultTheme() {
|
||||||
|
window.runtime.WindowSetSystemDefaultTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetLightTheme() {
|
||||||
|
window.runtime.WindowSetLightTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetDarkTheme() {
|
||||||
|
window.runtime.WindowSetDarkTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowCenter() {
|
||||||
|
window.runtime.WindowCenter();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetTitle(title) {
|
||||||
|
window.runtime.WindowSetTitle(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowFullscreen() {
|
||||||
|
window.runtime.WindowFullscreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowUnfullscreen() {
|
||||||
|
window.runtime.WindowUnfullscreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowIsFullscreen() {
|
||||||
|
return window.runtime.WindowIsFullscreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowGetSize() {
|
||||||
|
return window.runtime.WindowGetSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetSize(width, height) {
|
||||||
|
window.runtime.WindowSetSize(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetMaxSize(width, height) {
|
||||||
|
window.runtime.WindowSetMaxSize(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetMinSize(width, height) {
|
||||||
|
window.runtime.WindowSetMinSize(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetPosition(x, y) {
|
||||||
|
window.runtime.WindowSetPosition(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowGetPosition() {
|
||||||
|
return window.runtime.WindowGetPosition();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowHide() {
|
||||||
|
window.runtime.WindowHide();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowShow() {
|
||||||
|
window.runtime.WindowShow();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowMaximise() {
|
||||||
|
window.runtime.WindowMaximise();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowToggleMaximise() {
|
||||||
|
window.runtime.WindowToggleMaximise();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowUnmaximise() {
|
||||||
|
window.runtime.WindowUnmaximise();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowIsMaximised() {
|
||||||
|
return window.runtime.WindowIsMaximised();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowMinimise() {
|
||||||
|
window.runtime.WindowMinimise();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowUnminimise() {
|
||||||
|
window.runtime.WindowUnminimise();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetBackgroundColour(R, G, B, A) {
|
||||||
|
window.runtime.WindowSetBackgroundColour(R, G, B, A);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScreenGetAll() {
|
||||||
|
return window.runtime.ScreenGetAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowIsMinimised() {
|
||||||
|
return window.runtime.WindowIsMinimised();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowIsNormal() {
|
||||||
|
return window.runtime.WindowIsNormal();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BrowserOpenURL(url) {
|
||||||
|
window.runtime.BrowserOpenURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Environment() {
|
||||||
|
return window.runtime.Environment();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Quit() {
|
||||||
|
window.runtime.Quit();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Hide() {
|
||||||
|
window.runtime.Hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Show() {
|
||||||
|
window.runtime.Show();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClipboardGetText() {
|
||||||
|
return window.runtime.ClipboardGetText();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClipboardSetText(text) {
|
||||||
|
return window.runtime.ClipboardSetText(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @callback OnFileDropCallback
|
||||||
|
* @param {number} x - x coordinate of the drop
|
||||||
|
* @param {number} y - y coordinate of the drop
|
||||||
|
* @param {string[]} paths - A list of file paths.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
|
||||||
|
* @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target)
|
||||||
|
*/
|
||||||
|
export function OnFileDrop(callback, useDropTarget) {
|
||||||
|
return window.runtime.OnFileDrop(callback, useDropTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OnFileDropOff removes the drag and drop listeners and handlers.
|
||||||
|
*/
|
||||||
|
export function OnFileDropOff() {
|
||||||
|
return window.runtime.OnFileDropOff();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CanResolveFilePaths() {
|
||||||
|
return window.runtime.CanResolveFilePaths();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResolveFilePaths(files) {
|
||||||
|
return window.runtime.ResolveFilePaths(files);
|
||||||
|
}
|
||||||
322
cmd/dapp-fm-app/main.go
Normal file
322
cmd/dapp-fm-app/main.go
Normal file
|
|
@ -0,0 +1,322 @@
|
||||||
|
// dapp-fm-app is a native desktop media player for dapp.fm
|
||||||
|
// Decryption in Go, media served via Wails asset handler (same origin, no CORS)
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Snider/Borg/pkg/player"
|
||||||
|
"github.com/Snider/Borg/pkg/smsg"
|
||||||
|
"github.com/wailsapp/wails/v2"
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/options"
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed frontend
|
||||||
|
var frontendAssets embed.FS
|
||||||
|
|
||||||
|
// MediaStore holds decrypted media in memory
|
||||||
|
type MediaStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
media map[string]*MediaItem
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaItem struct {
|
||||||
|
Data []byte
|
||||||
|
MimeType string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
var globalStore = &MediaStore{media: make(map[string]*MediaItem)}
|
||||||
|
|
||||||
|
func (s *MediaStore) Set(id string, item *MediaItem) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.media[id] = item
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MediaStore) Get(id string) *MediaItem {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return s.media[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MediaStore) Clear() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.media = make(map[string]*MediaItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssetHandler serves both static assets and decrypted media
|
||||||
|
type AssetHandler struct {
|
||||||
|
assets fs.FS
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AssetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := r.URL.Path
|
||||||
|
if path == "/" {
|
||||||
|
path = "/index.html"
|
||||||
|
}
|
||||||
|
path = strings.TrimPrefix(path, "/")
|
||||||
|
|
||||||
|
// Check if this is a media request
|
||||||
|
if strings.HasPrefix(path, "media/") {
|
||||||
|
id := strings.TrimPrefix(path, "media/")
|
||||||
|
item := globalStore.Get(id)
|
||||||
|
if item == nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve with range support for seeking
|
||||||
|
w.Header().Set("Content-Type", item.MimeType)
|
||||||
|
w.Header().Set("Accept-Ranges", "bytes")
|
||||||
|
w.Header().Set("Content-Length", strconv.Itoa(len(item.Data)))
|
||||||
|
|
||||||
|
rangeHeader := r.Header.Get("Range")
|
||||||
|
if rangeHeader != "" && strings.HasPrefix(rangeHeader, "bytes=") {
|
||||||
|
rangeHeader = strings.TrimPrefix(rangeHeader, "bytes=")
|
||||||
|
parts := strings.Split(rangeHeader, "-")
|
||||||
|
start, _ := strconv.Atoi(parts[0])
|
||||||
|
end := len(item.Data) - 1
|
||||||
|
if len(parts) > 1 && parts[1] != "" {
|
||||||
|
end, _ = strconv.Atoi(parts[1])
|
||||||
|
}
|
||||||
|
if end >= len(item.Data) {
|
||||||
|
end = len(item.Data) - 1
|
||||||
|
}
|
||||||
|
if start > end || start >= len(item.Data) {
|
||||||
|
http.Error(w, "Invalid range", http.StatusRequestedRangeNotSatisfiable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(item.Data)))
|
||||||
|
w.Header().Set("Content-Length", strconv.Itoa(end-start+1))
|
||||||
|
w.WriteHeader(http.StatusPartialContent)
|
||||||
|
w.Write(item.Data[start : end+1])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.ServeContent(w, r, item.Name, time.Time{}, bytes.NewReader(item.Data))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve static assets
|
||||||
|
data, err := fs.ReadFile(h.assets, "frontend/"+path)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set content type
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(path, ".html"):
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
case strings.HasSuffix(path, ".js"):
|
||||||
|
w.Header().Set("Content-Type", "application/javascript")
|
||||||
|
case strings.HasSuffix(path, ".css"):
|
||||||
|
w.Header().Set("Content-Type", "text/css")
|
||||||
|
case strings.HasSuffix(path, ".wasm"):
|
||||||
|
w.Header().Set("Content-Type", "application/wasm")
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// App wraps player functionality
|
||||||
|
type App struct {
|
||||||
|
ctx context.Context
|
||||||
|
player *player.Player
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewApp() *App {
|
||||||
|
return &App{
|
||||||
|
player: player.NewPlayer(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Startup(ctx context.Context) {
|
||||||
|
a.ctx = ctx
|
||||||
|
a.player.Startup(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MediaResult holds URLs for playback
|
||||||
|
type MediaResult struct {
|
||||||
|
Body string `json:"body"`
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
|
From string `json:"from,omitempty"`
|
||||||
|
Attachments []MediaAttachment `json:"attachments,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaAttachment struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
MimeType string `json:"mime_type"`
|
||||||
|
Size int `json:"size"`
|
||||||
|
URL string `json:"url"` // /media/0, /media/1, etc.
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadDemo decrypts demo and stores in memory for streaming
|
||||||
|
func (a *App) LoadDemo() (*MediaResult, error) {
|
||||||
|
globalStore.Clear()
|
||||||
|
|
||||||
|
// Read demo from embedded filesystem
|
||||||
|
demoBytes, err := fs.ReadFile(frontendAssets, "frontend/demo-track.smsg")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("demo not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt
|
||||||
|
msg, err := smsg.Decrypt(demoBytes, "dapp-fm-2024")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decrypt failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &MediaResult{
|
||||||
|
Body: msg.Body,
|
||||||
|
Subject: msg.Subject,
|
||||||
|
From: msg.From,
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, att := range msg.Attachments {
|
||||||
|
// Decode base64 to raw bytes
|
||||||
|
data, err := base64.StdEncoding.DecodeString(att.Content)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in memory
|
||||||
|
id := strconv.Itoa(i)
|
||||||
|
globalStore.Set(id, &MediaItem{
|
||||||
|
Data: data,
|
||||||
|
MimeType: att.MimeType,
|
||||||
|
Name: att.Name,
|
||||||
|
})
|
||||||
|
|
||||||
|
result.Attachments = append(result.Attachments, MediaAttachment{
|
||||||
|
Name: att.Name,
|
||||||
|
MimeType: att.MimeType,
|
||||||
|
Size: len(data),
|
||||||
|
URL: "/media/" + id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDemoManifest returns manifest without decrypting
|
||||||
|
func (a *App) GetDemoManifest() (*player.ManifestInfo, error) {
|
||||||
|
demoBytes, err := fs.ReadFile(frontendAssets, "frontend/demo-track.smsg")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("demo not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := smsg.GetInfo(demoBytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &player.ManifestInfo{}
|
||||||
|
if info.Manifest != nil {
|
||||||
|
m := info.Manifest
|
||||||
|
result.Title = m.Title
|
||||||
|
result.Artist = m.Artist
|
||||||
|
result.Album = m.Album
|
||||||
|
result.ReleaseType = m.ReleaseType
|
||||||
|
result.Format = m.Format
|
||||||
|
result.LicenseType = m.LicenseType
|
||||||
|
|
||||||
|
for _, t := range m.Tracks {
|
||||||
|
result.Tracks = append(result.Tracks, player.TrackInfo{
|
||||||
|
Title: t.Title,
|
||||||
|
Start: t.Start,
|
||||||
|
End: t.End,
|
||||||
|
TrackNum: t.TrackNum,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecryptAndServe decrypts user-provided content and serves via asset handler
|
||||||
|
func (a *App) DecryptAndServe(encrypted string, password string) (*MediaResult, error) {
|
||||||
|
globalStore.Clear()
|
||||||
|
|
||||||
|
// Decrypt using player (handles base64 input)
|
||||||
|
msg, err := smsg.DecryptBase64(encrypted, password)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decrypt failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &MediaResult{
|
||||||
|
Body: msg.Body,
|
||||||
|
Subject: msg.Subject,
|
||||||
|
From: msg.From,
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, att := range msg.Attachments {
|
||||||
|
data, err := base64.StdEncoding.DecodeString(att.Content)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
id := strconv.Itoa(i)
|
||||||
|
globalStore.Set(id, &MediaItem{
|
||||||
|
Data: data,
|
||||||
|
MimeType: att.MimeType,
|
||||||
|
Name: att.Name,
|
||||||
|
})
|
||||||
|
|
||||||
|
result.Attachments = append(result.Attachments, MediaAttachment{
|
||||||
|
Name: att.Name,
|
||||||
|
MimeType: att.MimeType,
|
||||||
|
Size: len(data),
|
||||||
|
URL: "/media/" + id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proxy methods
|
||||||
|
func (a *App) GetManifest(encrypted string) (*player.ManifestInfo, error) {
|
||||||
|
return a.player.GetManifest(encrypted)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) IsLicenseValid(encrypted string) (bool, error) {
|
||||||
|
return a.player.IsLicenseValid(encrypted)
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
app := NewApp()
|
||||||
|
|
||||||
|
err := wails.Run(&options.App{
|
||||||
|
Title: "dapp.fm Player",
|
||||||
|
Width: 1200,
|
||||||
|
Height: 800,
|
||||||
|
MinWidth: 800,
|
||||||
|
MinHeight: 600,
|
||||||
|
AssetServer: &assetserver.Options{
|
||||||
|
Handler: &AssetHandler{assets: frontendAssets},
|
||||||
|
},
|
||||||
|
BackgroundColour: &options.RGBA{R: 18, G: 18, B: 18, A: 1},
|
||||||
|
OnStartup: app.Startup,
|
||||||
|
Bind: []interface{}{
|
||||||
|
app,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
println("Error:", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
20
cmd/dapp-fm-app/wails.json
Normal file
20
cmd/dapp-fm-app/wails.json
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://wails.io/schemas/config.v2.json",
|
||||||
|
"name": "dapp-fm",
|
||||||
|
"outputfilename": "dapp-fm",
|
||||||
|
"frontend:install": "",
|
||||||
|
"frontend:build": "",
|
||||||
|
"frontend:dev:watcher": "",
|
||||||
|
"frontend:dev:serverUrl": "",
|
||||||
|
"author": {
|
||||||
|
"name": "dapp.fm",
|
||||||
|
"email": "hello@dapp.fm"
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"companyName": "dapp.fm",
|
||||||
|
"productName": "dapp.fm Player",
|
||||||
|
"productVersion": "1.0.0",
|
||||||
|
"copyright": "Copyright (c) 2024 dapp.fm - EUPL-1.2",
|
||||||
|
"comments": "Decentralized Music Distribution - Zero-Trust DRM"
|
||||||
|
}
|
||||||
|
}
|
||||||
64
cmd/dapp-fm/main.go
Normal file
64
cmd/dapp-fm/main.go
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
// dapp-fm CLI provides headless media player functionality
|
||||||
|
// For native desktop app with WebView, use dapp-fm-app instead
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/Snider/Borg/pkg/player"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
rootCmd := &cobra.Command{
|
||||||
|
Use: "dapp-fm",
|
||||||
|
Short: "dapp.fm - Decentralized Music Player CLI",
|
||||||
|
Long: `dapp-fm is the CLI version of the dapp.fm player.
|
||||||
|
|
||||||
|
For the native desktop app with WebView, use dapp-fm-app instead.
|
||||||
|
This CLI provides HTTP server mode for automation and fallback scenarios.`,
|
||||||
|
}
|
||||||
|
|
||||||
|
serveCmd := &cobra.Command{
|
||||||
|
Use: "serve",
|
||||||
|
Short: "Start HTTP server for the media player",
|
||||||
|
Long: `Starts an HTTP server serving the media player interface.
|
||||||
|
This is the slower TCP path - for memory-speed decryption, use dapp-fm-app.`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
port, _ := cmd.Flags().GetString("port")
|
||||||
|
openBrowser, _ := cmd.Flags().GetBool("open")
|
||||||
|
|
||||||
|
p := player.NewPlayer()
|
||||||
|
|
||||||
|
addr := ":" + port
|
||||||
|
if openBrowser {
|
||||||
|
fmt.Printf("Opening browser at http://localhost%s\n", addr)
|
||||||
|
// Would need browser opener here
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.Serve(addr)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
serveCmd.Flags().StringP("port", "p", "8080", "Port to serve on")
|
||||||
|
serveCmd.Flags().Bool("open", false, "Open browser automatically")
|
||||||
|
|
||||||
|
versionCmd := &cobra.Command{
|
||||||
|
Use: "version",
|
||||||
|
Short: "Print version information",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
fmt.Println("dapp-fm v1.0.0")
|
||||||
|
fmt.Println("Decentralized Music Distribution")
|
||||||
|
fmt.Println("https://dapp.fm")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
rootCmd.AddCommand(serveCmd)
|
||||||
|
rootCmd.AddCommand(versionCmd)
|
||||||
|
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
// Usage:
|
// Usage:
|
||||||
//
|
//
|
||||||
// go run main.go -input video.mp4 -output video.smsg -password "license-token" -title "My Track" -artist "Artist Name"
|
// go run main.go -input video.mp4 -output video.smsg -password "license-token" -title "My Track" -artist "Artist Name"
|
||||||
|
// go run main.go -input video.mp4 -password "token" -track "0:Intro" -track "67:Sonnata, It Feels So Good"
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -13,20 +14,37 @@ import (
|
||||||
"mime"
|
"mime"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Snider/Borg/pkg/smsg"
|
"github.com/Snider/Borg/pkg/smsg"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// trackList allows multiple -track flags
|
||||||
|
type trackList []string
|
||||||
|
|
||||||
|
func (t *trackList) String() string {
|
||||||
|
return strings.Join(*t, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *trackList) Set(value string) error {
|
||||||
|
*t = append(*t, value)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
inputFile := flag.String("input", "", "Input media file (mp4, mp3, etc)")
|
inputFile := flag.String("input", "", "Input media file (mp4, mp3, etc)")
|
||||||
outputFile := flag.String("output", "", "Output SMSG file (default: input.smsg)")
|
outputFile := flag.String("output", "", "Output SMSG file (default: input.smsg)")
|
||||||
password := flag.String("password", "", "License token / password for encryption")
|
password := flag.String("password", "", "License token / password for encryption")
|
||||||
title := flag.String("title", "", "Track title (default: filename)")
|
title := flag.String("title", "", "Track title (default: filename)")
|
||||||
artist := flag.String("artist", "", "Artist name")
|
artist := flag.String("artist", "", "Artist name")
|
||||||
|
releaseType := flag.String("type", "single", "Release type: single, ep, album, djset, live")
|
||||||
hint := flag.String("hint", "", "Optional password hint")
|
hint := flag.String("hint", "", "Optional password hint")
|
||||||
outputBase64 := flag.Bool("base64", false, "Output as base64 text file instead of binary")
|
outputBase64 := flag.Bool("base64", false, "Output as base64 text file instead of binary")
|
||||||
|
|
||||||
|
var tracks trackList
|
||||||
|
flag.Var(&tracks, "track", "Track marker as 'seconds:title' or 'mm:ss:title' (can be repeated)")
|
||||||
|
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
if *inputFile == "" {
|
if *inputFile == "" {
|
||||||
|
|
@ -94,13 +112,44 @@ func main() {
|
||||||
contentB64 := base64.StdEncoding.EncodeToString(data)
|
contentB64 := base64.StdEncoding.EncodeToString(data)
|
||||||
msg.AddAttachment(filepath.Base(*inputFile), contentB64, mimeType)
|
msg.AddAttachment(filepath.Base(*inputFile), contentB64, mimeType)
|
||||||
|
|
||||||
// Encrypt
|
// Build manifest with public metadata
|
||||||
|
manifest := smsg.NewManifest(trackTitle)
|
||||||
|
manifest.Artist = *artist
|
||||||
|
manifest.ReleaseType = *releaseType
|
||||||
|
manifest.Format = "dapp.fm/v1"
|
||||||
|
|
||||||
|
// Parse track markers
|
||||||
|
for _, trackStr := range tracks {
|
||||||
|
parts := strings.SplitN(trackStr, ":", 3)
|
||||||
|
var startSec float64
|
||||||
|
var trackName string
|
||||||
|
|
||||||
|
if len(parts) == 2 {
|
||||||
|
// Format: "seconds:title"
|
||||||
|
startSec, _ = strconv.ParseFloat(parts[0], 64)
|
||||||
|
trackName = parts[1]
|
||||||
|
} else if len(parts) == 3 {
|
||||||
|
// Format: "mm:ss:title"
|
||||||
|
mins, _ := strconv.ParseFloat(parts[0], 64)
|
||||||
|
secs, _ := strconv.ParseFloat(parts[1], 64)
|
||||||
|
startSec = mins*60 + secs
|
||||||
|
trackName = parts[2]
|
||||||
|
} else {
|
||||||
|
log.Printf("Warning: Invalid track format '%s', expected 'seconds:title' or 'mm:ss:title'", trackStr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest.AddTrack(trackName, startSec)
|
||||||
|
fmt.Printf(" Track: %s @ %.0fs\n", trackName, startSec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt with manifest
|
||||||
var encrypted []byte
|
var encrypted []byte
|
||||||
if *hint != "" {
|
if *hint != "" {
|
||||||
encrypted, err = smsg.EncryptWithHint(msg, *password, *hint)
|
// For hint, we'd need to extend the API - for now just use manifest
|
||||||
} else {
|
_ = hint
|
||||||
encrypted, err = smsg.Encrypt(msg, *password)
|
|
||||||
}
|
}
|
||||||
|
encrypted, err = smsg.EncryptWithManifest(msg, *password, manifest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Encryption failed: %v", err)
|
log.Fatalf("Encryption failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
22
go.mod
22
go.mod
|
|
@ -11,6 +11,7 @@ require (
|
||||||
github.com/schollz/progressbar/v3 v3.18.0
|
github.com/schollz/progressbar/v3 v3.18.0
|
||||||
github.com/spf13/cobra v1.10.1
|
github.com/spf13/cobra v1.10.1
|
||||||
github.com/ulikunitz/xz v0.5.15
|
github.com/ulikunitz/xz v0.5.15
|
||||||
|
github.com/wailsapp/wails/v2 v2.11.0
|
||||||
golang.org/x/mod v0.30.0
|
golang.org/x/mod v0.30.0
|
||||||
golang.org/x/net v0.47.0
|
golang.org/x/net v0.47.0
|
||||||
golang.org/x/oauth2 v0.33.0
|
golang.org/x/oauth2 v0.33.0
|
||||||
|
|
@ -20,26 +21,47 @@ require (
|
||||||
dario.cat/mergo v1.0.0 // indirect
|
dario.cat/mergo v1.0.0 // indirect
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||||
|
github.com/bep/debounce v1.2.1 // indirect
|
||||||
github.com/cloudflare/circl v1.6.1 // indirect
|
github.com/cloudflare/circl v1.6.1 // indirect
|
||||||
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
|
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
|
||||||
github.com/emirpasic/gods v1.18.1 // indirect
|
github.com/emirpasic/gods v1.18.1 // indirect
|
||||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||||
github.com/go-git/go-billy/v5 v5.6.2 // indirect
|
github.com/go-git/go-billy/v5 v5.6.2 // indirect
|
||||||
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||||
github.com/google/go-querystring v1.1.0 // indirect
|
github.com/google/go-querystring v1.1.0 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||||
|
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||||
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
||||||
|
github.com/labstack/echo/v4 v4.13.3 // indirect
|
||||||
|
github.com/labstack/gommon v0.4.2 // indirect
|
||||||
|
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||||
|
github.com/leaanthony/gosod v1.0.4 // indirect
|
||||||
|
github.com/leaanthony/slicer v1.6.0 // indirect
|
||||||
|
github.com/leaanthony/u v1.1.1 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
|
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
|
||||||
github.com/pjbgf/sha1cd v0.3.2 // indirect
|
github.com/pjbgf/sha1cd v0.3.2 // indirect
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/samber/lo v1.49.1 // indirect
|
||||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
|
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
|
||||||
github.com/skeema/knownhosts v1.3.1 // indirect
|
github.com/skeema/knownhosts v1.3.1 // indirect
|
||||||
github.com/spf13/pflag v1.0.9 // indirect
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
|
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
|
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
||||||
|
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||||
golang.org/x/crypto v0.44.0 // indirect
|
golang.org/x/crypto v0.44.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
golang.org/x/term v0.37.0 // indirect
|
golang.org/x/term v0.37.0 // indirect
|
||||||
|
golang.org/x/text v0.31.0 // indirect
|
||||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
51
go.sum
51
go.sum
|
|
@ -5,14 +5,14 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
|
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
|
||||||
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
|
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
|
||||||
github.com/Snider/Enchantrix v0.0.0-20251113213145-deff3a80c600 h1:9jyEgos5SNTVp3aJkhPs/fb4eTZE5l73YqaT+vFmFu0=
|
|
||||||
github.com/Snider/Enchantrix v0.0.0-20251113213145-deff3a80c600/go.mod h1:v9HATMgLJWycy/R5ho1SL0OHbggXgEhu/qRB9gbS0BM=
|
|
||||||
github.com/Snider/Enchantrix v0.0.2 h1:ExZQiBhfS/p/AHFTKhY80TOd+BXZjK95EzByAEgwvjs=
|
github.com/Snider/Enchantrix v0.0.2 h1:ExZQiBhfS/p/AHFTKhY80TOd+BXZjK95EzByAEgwvjs=
|
||||||
github.com/Snider/Enchantrix v0.0.2/go.mod h1:CtFcLAvnDT1KcuF1JBb/DJj0KplY8jHryO06KzQ1hsQ=
|
github.com/Snider/Enchantrix v0.0.2/go.mod h1:CtFcLAvnDT1KcuF1JBb/DJj0KplY8jHryO06KzQ1hsQ=
|
||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||||
|
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||||
|
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||||
github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM=
|
github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM=
|
||||||
github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY=
|
github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY=
|
||||||
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
|
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
|
||||||
|
|
@ -39,6 +39,10 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj
|
||||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
||||||
github.com/go-git/go-git/v5 v5.16.3 h1:Z8BtvxZ09bYm/yYNgPKCzgWtaRqDTgIKRgIRHBfU6Z8=
|
github.com/go-git/go-git/v5 v5.16.3 h1:Z8BtvxZ09bYm/yYNgPKCzgWtaRqDTgIKRgIRHBfU6Z8=
|
||||||
github.com/go-git/go-git/v5 v5.16.3/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
|
github.com/go-git/go-git/v5 v5.16.3/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
|
||||||
|
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
|
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
|
@ -51,10 +55,16 @@ github.com/google/go-github/v39 v39.2.0 h1:rNNM311XtPOz5rDdsJXAp2o8F67X9FnROXTvt
|
||||||
github.com/google/go-github/v39 v39.2.0/go.mod h1:C1s8C5aCC9L+JXIYpJM5GYytdX52vC1bLvHEF1IhBrE=
|
github.com/google/go-github/v39 v39.2.0/go.mod h1:C1s8C5aCC9L+JXIYpJM5GYytdX52vC1bLvHEF1IhBrE=
|
||||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||||
|
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
||||||
|
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||||
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
|
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
|
||||||
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
|
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
|
@ -64,6 +74,23 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
||||||
|
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
|
||||||
|
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||||
|
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||||
|
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
|
||||||
|
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
|
||||||
|
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
|
||||||
|
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
||||||
|
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
|
||||||
|
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
|
||||||
|
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
|
||||||
|
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
||||||
|
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
||||||
|
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||||
|
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||||
|
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
||||||
|
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
|
@ -77,15 +104,20 @@ github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
|
||||||
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
|
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
|
||||||
github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
|
github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
|
||||||
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
|
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
||||||
|
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
||||||
github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQmCN62HpA=
|
github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQmCN62HpA=
|
||||||
github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
|
github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
|
||||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
|
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
|
||||||
|
|
@ -102,8 +134,20 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||||
|
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||||
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
||||||
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
|
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||||
|
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||||
|
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
|
||||||
|
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||||
|
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||||
|
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||||
|
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
|
||||||
|
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
|
||||||
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||||
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
|
@ -117,6 +161,7 @@ golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||||
|
|
@ -125,12 +170,14 @@ golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
|
||||||
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
|
|
||||||
102
go.work.sum
102
go.work.sum
|
|
@ -1,24 +1,122 @@
|
||||||
|
atomicgo.dev/cursor v0.2.0 h1:H6XN5alUJ52FZZUkI7AlJbUc1aW38GWZalpYRPpoPOw=
|
||||||
|
atomicgo.dev/cursor v0.2.0/go.mod h1:Lr4ZJB3U7DfPPOkbH7/6TOtJ4vFGHlgj1nc+n900IpU=
|
||||||
|
atomicgo.dev/keyboard v0.2.9 h1:tOsIid3nlPLZ3lwgG8KZMp/SFmr7P0ssEN5JUsm78K8=
|
||||||
|
atomicgo.dev/keyboard v0.2.9/go.mod h1:BC4w9g00XkxH/f1HXhW2sXmJFOCWbKn9xrOunSFtExQ=
|
||||||
|
atomicgo.dev/schedule v0.1.0 h1:nTthAbhZS5YZmgYbb2+DH8uQIZcTlIrd4eYr3UQxEjs=
|
||||||
|
atomicgo.dev/schedule v0.1.0/go.mod h1:xeUa3oAkiuHYh8bKiQBRojqAMq3PXXbJujjb0hw8pEU=
|
||||||
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
|
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
|
||||||
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||||
|
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
|
||||||
|
github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
|
||||||
|
github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA=
|
||||||
|
github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8=
|
||||||
|
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8=
|
||||||
|
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo=
|
||||||
|
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
|
||||||
|
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
|
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
|
github.com/bitfield/script v0.24.0 h1:ic0Tbx+2AgRtkGGIcUyr+Un60vu4WXvqFrCSumf+T7M=
|
||||||
|
github.com/bitfield/script v0.24.0/go.mod h1:fv+6x4OzVsRs6qAlc7wiGq8fq1b5orhtQdtW0dwjUHI=
|
||||||
github.com/bwesterb/go-ristretto v1.2.3 h1:1w53tCkGhCQ5djbat3+MH0BAQ5Kfgbt56UZQ/JMzngw=
|
github.com/bwesterb/go-ristretto v1.2.3 h1:1w53tCkGhCQ5djbat3+MH0BAQ5Kfgbt56UZQ/JMzngw=
|
||||||
github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
|
github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
|
||||||
|
github.com/charmbracelet/glamour v0.8.0 h1:tPrjL3aRcQbn++7t18wOpgLyl8wrOHUEDS7IZ68QtZs=
|
||||||
|
github.com/charmbracelet/glamour v0.8.0/go.mod h1:ViRgmKkf3u5S7uakt2czJ272WSg2ZenlYEZXT2x7Bjw=
|
||||||
|
github.com/charmbracelet/lipgloss v0.12.1 h1:/gmzszl+pedQpjCOH+wFkZr/N90Snz40J/NR7A0zQcs=
|
||||||
|
github.com/charmbracelet/lipgloss v0.12.1/go.mod h1:V2CiwIuhx9S1S1ZlADfOj9HmxeMAORuz5izHb0zGbB8=
|
||||||
|
github.com/charmbracelet/x/ansi v0.1.4 h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831KfiLM=
|
||||||
|
github.com/charmbracelet/x/ansi v0.1.4/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
|
||||||
|
github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
|
||||||
|
github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
|
||||||
|
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||||
|
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
|
github.com/flytam/filenamify v1.2.0 h1:7RiSqXYR4cJftDQ5NuvljKMfd/ubKnW/j9C6iekChgI=
|
||||||
|
github.com/flytam/filenamify v1.2.0/go.mod h1:Dzf9kVycwcsBlr2ATg6uxjqiFgKGH+5SKFuhdeP5zu8=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
|
||||||
|
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
|
||||||
|
github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0=
|
||||||
|
github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w=
|
||||||
|
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
|
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||||
|
github.com/itchyny/gojq v0.12.13 h1:IxyYlHYIlspQHHTE0f3cJF0NKDMfajxViuhBLnHd/QU=
|
||||||
|
github.com/itchyny/gojq v0.12.13/go.mod h1:JzwzAqenfhrPUuwbmEz3nu3JQmFLlQTQMUcOdnu/Sf4=
|
||||||
|
github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm6GE=
|
||||||
|
github.com/itchyny/timefmt-go v0.1.5/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8=
|
||||||
|
github.com/jackmordaunt/icns v1.0.0 h1:RYSxplerf/l/DUd09AHtITwckkv/mqjVv4DjYdPmAMQ=
|
||||||
|
github.com/jackmordaunt/icns v1.0.0/go.mod h1:7TTQVEuGzVVfOPPlLNHJIkzA6CoV7aH1Dv9dW351oOo=
|
||||||
|
github.com/jaypipes/ghw v0.13.0 h1:log8MXuB8hzTNnSktqpXMHc0c/2k/WgjOMSUtnI1RV4=
|
||||||
|
github.com/jaypipes/ghw v0.13.0/go.mod h1:In8SsaDqlb1oTyrbmTC14uy+fbBMvp+xdqX51MidlD8=
|
||||||
|
github.com/jaypipes/pcidb v1.0.1 h1:WB2zh27T3nwg8AE8ei81sNRb9yWBii3JGNJtT7K9Oic=
|
||||||
|
github.com/jaypipes/pcidb v1.0.1/go.mod h1:6xYUz/yYEyOkIkUt2t2J2folIuZ4Yg6uByCGFXMCeE4=
|
||||||
github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 h1:qGQQKEcAR99REcMpsXCp3lJ03zYT1PkRd3kQGPn9GVg=
|
github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 h1:qGQQKEcAR99REcMpsXCp3lJ03zYT1PkRd3kQGPn9GVg=
|
||||||
github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw=
|
github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw=
|
||||||
github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw=
|
github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw=
|
||||||
|
github.com/leaanthony/clir v1.3.0 h1:L9nPDWrmc/qU9UWZZvRaFajWYuO0np9V5p+5gxyYno0=
|
||||||
|
github.com/leaanthony/clir v1.3.0/go.mod h1:k/RBkdkFl18xkkACMCLt09bhiZnrGORoxmomeMvDpE0=
|
||||||
|
github.com/leaanthony/winicon v1.0.0 h1:ZNt5U5dY71oEoKZ97UVwJRT4e+5xo5o/ieKuHuk8NqQ=
|
||||||
|
github.com/leaanthony/winicon v1.0.0/go.mod h1:en5xhijl92aphrJdmRPlh4NI1L6wq3gEm0LpXAPghjU=
|
||||||
|
github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
|
||||||
|
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||||
|
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||||
|
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||||
|
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||||
|
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||||
|
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg=
|
||||||
|
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ=
|
||||||
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||||
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||||
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||||
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||||
|
github.com/pterm/pterm v0.12.80 h1:mM55B+GnKUnLMUSqhdINe4s6tOuVQIetQ3my8JGyAIg=
|
||||||
|
github.com/pterm/pterm v0.12.80/go.mod h1:c6DeF9bSnOSeFPZlfs4ZRAFcf5SCoTwvwQ5xaKGQlHo=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||||
|
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
|
||||||
|
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
|
||||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
|
github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
|
||||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/tc-hib/winres v0.3.1 h1:CwRjEGrKdbi5CvZ4ID+iyVhgyfatxFoizjPhzez9Io4=
|
||||||
|
github.com/tc-hib/winres v0.3.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
|
||||||
|
github.com/tidwall/gjson v1.14.2 h1:6BBkirS0rAHjumnjHF6qgy5d2YAJ1TLIaFE2lzfOLqo=
|
||||||
|
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
|
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||||
|
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||||
|
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
||||||
|
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
|
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||||
|
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||||
|
github.com/wzshiming/ctc v1.2.3 h1:q+hW3IQNsjIlOFBTGZZZeIXTElFM4grF4spW/errh/c=
|
||||||
|
github.com/wzshiming/ctc v1.2.3/go.mod h1:2tVAtIY7SUyraSk0JxvwmONNPFL4ARavPuEsg5+KA28=
|
||||||
|
github.com/wzshiming/winseq v0.0.0-20200112104235-db357dc107ae h1:tpXvBXC3hpQBDCc9OojJZCQMVRAbT3TTdUMP8WguXkY=
|
||||||
|
github.com/wzshiming/winseq v0.0.0-20200112104235-db357dc107ae/go.mod h1:VTAq37rkGeV+WOybvZwjXiJOicICdpLCN8ifpISjK20=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg=
|
||||||
|
github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||||
|
github.com/yuin/goldmark-emoji v1.0.3 h1:aLRkLHOuBR2czCY4R8olwMjID+tENfhyFDMCRhbIQY4=
|
||||||
|
github.com/yuin/goldmark-emoji v1.0.3/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
|
||||||
golang.org/x/crypto v0.11.1-0.20230711161743-2e82bdd1719d/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
|
golang.org/x/crypto v0.11.1-0.20230711161743-2e82bdd1719d/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
|
||||||
|
golang.org/x/image v0.12.0 h1:w13vZbU4o5rKOFFR8y7M+c4A5jXDC0uXTdHYRP8X2DQ=
|
||||||
|
golang.org/x/image v0.12.0/go.mod h1:Lu90jvHG7GfemOIcldsh9A2hS01ocl6oNO7ype5mEnk=
|
||||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
|
||||||
|
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||||
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||||
|
|
@ -28,3 +126,7 @@ google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6
|
||||||
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
|
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
|
||||||
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
|
howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
|
||||||
|
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||||
|
mvdan.cc/sh/v3 v3.7.0 h1:lSTjdP/1xsddtaKfGg7Myu7DnlHItd3/M2tomOcNNBg=
|
||||||
|
mvdan.cc/sh/v3 v3.7.0/go.mod h1:K2gwkaesF/D7av7Kxl0HbF5kGOd2ArupNTX3X44+8l8=
|
||||||
|
|
|
||||||
|
|
@ -481,6 +481,81 @@
|
||||||
<input type="text" id="license-price" placeholder="Price (e.g., $9.99)" style="max-width: 150px;">
|
<input type="text" id="license-price" placeholder="Price (e.g., $9.99)" style="max-width: 150px;">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Track List / Master Record -->
|
||||||
|
<div style="background: rgba(0,0,0,0.2); border-radius: 12px; padding: 1.25rem; margin-bottom: 1.25rem;">
|
||||||
|
<h3 style="font-size: 0.95rem; margin-bottom: 1rem; color: #ff006e; display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<span>💿</span> Master Track List
|
||||||
|
</h3>
|
||||||
|
<p style="font-size: 0.8rem; color: #888; margin-bottom: 1rem;">
|
||||||
|
Define tracks like a CD master - timestamps become chapter markers in the player.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div id="track-list-container">
|
||||||
|
<!-- Tracks will be added here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" id="add-track-btn" class="secondary" style="width: 100%; margin-top: 0.75rem; padding: 0.75rem;">
|
||||||
|
+ Add Track
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style="margin-top: 1rem; padding-top: 1rem; border-top: 1px solid rgba(255,255,255,0.1);">
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
|
||||||
|
<div class="input-group" style="margin-bottom: 0;">
|
||||||
|
<label for="release-name">Release / Album Name</label>
|
||||||
|
<input type="text" id="release-name" placeholder="e.g., Summer EP 2024">
|
||||||
|
</div>
|
||||||
|
<div class="input-group" style="margin-bottom: 0;">
|
||||||
|
<label for="release-type">Release Type</label>
|
||||||
|
<select id="release-type" style="width: 100%; padding: 0.8rem; border-radius: 8px; background: rgba(0,0,0,0.4); border: 2px solid rgba(255,255,255,0.1); color: #fff;">
|
||||||
|
<option value="single">Single</option>
|
||||||
|
<option value="ep">EP</option>
|
||||||
|
<option value="album">Album</option>
|
||||||
|
<option value="mixtape">Mixtape</option>
|
||||||
|
<option value="djset">DJ Set / Mix</option>
|
||||||
|
<option value="live">Live Recording</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- License Type & Expiration -->
|
||||||
|
<div style="background: rgba(0,0,0,0.2); border-radius: 12px; padding: 1.25rem; margin-bottom: 1.25rem;">
|
||||||
|
<h3 style="font-size: 0.95rem; margin-bottom: 1rem; color: #00ff94; display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<span>🎫</span> License Configuration
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem;">
|
||||||
|
<div class="input-group" style="margin-bottom: 0;">
|
||||||
|
<label for="license-type">License Type</label>
|
||||||
|
<select id="license-type" style="width: 100%; padding: 0.8rem; border-radius: 8px; background: rgba(0,0,0,0.4); border: 2px solid rgba(255,255,255,0.1); color: #fff;">
|
||||||
|
<option value="perpetual">Perpetual (Own Forever)</option>
|
||||||
|
<option value="rental">Rental (Time-Limited)</option>
|
||||||
|
<option value="stream">Streaming (24h Access)</option>
|
||||||
|
<option value="preview">Preview (30 seconds)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="input-group" style="margin-bottom: 0;" id="expiration-group">
|
||||||
|
<label for="expiration-duration">Duration</label>
|
||||||
|
<select id="expiration-duration" style="width: 100%; padding: 0.8rem; border-radius: 8px; background: rgba(0,0,0,0.4); border: 2px solid rgba(255,255,255,0.1); color: #fff;" disabled>
|
||||||
|
<option value="0">No Expiration</option>
|
||||||
|
<option value="1800">30 minutes</option>
|
||||||
|
<option value="3600">1 hour</option>
|
||||||
|
<option value="86400">24 hours</option>
|
||||||
|
<option value="259200">3 days</option>
|
||||||
|
<option value="604800">7 days</option>
|
||||||
|
<option value="2592000">30 days</option>
|
||||||
|
<option value="7776000">90 days</option>
|
||||||
|
<option value="31536000">1 year</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="license-type-info" style="font-size: 0.8rem; color: #888; padding: 0.75rem; background: rgba(0,255,148,0.1); border-radius: 8px;">
|
||||||
|
<strong style="color: #00ff94;">Perpetual:</strong> Customer owns the content forever. No expiration.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="license-generator">
|
<div class="license-generator">
|
||||||
<div class="input-group" style="margin-bottom: 0;">
|
<div class="input-group" style="margin-bottom: 0;">
|
||||||
<label for="custom-token">License Token (auto-generated or custom):</label>
|
<label for="custom-token">License Token (auto-generated or custom):</label>
|
||||||
|
|
@ -491,7 +566,7 @@
|
||||||
|
|
||||||
<div class="info-box">
|
<div class="info-box">
|
||||||
<h4>How It Works</h4>
|
<h4>How It Works</h4>
|
||||||
<p>Each license token encrypts your content uniquely. The customer receives the encrypted file + their personal token. Only their token unlocks their copy. You can issue unlimited unique licenses - each one is cryptographically distinct.</p>
|
<p>Each license token encrypts your content uniquely with its own playback config. Create "Intro Mix" licenses that start at the build-up, "DJ Extended" with loop points, or "Radio Edit" cuts. Same master file, unlimited unique licensed versions!</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -512,6 +587,133 @@
|
||||||
let contentMime = 'video/mp4';
|
let contentMime = 'video/mp4';
|
||||||
let licenses = [];
|
let licenses = [];
|
||||||
let licenseCounter = 0;
|
let licenseCounter = 0;
|
||||||
|
let tracks = [];
|
||||||
|
let trackCounter = 0;
|
||||||
|
|
||||||
|
// Track management
|
||||||
|
function addTrack(title = '', start = '', end = '', mixType = 'full') {
|
||||||
|
trackCounter++;
|
||||||
|
const track = {
|
||||||
|
id: trackCounter,
|
||||||
|
title: title,
|
||||||
|
start: start,
|
||||||
|
end: end,
|
||||||
|
mixType: mixType
|
||||||
|
};
|
||||||
|
tracks.push(track);
|
||||||
|
renderTrackList();
|
||||||
|
return track;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTrack(id) {
|
||||||
|
tracks = tracks.filter(t => t.id !== id);
|
||||||
|
renderTrackList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTrackList() {
|
||||||
|
const container = document.getElementById('track-list-container');
|
||||||
|
while (container.firstChild) {
|
||||||
|
container.removeChild(container.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tracks.length === 0) {
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.style.cssText = 'color: #666; text-align: center; padding: 1rem; font-size: 0.85rem;';
|
||||||
|
empty.textContent = 'No tracks defined. Click "Add Track" to create chapter markers.';
|
||||||
|
container.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks.forEach((track, index) => {
|
||||||
|
const trackEl = document.createElement('div');
|
||||||
|
trackEl.style.cssText = 'background: rgba(131, 56, 236, 0.1); border: 1px solid rgba(131, 56, 236, 0.2); border-radius: 8px; padding: 0.75rem; margin-bottom: 0.5rem;';
|
||||||
|
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.style.cssText = 'display: grid; grid-template-columns: auto 1fr 80px 80px 120px auto; gap: 0.5rem; align-items: center;';
|
||||||
|
|
||||||
|
// Track number
|
||||||
|
const numSpan = document.createElement('span');
|
||||||
|
numSpan.style.cssText = 'color: #8338ec; font-weight: 700; font-size: 0.9rem; min-width: 30px;';
|
||||||
|
numSpan.textContent = (index + 1) + '.';
|
||||||
|
|
||||||
|
// Title input
|
||||||
|
const titleInput = document.createElement('input');
|
||||||
|
titleInput.type = 'text';
|
||||||
|
titleInput.placeholder = 'Track title';
|
||||||
|
titleInput.value = track.title;
|
||||||
|
titleInput.style.cssText = 'padding: 0.5rem; border-radius: 6px; background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.1); color: #fff; font-size: 0.85rem;';
|
||||||
|
titleInput.addEventListener('change', (e) => { track.title = e.target.value; });
|
||||||
|
|
||||||
|
// Start time
|
||||||
|
const startInput = document.createElement('input');
|
||||||
|
startInput.type = 'text';
|
||||||
|
startInput.placeholder = '0:00';
|
||||||
|
startInput.value = track.start;
|
||||||
|
startInput.style.cssText = 'padding: 0.5rem; border-radius: 6px; background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.1); color: #00ff94; font-size: 0.85rem; text-align: center;';
|
||||||
|
startInput.addEventListener('change', (e) => { track.start = e.target.value; });
|
||||||
|
|
||||||
|
// End time
|
||||||
|
const endInput = document.createElement('input');
|
||||||
|
endInput.type = 'text';
|
||||||
|
endInput.placeholder = 'end';
|
||||||
|
endInput.value = track.end;
|
||||||
|
endInput.style.cssText = 'padding: 0.5rem; border-radius: 6px; background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.1); color: #ff006e; font-size: 0.85rem; text-align: center;';
|
||||||
|
endInput.addEventListener('change', (e) => { track.end = e.target.value; });
|
||||||
|
|
||||||
|
// Mix type select
|
||||||
|
const mixSelect = document.createElement('select');
|
||||||
|
mixSelect.style.cssText = 'padding: 0.5rem; border-radius: 6px; background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.1); color: #fff; font-size: 0.75rem;';
|
||||||
|
const mixTypes = ['full', 'intro', 'verse', 'chorus', 'bridge', 'drop', 'outro', 'buildup'];
|
||||||
|
mixTypes.forEach(mt => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = mt;
|
||||||
|
opt.textContent = mt.charAt(0).toUpperCase() + mt.slice(1);
|
||||||
|
if (mt === track.mixType) opt.selected = true;
|
||||||
|
mixSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
mixSelect.addEventListener('change', (e) => { track.mixType = e.target.value; });
|
||||||
|
|
||||||
|
// Remove button
|
||||||
|
const removeBtn = document.createElement('button');
|
||||||
|
removeBtn.textContent = '✕';
|
||||||
|
removeBtn.style.cssText = 'padding: 0.4rem 0.6rem; background: rgba(255,82,82,0.2); border: 1px solid rgba(255,82,82,0.4); color: #ff5252; border-radius: 6px; cursor: pointer; font-size: 0.8rem;';
|
||||||
|
removeBtn.addEventListener('click', () => removeTrack(track.id));
|
||||||
|
|
||||||
|
row.appendChild(numSpan);
|
||||||
|
row.appendChild(titleInput);
|
||||||
|
row.appendChild(startInput);
|
||||||
|
row.appendChild(endInput);
|
||||||
|
row.appendChild(mixSelect);
|
||||||
|
row.appendChild(removeBtn);
|
||||||
|
|
||||||
|
trackEl.appendChild(row);
|
||||||
|
container.appendChild(trackEl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTimeToSeconds(timeStr) {
|
||||||
|
if (!timeStr) return 0;
|
||||||
|
timeStr = timeStr.trim();
|
||||||
|
if (timeStr.includes(':')) {
|
||||||
|
const parts = timeStr.split(':');
|
||||||
|
if (parts.length === 2) {
|
||||||
|
return parseFloat(parts[0]) * 60 + parseFloat(parts[1]);
|
||||||
|
} else if (parts.length === 3) {
|
||||||
|
return parseFloat(parts[0]) * 3600 + parseFloat(parts[1]) * 60 + parseFloat(parts[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parseFloat(timeStr) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTrackListMetadata() {
|
||||||
|
return tracks.map((t, i) => ({
|
||||||
|
number: i + 1,
|
||||||
|
title: t.title || 'Track ' + (i + 1),
|
||||||
|
start: parseTimeToSeconds(t.start),
|
||||||
|
end: t.end ? parseTimeToSeconds(t.end) : null,
|
||||||
|
type: t.mixType
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize WASM
|
// Initialize WASM
|
||||||
async function initWasm() {
|
async function initWasm() {
|
||||||
|
|
@ -642,17 +844,52 @@
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create message with content
|
// Gather release metadata
|
||||||
const encrypted = await BorgSMSG.encrypt({
|
const releaseName = document.getElementById('release-name').value.trim() || contentName;
|
||||||
|
const releaseType = document.getElementById('release-type').value;
|
||||||
|
const trackList = getTrackListMetadata();
|
||||||
|
|
||||||
|
// Get license configuration
|
||||||
|
const licenseType = document.getElementById('license-type').value;
|
||||||
|
const expirationDuration = parseInt(document.getElementById('expiration-duration').value) || 0;
|
||||||
|
|
||||||
|
// Calculate expiration time
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
let expiresAt = 0;
|
||||||
|
if (licenseType === 'preview') {
|
||||||
|
expiresAt = now + 30; // 30 seconds for preview
|
||||||
|
} else if (licenseType !== 'perpetual' && expirationDuration > 0) {
|
||||||
|
expiresAt = now + expirationDuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build public manifest (visible WITHOUT decryption - for discovery/indexing)
|
||||||
|
const manifest = {
|
||||||
|
title: releaseName,
|
||||||
|
artist: 'Artist', // Could be from user input
|
||||||
|
releaseType: releaseType,
|
||||||
|
format: 'dapp.fm/v1',
|
||||||
|
licenseType: licenseType,
|
||||||
|
issuedAt: now,
|
||||||
|
expiresAt: expiresAt,
|
||||||
|
tracks: trackList.map(t => ({
|
||||||
|
title: t.title,
|
||||||
|
start: t.start,
|
||||||
|
end: t.end || 0,
|
||||||
|
type: t.type
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create message with content (attachments encrypted, manifest public)
|
||||||
|
const encrypted = await BorgSMSG.encryptWithManifest({
|
||||||
body: 'Licensed content from dapp.fm',
|
body: 'Licensed content from dapp.fm',
|
||||||
subject: contentName,
|
subject: releaseName,
|
||||||
from: 'Artist',
|
from: 'Artist',
|
||||||
attachments: [{
|
attachments: [{
|
||||||
name: contentName.includes('.') ? contentName : contentName + '.mp4',
|
name: contentName.includes('.') ? contentName : contentName + '.mp4',
|
||||||
content: contentData || btoa('Demo content - upload your own file for real encryption'),
|
content: contentData || btoa('Demo content - upload your own file for real encryption'),
|
||||||
mime: contentMime
|
mime: contentMime
|
||||||
}]
|
}]
|
||||||
}, token);
|
}, token, manifest);
|
||||||
|
|
||||||
// Add to licenses list
|
// Add to licenses list
|
||||||
licenseCounter++;
|
licenseCounter++;
|
||||||
|
|
@ -663,7 +900,11 @@
|
||||||
price: price,
|
price: price,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
encrypted: encrypted,
|
encrypted: encrypted,
|
||||||
contentName: contentName
|
contentName: releaseName,
|
||||||
|
trackCount: trackList.length,
|
||||||
|
releaseType: releaseType,
|
||||||
|
licenseType: licenseType,
|
||||||
|
expiresAt: expiresAt
|
||||||
};
|
};
|
||||||
licenses.unshift(license);
|
licenses.unshift(license);
|
||||||
|
|
||||||
|
|
@ -752,6 +993,36 @@
|
||||||
meta.appendChild(priceSpan);
|
meta.appendChild(priceSpan);
|
||||||
meta.appendChild(contentSpan);
|
meta.appendChild(contentSpan);
|
||||||
|
|
||||||
|
// License type badge
|
||||||
|
if (license.licenseType && license.licenseType !== 'perpetual') {
|
||||||
|
const typeBadge = document.createElement('span');
|
||||||
|
typeBadge.style.cssText = 'padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 0.7rem; font-weight: 600; text-transform: uppercase;';
|
||||||
|
|
||||||
|
if (license.licenseType === 'stream') {
|
||||||
|
typeBadge.style.background = 'rgba(58, 134, 255, 0.2)';
|
||||||
|
typeBadge.style.color = '#3a86ff';
|
||||||
|
typeBadge.textContent = 'STREAM';
|
||||||
|
} else if (license.licenseType === 'rental') {
|
||||||
|
typeBadge.style.background = 'rgba(255, 193, 7, 0.2)';
|
||||||
|
typeBadge.style.color = '#ffc107';
|
||||||
|
typeBadge.textContent = 'RENTAL';
|
||||||
|
} else if (license.licenseType === 'preview') {
|
||||||
|
typeBadge.style.background = 'rgba(255, 0, 110, 0.2)';
|
||||||
|
typeBadge.style.color = '#ff006e';
|
||||||
|
typeBadge.textContent = 'PREVIEW';
|
||||||
|
}
|
||||||
|
meta.appendChild(typeBadge);
|
||||||
|
|
||||||
|
// Show expiration
|
||||||
|
if (license.expiresAt > 0) {
|
||||||
|
const expiresSpan = document.createElement('span');
|
||||||
|
const expiresDate = new Date(license.expiresAt * 1000);
|
||||||
|
expiresSpan.textContent = 'Expires: ' + expiresDate.toLocaleString();
|
||||||
|
expiresSpan.style.color = '#888';
|
||||||
|
meta.appendChild(expiresSpan);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'license-actions';
|
actions.className = 'license-actions';
|
||||||
|
|
@ -825,11 +1096,66 @@
|
||||||
window.location.href = 'media-player.html?test=1';
|
window.location.href = 'media-player.html?test=1';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// License type handler
|
||||||
|
document.getElementById('license-type').addEventListener('change', (e) => {
|
||||||
|
const licenseType = e.target.value;
|
||||||
|
const durationSelect = document.getElementById('expiration-duration');
|
||||||
|
const infoBox = document.getElementById('license-type-info');
|
||||||
|
|
||||||
|
const licenseInfo = {
|
||||||
|
perpetual: {
|
||||||
|
label: 'Perpetual:',
|
||||||
|
labelColor: '#00ff94',
|
||||||
|
text: 'Customer owns the content forever. No expiration.',
|
||||||
|
duration: false,
|
||||||
|
defaultDuration: '0'
|
||||||
|
},
|
||||||
|
rental: {
|
||||||
|
label: 'Rental:',
|
||||||
|
labelColor: '#ffc107',
|
||||||
|
text: 'Time-limited access. Great for album releases, limited editions, or seasonal content.',
|
||||||
|
duration: true,
|
||||||
|
defaultDuration: '604800'
|
||||||
|
},
|
||||||
|
stream: {
|
||||||
|
label: 'Streaming:',
|
||||||
|
labelColor: '#3a86ff',
|
||||||
|
text: 'Short-term access (default 24h). Ideal for on-demand streaming platforms.',
|
||||||
|
duration: true,
|
||||||
|
defaultDuration: '86400'
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
label: 'Preview:',
|
||||||
|
labelColor: '#ff006e',
|
||||||
|
text: 'Ultra-short access (30 seconds). Let fans sample before buying.',
|
||||||
|
duration: false,
|
||||||
|
defaultDuration: '30'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = licenseInfo[licenseType] || licenseInfo.perpetual;
|
||||||
|
|
||||||
|
// Update info box safely using DOM methods
|
||||||
|
while (infoBox.firstChild) infoBox.removeChild(infoBox.firstChild);
|
||||||
|
const labelEl = document.createElement('strong');
|
||||||
|
labelEl.style.color = config.labelColor;
|
||||||
|
labelEl.textContent = config.label + ' ';
|
||||||
|
const textEl = document.createTextNode(config.text);
|
||||||
|
infoBox.appendChild(labelEl);
|
||||||
|
infoBox.appendChild(textEl);
|
||||||
|
|
||||||
|
// Enable/disable duration selector
|
||||||
|
durationSelect.disabled = !config.duration;
|
||||||
|
durationSelect.value = config.defaultDuration;
|
||||||
|
});
|
||||||
|
|
||||||
// Event listeners
|
// Event listeners
|
||||||
document.getElementById('generate-btn').addEventListener('click', generateLicense);
|
document.getElementById('generate-btn').addEventListener('click', generateLicense);
|
||||||
|
document.getElementById('add-track-btn').addEventListener('click', () => addTrack());
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
initWasm();
|
initWasm();
|
||||||
|
renderTrackList(); // Show empty state
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -355,6 +355,75 @@
|
||||||
|
|
||||||
.download-section button {
|
.download-section button {
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Track List */
|
||||||
|
.track-list-section {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-list-section h3 {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: rgba(0,0,0,0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-item:hover {
|
||||||
|
background: rgba(131, 56, 236, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-item.active {
|
||||||
|
background: rgba(255, 0, 110, 0.2);
|
||||||
|
border: 1px solid rgba(255, 0, 110, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-number {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #8338ec;
|
||||||
|
min-width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-name {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-type {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #888;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-time {
|
||||||
|
font-family: 'Monaco', 'Menlo', monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #00ff94;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -515,6 +584,9 @@
|
||||||
|
|
||||||
<div id="error-banner" class="error-banner"></div>
|
<div id="error-banner" class="error-banner"></div>
|
||||||
|
|
||||||
|
<!-- Manifest preview (shown without decryption) -->
|
||||||
|
<div id="manifest-preview" style="display: none; background: rgba(131, 56, 236, 0.1); border: 1px solid rgba(131, 56, 236, 0.3); border-radius: 12px; padding: 1.25rem; margin-bottom: 1rem;"></div>
|
||||||
|
|
||||||
<div class="unlock-row">
|
<div class="unlock-row">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<label for="license-token">License Token (Password):</label>
|
<label for="license-token">License Token (Password):</label>
|
||||||
|
|
@ -538,6 +610,13 @@
|
||||||
<!-- Audio/Video player inserted here -->
|
<!-- Audio/Video player inserted here -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="track-list-section" class="track-list-section" style="display: none;">
|
||||||
|
<h3><span>💿</span> Track List</h3>
|
||||||
|
<div id="track-list" class="track-list">
|
||||||
|
<!-- Tracks populated by JS -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="license-info">
|
<div class="license-info">
|
||||||
<h4>🔓 Licensed Content</h4>
|
<h4>🔓 Licensed Content</h4>
|
||||||
<p>This content was unlocked with your personal license token.
|
<p>This content was unlocked with your personal license token.
|
||||||
|
|
@ -583,6 +662,7 @@
|
||||||
let currentMediaBlob = null;
|
let currentMediaBlob = null;
|
||||||
let currentMediaName = null;
|
let currentMediaName = null;
|
||||||
let currentMediaMime = null;
|
let currentMediaMime = null;
|
||||||
|
let currentManifest = null; // Public metadata from header
|
||||||
|
|
||||||
// Initialize WASM
|
// Initialize WASM
|
||||||
async function initWasm() {
|
async function initWasm() {
|
||||||
|
|
@ -652,11 +732,209 @@
|
||||||
const content = await file.arrayBuffer();
|
const content = await file.arrayBuffer();
|
||||||
const base64 = btoa(String.fromCharCode(...new Uint8Array(content)));
|
const base64 = btoa(String.fromCharCode(...new Uint8Array(content)));
|
||||||
document.getElementById('encrypted-content').value = base64;
|
document.getElementById('encrypted-content').value = base64;
|
||||||
|
// Show preview from manifest
|
||||||
|
await showManifestPreview(base64);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError('Failed to read file: ' + err.message);
|
showError('Failed to read file: ' + err.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Listen for content paste/input to show preview
|
||||||
|
let previewDebounce = null;
|
||||||
|
document.getElementById('encrypted-content').addEventListener('input', async (e) => {
|
||||||
|
const content = e.target.value.trim();
|
||||||
|
// Debounce to avoid calling on every keystroke
|
||||||
|
clearTimeout(previewDebounce);
|
||||||
|
previewDebounce = setTimeout(async () => {
|
||||||
|
if (content && content.length > 100) { // Only if substantial content
|
||||||
|
await showManifestPreview(content);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show manifest preview WITHOUT decryption
|
||||||
|
async function showManifestPreview(encryptedB64) {
|
||||||
|
if (!wasmReady) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await BorgSMSG.getInfo(encryptedB64);
|
||||||
|
currentManifest = info.manifest;
|
||||||
|
|
||||||
|
const previewSection = document.getElementById('manifest-preview');
|
||||||
|
if (!previewSection) return;
|
||||||
|
|
||||||
|
// Clear previous content
|
||||||
|
while (previewSection.firstChild) {
|
||||||
|
previewSection.removeChild(previewSection.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.manifest) {
|
||||||
|
previewSection.style.display = 'block';
|
||||||
|
previewSection.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
|
||||||
|
// Title and artist
|
||||||
|
const headerDiv = document.createElement('div');
|
||||||
|
headerDiv.style.cssText = 'display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem;';
|
||||||
|
|
||||||
|
const icon = document.createElement('span');
|
||||||
|
icon.style.cssText = 'font-size: 2.5rem;';
|
||||||
|
icon.textContent = info.manifest.releaseType === 'djset' ? '🎧' :
|
||||||
|
info.manifest.releaseType === 'live' ? '🎤' : '💿';
|
||||||
|
|
||||||
|
const titleDiv = document.createElement('div');
|
||||||
|
|
||||||
|
const titleEl = document.createElement('div');
|
||||||
|
titleEl.style.cssText = 'font-size: 1.2rem; font-weight: 700; color: #fff;';
|
||||||
|
titleEl.textContent = info.manifest.title || 'Untitled';
|
||||||
|
|
||||||
|
const artistEl = document.createElement('div');
|
||||||
|
artistEl.style.cssText = 'font-size: 0.9rem; color: #888;';
|
||||||
|
artistEl.textContent = info.manifest.artist || 'Unknown Artist';
|
||||||
|
|
||||||
|
titleDiv.appendChild(titleEl);
|
||||||
|
titleDiv.appendChild(artistEl);
|
||||||
|
headerDiv.appendChild(icon);
|
||||||
|
headerDiv.appendChild(titleDiv);
|
||||||
|
previewSection.appendChild(headerDiv);
|
||||||
|
|
||||||
|
// Release info
|
||||||
|
if (info.manifest.releaseType || info.manifest.format) {
|
||||||
|
const metaDiv = document.createElement('div');
|
||||||
|
metaDiv.style.cssText = 'font-size: 0.8rem; color: #666; margin-bottom: 1rem;';
|
||||||
|
const parts = [];
|
||||||
|
if (info.manifest.releaseType) parts.push(info.manifest.releaseType.toUpperCase());
|
||||||
|
if (info.manifest.format) parts.push(info.manifest.format);
|
||||||
|
metaDiv.textContent = parts.join(' • ');
|
||||||
|
previewSection.appendChild(metaDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track list preview
|
||||||
|
if (info.manifest.tracks && info.manifest.tracks.length > 0) {
|
||||||
|
const trackHeader = document.createElement('div');
|
||||||
|
trackHeader.style.cssText = 'font-size: 0.85rem; color: #8338ec; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem;';
|
||||||
|
trackHeader.textContent = '💿 ' + info.manifest.tracks.length + ' track' + (info.manifest.tracks.length > 1 ? 's' : '');
|
||||||
|
previewSection.appendChild(trackHeader);
|
||||||
|
|
||||||
|
const trackList = document.createElement('div');
|
||||||
|
trackList.style.cssText = 'max-height: 150px; overflow-y: auto;';
|
||||||
|
|
||||||
|
info.manifest.tracks.forEach((track, i) => {
|
||||||
|
const trackEl = document.createElement('div');
|
||||||
|
trackEl.style.cssText = 'display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem; background: rgba(0,0,0,0.2); border-radius: 6px; margin-bottom: 0.25rem; font-size: 0.85rem;';
|
||||||
|
|
||||||
|
const numEl = document.createElement('span');
|
||||||
|
numEl.style.cssText = 'color: #8338ec; font-weight: 600; min-width: 20px;';
|
||||||
|
numEl.textContent = (track.trackNum || (i + 1)) + '.';
|
||||||
|
|
||||||
|
const nameEl = document.createElement('span');
|
||||||
|
nameEl.style.cssText = 'flex: 1; color: #ccc;';
|
||||||
|
nameEl.textContent = track.title || 'Track ' + (i + 1);
|
||||||
|
|
||||||
|
const timeEl = document.createElement('span');
|
||||||
|
timeEl.style.cssText = 'color: #00ff94; font-family: monospace; font-size: 0.8rem;';
|
||||||
|
timeEl.textContent = formatTime(track.start || 0);
|
||||||
|
|
||||||
|
trackEl.appendChild(numEl);
|
||||||
|
trackEl.appendChild(nameEl);
|
||||||
|
trackEl.appendChild(timeEl);
|
||||||
|
trackList.appendChild(trackEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
previewSection.appendChild(trackList);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show license type and expiration
|
||||||
|
if (info.manifest.licenseType || info.manifest.expiresAt) {
|
||||||
|
const licenseDiv = document.createElement('div');
|
||||||
|
licenseDiv.style.cssText = 'margin-top: 1rem; padding: 0.75rem; border-radius: 8px;';
|
||||||
|
|
||||||
|
if (info.manifest.isExpired) {
|
||||||
|
// Expired license
|
||||||
|
licenseDiv.style.background = 'rgba(255, 82, 82, 0.2)';
|
||||||
|
licenseDiv.style.border = '1px solid rgba(255, 82, 82, 0.4)';
|
||||||
|
|
||||||
|
const expiredLabel = document.createElement('div');
|
||||||
|
expiredLabel.style.cssText = 'color: #ff5252; font-weight: 600; font-size: 0.9rem;';
|
||||||
|
expiredLabel.textContent = 'LICENSE EXPIRED';
|
||||||
|
|
||||||
|
const expiredInfo = document.createElement('div');
|
||||||
|
expiredInfo.style.cssText = 'color: #888; font-size: 0.8rem; margin-top: 0.25rem;';
|
||||||
|
const expDate = new Date(info.manifest.expiresAt * 1000);
|
||||||
|
expiredInfo.textContent = 'Expired on ' + expDate.toLocaleString();
|
||||||
|
|
||||||
|
licenseDiv.appendChild(expiredLabel);
|
||||||
|
licenseDiv.appendChild(expiredInfo);
|
||||||
|
} else if (info.manifest.expiresAt > 0) {
|
||||||
|
// Active but expiring license
|
||||||
|
const remaining = info.manifest.timeRemaining;
|
||||||
|
const hours = Math.floor(remaining / 3600);
|
||||||
|
const mins = Math.floor((remaining % 3600) / 60);
|
||||||
|
|
||||||
|
let bgColor, borderColor, textColor;
|
||||||
|
if (remaining < 3600) {
|
||||||
|
bgColor = 'rgba(255, 193, 7, 0.2)';
|
||||||
|
borderColor = 'rgba(255, 193, 7, 0.4)';
|
||||||
|
textColor = '#ffc107';
|
||||||
|
} else {
|
||||||
|
bgColor = 'rgba(0, 255, 148, 0.1)';
|
||||||
|
borderColor = 'rgba(0, 255, 148, 0.3)';
|
||||||
|
textColor = '#00ff94';
|
||||||
|
}
|
||||||
|
|
||||||
|
licenseDiv.style.background = bgColor;
|
||||||
|
licenseDiv.style.border = '1px solid ' + borderColor;
|
||||||
|
|
||||||
|
const typeLabel = document.createElement('span');
|
||||||
|
typeLabel.style.cssText = 'color: ' + textColor + '; font-weight: 600; font-size: 0.8rem; text-transform: uppercase;';
|
||||||
|
typeLabel.textContent = (info.manifest.licenseType || 'rental').toUpperCase() + ' LICENSE';
|
||||||
|
|
||||||
|
const timeLabel = document.createElement('span');
|
||||||
|
timeLabel.style.cssText = 'color: #888; font-size: 0.8rem; margin-left: 0.5rem;';
|
||||||
|
if (hours > 24) {
|
||||||
|
timeLabel.textContent = Math.floor(hours / 24) + ' days remaining';
|
||||||
|
} else if (hours > 0) {
|
||||||
|
timeLabel.textContent = hours + 'h ' + mins + 'm remaining';
|
||||||
|
} else {
|
||||||
|
timeLabel.textContent = mins + ' minutes remaining';
|
||||||
|
}
|
||||||
|
|
||||||
|
licenseDiv.appendChild(typeLabel);
|
||||||
|
licenseDiv.appendChild(timeLabel);
|
||||||
|
} else {
|
||||||
|
// Perpetual license
|
||||||
|
licenseDiv.style.background = 'rgba(0, 255, 148, 0.1)';
|
||||||
|
licenseDiv.style.border = '1px solid rgba(0, 255, 148, 0.3)';
|
||||||
|
|
||||||
|
const perpetualLabel = document.createElement('span');
|
||||||
|
perpetualLabel.style.cssText = 'color: #00ff94; font-weight: 600; font-size: 0.8rem;';
|
||||||
|
perpetualLabel.textContent = 'PERPETUAL LICENSE';
|
||||||
|
|
||||||
|
licenseDiv.appendChild(perpetualLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
previewSection.appendChild(licenseDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unlockHint = document.createElement('div');
|
||||||
|
unlockHint.style.cssText = 'margin-top: 1rem; padding-top: 1rem; border-top: 1px solid rgba(255,255,255,0.1); font-size: 0.85rem; color: #888; text-align: center;';
|
||||||
|
|
||||||
|
if (info.manifest.isExpired) {
|
||||||
|
unlockHint.style.color = '#ff5252';
|
||||||
|
unlockHint.textContent = 'This license has expired. Contact the artist for a new license.';
|
||||||
|
} else {
|
||||||
|
unlockHint.textContent = 'Enter your license token to unlock and play';
|
||||||
|
}
|
||||||
|
previewSection.appendChild(unlockHint);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
previewSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log('Could not read manifest:', err);
|
||||||
|
// Not a fatal error - content might not have manifest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Unlock content
|
// Unlock content
|
||||||
async function unlockContent() {
|
async function unlockContent() {
|
||||||
hideError();
|
hideError();
|
||||||
|
|
@ -675,6 +953,23 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Load manifest if not already loaded (for track info and expiration check)
|
||||||
|
if (!currentManifest) {
|
||||||
|
try {
|
||||||
|
const info = await BorgSMSG.getInfo(encryptedB64);
|
||||||
|
currentManifest = info.manifest;
|
||||||
|
} catch (e) {
|
||||||
|
// Non-fatal - might not have manifest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check expiration BEFORE decrypting
|
||||||
|
if (currentManifest && currentManifest.isExpired) {
|
||||||
|
const expDate = new Date(currentManifest.expiresAt * 1000);
|
||||||
|
showError('License expired on ' + expDate.toLocaleString() + '. Contact the artist for a new license.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const message = await BorgSMSG.decrypt(encryptedB64, password);
|
const message = await BorgSMSG.decrypt(encryptedB64, password);
|
||||||
displayMedia(message, password);
|
displayMedia(message, password);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -689,9 +984,11 @@
|
||||||
const mediaWrapper = document.getElementById('media-player-wrapper');
|
const mediaWrapper = document.getElementById('media-player-wrapper');
|
||||||
const artworkEl = document.getElementById('track-artwork');
|
const artworkEl = document.getElementById('track-artwork');
|
||||||
|
|
||||||
// Set track info
|
// Set track info (prefer manifest, fallback to message fields)
|
||||||
document.getElementById('track-title').textContent = msg.subject || 'Untitled Track';
|
const title = (currentManifest && currentManifest.title) || msg.subject || 'Untitled Track';
|
||||||
document.getElementById('track-artist').textContent = msg.from || 'Unknown Artist';
|
const artist = (currentManifest && currentManifest.artist) || msg.from || 'Unknown Artist';
|
||||||
|
document.getElementById('track-title').textContent = title;
|
||||||
|
document.getElementById('track-artist').textContent = artist;
|
||||||
|
|
||||||
// Show partial license token (safely using textContent)
|
// Show partial license token (safely using textContent)
|
||||||
const maskedToken = password.substring(0, 4) + String.fromCharCode(0x2022).repeat(8) + password.substring(password.length - 4);
|
const maskedToken = password.substring(0, 4) + String.fromCharCode(0x2022).repeat(8) + password.substring(password.length - 4);
|
||||||
|
|
@ -771,11 +1068,140 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse and display track list from manifest (public header)
|
||||||
|
const trackListSection = document.getElementById('track-list-section');
|
||||||
|
const trackListEl = document.getElementById('track-list');
|
||||||
|
|
||||||
|
// Clear previous tracks
|
||||||
|
while (trackListEl.firstChild) {
|
||||||
|
trackListEl.removeChild(trackListEl.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use manifest from header (public, no decryption needed)
|
||||||
|
const tracks = currentManifest && currentManifest.tracks;
|
||||||
|
if (tracks && tracks.length > 0) {
|
||||||
|
trackListSection.style.display = 'block';
|
||||||
|
|
||||||
|
tracks.forEach((track, index) => {
|
||||||
|
const trackItem = document.createElement('div');
|
||||||
|
trackItem.className = 'track-item';
|
||||||
|
trackItem.dataset.start = track.start || 0;
|
||||||
|
|
||||||
|
// Click to seek
|
||||||
|
trackItem.addEventListener('click', () => {
|
||||||
|
const mediaEl = document.querySelector('audio, video');
|
||||||
|
if (mediaEl) {
|
||||||
|
mediaEl.currentTime = parseFloat(track.start) || 0;
|
||||||
|
mediaEl.play();
|
||||||
|
// Update active state
|
||||||
|
document.querySelectorAll('.track-item').forEach(t => t.classList.remove('active'));
|
||||||
|
trackItem.classList.add('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track number
|
||||||
|
const numSpan = document.createElement('span');
|
||||||
|
numSpan.className = 'track-number';
|
||||||
|
numSpan.textContent = track.trackNum || (index + 1);
|
||||||
|
|
||||||
|
// Track info
|
||||||
|
const infoDiv = document.createElement('div');
|
||||||
|
infoDiv.className = 'track-info';
|
||||||
|
|
||||||
|
const nameDiv = document.createElement('div');
|
||||||
|
nameDiv.className = 'track-name';
|
||||||
|
nameDiv.textContent = track.title || 'Track ' + (index + 1);
|
||||||
|
|
||||||
|
const typeDiv = document.createElement('div');
|
||||||
|
typeDiv.className = 'track-type';
|
||||||
|
typeDiv.textContent = track.type || 'full';
|
||||||
|
|
||||||
|
infoDiv.appendChild(nameDiv);
|
||||||
|
infoDiv.appendChild(typeDiv);
|
||||||
|
|
||||||
|
// Time display
|
||||||
|
const timeSpan = document.createElement('span');
|
||||||
|
timeSpan.className = 'track-time';
|
||||||
|
timeSpan.textContent = formatTime(track.start || 0);
|
||||||
|
|
||||||
|
trackItem.appendChild(numSpan);
|
||||||
|
trackItem.appendChild(infoDiv);
|
||||||
|
trackItem.appendChild(timeSpan);
|
||||||
|
|
||||||
|
trackListEl.appendChild(trackItem);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
trackListSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide manifest preview since we're now showing player
|
||||||
|
const previewSection = document.getElementById('manifest-preview');
|
||||||
|
if (previewSection) previewSection.style.display = 'none';
|
||||||
|
|
||||||
|
// Update license info display with expiration
|
||||||
|
const licenseInfoEl = document.querySelector('.license-info p');
|
||||||
|
if (licenseInfoEl && currentManifest) {
|
||||||
|
if (currentManifest.expiresAt > 0) {
|
||||||
|
const remaining = currentManifest.timeRemaining;
|
||||||
|
const hours = Math.floor(remaining / 3600);
|
||||||
|
const mins = Math.floor((remaining % 3600) / 60);
|
||||||
|
|
||||||
|
let timeText;
|
||||||
|
if (hours > 24) {
|
||||||
|
timeText = Math.floor(hours / 24) + ' days';
|
||||||
|
} else if (hours > 0) {
|
||||||
|
timeText = hours + 'h ' + mins + 'm';
|
||||||
|
} else {
|
||||||
|
timeText = mins + ' minutes';
|
||||||
|
}
|
||||||
|
|
||||||
|
licenseInfoEl.textContent = 'This is a ' + (currentManifest.licenseType || 'rental').toUpperCase() +
|
||||||
|
' license with ' + timeText + ' remaining. Decryption happens entirely in your browser.';
|
||||||
|
|
||||||
|
// Start countdown timer for expiring licenses
|
||||||
|
if (remaining < 3600) {
|
||||||
|
startExpirationTimer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Show player
|
// Show player
|
||||||
playerContainer.classList.add('visible');
|
playerContainer.classList.add('visible');
|
||||||
playerContainer.scrollIntoView({ behavior: 'smooth' });
|
playerContainer.scrollIntoView({ behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Timer for expiring licenses
|
||||||
|
let expirationTimer = null;
|
||||||
|
function startExpirationTimer() {
|
||||||
|
if (expirationTimer) clearInterval(expirationTimer);
|
||||||
|
|
||||||
|
expirationTimer = setInterval(() => {
|
||||||
|
if (!currentManifest || !currentManifest.expiresAt) return;
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const remaining = currentManifest.expiresAt - now;
|
||||||
|
|
||||||
|
if (remaining <= 0) {
|
||||||
|
// License expired during playback
|
||||||
|
clearInterval(expirationTimer);
|
||||||
|
const mediaEl = document.querySelector('audio, video');
|
||||||
|
if (mediaEl) {
|
||||||
|
mediaEl.pause();
|
||||||
|
mediaEl.src = '';
|
||||||
|
}
|
||||||
|
showError('License has expired. Playback stopped.');
|
||||||
|
document.getElementById('player-container').classList.remove('visible');
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format seconds to mm:ss
|
||||||
|
function formatTime(seconds) {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return mins + ':' + secs.toString().padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
// Download handler
|
// Download handler
|
||||||
document.getElementById('download-btn').addEventListener('click', () => {
|
document.getElementById('download-btn').addEventListener('click', () => {
|
||||||
if (!currentMediaBlob) {
|
if (!currentMediaBlob) {
|
||||||
|
|
@ -808,6 +1234,13 @@
|
||||||
const content = await response.text();
|
const content = await response.text();
|
||||||
document.getElementById('encrypted-content').value = content;
|
document.getElementById('encrypted-content').value = content;
|
||||||
document.getElementById('license-token').value = 'dapp-fm-2024';
|
document.getElementById('license-token').value = 'dapp-fm-2024';
|
||||||
|
|
||||||
|
// Show preview
|
||||||
|
await showManifestPreview(content);
|
||||||
|
|
||||||
|
// Auto-unlock and play
|
||||||
|
await unlockContent();
|
||||||
|
|
||||||
btn.textContent = 'Demo Loaded!';
|
btn.textContent = 'Demo Loaded!';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
btn.textContent = originalText;
|
btn.textContent = originalText;
|
||||||
|
|
|
||||||
Binary file not shown.
36
pkg/player/assets.go
Normal file
36
pkg/player/assets.go
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
package player
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assets embeds all frontend files for the media player
|
||||||
|
// These are served both by Wails (memory) and HTTP (fallback)
|
||||||
|
//
|
||||||
|
//go:embed frontend/index.html
|
||||||
|
//go:embed frontend/wasm_exec.js
|
||||||
|
//go:embed frontend/stmf.wasm
|
||||||
|
//go:embed frontend/demo-track.smsg
|
||||||
|
var assets embed.FS
|
||||||
|
|
||||||
|
// Assets returns the embedded filesystem with frontend/ prefix stripped
|
||||||
|
var Assets fs.FS
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
var err error
|
||||||
|
Assets, err = fs.Sub(assets, "frontend")
|
||||||
|
if err != nil {
|
||||||
|
panic("failed to create sub filesystem: " + err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDemoTrack returns the embedded demo track content
|
||||||
|
func GetDemoTrack() ([]byte, error) {
|
||||||
|
return fs.ReadFile(Assets, "demo-track.smsg")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIndex returns the main HTML page
|
||||||
|
func GetIndex() ([]byte, error) {
|
||||||
|
return fs.ReadFile(Assets, "index.html")
|
||||||
|
}
|
||||||
1290
pkg/player/frontend/index.html
Normal file
1290
pkg/player/frontend/index.html
Normal file
File diff suppressed because it is too large
Load diff
575
pkg/player/frontend/wasm_exec.js
Normal file
575
pkg/player/frontend/wasm_exec.js
Normal file
|
|
@ -0,0 +1,575 @@
|
||||||
|
// Copyright 2018 The Go Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
const enosys = () => {
|
||||||
|
const err = new Error("not implemented");
|
||||||
|
err.code = "ENOSYS";
|
||||||
|
return err;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!globalThis.fs) {
|
||||||
|
let outputBuf = "";
|
||||||
|
globalThis.fs = {
|
||||||
|
constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused
|
||||||
|
writeSync(fd, buf) {
|
||||||
|
outputBuf += decoder.decode(buf);
|
||||||
|
const nl = outputBuf.lastIndexOf("\n");
|
||||||
|
if (nl != -1) {
|
||||||
|
console.log(outputBuf.substring(0, nl));
|
||||||
|
outputBuf = outputBuf.substring(nl + 1);
|
||||||
|
}
|
||||||
|
return buf.length;
|
||||||
|
},
|
||||||
|
write(fd, buf, offset, length, position, callback) {
|
||||||
|
if (offset !== 0 || length !== buf.length || position !== null) {
|
||||||
|
callback(enosys());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = this.writeSync(fd, buf);
|
||||||
|
callback(null, n);
|
||||||
|
},
|
||||||
|
chmod(path, mode, callback) { callback(enosys()); },
|
||||||
|
chown(path, uid, gid, callback) { callback(enosys()); },
|
||||||
|
close(fd, callback) { callback(enosys()); },
|
||||||
|
fchmod(fd, mode, callback) { callback(enosys()); },
|
||||||
|
fchown(fd, uid, gid, callback) { callback(enosys()); },
|
||||||
|
fstat(fd, callback) { callback(enosys()); },
|
||||||
|
fsync(fd, callback) { callback(null); },
|
||||||
|
ftruncate(fd, length, callback) { callback(enosys()); },
|
||||||
|
lchown(path, uid, gid, callback) { callback(enosys()); },
|
||||||
|
link(path, link, callback) { callback(enosys()); },
|
||||||
|
lstat(path, callback) { callback(enosys()); },
|
||||||
|
mkdir(path, perm, callback) { callback(enosys()); },
|
||||||
|
open(path, flags, mode, callback) { callback(enosys()); },
|
||||||
|
read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
|
||||||
|
readdir(path, callback) { callback(enosys()); },
|
||||||
|
readlink(path, callback) { callback(enosys()); },
|
||||||
|
rename(from, to, callback) { callback(enosys()); },
|
||||||
|
rmdir(path, callback) { callback(enosys()); },
|
||||||
|
stat(path, callback) { callback(enosys()); },
|
||||||
|
symlink(path, link, callback) { callback(enosys()); },
|
||||||
|
truncate(path, length, callback) { callback(enosys()); },
|
||||||
|
unlink(path, callback) { callback(enosys()); },
|
||||||
|
utimes(path, atime, mtime, callback) { callback(enosys()); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.process) {
|
||||||
|
globalThis.process = {
|
||||||
|
getuid() { return -1; },
|
||||||
|
getgid() { return -1; },
|
||||||
|
geteuid() { return -1; },
|
||||||
|
getegid() { return -1; },
|
||||||
|
getgroups() { throw enosys(); },
|
||||||
|
pid: -1,
|
||||||
|
ppid: -1,
|
||||||
|
umask() { throw enosys(); },
|
||||||
|
cwd() { throw enosys(); },
|
||||||
|
chdir() { throw enosys(); },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.path) {
|
||||||
|
globalThis.path = {
|
||||||
|
resolve(...pathSegments) {
|
||||||
|
return pathSegments.join("/");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.crypto) {
|
||||||
|
throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.performance) {
|
||||||
|
throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.TextEncoder) {
|
||||||
|
throw new Error("globalThis.TextEncoder is not available, polyfill required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.TextDecoder) {
|
||||||
|
throw new Error("globalThis.TextDecoder is not available, polyfill required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = new TextEncoder("utf-8");
|
||||||
|
const decoder = new TextDecoder("utf-8");
|
||||||
|
|
||||||
|
globalThis.Go = class {
|
||||||
|
constructor() {
|
||||||
|
this.argv = ["js"];
|
||||||
|
this.env = {};
|
||||||
|
this.exit = (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
console.warn("exit code:", code);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this._exitPromise = new Promise((resolve) => {
|
||||||
|
this._resolveExitPromise = resolve;
|
||||||
|
});
|
||||||
|
this._pendingEvent = null;
|
||||||
|
this._scheduledTimeouts = new Map();
|
||||||
|
this._nextCallbackTimeoutID = 1;
|
||||||
|
|
||||||
|
const setInt64 = (addr, v) => {
|
||||||
|
this.mem.setUint32(addr + 0, v, true);
|
||||||
|
this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const setInt32 = (addr, v) => {
|
||||||
|
this.mem.setUint32(addr + 0, v, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInt64 = (addr) => {
|
||||||
|
const low = this.mem.getUint32(addr + 0, true);
|
||||||
|
const high = this.mem.getInt32(addr + 4, true);
|
||||||
|
return low + high * 4294967296;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadValue = (addr) => {
|
||||||
|
const f = this.mem.getFloat64(addr, true);
|
||||||
|
if (f === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!isNaN(f)) {
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = this.mem.getUint32(addr, true);
|
||||||
|
return this._values[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeValue = (addr, v) => {
|
||||||
|
const nanHead = 0x7FF80000;
|
||||||
|
|
||||||
|
if (typeof v === "number" && v !== 0) {
|
||||||
|
if (isNaN(v)) {
|
||||||
|
this.mem.setUint32(addr + 4, nanHead, true);
|
||||||
|
this.mem.setUint32(addr, 0, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.mem.setFloat64(addr, v, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v === undefined) {
|
||||||
|
this.mem.setFloat64(addr, 0, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = this._ids.get(v);
|
||||||
|
if (id === undefined) {
|
||||||
|
id = this._idPool.pop();
|
||||||
|
if (id === undefined) {
|
||||||
|
id = this._values.length;
|
||||||
|
}
|
||||||
|
this._values[id] = v;
|
||||||
|
this._goRefCounts[id] = 0;
|
||||||
|
this._ids.set(v, id);
|
||||||
|
}
|
||||||
|
this._goRefCounts[id]++;
|
||||||
|
let typeFlag = 0;
|
||||||
|
switch (typeof v) {
|
||||||
|
case "object":
|
||||||
|
if (v !== null) {
|
||||||
|
typeFlag = 1;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "string":
|
||||||
|
typeFlag = 2;
|
||||||
|
break;
|
||||||
|
case "symbol":
|
||||||
|
typeFlag = 3;
|
||||||
|
break;
|
||||||
|
case "function":
|
||||||
|
typeFlag = 4;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
this.mem.setUint32(addr + 4, nanHead | typeFlag, true);
|
||||||
|
this.mem.setUint32(addr, id, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSlice = (addr) => {
|
||||||
|
const array = getInt64(addr + 0);
|
||||||
|
const len = getInt64(addr + 8);
|
||||||
|
return new Uint8Array(this._inst.exports.mem.buffer, array, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSliceOfValues = (addr) => {
|
||||||
|
const array = getInt64(addr + 0);
|
||||||
|
const len = getInt64(addr + 8);
|
||||||
|
const a = new Array(len);
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
a[i] = loadValue(array + i * 8);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadString = (addr) => {
|
||||||
|
const saddr = getInt64(addr + 0);
|
||||||
|
const len = getInt64(addr + 8);
|
||||||
|
return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
|
||||||
|
}
|
||||||
|
|
||||||
|
const testCallExport = (a, b) => {
|
||||||
|
this._inst.exports.testExport0();
|
||||||
|
return this._inst.exports.testExport(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeOrigin = Date.now() - performance.now();
|
||||||
|
this.importObject = {
|
||||||
|
_gotest: {
|
||||||
|
add: (a, b) => a + b,
|
||||||
|
callExport: testCallExport,
|
||||||
|
},
|
||||||
|
gojs: {
|
||||||
|
// Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
|
||||||
|
// may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
|
||||||
|
// function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
|
||||||
|
// This changes the SP, thus we have to update the SP used by the imported function.
|
||||||
|
|
||||||
|
// func wasmExit(code int32)
|
||||||
|
"runtime.wasmExit": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const code = this.mem.getInt32(sp + 8, true);
|
||||||
|
this.exited = true;
|
||||||
|
delete this._inst;
|
||||||
|
delete this._values;
|
||||||
|
delete this._goRefCounts;
|
||||||
|
delete this._ids;
|
||||||
|
delete this._idPool;
|
||||||
|
this.exit(code);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
|
||||||
|
"runtime.wasmWrite": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const fd = getInt64(sp + 8);
|
||||||
|
const p = getInt64(sp + 16);
|
||||||
|
const n = this.mem.getInt32(sp + 24, true);
|
||||||
|
fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func resetMemoryDataView()
|
||||||
|
"runtime.resetMemoryDataView": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func nanotime1() int64
|
||||||
|
"runtime.nanotime1": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func walltime() (sec int64, nsec int32)
|
||||||
|
"runtime.walltime": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const msec = (new Date).getTime();
|
||||||
|
setInt64(sp + 8, msec / 1000);
|
||||||
|
this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func scheduleTimeoutEvent(delay int64) int32
|
||||||
|
"runtime.scheduleTimeoutEvent": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const id = this._nextCallbackTimeoutID;
|
||||||
|
this._nextCallbackTimeoutID++;
|
||||||
|
this._scheduledTimeouts.set(id, setTimeout(
|
||||||
|
() => {
|
||||||
|
this._resume();
|
||||||
|
while (this._scheduledTimeouts.has(id)) {
|
||||||
|
// for some reason Go failed to register the timeout event, log and try again
|
||||||
|
// (temporary workaround for https://github.com/golang/go/issues/28975)
|
||||||
|
console.warn("scheduleTimeoutEvent: missed timeout event");
|
||||||
|
this._resume();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getInt64(sp + 8),
|
||||||
|
));
|
||||||
|
this.mem.setInt32(sp + 16, id, true);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func clearTimeoutEvent(id int32)
|
||||||
|
"runtime.clearTimeoutEvent": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const id = this.mem.getInt32(sp + 8, true);
|
||||||
|
clearTimeout(this._scheduledTimeouts.get(id));
|
||||||
|
this._scheduledTimeouts.delete(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func getRandomData(r []byte)
|
||||||
|
"runtime.getRandomData": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
crypto.getRandomValues(loadSlice(sp + 8));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func finalizeRef(v ref)
|
||||||
|
"syscall/js.finalizeRef": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const id = this.mem.getUint32(sp + 8, true);
|
||||||
|
this._goRefCounts[id]--;
|
||||||
|
if (this._goRefCounts[id] === 0) {
|
||||||
|
const v = this._values[id];
|
||||||
|
this._values[id] = null;
|
||||||
|
this._ids.delete(v);
|
||||||
|
this._idPool.push(id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func stringVal(value string) ref
|
||||||
|
"syscall/js.stringVal": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
storeValue(sp + 24, loadString(sp + 8));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueGet(v ref, p string) ref
|
||||||
|
"syscall/js.valueGet": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 32, result);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueSet(v ref, p string, x ref)
|
||||||
|
"syscall/js.valueSet": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueDelete(v ref, p string)
|
||||||
|
"syscall/js.valueDelete": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueIndex(v ref, i int) ref
|
||||||
|
"syscall/js.valueIndex": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
|
||||||
|
},
|
||||||
|
|
||||||
|
// valueSetIndex(v ref, i int, x ref)
|
||||||
|
"syscall/js.valueSetIndex": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueCall(v ref, m string, args []ref) (ref, bool)
|
||||||
|
"syscall/js.valueCall": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
try {
|
||||||
|
const v = loadValue(sp + 8);
|
||||||
|
const m = Reflect.get(v, loadString(sp + 16));
|
||||||
|
const args = loadSliceOfValues(sp + 32);
|
||||||
|
const result = Reflect.apply(m, v, args);
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 56, result);
|
||||||
|
this.mem.setUint8(sp + 64, 1);
|
||||||
|
} catch (err) {
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 56, err);
|
||||||
|
this.mem.setUint8(sp + 64, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueInvoke(v ref, args []ref) (ref, bool)
|
||||||
|
"syscall/js.valueInvoke": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
try {
|
||||||
|
const v = loadValue(sp + 8);
|
||||||
|
const args = loadSliceOfValues(sp + 16);
|
||||||
|
const result = Reflect.apply(v, undefined, args);
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, result);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
} catch (err) {
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, err);
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueNew(v ref, args []ref) (ref, bool)
|
||||||
|
"syscall/js.valueNew": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
try {
|
||||||
|
const v = loadValue(sp + 8);
|
||||||
|
const args = loadSliceOfValues(sp + 16);
|
||||||
|
const result = Reflect.construct(v, args);
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, result);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
} catch (err) {
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, err);
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueLength(v ref) int
|
||||||
|
"syscall/js.valueLength": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
setInt64(sp + 16, parseInt(loadValue(sp + 8).length));
|
||||||
|
},
|
||||||
|
|
||||||
|
// valuePrepareString(v ref) (ref, int)
|
||||||
|
"syscall/js.valuePrepareString": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const str = encoder.encode(String(loadValue(sp + 8)));
|
||||||
|
storeValue(sp + 16, str);
|
||||||
|
setInt64(sp + 24, str.length);
|
||||||
|
},
|
||||||
|
|
||||||
|
// valueLoadString(v ref, b []byte)
|
||||||
|
"syscall/js.valueLoadString": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const str = loadValue(sp + 8);
|
||||||
|
loadSlice(sp + 16).set(str);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueInstanceOf(v ref, t ref) bool
|
||||||
|
"syscall/js.valueInstanceOf": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func copyBytesToGo(dst []byte, src ref) (int, bool)
|
||||||
|
"syscall/js.copyBytesToGo": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const dst = loadSlice(sp + 8);
|
||||||
|
const src = loadValue(sp + 32);
|
||||||
|
if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const toCopy = src.subarray(0, dst.length);
|
||||||
|
dst.set(toCopy);
|
||||||
|
setInt64(sp + 40, toCopy.length);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func copyBytesToJS(dst ref, src []byte) (int, bool)
|
||||||
|
"syscall/js.copyBytesToJS": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const dst = loadValue(sp + 8);
|
||||||
|
const src = loadSlice(sp + 16);
|
||||||
|
if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const toCopy = src.subarray(0, dst.length);
|
||||||
|
dst.set(toCopy);
|
||||||
|
setInt64(sp + 40, toCopy.length);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
"debug": (value) => {
|
||||||
|
console.log(value);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(instance) {
|
||||||
|
if (!(instance instanceof WebAssembly.Instance)) {
|
||||||
|
throw new Error("Go.run: WebAssembly.Instance expected");
|
||||||
|
}
|
||||||
|
this._inst = instance;
|
||||||
|
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||||
|
this._values = [ // JS values that Go currently has references to, indexed by reference id
|
||||||
|
NaN,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
globalThis,
|
||||||
|
this,
|
||||||
|
];
|
||||||
|
this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
|
||||||
|
this._ids = new Map([ // mapping from JS values to reference ids
|
||||||
|
[0, 1],
|
||||||
|
[null, 2],
|
||||||
|
[true, 3],
|
||||||
|
[false, 4],
|
||||||
|
[globalThis, 5],
|
||||||
|
[this, 6],
|
||||||
|
]);
|
||||||
|
this._idPool = []; // unused ids that have been garbage collected
|
||||||
|
this.exited = false; // whether the Go program has exited
|
||||||
|
|
||||||
|
// Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
|
||||||
|
let offset = 4096;
|
||||||
|
|
||||||
|
const strPtr = (str) => {
|
||||||
|
const ptr = offset;
|
||||||
|
const bytes = encoder.encode(str + "\0");
|
||||||
|
new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes);
|
||||||
|
offset += bytes.length;
|
||||||
|
if (offset % 8 !== 0) {
|
||||||
|
offset += 8 - (offset % 8);
|
||||||
|
}
|
||||||
|
return ptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
const argc = this.argv.length;
|
||||||
|
|
||||||
|
const argvPtrs = [];
|
||||||
|
this.argv.forEach((arg) => {
|
||||||
|
argvPtrs.push(strPtr(arg));
|
||||||
|
});
|
||||||
|
argvPtrs.push(0);
|
||||||
|
|
||||||
|
const keys = Object.keys(this.env).sort();
|
||||||
|
keys.forEach((key) => {
|
||||||
|
argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
|
||||||
|
});
|
||||||
|
argvPtrs.push(0);
|
||||||
|
|
||||||
|
const argv = offset;
|
||||||
|
argvPtrs.forEach((ptr) => {
|
||||||
|
this.mem.setUint32(offset, ptr, true);
|
||||||
|
this.mem.setUint32(offset + 4, 0, true);
|
||||||
|
offset += 8;
|
||||||
|
});
|
||||||
|
|
||||||
|
// The linker guarantees global data starts from at least wasmMinDataAddr.
|
||||||
|
// Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
|
||||||
|
const wasmMinDataAddr = 4096 + 8192;
|
||||||
|
if (offset >= wasmMinDataAddr) {
|
||||||
|
throw new Error("total length of command line and environment variables exceeds limit");
|
||||||
|
}
|
||||||
|
|
||||||
|
this._inst.exports.run(argc, argv);
|
||||||
|
if (this.exited) {
|
||||||
|
this._resolveExitPromise();
|
||||||
|
}
|
||||||
|
await this._exitPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
_resume() {
|
||||||
|
if (this.exited) {
|
||||||
|
throw new Error("Go program has already exited");
|
||||||
|
}
|
||||||
|
this._inst.exports.resume();
|
||||||
|
if (this.exited) {
|
||||||
|
this._resolveExitPromise();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_makeFuncWrapper(id) {
|
||||||
|
const go = this;
|
||||||
|
return function () {
|
||||||
|
const event = { id: id, this: this, args: arguments };
|
||||||
|
go._pendingEvent = event;
|
||||||
|
go._resume();
|
||||||
|
return event.result;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
329
pkg/player/player.go
Normal file
329
pkg/player/player.go
Normal file
|
|
@ -0,0 +1,329 @@
|
||||||
|
// Package player provides the core media player functionality for dapp.fm
|
||||||
|
// It can be used both as Wails bindings (memory speed) or HTTP server (fallback)
|
||||||
|
package player
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Snider/Borg/pkg/smsg"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Player provides media decryption and playback services
|
||||||
|
// Methods are exposed to JavaScript via Wails bindings
|
||||||
|
type Player struct {
|
||||||
|
ctx context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPlayer creates a new Player instance
|
||||||
|
func NewPlayer() *Player {
|
||||||
|
return &Player{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Startup is called when the Wails app starts
|
||||||
|
func (p *Player) Startup(ctx context.Context) {
|
||||||
|
p.ctx = ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecryptResult holds the decrypted message data
|
||||||
|
type DecryptResult struct {
|
||||||
|
Body string `json:"body"`
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
|
From string `json:"from,omitempty"`
|
||||||
|
Attachments []AttachmentInfo `json:"attachments,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AttachmentInfo describes a decrypted attachment
|
||||||
|
type AttachmentInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
MimeType string `json:"mime_type"`
|
||||||
|
Size int `json:"size"`
|
||||||
|
DataURL string `json:"data_url"` // Base64 data URL for direct playback
|
||||||
|
}
|
||||||
|
|
||||||
|
// ManifestInfo holds public metadata (readable without decryption)
|
||||||
|
type ManifestInfo struct {
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Artist string `json:"artist,omitempty"`
|
||||||
|
Album string `json:"album,omitempty"`
|
||||||
|
Genre string `json:"genre,omitempty"`
|
||||||
|
Year int `json:"year,omitempty"`
|
||||||
|
ReleaseType string `json:"release_type,omitempty"`
|
||||||
|
Duration int `json:"duration,omitempty"`
|
||||||
|
Format string `json:"format,omitempty"`
|
||||||
|
ExpiresAt int64 `json:"expires_at,omitempty"`
|
||||||
|
IssuedAt int64 `json:"issued_at,omitempty"`
|
||||||
|
LicenseType string `json:"license_type,omitempty"`
|
||||||
|
Tracks []TrackInfo `json:"tracks,omitempty"`
|
||||||
|
IsExpired bool `json:"is_expired"`
|
||||||
|
TimeRemaining string `json:"time_remaining,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrackInfo describes a track marker
|
||||||
|
type TrackInfo struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Start float64 `json:"start"`
|
||||||
|
End float64 `json:"end,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
TrackNum int `json:"track_num,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetManifest returns public metadata without decryption
|
||||||
|
// This is memory-speed via Wails bindings
|
||||||
|
func (p *Player) GetManifest(encrypted string) (*ManifestInfo, error) {
|
||||||
|
info, err := smsg.GetInfoBase64(encrypted)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &ManifestInfo{}
|
||||||
|
|
||||||
|
if info.Manifest != nil {
|
||||||
|
m := info.Manifest
|
||||||
|
result.Title = m.Title
|
||||||
|
result.Artist = m.Artist
|
||||||
|
result.Album = m.Album
|
||||||
|
result.Genre = m.Genre
|
||||||
|
result.Year = m.Year
|
||||||
|
result.ReleaseType = m.ReleaseType
|
||||||
|
result.Duration = m.Duration
|
||||||
|
result.Format = m.Format
|
||||||
|
result.ExpiresAt = m.ExpiresAt
|
||||||
|
result.IssuedAt = m.IssuedAt
|
||||||
|
result.LicenseType = m.LicenseType
|
||||||
|
result.IsExpired = m.IsExpired()
|
||||||
|
|
||||||
|
if !result.IsExpired && m.ExpiresAt > 0 {
|
||||||
|
remaining := m.TimeRemaining()
|
||||||
|
result.TimeRemaining = formatDurationSeconds(remaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, t := range m.Tracks {
|
||||||
|
result.Tracks = append(result.Tracks, TrackInfo{
|
||||||
|
Title: t.Title,
|
||||||
|
Start: t.Start,
|
||||||
|
End: t.End,
|
||||||
|
Type: t.Type,
|
||||||
|
TrackNum: t.TrackNum,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsLicenseValid checks if the license has expired
|
||||||
|
// This is memory-speed via Wails bindings
|
||||||
|
func (p *Player) IsLicenseValid(encrypted string) (bool, error) {
|
||||||
|
info, err := smsg.GetInfoBase64(encrypted)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to check license: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.Manifest != nil && info.Manifest.ExpiresAt > 0 {
|
||||||
|
return !info.Manifest.IsExpired(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// No expiration set = perpetual license
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt decrypts the SMSG content and returns playable media
|
||||||
|
// This is memory-speed via Wails bindings - no HTTP, no WASM
|
||||||
|
func (p *Player) Decrypt(encrypted string, password string) (*DecryptResult, error) {
|
||||||
|
// Check license first
|
||||||
|
valid, err := p.IsLicenseValid(encrypted)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
return nil, fmt.Errorf("license has expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt using pkg/smsg (Base64 variant for string input)
|
||||||
|
msg, err := smsg.DecryptBase64(encrypted, password)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decryption failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &DecryptResult{
|
||||||
|
Body: msg.Body,
|
||||||
|
Subject: msg.Subject,
|
||||||
|
From: msg.From,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert attachments to data URLs for direct playback
|
||||||
|
for _, att := range msg.Attachments {
|
||||||
|
// Decode base64 content to get size
|
||||||
|
data, err := base64.StdEncoding.DecodeString(att.Content)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create data URL for the browser to play directly
|
||||||
|
dataURL := fmt.Sprintf("data:%s;base64,%s", att.MimeType, att.Content)
|
||||||
|
|
||||||
|
result.Attachments = append(result.Attachments, AttachmentInfo{
|
||||||
|
Name: att.Name,
|
||||||
|
MimeType: att.MimeType,
|
||||||
|
Size: len(data),
|
||||||
|
DataURL: dataURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuickDecrypt returns just the first attachment as a data URL
|
||||||
|
// Optimized for single-track playback
|
||||||
|
func (p *Player) QuickDecrypt(encrypted string, password string) (string, error) {
|
||||||
|
result, err := p.Decrypt(encrypted, password)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Attachments) == 0 {
|
||||||
|
return "", fmt.Errorf("no media attachments found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Attachments[0].DataURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLicenseInfo returns detailed license information
|
||||||
|
func (p *Player) GetLicenseInfo(encrypted string) (map[string]interface{}, error) {
|
||||||
|
manifest, err := p.GetManifest(encrypted)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
info := map[string]interface{}{
|
||||||
|
"is_valid": !manifest.IsExpired,
|
||||||
|
"license_type": manifest.LicenseType,
|
||||||
|
"time_remaining": manifest.TimeRemaining,
|
||||||
|
}
|
||||||
|
|
||||||
|
if manifest.ExpiresAt > 0 {
|
||||||
|
info["expires_at"] = time.Unix(manifest.ExpiresAt, 0).Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
if manifest.IssuedAt > 0 {
|
||||||
|
info["issued_at"] = time.Unix(manifest.IssuedAt, 0).Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve starts an HTTP server for CLI/fallback mode
|
||||||
|
// This is the slower TCP path - use Wails bindings when possible
|
||||||
|
func (p *Player) Serve(addr string) error {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// Serve embedded assets
|
||||||
|
mux.Handle("/", http.FileServer(http.FS(Assets)))
|
||||||
|
|
||||||
|
// API endpoints for WASM fallback
|
||||||
|
mux.HandleFunc("/api/manifest", p.handleManifest)
|
||||||
|
mux.HandleFunc("/api/decrypt", p.handleDecrypt)
|
||||||
|
mux.HandleFunc("/api/license", p.handleLicense)
|
||||||
|
|
||||||
|
fmt.Printf("dapp.fm player serving at http://localhost%s\n", addr)
|
||||||
|
return http.ListenAndServe(addr, mux)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Player) handleManifest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
encrypted := r.URL.Query().Get("data")
|
||||||
|
if encrypted == "" {
|
||||||
|
http.Error(w, "missing data parameter", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest, err := p.GetManifest(encrypted)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Player) handleDecrypt(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "POST required", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Encrypted string `json:"encrypted"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid JSON", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := p.Decrypt(req.Encrypted, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Player) handleLicense(w http.ResponseWriter, r *http.Request) {
|
||||||
|
encrypted := r.URL.Query().Get("data")
|
||||||
|
if encrypted == "" {
|
||||||
|
http.Error(w, "missing data parameter", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := p.GetLicenseInfo(encrypted)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDuration(d time.Duration) string {
|
||||||
|
if d < 0 {
|
||||||
|
return "expired"
|
||||||
|
}
|
||||||
|
|
||||||
|
days := int(d.Hours()) / 24
|
||||||
|
hours := int(d.Hours()) % 24
|
||||||
|
minutes := int(d.Minutes()) % 60
|
||||||
|
|
||||||
|
if days > 0 {
|
||||||
|
return fmt.Sprintf("%dd %dh", days, hours)
|
||||||
|
}
|
||||||
|
if hours > 0 {
|
||||||
|
return fmt.Sprintf("%dh %dm", hours, minutes)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%dm", minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDurationSeconds(seconds int64) string {
|
||||||
|
if seconds < 0 {
|
||||||
|
return "expired"
|
||||||
|
}
|
||||||
|
|
||||||
|
days := seconds / 86400
|
||||||
|
hours := (seconds % 86400) / 3600
|
||||||
|
minutes := (seconds % 3600) / 60
|
||||||
|
|
||||||
|
if days > 0 {
|
||||||
|
return fmt.Sprintf("%dd %dh", days, hours)
|
||||||
|
}
|
||||||
|
if hours > 0 {
|
||||||
|
return fmt.Sprintf("%dh %dm", hours, minutes)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%dm", minutes)
|
||||||
|
}
|
||||||
|
|
@ -120,6 +120,62 @@ func EncryptWithHint(msg *Message, password, hint string) ([]byte, error) {
|
||||||
return trix.Encode(t, Magic, nil)
|
return trix.Encode(t, Magic, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EncryptWithManifest encrypts with public manifest metadata in the clear text header
|
||||||
|
// The manifest is visible without decryption, enabling content discovery and indexing
|
||||||
|
func EncryptWithManifest(msg *Message, password string, manifest *Manifest) ([]byte, error) {
|
||||||
|
if password == "" {
|
||||||
|
return nil, ErrPasswordRequired
|
||||||
|
}
|
||||||
|
if msg.Body == "" && len(msg.Attachments) == 0 {
|
||||||
|
return nil, ErrEmptyMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.Timestamp == 0 {
|
||||||
|
msg.Timestamp = time.Now().Unix()
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key := DeriveKey(password)
|
||||||
|
sigil, err := enchantrix.NewChaChaPolySigil(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create sigil: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypted, err := sigil.In(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encryption failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build header with manifest
|
||||||
|
headerMap := map[string]interface{}{
|
||||||
|
"version": Version,
|
||||||
|
"algorithm": "chacha20poly1305",
|
||||||
|
}
|
||||||
|
if manifest != nil {
|
||||||
|
headerMap["manifest"] = manifest
|
||||||
|
}
|
||||||
|
|
||||||
|
t := &trix.Trix{
|
||||||
|
Header: headerMap,
|
||||||
|
Payload: encrypted,
|
||||||
|
}
|
||||||
|
|
||||||
|
return trix.Encode(t, Magic, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncryptWithManifestBase64 encrypts with manifest and returns base64
|
||||||
|
func EncryptWithManifestBase64(msg *Message, password string, manifest *Manifest) (string, error) {
|
||||||
|
encrypted, err := EncryptWithManifest(msg, password, manifest)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(encrypted), nil
|
||||||
|
}
|
||||||
|
|
||||||
// Decrypt decrypts an SMSG container with a password
|
// Decrypt decrypts an SMSG container with a password
|
||||||
func Decrypt(data []byte, password string) (*Message, error) {
|
func Decrypt(data []byte, password string) (*Message, error) {
|
||||||
if password == "" {
|
if password == "" {
|
||||||
|
|
@ -181,6 +237,18 @@ func GetInfo(data []byte) (*Header, error) {
|
||||||
header.Hint = v
|
header.Hint = v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract manifest if present
|
||||||
|
if manifestData, ok := t.Header["manifest"]; ok && manifestData != nil {
|
||||||
|
// Re-marshal and unmarshal to properly convert the map to Manifest struct
|
||||||
|
manifestBytes, err := json.Marshal(manifestData)
|
||||||
|
if err == nil {
|
||||||
|
var manifest Manifest
|
||||||
|
if err := json.Unmarshal(manifestBytes, &manifest); err == nil {
|
||||||
|
header.Manifest = &manifest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return header, nil
|
return header, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -268,3 +268,197 @@ func TestEmptyMessageError(t *testing.T) {
|
||||||
t.Errorf("Expected ErrEmptyMessage, got %v", err)
|
t.Errorf("Expected ErrEmptyMessage, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEncryptWithManifest(t *testing.T) {
|
||||||
|
msg := NewMessage("Licensed content")
|
||||||
|
password := "license-token-123"
|
||||||
|
|
||||||
|
// Create manifest with tracks
|
||||||
|
manifest := NewManifest("Summer EP 2024").
|
||||||
|
AddTrackFull("Intro", 0, 30, "intro").
|
||||||
|
AddTrackFull("Main Track", 30, 180, "full").
|
||||||
|
AddTrack("Outro", 180)
|
||||||
|
manifest.Artist = "Test Artist"
|
||||||
|
manifest.ReleaseType = "ep"
|
||||||
|
manifest.Format = "dapp.fm/v1"
|
||||||
|
|
||||||
|
encrypted, err := EncryptWithManifest(msg, password, manifest)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncryptWithManifest failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get info without decryption - should have manifest
|
||||||
|
header, err := GetInfo(encrypted)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetInfo failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if header.Manifest == nil {
|
||||||
|
t.Fatal("Expected manifest in header")
|
||||||
|
}
|
||||||
|
|
||||||
|
if header.Manifest.Title != "Summer EP 2024" {
|
||||||
|
t.Errorf("Title = %q, want %q", header.Manifest.Title, "Summer EP 2024")
|
||||||
|
}
|
||||||
|
|
||||||
|
if header.Manifest.Artist != "Test Artist" {
|
||||||
|
t.Errorf("Artist = %q, want %q", header.Manifest.Artist, "Test Artist")
|
||||||
|
}
|
||||||
|
|
||||||
|
if header.Manifest.ReleaseType != "ep" {
|
||||||
|
t.Errorf("ReleaseType = %q, want %q", header.Manifest.ReleaseType, "ep")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(header.Manifest.Tracks) != 3 {
|
||||||
|
t.Errorf("Tracks count = %d, want 3", len(header.Manifest.Tracks))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify tracks
|
||||||
|
if header.Manifest.Tracks[0].Title != "Intro" {
|
||||||
|
t.Errorf("Track 0 Title = %q, want %q", header.Manifest.Tracks[0].Title, "Intro")
|
||||||
|
}
|
||||||
|
if header.Manifest.Tracks[0].Start != 0 {
|
||||||
|
t.Errorf("Track 0 Start = %v, want 0", header.Manifest.Tracks[0].Start)
|
||||||
|
}
|
||||||
|
if header.Manifest.Tracks[0].Type != "intro" {
|
||||||
|
t.Errorf("Track 0 Type = %q, want %q", header.Manifest.Tracks[0].Type, "intro")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Can still decrypt normally
|
||||||
|
decrypted, err := Decrypt(encrypted, password)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decrypt failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if decrypted.Body != "Licensed content" {
|
||||||
|
t.Errorf("Body = %q, want %q", decrypted.Body, "Licensed content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManifestBuilder(t *testing.T) {
|
||||||
|
manifest := NewManifest("Test Album")
|
||||||
|
manifest.Artist = "Artist Name"
|
||||||
|
manifest.Album = "Album Name"
|
||||||
|
manifest.Year = 2024
|
||||||
|
manifest.Genre = "Electronic"
|
||||||
|
manifest.ReleaseType = "album"
|
||||||
|
manifest.Tags = []string{"electronic", "ambient"}
|
||||||
|
manifest.Extra["custom_field"] = "custom_value"
|
||||||
|
|
||||||
|
// Add tracks
|
||||||
|
manifest.AddTrack("Track 1", 0)
|
||||||
|
manifest.AddTrack("Track 2", 120)
|
||||||
|
manifest.AddTrackFull("Track 3", 240, 360, "outro")
|
||||||
|
|
||||||
|
if manifest.Title != "Test Album" {
|
||||||
|
t.Errorf("Title = %q, want %q", manifest.Title, "Test Album")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(manifest.Tracks) != 3 {
|
||||||
|
t.Fatalf("Track count = %d, want 3", len(manifest.Tracks))
|
||||||
|
}
|
||||||
|
|
||||||
|
// First track should have TrackNum 1
|
||||||
|
if manifest.Tracks[0].TrackNum != 1 {
|
||||||
|
t.Errorf("Track 1 TrackNum = %d, want 1", manifest.Tracks[0].TrackNum)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Third track should have end time
|
||||||
|
if manifest.Tracks[2].End != 360 {
|
||||||
|
t.Errorf("Track 3 End = %v, want 360", manifest.Tracks[2].End)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManifestExpiration(t *testing.T) {
|
||||||
|
// Test perpetual license (no expiration)
|
||||||
|
perpetual := NewManifest("Perpetual Album")
|
||||||
|
if perpetual.IsExpired() {
|
||||||
|
t.Error("Perpetual license should not be expired")
|
||||||
|
}
|
||||||
|
if perpetual.TimeRemaining() != 0 {
|
||||||
|
t.Error("Perpetual license should have 0 time remaining (infinite)")
|
||||||
|
}
|
||||||
|
if perpetual.LicenseType != "perpetual" {
|
||||||
|
t.Errorf("LicenseType = %q, want perpetual", perpetual.LicenseType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test streaming access (24 hours)
|
||||||
|
stream := NewManifest("Stream Album").WithStreamingAccess(24)
|
||||||
|
if stream.IsExpired() {
|
||||||
|
t.Error("Streaming license should not be expired immediately")
|
||||||
|
}
|
||||||
|
if stream.LicenseType != "stream" {
|
||||||
|
t.Errorf("LicenseType = %q, want stream", stream.LicenseType)
|
||||||
|
}
|
||||||
|
remaining := stream.TimeRemaining()
|
||||||
|
if remaining < 86000 || remaining > 86400 {
|
||||||
|
t.Errorf("TimeRemaining = %d, expected ~86400", remaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test rental with duration
|
||||||
|
rental := NewManifest("Rental Album").WithRentalDuration(3600) // 1 hour
|
||||||
|
if rental.IsExpired() {
|
||||||
|
t.Error("Rental license should not be expired immediately")
|
||||||
|
}
|
||||||
|
if rental.LicenseType != "rental" {
|
||||||
|
t.Errorf("LicenseType = %q, want rental", rental.LicenseType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test preview (30 seconds)
|
||||||
|
preview := NewManifest("Preview Track").WithPreviewAccess(30)
|
||||||
|
if preview.IsExpired() {
|
||||||
|
t.Error("Preview license should not be expired immediately")
|
||||||
|
}
|
||||||
|
if preview.LicenseType != "preview" {
|
||||||
|
t.Errorf("LicenseType = %q, want preview", preview.LicenseType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test already expired license
|
||||||
|
expired := NewManifest("Expired Album")
|
||||||
|
expired.ExpiresAt = 1000 // Very old timestamp
|
||||||
|
if !expired.IsExpired() {
|
||||||
|
t.Error("License with old expiration should be expired")
|
||||||
|
}
|
||||||
|
if expired.TimeRemaining() >= 0 {
|
||||||
|
t.Error("Expired license should have negative time remaining")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpirationInHeader(t *testing.T) {
|
||||||
|
msg := NewMessage("Licensed content")
|
||||||
|
password := "stream-token-123"
|
||||||
|
|
||||||
|
// Create streaming license (24 hours)
|
||||||
|
manifest := NewManifest("Streaming EP").WithStreamingAccess(24)
|
||||||
|
|
||||||
|
encrypted, err := EncryptWithManifest(msg, password, manifest)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncryptWithManifest failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get info should show expiration
|
||||||
|
header, err := GetInfo(encrypted)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetInfo failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if header.Manifest == nil {
|
||||||
|
t.Fatal("Expected manifest in header")
|
||||||
|
}
|
||||||
|
|
||||||
|
if header.Manifest.LicenseType != "stream" {
|
||||||
|
t.Errorf("LicenseType = %q, want stream", header.Manifest.LicenseType)
|
||||||
|
}
|
||||||
|
|
||||||
|
if header.Manifest.ExpiresAt == 0 {
|
||||||
|
t.Error("ExpiresAt should not be 0 for streaming license")
|
||||||
|
}
|
||||||
|
|
||||||
|
if header.Manifest.IssuedAt == 0 {
|
||||||
|
t.Error("IssuedAt should not be 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
if header.Manifest.IsExpired() {
|
||||||
|
t.Error("New streaming license should not be expired")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ package smsg
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Magic bytes for SMSG format
|
// Magic bytes for SMSG format
|
||||||
|
|
@ -128,9 +129,129 @@ func (m *Message) GetAttachment(name string) *Attachment {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track represents a track marker in a release (like CD chapters)
|
||||||
|
type Track struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Start float64 `json:"start"` // start time in seconds
|
||||||
|
End float64 `json:"end,omitempty"` // end time in seconds (0 = until next track)
|
||||||
|
Type string `json:"type,omitempty"` // intro, verse, chorus, drop, outro, etc.
|
||||||
|
TrackNum int `json:"track_num,omitempty"` // track number for multi-track releases
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manifest contains public metadata visible without decryption
|
||||||
|
// This enables content discovery, indexing, and preview
|
||||||
|
type Manifest struct {
|
||||||
|
// Content identification
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Artist string `json:"artist,omitempty"`
|
||||||
|
Album string `json:"album,omitempty"`
|
||||||
|
Genre string `json:"genre,omitempty"`
|
||||||
|
Year int `json:"year,omitempty"`
|
||||||
|
|
||||||
|
// Release info
|
||||||
|
ReleaseType string `json:"release_type,omitempty"` // single, album, ep, mix
|
||||||
|
Duration int `json:"duration,omitempty"` // total duration in seconds
|
||||||
|
Format string `json:"format,omitempty"` // dapp.fm/v1, etc.
|
||||||
|
|
||||||
|
// License expiration (for streaming/rental models)
|
||||||
|
ExpiresAt int64 `json:"expires_at,omitempty"` // Unix timestamp when license expires (0 = never)
|
||||||
|
IssuedAt int64 `json:"issued_at,omitempty"` // Unix timestamp when license was issued
|
||||||
|
LicenseType string `json:"license_type,omitempty"` // perpetual, rental, stream, preview
|
||||||
|
|
||||||
|
// Track list (like CD master)
|
||||||
|
Tracks []Track `json:"tracks,omitempty"`
|
||||||
|
|
||||||
|
// Custom metadata
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
Extra map[string]string `json:"extra,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManifest creates a new manifest with title
|
||||||
|
func NewManifest(title string) *Manifest {
|
||||||
|
return &Manifest{
|
||||||
|
Title: title,
|
||||||
|
Extra: make(map[string]string),
|
||||||
|
LicenseType: "perpetual",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithExpiration sets the license expiration time
|
||||||
|
func (m *Manifest) WithExpiration(expiresAt int64) *Manifest {
|
||||||
|
m.ExpiresAt = expiresAt
|
||||||
|
if m.LicenseType == "perpetual" {
|
||||||
|
m.LicenseType = "rental"
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithRentalDuration sets expiration relative to issue time
|
||||||
|
func (m *Manifest) WithRentalDuration(durationSeconds int64) *Manifest {
|
||||||
|
if m.IssuedAt == 0 {
|
||||||
|
m.IssuedAt = time.Now().Unix()
|
||||||
|
}
|
||||||
|
m.ExpiresAt = m.IssuedAt + durationSeconds
|
||||||
|
m.LicenseType = "rental"
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithStreamingAccess sets up for streaming (short expiration, e.g., 24 hours)
|
||||||
|
func (m *Manifest) WithStreamingAccess(hours int) *Manifest {
|
||||||
|
m.IssuedAt = time.Now().Unix()
|
||||||
|
m.ExpiresAt = m.IssuedAt + int64(hours*3600)
|
||||||
|
m.LicenseType = "stream"
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPreviewAccess sets up for preview (very short, e.g., 30 seconds)
|
||||||
|
func (m *Manifest) WithPreviewAccess(seconds int) *Manifest {
|
||||||
|
m.IssuedAt = time.Now().Unix()
|
||||||
|
m.ExpiresAt = m.IssuedAt + int64(seconds)
|
||||||
|
m.LicenseType = "preview"
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsExpired checks if the license has expired
|
||||||
|
func (m *Manifest) IsExpired() bool {
|
||||||
|
if m.ExpiresAt == 0 {
|
||||||
|
return false // No expiration = perpetual
|
||||||
|
}
|
||||||
|
return time.Now().Unix() > m.ExpiresAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimeRemaining returns seconds until expiration (0 if perpetual, negative if expired)
|
||||||
|
func (m *Manifest) TimeRemaining() int64 {
|
||||||
|
if m.ExpiresAt == 0 {
|
||||||
|
return 0 // Perpetual
|
||||||
|
}
|
||||||
|
return m.ExpiresAt - time.Now().Unix()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddTrack adds a track marker to the manifest
|
||||||
|
func (m *Manifest) AddTrack(title string, start float64) *Manifest {
|
||||||
|
m.Tracks = append(m.Tracks, Track{
|
||||||
|
Title: title,
|
||||||
|
Start: start,
|
||||||
|
TrackNum: len(m.Tracks) + 1,
|
||||||
|
})
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddTrackFull adds a track with all details
|
||||||
|
func (m *Manifest) AddTrackFull(title string, start, end float64, trackType string) *Manifest {
|
||||||
|
m.Tracks = append(m.Tracks, Track{
|
||||||
|
Title: title,
|
||||||
|
Start: start,
|
||||||
|
End: end,
|
||||||
|
Type: trackType,
|
||||||
|
TrackNum: len(m.Tracks) + 1,
|
||||||
|
})
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
// Header represents the SMSG container header
|
// Header represents the SMSG container header
|
||||||
type Header struct {
|
type Header struct {
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Algorithm string `json:"algorithm"`
|
Algorithm string `json:"algorithm"`
|
||||||
Hint string `json:"hint,omitempty"` // optional password hint
|
Hint string `json:"hint,omitempty"` // optional password hint
|
||||||
|
Manifest *Manifest `json:"manifest,omitempty"` // public metadata for discovery
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ func main() {
|
||||||
js.Global().Set("BorgSMSG", js.ValueOf(map[string]interface{}{
|
js.Global().Set("BorgSMSG", js.ValueOf(map[string]interface{}{
|
||||||
"decrypt": js.FuncOf(smsgDecrypt),
|
"decrypt": js.FuncOf(smsgDecrypt),
|
||||||
"encrypt": js.FuncOf(smsgEncrypt),
|
"encrypt": js.FuncOf(smsgEncrypt),
|
||||||
|
"encryptWithManifest": js.FuncOf(smsgEncryptWithManifest),
|
||||||
"getInfo": js.FuncOf(smsgGetInfo),
|
"getInfo": js.FuncOf(smsgGetInfo),
|
||||||
"quickDecrypt": js.FuncOf(smsgQuickDecrypt),
|
"quickDecrypt": js.FuncOf(smsgQuickDecrypt),
|
||||||
"version": Version,
|
"version": Version,
|
||||||
|
|
@ -383,6 +384,7 @@ func smsgEncrypt(this js.Value, args []js.Value) interface{} {
|
||||||
// const info = await BorgSMSG.getInfo(encryptedBase64);
|
// const info = await BorgSMSG.getInfo(encryptedBase64);
|
||||||
// console.log(info.hint); // password hint if set
|
// console.log(info.hint); // password hint if set
|
||||||
// console.log(info.version);
|
// console.log(info.version);
|
||||||
|
// console.log(info.manifest); // public metadata (title, artist, tracks, etc.)
|
||||||
func smsgGetInfo(this js.Value, args []js.Value) interface{} {
|
func smsgGetInfo(this js.Value, args []js.Value) interface{} {
|
||||||
handler := js.FuncOf(func(this js.Value, promiseArgs []js.Value) interface{} {
|
handler := js.FuncOf(func(this js.Value, promiseArgs []js.Value) interface{} {
|
||||||
resolve := promiseArgs[0]
|
resolve := promiseArgs[0]
|
||||||
|
|
@ -410,6 +412,11 @@ func smsgGetInfo(this js.Value, args []js.Value) interface{} {
|
||||||
result["hint"] = header.Hint
|
result["hint"] = header.Hint
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Include manifest if present
|
||||||
|
if header.Manifest != nil {
|
||||||
|
result["manifest"] = manifestToJS(header.Manifest)
|
||||||
|
}
|
||||||
|
|
||||||
resolve.Invoke(js.ValueOf(result))
|
resolve.Invoke(js.ValueOf(result))
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
@ -420,6 +427,88 @@ func smsgGetInfo(this js.Value, args []js.Value) interface{} {
|
||||||
return promiseConstructor.New(handler)
|
return promiseConstructor.New(handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// smsgEncryptWithManifest encrypts a message with public manifest metadata.
|
||||||
|
// JavaScript usage:
|
||||||
|
//
|
||||||
|
// const encrypted = await BorgSMSG.encryptWithManifest({
|
||||||
|
// body: 'Licensed content',
|
||||||
|
// attachments: [{name: 'track.mp3', content: '...', mime: 'audio/mpeg'}]
|
||||||
|
// }, password, {
|
||||||
|
// title: 'My Song',
|
||||||
|
// artist: 'Artist Name',
|
||||||
|
// tracks: [{title: 'Intro', start: 0}, {title: 'Drop', start: 60}]
|
||||||
|
// });
|
||||||
|
func smsgEncryptWithManifest(this js.Value, args []js.Value) interface{} {
|
||||||
|
handler := js.FuncOf(func(this js.Value, promiseArgs []js.Value) interface{} {
|
||||||
|
resolve := promiseArgs[0]
|
||||||
|
reject := promiseArgs[1]
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if len(args) < 3 {
|
||||||
|
reject.Invoke(newError("encryptWithManifest requires 3 arguments: messageObject, password, manifestObject"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msgObj := args[0]
|
||||||
|
password := args[1].String()
|
||||||
|
manifestObj := args[2]
|
||||||
|
|
||||||
|
// Build message from JS object
|
||||||
|
msg := smsg.NewMessage(msgObj.Get("body").String())
|
||||||
|
|
||||||
|
if !msgObj.Get("subject").IsUndefined() {
|
||||||
|
msg.WithSubject(msgObj.Get("subject").String())
|
||||||
|
}
|
||||||
|
if !msgObj.Get("from").IsUndefined() {
|
||||||
|
msg.WithFrom(msgObj.Get("from").String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle attachments
|
||||||
|
attachments := msgObj.Get("attachments")
|
||||||
|
if !attachments.IsUndefined() && attachments.Length() > 0 {
|
||||||
|
for i := 0; i < attachments.Length(); i++ {
|
||||||
|
att := attachments.Index(i)
|
||||||
|
name := att.Get("name").String()
|
||||||
|
content := att.Get("content").String()
|
||||||
|
mimeType := ""
|
||||||
|
if !att.Get("mime").IsUndefined() {
|
||||||
|
mimeType = att.Get("mime").String()
|
||||||
|
}
|
||||||
|
msg.AddAttachment(name, content, mimeType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle metadata (encrypted, inside payload)
|
||||||
|
meta := msgObj.Get("meta")
|
||||||
|
if !meta.IsUndefined() && meta.Type() == js.TypeObject {
|
||||||
|
keys := js.Global().Get("Object").Call("keys", meta)
|
||||||
|
for i := 0; i < keys.Length(); i++ {
|
||||||
|
key := keys.Index(i).String()
|
||||||
|
value := meta.Get(key).String()
|
||||||
|
msg.SetMeta(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build manifest from JS object (public, in header)
|
||||||
|
manifest := jsToManifest(manifestObj)
|
||||||
|
|
||||||
|
encrypted, err := smsg.EncryptWithManifest(msg, password, manifest)
|
||||||
|
if err != nil {
|
||||||
|
reject.Invoke(newError("encryption failed: " + err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
encryptedB64 := base64.StdEncoding.EncodeToString(encrypted)
|
||||||
|
resolve.Invoke(encryptedB64)
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
promiseConstructor := js.Global().Get("Promise")
|
||||||
|
return promiseConstructor.New(handler)
|
||||||
|
}
|
||||||
|
|
||||||
// smsgQuickDecrypt is a convenience function that just returns the body text.
|
// smsgQuickDecrypt is a convenience function that just returns the body text.
|
||||||
// JavaScript usage:
|
// JavaScript usage:
|
||||||
//
|
//
|
||||||
|
|
@ -503,3 +592,179 @@ func messageToJS(msg *smsg.Message) js.Value {
|
||||||
|
|
||||||
return js.ValueOf(result)
|
return js.ValueOf(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// manifestToJS converts an smsg.Manifest to a JavaScript object
|
||||||
|
func manifestToJS(m *smsg.Manifest) map[string]interface{} {
|
||||||
|
result := make(map[string]interface{})
|
||||||
|
|
||||||
|
if m.Title != "" {
|
||||||
|
result["title"] = m.Title
|
||||||
|
}
|
||||||
|
if m.Artist != "" {
|
||||||
|
result["artist"] = m.Artist
|
||||||
|
}
|
||||||
|
if m.Album != "" {
|
||||||
|
result["album"] = m.Album
|
||||||
|
}
|
||||||
|
if m.Genre != "" {
|
||||||
|
result["genre"] = m.Genre
|
||||||
|
}
|
||||||
|
if m.Year > 0 {
|
||||||
|
result["year"] = m.Year
|
||||||
|
}
|
||||||
|
if m.ReleaseType != "" {
|
||||||
|
result["releaseType"] = m.ReleaseType
|
||||||
|
}
|
||||||
|
if m.Duration > 0 {
|
||||||
|
result["duration"] = m.Duration
|
||||||
|
}
|
||||||
|
if m.Format != "" {
|
||||||
|
result["format"] = m.Format
|
||||||
|
}
|
||||||
|
|
||||||
|
// License expiration fields
|
||||||
|
if m.ExpiresAt > 0 {
|
||||||
|
result["expiresAt"] = m.ExpiresAt
|
||||||
|
}
|
||||||
|
if m.IssuedAt > 0 {
|
||||||
|
result["issuedAt"] = m.IssuedAt
|
||||||
|
}
|
||||||
|
if m.LicenseType != "" {
|
||||||
|
result["licenseType"] = m.LicenseType
|
||||||
|
}
|
||||||
|
// Computed fields for convenience
|
||||||
|
result["isExpired"] = m.IsExpired()
|
||||||
|
result["timeRemaining"] = m.TimeRemaining()
|
||||||
|
|
||||||
|
// Convert tracks
|
||||||
|
if len(m.Tracks) > 0 {
|
||||||
|
tracks := make([]interface{}, len(m.Tracks))
|
||||||
|
for i, t := range m.Tracks {
|
||||||
|
track := map[string]interface{}{
|
||||||
|
"title": t.Title,
|
||||||
|
"start": t.Start,
|
||||||
|
}
|
||||||
|
if t.End > 0 {
|
||||||
|
track["end"] = t.End
|
||||||
|
}
|
||||||
|
if t.Type != "" {
|
||||||
|
track["type"] = t.Type
|
||||||
|
}
|
||||||
|
if t.TrackNum > 0 {
|
||||||
|
track["trackNum"] = t.TrackNum
|
||||||
|
}
|
||||||
|
tracks[i] = track
|
||||||
|
}
|
||||||
|
result["tracks"] = tracks
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert tags
|
||||||
|
if len(m.Tags) > 0 {
|
||||||
|
tags := make([]interface{}, len(m.Tags))
|
||||||
|
for i, tag := range m.Tags {
|
||||||
|
tags[i] = tag
|
||||||
|
}
|
||||||
|
result["tags"] = tags
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert extra
|
||||||
|
if len(m.Extra) > 0 {
|
||||||
|
extra := make(map[string]interface{})
|
||||||
|
for k, v := range m.Extra {
|
||||||
|
extra[k] = v
|
||||||
|
}
|
||||||
|
result["extra"] = extra
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsToManifest converts a JavaScript object to an smsg.Manifest
|
||||||
|
func jsToManifest(obj js.Value) *smsg.Manifest {
|
||||||
|
if obj.IsUndefined() || obj.IsNull() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest := &smsg.Manifest{
|
||||||
|
Extra: make(map[string]string),
|
||||||
|
}
|
||||||
|
|
||||||
|
if !obj.Get("title").IsUndefined() {
|
||||||
|
manifest.Title = obj.Get("title").String()
|
||||||
|
}
|
||||||
|
if !obj.Get("artist").IsUndefined() {
|
||||||
|
manifest.Artist = obj.Get("artist").String()
|
||||||
|
}
|
||||||
|
if !obj.Get("album").IsUndefined() {
|
||||||
|
manifest.Album = obj.Get("album").String()
|
||||||
|
}
|
||||||
|
if !obj.Get("genre").IsUndefined() {
|
||||||
|
manifest.Genre = obj.Get("genre").String()
|
||||||
|
}
|
||||||
|
if !obj.Get("year").IsUndefined() {
|
||||||
|
manifest.Year = obj.Get("year").Int()
|
||||||
|
}
|
||||||
|
if !obj.Get("releaseType").IsUndefined() {
|
||||||
|
manifest.ReleaseType = obj.Get("releaseType").String()
|
||||||
|
}
|
||||||
|
if !obj.Get("duration").IsUndefined() {
|
||||||
|
manifest.Duration = obj.Get("duration").Int()
|
||||||
|
}
|
||||||
|
if !obj.Get("format").IsUndefined() {
|
||||||
|
manifest.Format = obj.Get("format").String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// License expiration fields
|
||||||
|
if !obj.Get("expiresAt").IsUndefined() {
|
||||||
|
manifest.ExpiresAt = int64(obj.Get("expiresAt").Float())
|
||||||
|
}
|
||||||
|
if !obj.Get("issuedAt").IsUndefined() {
|
||||||
|
manifest.IssuedAt = int64(obj.Get("issuedAt").Float())
|
||||||
|
}
|
||||||
|
if !obj.Get("licenseType").IsUndefined() {
|
||||||
|
manifest.LicenseType = obj.Get("licenseType").String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse tracks array
|
||||||
|
tracks := obj.Get("tracks")
|
||||||
|
if !tracks.IsUndefined() && tracks.Length() > 0 {
|
||||||
|
for i := 0; i < tracks.Length(); i++ {
|
||||||
|
t := tracks.Index(i)
|
||||||
|
track := smsg.Track{
|
||||||
|
Title: t.Get("title").String(),
|
||||||
|
Start: t.Get("start").Float(),
|
||||||
|
TrackNum: i + 1,
|
||||||
|
}
|
||||||
|
if !t.Get("end").IsUndefined() {
|
||||||
|
track.End = t.Get("end").Float()
|
||||||
|
}
|
||||||
|
if !t.Get("type").IsUndefined() {
|
||||||
|
track.Type = t.Get("type").String()
|
||||||
|
}
|
||||||
|
if !t.Get("trackNum").IsUndefined() {
|
||||||
|
track.TrackNum = t.Get("trackNum").Int()
|
||||||
|
}
|
||||||
|
manifest.Tracks = append(manifest.Tracks, track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse tags array
|
||||||
|
tags := obj.Get("tags")
|
||||||
|
if !tags.IsUndefined() && tags.Length() > 0 {
|
||||||
|
for i := 0; i < tags.Length(); i++ {
|
||||||
|
manifest.Tags = append(manifest.Tags, tags.Index(i).String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse extra object
|
||||||
|
extra := obj.Get("extra")
|
||||||
|
if !extra.IsUndefined() && extra.Type() == js.TypeObject {
|
||||||
|
keys := js.Global().Get("Object").Call("keys", extra)
|
||||||
|
for i := 0; i < keys.Length(); i++ {
|
||||||
|
key := keys.Index(i).String()
|
||||||
|
manifest.Extra[key] = extra.Get(key).String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return manifest
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue