324 lines
7.4 KiB
Go
324 lines
7.4 KiB
Go
// +build dappfm
|
|
|
|
// 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())
|
|
}
|
|
}
|