完成Web ASM

This commit is contained in:
Misaki
2026-06-08 22:09:35 +08:00
parent e565b80bc2
commit 36660519c1
28 changed files with 1148 additions and 178 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules/
web/node_modules/
web/dist/
cmd/server/static/dist/
build/
.env
*.log
+8
View File
@@ -27,6 +27,14 @@ config.yaml
# build files
build/*
# web frontend
web/node_modules/
cmd/server/static/dist/
# wasm artifacts
web/public/ncmdump.wasm
web/public/wasm_exec.js
# database files
*.sqlite3
+30
View File
@@ -0,0 +1,30 @@
FROM node:20-alpine AS frontend
WORKDIR /app/web
COPY web/package.json ./
RUN npm install
COPY web/ ./
RUN npm run build
FROM golang:1.23-alpine AS wasm
WORKDIR /app
COPY go.mod ./
RUN go mod download
COPY . .
RUN GOOS=js GOARCH=wasm go build -o web/public/ncmdump.wasm ./cmd/wasm
RUN cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" web/public/
FROM golang:1.23-alpine AS backend
WORKDIR /app
COPY go.mod ./
RUN go mod download
COPY . .
COPY --from=frontend /app/cmd/server/static/dist ./cmd/server/static/dist
COPY --from=wasm /app/web/public/ncmdump.wasm ./cmd/server/static/dist/ncmdump.wasm
COPY --from=wasm /app/web/public/wasm_exec.js ./cmd/server/static/dist/wasm_exec.js
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /app/server ./cmd/server
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata
COPY --from=backend /app/server /usr/local/bin/ncmdump-server
EXPOSE 8080
ENTRYPOINT ["ncmdump-server"]
+55 -78
View File
@@ -1,114 +1,91 @@
# ncmdump-go
基于 https://github.com/taurusxin/ncmdump 的 Golang 移植版
基于 [ncmdump](https://github.com/taurusxin/ncmdump) 的 Golang 移植版,支持网易云音乐 `.ncm` 文件解密为 MP3 / FLAC。
支持网易云音乐最新的 3.x 版本,但需要注意:从 3. x开始的某些网易云音乐版本不再在 ncm 文件中内置封面图片,本项目支持从网易服务器上自动下载对应歌曲的封面图并写入到最终的音乐文件中
提供两种使用方式:**Web 图形界面** 和 **命令行工具**
你也可以去 https://git.taurusxin.com/taurusxin/ncmdump-gui 下载基于本项目的 gui 可视化图形应用,只需简单点击即可自动转换。
## Web 界面(WASM,推荐)
## 如何提 Issue
所有加解密在浏览器本地完成,**不会上传你的文件到任何服务器**。解密引擎使用 Go 编译为 WebAssembly,性能接近原生。
由于本站恶意机器人注册过多,已关闭账号注册,如果需要提 Issue 请前往 [GitHub](https://github.com/taurusxin/ncmdump),必须注明 Issue 的主题为 ncmdump-go,敬请谅解。
### 使用
## 安装
1. 打开网页
2. 选择包含 `.ncm` 文件的**输入文件夹**
3. 选择**输出文件夹**
4. 点击「开始转换」
你可以使用去 [releases](https://git.taurusxin.com/taurusxin/ncmdump-go/releases/latest) 下载最新版预编译好的二进制文件,或者你也可以用包管理器来安装
歌曲名、歌手、专辑信息以及封面图片会自动写入转换后的文件中。
> 要求:Chrome / Edge 浏览器,通过 `localhost` 或 HTTPS 访问。
### Docker 部署
```shell
# Windows Scoop
scoop bucket add taurusxin https://git.taurusxin.com/taurusxin/scoop-bucket.git # 添加 scoop 源
scoop install ncmdump-go # 安装 ncmdump-go
# macOS & Linux 之后会支持
docker compose up -d --build
```
## 使用方法
然后浏览器访问 `http://localhost:8080`
使用 `-h``--help` 参数来打印帮助
### 本地开发
```shell
ncmdump-go -h
./build-wasm.sh # 编译 WASM + 前端 + 服务器
./ncmdump-web --port 8080
```
使用 `-v``--version` 参数来打印版本信息
## 命令行
### 安装
```shell
ncmdump-go -v
go install git.taurusxin.com/taurusxin/ncmdump-go@latest
```
处理单个或多个文件
### 使用
```shell
ncmdump-go 1.ncm 2.ncm...
```
# 单文件
ncmdump-go song.ncm
使用 `-d` 参数来指定一个文件夹,对文件夹下的所有以 ncm 为扩展名的文件进行批量处理
```shell
# 批量处理目录(不递归子目录)
ncmdump-go -d source_dir
# 递归处理 + 指定输出目录
ncmdump-go -d source_dir -r -o output_dir
```
使用 `-r` 配合 `-d` 参数来递归处理文件夹下的所有以 ncm 为扩展名的文件
### 作为库使用
```shell
ncmdump-go -d source_dir -r
```
使用 `-o` 参数来指定输出目录,将转换后的文件输出到指定目录,该参数支持与 `-r` 参数一起使用
```shell
# 处理单个或多个文件并输出到指定目录
ncmdump-go 1.ncm 2.ncm -o output_dir
# 处理文件夹下的所有以 ncm 为扩展名并输出到指定目录,不包含子文件夹
ncmdump-go -d source_dir -o output_dir
# 递归处理文件夹并输出到指定目录,并保留目录结构
ncmdump-go -d source_dir -o output_dir -r
```
## 开发
使用 go module 下载 ncmdump-go 包
```shell
go get -u git.taurusxin.com/taurusxin/ncmdump-go
```
导入并使用
```go
package main
import "git.taurusxin.com/taurusxin/ncmdump-go/ncmcrypt"
import (
"fmt"
"git.taurusxin.com/taurusxin/ncmdump-go/ncmcrypt"
)
func main() {
filePath := "test.ncm"
// 创建实例
ncm, err := ncmcrypt.NewNeteaseCloudMusic(filePath)
if err != nil {
fmt.Printf("Reading '%s' failed: '%s'\n", filePath, err.Error())
return
}
// 转换格式,若目标文件夹为空,则保存在原目录
dumpResult, err := ncm.Dump("")
if err != nil {
fmt.Printf("Processing '%s' failed: '%s'\n", filePath, err.Error())
}
if dumpResult {
// 使用源文件的元数据修补转换后的音乐文件
// 注意:自网易云音乐 3.0 版本开始,ncm 文件中不再内嵌专辑封面图片,参数若为 true 则表示从网易服务器上下载图片并嵌入到目标音乐文件(需要联网)
metadata, err := ncm.FixMetadata(true)
if !metadata {
fmt.Printf("Fix metadata for '%s' failed: '%s'", filePath, err.Error())
}
fmt.Printf("'%s' -> '%s'\n", filePath, ncm.GetDumpFilePath())
}
}
ncm, _ := ncmcrypt.NewNeteaseCloudMusic("song.ncm")
ncm.Dump("/path/to/output")
ncm.FixMetadata(true)
fmt.Println(ncm.GetDumpFilePath())
```
## 项目结构
```
├── main.go # CLI 命令行入口
├── ncmcrypt/ # 核心加解密库
│ ├── ncmcrypt.go # NCM 格式解析 / RC4 解密
│ ├── metadata.go # 元数据解析(歌名/歌手/封面URL)
│ └── embed.go # 内存写入 ID3 / FLAC 标签
├── utils/ # AES / 文件工具
├── cmd/
│ ├── wasm/main.go # WASM 编译入口(浏览器端)
│ └── server/main.go # 静态文件服务器
├── web/ # 前端(React + Tailwind
└── Dockerfile # 多阶段构建
```
## License
MIT
Executable
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -e
VERSION=1.8.0
echo "=== Building ncmdump WASM ==="
GOOS=js GOARCH=wasm go build -o web/public/ncmdump.wasm ./cmd/wasm
echo "=== Copying wasm_exec.js ==="
cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" web/public/
echo "=== Building frontend ==="
cd web
npm install
npm run build
cd ..
echo "=== Building server ==="
CGO_ENABLED=0 go build -ldflags="-w -s" -o ncmdump-web ./cmd/server
echo "=== Done ==="
echo "Run: ./ncmdump-web --port 8080"
+56
View File
@@ -0,0 +1,56 @@
package main
import (
"embed"
"flag"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
)
//go:embed all:static/dist
var staticFiles embed.FS
func main() {
port := flag.Int("port", 8080, "server port")
flag.Parse()
distFS, err := fs.Sub(staticFiles, "static/dist")
if err != nil {
log.Fatalf("failed to open embedded files: %v", err)
}
mux := http.NewServeMux()
stripped, err := fs.Sub(distFS, ".")
if err != nil {
log.Fatalf("failed to sub: %v", err)
}
fileServer := http.FileServer(http.FS(stripped))
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, ".wasm") {
w.Header().Set("Content-Type", "application/wasm")
}
fileServer.ServeHTTP(w, r)
})
go func() {
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("shutting down...")
os.Exit(0)
}()
log.Printf("ncmdump-web (WASM mode) starting on :%d", *port)
addr := fmt.Sprintf(":%d", *port)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatalf("server error: %v", err)
}
}
+114
View File
@@ -0,0 +1,114 @@
//go:build js && wasm
package main
import (
"bytes"
"syscall/js"
"git.taurusxin.com/taurusxin/ncmdump-go/ncmcrypt"
)
func convertNcm(this js.Value, args []js.Value) interface{} {
if len(args) < 1 {
return errorResult("no data provided")
}
dataLen := args[0].Get("byteLength").Int()
if dataLen == 0 {
return errorResult("empty data")
}
data := make([]byte, dataLen)
js.CopyBytesToGo(data, args[0])
ncm, err := ncmcrypt.NewNeteaseCloudMusicFromBytes(data)
if err != nil {
return errorResult(err.Error())
}
defer ncm.Close()
var buf bytes.Buffer
if err := ncm.DumpStream(&buf, nil); err != nil {
return errorResult(err.Error())
}
format := ncm.GetFormat()
result := js.Global().Get("Uint8Array").New(js.ValueOf(buf.Len()))
js.CopyBytesToJS(result, buf.Bytes())
meta := ncm.GetMetadata()
return js.ValueOf(map[string]interface{}{
"data": result,
"format": format,
"title": meta["title"],
"artist": meta["artist"],
"album": meta["album"],
"coverUrl": meta["coverUrl"],
})
}
func embedMetadata(this js.Value, args []js.Value) interface{} {
if len(args) < 2 {
return errorResult("insufficient arguments")
}
audioLen := args[0].Get("byteLength").Int()
audio := make([]byte, audioLen)
js.CopyBytesToGo(audio, args[0])
format := args[1].String()
title := ""
artist := ""
album := ""
var coverData []byte
if len(args) > 2 && args[2].Type() == js.TypeObject {
opts := args[2]
if v := opts.Get("title"); v.Type() == js.TypeString {
title = v.String()
}
if v := opts.Get("artist"); v.Type() == js.TypeString {
artist = v.String()
}
if v := opts.Get("album"); v.Type() == js.TypeString {
album = v.String()
}
if v := opts.Get("coverData"); v.Type() != js.TypeUndefined && v.Type() != js.TypeNull {
coverLen := v.Get("byteLength").Int()
coverData = make([]byte, coverLen)
js.CopyBytesToGo(coverData, v)
}
}
result, err := ncmcrypt.EmbedMetadata(audio, format, title, artist, album, coverData)
if err != nil {
return errorResult(err.Error())
}
out := js.Global().Get("Uint8Array").New(js.ValueOf(len(result)))
js.CopyBytesToJS(out, result)
return js.ValueOf(map[string]interface{}{
"data": out,
"format": format,
})
}
func errorResult(msg string) js.Value {
return js.ValueOf(map[string]interface{}{
"error": msg,
})
}
func main() {
js.Global().Set("ncmdumpConvert", js.FuncOf(convertNcm))
js.Global().Set("ncmdumpEmbed", js.FuncOf(embedMetadata))
ready := js.Global().Get("ncmdumpOnReady")
if ready.Type() == js.TypeFunction {
ready.Invoke()
}
select {}
}
+7
View File
@@ -0,0 +1,7 @@
services:
ncmdump:
build: .
container_name: ncmdump-web
ports:
- "8080:8080"
restart: unless-stopped
+3 -6
View File
@@ -2,17 +2,14 @@ module git.taurusxin.com/taurusxin/ncmdump-go
go 1.23.0
require github.com/tidwall/gjson v1.17.3
require github.com/spf13/pflag v1.0.5
require github.com/bogem/id3v2/v2 v2.1.4
require (
github.com/TwiN/go-color v1.4.1
github.com/bogem/id3v2/v2 v2.1.4
github.com/go-flac/flacpicture v0.3.0
github.com/go-flac/flacvorbis v0.2.0
github.com/go-flac/go-flac v1.0.0
github.com/spf13/pflag v1.0.5
github.com/tidwall/gjson v1.17.3
)
require (
+44
View File
@@ -0,0 +1,44 @@
package ncmcrypt
import (
"fmt"
"os"
)
func EmbedMetadata(audio []byte, format NcmFormat, title, artist, album string, coverData []byte) ([]byte, error) {
tmpFile, err := os.CreateTemp("", "ncmdump-embed-*")
if err != nil {
return nil, fmt.Errorf("create temp file: %w", err)
}
tmpPath := tmpFile.Name()
defer os.Remove(tmpPath)
if _, err := tmpFile.Write(audio); err != nil {
tmpFile.Close()
return nil, fmt.Errorf("write temp: %w", err)
}
tmpFile.Close()
ncm := &NeteaseCloudMusic{
mPng: [8]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A},
mFormat: format,
mDumpFilePath: tmpPath,
mImageData: coverData,
mMetadata: &NeteaseClousMusicMetadata{
mName: title,
mArtist: artist,
mAlbum: album,
},
}
if _, err := ncm.FixMetadata(false); err != nil {
return nil, fmt.Errorf("write metadata: %w", err)
}
result, err := os.ReadFile(tmpPath)
if err != nil {
return nil, fmt.Errorf("read result: %w", err)
}
return result, nil
}
+128 -92
View File
@@ -21,8 +21,6 @@ type NcmFormat = string
const (
Mp3 NcmFormat = "mp3"
)
const (
Flac NcmFormat = "flac"
)
@@ -32,55 +30,36 @@ type NeteaseCloudMusic struct {
mPng [8]byte
mFilePath string
mDumpFilePath string
mFormat NcmFormat
mImageData []byte
mFileStream *os.File
mReader io.ReadSeeker
mKeyBox [256]byte
mMetadata *NeteaseClousMusicMetadata
mAlbumPicUrl string
}
func (ncm *NeteaseCloudMusic) Close() {
if closer, ok := ncm.mReader.(io.Closer); ok {
closer.Close()
}
ncm.mReader = nil
}
func (ncm *NeteaseCloudMusic) read(buffer *[]byte, size int) int {
if len(*buffer) < size {
*buffer = make([]byte, size)
}
res, err := ncm.mFileStream.Read(*buffer)
res, err := ncm.mReader.Read((*buffer)[:size])
if err != nil {
return 0
}
return res
}
func (ncm *NeteaseCloudMusic) openFile() bool {
file, err := os.Open(ncm.mFilePath)
if err != nil {
return false
}
ncm.mFileStream = file
return true
}
func (ncm *NeteaseCloudMusic) isNcmFile() bool {
header := make([]byte, 4)
// check magic header 4E455443 4D414446
if ncm.read(&header, 4) != 4 {
return false
}
if int(binary.LittleEndian.Uint32(header)) != 0x4E455443 {
return false
}
if ncm.read(&header, 4) != 4 {
return false
}
if int(binary.LittleEndian.Uint32(header)) != 0x4D414446 {
return false
}
return true
func (ncm *NeteaseCloudMusic) skip(n int64) error {
_, err := ncm.mReader.Seek(n, io.SeekCurrent)
return err
}
func (ncm *NeteaseCloudMusic) buildKeyBox(key []byte, keyLen int) {
@@ -113,7 +92,6 @@ func (ncm *NeteaseCloudMusic) mimeType() string {
return "image/jpeg"
}
// Dump encrypted ncm file to normal music file. If `targetDir` is "", the converted file will be saved to the original directory.
func (ncm *NeteaseCloudMusic) Dump(targetDir string) (bool, error) {
ncm.mDumpFilePath = ncm.mFilePath
var outputStream *os.File
@@ -123,7 +101,6 @@ func (ncm *NeteaseCloudMusic) Dump(targetDir string) (bool, error) {
for {
n := ncm.read(&buffer, len(buffer))
if n == 0 {
break
}
@@ -141,7 +118,7 @@ func (ncm *NeteaseCloudMusic) Dump(targetDir string) (bool, error) {
ncm.mFormat = Flac
ncm.mDumpFilePath = utils.ReplaceExtension(ncm.mDumpFilePath, ".flac")
}
if targetDir != "" { // change save dir
if targetDir != "" {
ncm.mDumpFilePath = filepath.Join(targetDir, filepath.Base(ncm.mDumpFilePath))
}
findFormatFlag = true
@@ -160,12 +137,63 @@ func (ncm *NeteaseCloudMusic) Dump(targetDir string) (bool, error) {
return true, nil
}
// FixMetadata will fix the missing metadata for target music file, the source of the metadata comes from origin ncm file.
// Since NeteaseCloudMusic version 3.0, the album cover image is no longer embedded in the ncm file. If the parameter is true, it means downloading the image from the NetEase server and embedding it into the target music file (network connection required)
func (ncm *NeteaseCloudMusic) DumpStream(w io.Writer, onFormat func(NcmFormat)) error {
buffer := make([]byte, 0x8000)
findFormatFlag := false
for {
n := ncm.read(&buffer, len(buffer))
if n == 0 {
break
}
for i := 0; i < n; i++ {
j := (i + 1) & 0xff
buffer[i] ^= ncm.mKeyBox[(ncm.mKeyBox[j]+ncm.mKeyBox[(int(ncm.mKeyBox[j])+j)&0xff])&0xff]
}
if !findFormatFlag {
if buffer[0] == 0x49 && buffer[1] == 0x44 && buffer[2] == 0x33 {
ncm.mFormat = Mp3
} else {
ncm.mFormat = Flac
}
findFormatFlag = true
if onFormat != nil {
onFormat(ncm.mFormat)
}
}
if _, err := w.Write(buffer[:n]); err != nil {
return fmt.Errorf("stream write failed: %w", err)
}
}
return nil
}
func (ncm *NeteaseCloudMusic) GetFormat() NcmFormat {
return ncm.mFormat
}
func (ncm *NeteaseCloudMusic) GetMetadata() map[string]string {
m := map[string]string{}
if ncm.mMetadata != nil {
m["title"] = ncm.mMetadata.mName
m["artist"] = ncm.mMetadata.mArtist
m["album"] = ncm.mMetadata.mAlbum
}
if ncm.mAlbumPicUrl != "" {
m["coverUrl"] = ncm.mAlbumPicUrl
}
return m
}
func (ncm *NeteaseCloudMusic) FixMetadata(fetchAlbumImageFromRemote bool) (bool, error) {
// only fetch album image from remote when it's not embedded in the ncm file
if ncm.mMetadata == nil {
return true, nil
}
if len(ncm.mImageData) <= 0 && fetchAlbumImageFromRemote {
// get the album pic from url
resp, err := http.Get(ncm.mAlbumPicUrl)
if err != nil {
return false, err
@@ -237,7 +265,6 @@ func (ncm *NeteaseCloudMusic) FixMetadata(fetchAlbumImageFromRemote bool) (bool,
cmts = flacvorbis.New()
}
// flac 可能自带元数据 当且仅当没有该项时才向目标添加元数据
if res, _ := cmts.Get(flacvorbis.FIELD_TITLE); len(res) == 0 {
_ = cmts.Add(flacvorbis.FIELD_TITLE, ncm.mMetadata.mName)
}
@@ -257,7 +284,6 @@ func (ncm *NeteaseCloudMusic) FixMetadata(fetchAlbumImageFromRemote bool) (bool,
}
err = audioFile.Save(ncm.mDumpFilePath)
if err != nil {
return false, err
}
@@ -265,7 +291,6 @@ func (ncm *NeteaseCloudMusic) FixMetadata(fetchAlbumImageFromRemote bool) (bool,
return true, nil
}
// GetDumpFilePath returns the absolute path of dumped music file
func (ncm *NeteaseCloudMusic) GetDumpFilePath() string {
path, err := filepath.Abs(ncm.mDumpFilePath)
if err != nil {
@@ -274,38 +299,32 @@ func (ncm *NeteaseCloudMusic) GetDumpFilePath() string {
return path
}
// NewNeteaseCloudMusic returns a new NeteaseCloudMusic instance, if the format of the file is incorrect, the error will be returned.
func NewNeteaseCloudMusic(filePath string) (*NeteaseCloudMusic, error) {
ncm := &NeteaseCloudMusic{
sCoreKey: [17]byte{0x68, 0x7A, 0x48, 0x52, 0x41, 0x6D, 0x73, 0x6F, 0x35, 0x6B, 0x49, 0x6E, 0x62, 0x61, 0x78, 0x57, 0},
sModifyKey: [17]byte{0x23, 0x31, 0x34, 0x6C, 0x6A, 0x6B, 0x5F, 0x21, 0x5C, 0x5D, 0x26, 0x30, 0x55, 0x3C, 0x27, 0x28, 0},
mPng: [8]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A},
func parseNcmHeader(ncm *NeteaseCloudMusic) error {
header := make([]byte, 4)
mFilePath: filePath,
if ncm.read(&header, 4) != 4 {
return fmt.Errorf("read header failed")
}
if int(binary.LittleEndian.Uint32(header)) != 0x4E455443 {
return fmt.Errorf("not a ncm file")
}
if ncm.read(&header, 4) != 4 {
return fmt.Errorf("read header failed")
}
if int(binary.LittleEndian.Uint32(header)) != 0x4D414446 {
return fmt.Errorf("not a ncm file")
}
if !ncm.openFile() {
return nil, fmt.Errorf("open file failed")
if err := ncm.skip(2); err != nil {
return fmt.Errorf("seek version failed")
}
if !ncm.isNcmFile() {
return nil, fmt.Errorf("not a ncm file")
}
// actually this 2 bytes is the version, now we just skip it
if _, err := ncm.mFileStream.Seek(2, 1); err != nil {
return nil, fmt.Errorf("seek version failed")
}
// the length of the RC4 key, encrypted by AES128
n := make([]byte, 4)
if ncm.read(&n, len(n)) != 4 {
return nil, fmt.Errorf("read key len failed")
return fmt.Errorf("read key len failed")
}
keyLen := int(binary.LittleEndian.Uint32(n))
keydata := make([]byte, keyLen)
ncm.read(&keydata, keyLen)
@@ -314,25 +333,19 @@ func NewNeteaseCloudMusic(filePath string) (*NeteaseCloudMusic, error) {
}
mKeyData, err := utils.AesEcbDecrypt(ncm.sCoreKey[:16], keydata)
if err != nil {
return nil, fmt.Errorf("decrypt key failed")
return fmt.Errorf("decrypt key failed")
}
// build the key box
ncm.buildKeyBox(mKeyData[17:], len(mKeyData)-17)
if ncm.read(&n, len(n)) != 4 {
return nil, fmt.Errorf("read metadata len failed")
return fmt.Errorf("read metadata len failed")
}
metadataLen := int(binary.LittleEndian.Uint32(n))
if metadataLen <= 0 {
// process meta here
ncm.mMetadata = nil
} else {
// read metadata
if metadataLen > 0 {
modifyData := make([]byte, metadataLen)
ncm.read(&modifyData, metadataLen)
@@ -340,43 +353,32 @@ func NewNeteaseCloudMusic(filePath string) (*NeteaseCloudMusic, error) {
modifyData[i] ^= 0x63
}
// escape `163 key(Don't modify):`
swapModifyData := string(modifyData[22:])
modifyOutData, err := base64.StdEncoding.DecodeString(swapModifyData)
if err != nil {
panic("base64 decode modify data failed")
return fmt.Errorf("base64 decode modify data failed")
}
modifyDecryptData, err := utils.AesEcbDecrypt(ncm.sModifyKey[:16], modifyOutData)
if err != nil {
panic("decrypt modify data failed")
return fmt.Errorf("decrypt modify data failed")
}
// escape `music:`
mMetadataString := string(modifyDecryptData[6:])
// extract the album pic url
ncm.mAlbumPicUrl = GetAlbumPicUrl(mMetadataString)
ncm.mMetadata = NewNeteaseCloudMusicMetadata(mMetadataString)
}
// skip the 5 bytes gap
if _, err := ncm.mFileStream.Seek(5, 1); err != nil {
return nil, fmt.Errorf("seek gap failed")
if err := ncm.skip(5); err != nil {
return fmt.Errorf("seek gap failed")
}
// read the cover frame
coverFrameLen := make([]byte, 4)
if ncm.read(&coverFrameLen, len(coverFrameLen)) != 4 {
return nil, fmt.Errorf("read cover frame len failed")
return fmt.Errorf("read cover frame len failed")
}
if ncm.read(&n, len(n)) != 4 {
return nil, fmt.Errorf("read cover frame data len failed")
return fmt.Errorf("read cover frame data len failed")
}
coverFrameLenInt := int(binary.LittleEndian.Uint32(coverFrameLen))
@@ -386,7 +388,41 @@ func NewNeteaseCloudMusic(filePath string) (*NeteaseCloudMusic, error) {
ncm.read(&ncm.mImageData, coverFrameDataLen)
}
ncm.mFileStream.Seek(int64(coverFrameLenInt)-int64(coverFrameDataLen), 1)
ncm.skip(int64(coverFrameLenInt) - int64(coverFrameDataLen))
return nil
}
func NewNeteaseCloudMusic(filePath string) (*NeteaseCloudMusic, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("open file failed")
}
ncm := &NeteaseCloudMusic{
sCoreKey: [17]byte{0x68, 0x7A, 0x48, 0x52, 0x41, 0x6D, 0x73, 0x6F, 0x35, 0x6B, 0x49, 0x6E, 0x62, 0x61, 0x78, 0x57, 0},
sModifyKey: [17]byte{0x23, 0x31, 0x34, 0x6C, 0x6A, 0x6B, 0x5F, 0x21, 0x5C, 0x5D, 0x26, 0x30, 0x55, 0x3C, 0x27, 0x28, 0},
mPng: [8]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A},
mFilePath: filePath,
mReader: f,
}
if err := parseNcmHeader(ncm); err != nil {
f.Close()
return nil, err
}
return ncm, nil
}
func NewNeteaseCloudMusicFromBytes(data []byte) (*NeteaseCloudMusic, error) {
ncm := &NeteaseCloudMusic{
sCoreKey: [17]byte{0x68, 0x7A, 0x48, 0x52, 0x41, 0x6D, 0x73, 0x6F, 0x35, 0x6B, 0x49, 0x6E, 0x62, 0x61, 0x78, 0x57, 0},
sModifyKey: [17]byte{0x23, 0x31, 0x34, 0x6C, 0x6A, 0x6B, 0x5F, 0x21, 0x5C, 0x5D, 0x26, 0x30, 0x55, 0x3C, 0x27, 0x28, 0},
mPng: [8]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A},
mReader: bytes.NewReader(data),
}
if err := parseNcmHeader(ncm); err != nil {
return nil, err
}
return ncm, nil
}
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh-CN" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NCM Dump</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<script src="/wasm_exec.js"></script>
</head>
<body class="bg-slate-950 text-slate-100 font-sans antialiased">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
{
"name": "ncmdump-web",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-dropzone": "^14.2.3"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.6",
"typescript": "^5.5.3",
"vite": "^5.3.4"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+200
View File
@@ -0,0 +1,200 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import Header from './components/Header'
import FolderPicker from './components/FolderPicker'
import FileList from './components/FileList'
import StatusBar from './components/StatusBar'
import { initWasm, convertNcm, embedMetadata, isWasmSupported } from './wasm'
import type { FileItem } from './types'
export default function App() {
const [wasmReady, setWasmReady] = useState(false)
const [wasmError, setWasmError] = useState<string | null>(null)
const [inputFiles, setInputFiles] = useState<Map<string, { file: File; name: string }>>(new Map())
const [outputHandle, setOutputHandle] = useState<any>(null)
const [fileItems, setFileItems] = useState<FileItem[]>([])
const [converting, setConverting] = useState(false)
const [progress, setProgress] = useState({ done: 0, total: 0 })
const abortRef = useRef(false)
useEffect(() => {
if (isWasmSupported()) {
initWasm()
.then(() => setWasmReady(true))
.catch((e) => setWasmError(`WASM 加载失败: ${e.message}`))
} else {
setWasmError('浏览器不支持 WebAssembly')
}
}, [])
const handleInputFolder = useCallback(async (dirHandle: any) => {
const files = new Map<string, { file: File; name: string }>()
for await (const entry of dirHandle.values()) {
if (entry.kind === 'file' && entry.name.toLowerCase().endsWith('.ncm')) {
const file = await entry.getFile()
files.set(entry.name, { file, name: entry.name })
}
}
setInputFiles(files)
setFileItems(
Array.from(files.values()).map((f) => ({
name: f.name,
status: 'pending' as const,
}))
)
}, [])
const handleOutputFolder = useCallback((handle: any) => {
setOutputHandle(handle)
}, [])
const handleConvert = useCallback(async () => {
if (!outputHandle || inputFiles.size === 0 || !wasmReady) return
abortRef.current = false
setConverting(true)
setProgress({ done: 0, total: inputFiles.size })
const entries = Array.from(inputFiles.values())
for (let i = 0; i < entries.length; i++) {
if (abortRef.current) break
const entry = entries[i]
const setStatus = (status: FileItem['status'], extra?: Partial<FileItem>) =>
setFileItems((prev) => prev.map((item, idx) =>
idx === i ? { ...item, status, ...extra } : item
))
setStatus('converting')
try {
const buf = await entry.file.arrayBuffer()
const result = await convertNcm(buf)
let audio = result.data
// Fetch cover image if available
let coverData: Uint8Array | undefined
if (result.coverUrl) {
setStatus('fetching_cover')
try {
const resp = await fetch(result.coverUrl)
if (resp.ok) {
const coverBuf = await resp.arrayBuffer()
coverData = new Uint8Array(coverBuf)
}
} catch {
// cover fetch failed, proceed without it
}
}
// Embed metadata + cover
if (result.title || result.artist || result.album || coverData) {
setStatus('writing')
audio = await embedMetadata(
audio,
result.format,
{ title: result.title, artist: result.artist, album: result.album },
coverData
)
} else {
setStatus('writing')
}
const base = entry.name.replace(/\.ncm$/i, '')
const outName = `${base}.${result.format}`
const fileHandle = await outputHandle.getFileHandle(outName, { create: true })
const writable = await fileHandle.createWritable()
await writable.write(audio)
await writable.close()
setStatus('done', { format: result.format })
} catch (e: any) {
setStatus('error', { error: e.message })
}
setProgress({ done: i + 1, total: entries.length })
}
setConverting(false)
}, [inputFiles, outputHandle, wasmReady])
const handleAbort = useCallback(() => {
abortRef.current = true
setConverting(false)
}, [])
const canConvert = inputFiles.size > 0 && outputHandle !== null && wasmReady && !converting
return (
<div className="min-h-screen bg-grid">
<div className="mx-auto max-w-2xl px-4 py-8 sm:py-12">
<Header />
{wasmError && (
<div className="mt-4 animate-fade-in rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-400">
{wasmError}
</div>
)}
{!wasmError && !wasmReady && (
<div className="mt-4 animate-fade-in rounded-xl border border-slate-800 bg-slate-900/60 px-4 py-3 text-center text-sm text-slate-400">
<svg className="mx-auto mb-2 h-5 w-5 animate-spin text-indigo-400" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
WASM ...
</div>
)}
{wasmReady && (
<>
<div className="mt-4 grid grid-cols-2 gap-3 animate-slide-up">
<FolderPicker
label="输入文件夹"
description="选择包含 .ncm 文件的文件夹"
onSelect={handleInputFolder}
disabled={converting}
/>
<FolderPicker
label="输出文件夹"
description="选择转换后文件的保存位置"
onSelect={handleOutputFolder}
disabled={converting}
/>
</div>
{inputFiles.size > 0 && (
<div className="mt-3 animate-fade-in flex items-center gap-3">
<StatusBar progress={progress} />
<button
onClick={handleConvert}
disabled={!canConvert}
className="shrink-0 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-medium text-white
hover:bg-indigo-500 active:bg-indigo-700 transition-colors
disabled:opacity-40 disabled:cursor-not-allowed"
>
</button>
{converting && (
<button
onClick={handleAbort}
className="shrink-0 rounded-lg bg-slate-700 px-4 py-2.5 text-sm font-medium text-slate-300
hover:bg-slate-600 transition-colors"
>
</button>
)}
</div>
)}
{fileItems.length > 0 && (
<FileList items={fileItems} />
)}
</>
)}
</div>
</div>
)
}
+28
View File
@@ -0,0 +1,28 @@
import type { FileItem as FileItemType } from '../types'
import FileRow from './FileRow'
interface Props {
items: FileItemType[]
}
export default function FileList({ items }: Props) {
const done = items.filter((f) => f.status === 'done').length
const error = items.filter((f) => f.status === 'error').length
const total = items.length
return (
<div className="mt-6 animate-fade-in">
<div className="mb-3 flex items-center gap-2">
<h3 className="text-sm font-semibold text-slate-300"></h3>
<span className="rounded-full bg-slate-800 px-2 py-0.5 text-xs text-slate-400">
{done + error}/{total}
</span>
</div>
<div className="space-y-2">
{items.map((item, idx) => (
<FileRow key={idx} item={item} />
))}
</div>
</div>
)
}
+83
View File
@@ -0,0 +1,83 @@
import type { FileItem } from '../types'
interface Props {
item: FileItem
}
export default function FileRow({ item }: Props) {
const ext = item.name.replace(/\.ncm$/i, item.format ? `.${item.format}` : '')
return (
<div className="rounded-xl border border-slate-800 bg-slate-900/60 px-4 py-3 transition-colors hover:border-slate-700">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-slate-200">
{item.name}
</p>
{item.status === 'done' && item.format && (
<p className="mt-0.5 truncate text-xs text-slate-500">
{'→ '}{ext}
</p>
)}
{item.status === 'error' && item.error && (
<p className="mt-0.5 truncate text-xs text-red-400">
{item.error}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
{item.status === 'pending' && (
<span className="flex items-center gap-1.5 text-xs text-slate-500">
<span className="h-1.5 w-1.5 rounded-full bg-slate-600" />
</span>
)}
{item.status === 'converting' && (
<span className="flex items-center gap-1.5 text-xs text-indigo-400">
<svg className="h-3.5 w-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</span>
)}
{item.status === 'fetching_cover' && (
<span className="flex items-center gap-1.5 text-xs text-cyan-400">
<svg className="h-3.5 w-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</span>
)}
{item.status === 'writing' && (
<span className="flex items-center gap-1.5 text-xs text-amber-400">
<svg className="h-3.5 w-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</span>
)}
{item.status === 'done' && (
<span className="flex items-center gap-1.5 text-xs text-emerald-400">
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</span>
)}
{item.status === 'error' && (
<span className="flex items-center gap-1.5 text-xs text-red-400">
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
</span>
)}
</div>
</div>
</div>
)
}
+67
View File
@@ -0,0 +1,67 @@
import { useState, useCallback } from 'react'
interface Props {
label: string
description: string
onSelect: (handle: any) => void
disabled: boolean
}
export default function FolderPicker({ label, description, onSelect, disabled }: Props) {
const [selected, setSelected] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const pick = useCallback(async () => {
setError(null)
try {
if (typeof (window as any).showDirectoryPicker !== 'function') {
setError('浏览器不支持此功能,请使用 Chrome/Edge 并通过 localhost 或 HTTPS 访问')
return
}
const handle = await (window as any).showDirectoryPicker()
setSelected(handle.name)
onSelect(handle)
} catch (e: any) {
if (e.name !== 'AbortError') {
setError(`选择文件夹失败: ${e.message || '请通过 localhost 或 HTTPS 访问'}`)
}
}
}, [onSelect])
return (
<div>
<button
onClick={pick}
disabled={disabled}
className={`
flex flex-col items-center gap-2 rounded-2xl border-2 border-dashed p-6 text-center w-full
transition-all duration-200
${selected
? 'border-indigo-500/50 bg-indigo-500/10'
: 'border-slate-700 bg-slate-900/50 hover:border-slate-600 hover:bg-slate-900/80'
}
disabled:opacity-40 disabled:cursor-not-allowed
`}
>
{selected ? (
<svg className="h-7 w-7 text-indigo-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
</svg>
) : (
<svg className="h-7 w-7 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 10.5v6m3-3H9m4.06-7.19l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
</svg>
)}
<div>
<p className="text-sm font-medium text-slate-300">{label}</p>
<p className="mt-0.5 text-xs text-slate-500">
{selected ? selected : description}
</p>
</div>
</button>
{error && (
<p className="mt-2 text-xs text-amber-400 text-center">{error}</p>
)}
</div>
)
}
+19
View File
@@ -0,0 +1,19 @@
export default function Header() {
return (
<header className="mb-8 text-center animate-fade-in">
<div className="inline-flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-indigo-500/20 ring-1 ring-indigo-500/30">
<svg className="h-5 w-5 text-indigo-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
</svg>
</div>
<h1 className="text-2xl font-bold tracking-tight text-white">
NCM Dump
</h1>
</div>
<p className="mt-2 text-sm text-slate-400">
.ncm
</p>
</header>
)
}
+22
View File
@@ -0,0 +1,22 @@
interface Props {
progress: { done: number; total: number }
}
export default function StatusBar({ progress }: Props) {
const pct = progress.total > 0 ? Math.round((progress.done / progress.total) * 100) : 0
return (
<div className="flex-1">
<div className="flex items-center justify-between text-xs text-slate-400 mb-1">
<span></span>
<span>{progress.done}/{progress.total} ({pct}%)</span>
</div>
<div className="h-1.5 rounded-full bg-slate-800 overflow-hidden">
<div
className="h-full rounded-full bg-indigo-500 transition-all duration-300"
style={{ width: `${pct}%` }}
/>
</div>
</div>
)
}
+29
View File
@@ -0,0 +1,29 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-slate-950 text-slate-200;
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
}
}
@layer utilities {
.bg-grid {
background-image:
linear-gradient(rgba(99, 102, 241, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(99, 102, 241, 0.03) 1px, transparent 1px);
background-size: 64px 64px;
}
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
@apply bg-slate-700 rounded-full;
}
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+30
View File
@@ -0,0 +1,30 @@
export interface ConvertResult {
data: Uint8Array
format: 'mp3' | 'flac'
title: string
artist: string
album: string
coverUrl: string
}
export type FileStatus = 'pending' | 'converting' | 'fetching_cover' | 'writing' | 'done' | 'error'
export interface FileItem {
name: string
status: FileStatus
error?: string
format?: string
}
declare global {
interface Window {
ncmdumpConvert?: (data: Uint8Array) => {
data?: Uint8Array; format?: string; error?: string
title?: string; artist?: string; album?: string; coverUrl?: string
}
ncmdumpEmbed?: (audio: Uint8Array, format: string, opts: {
title?: string; artist?: string; album?: string; coverData?: Uint8Array
}) => { data?: Uint8Array; error?: string }
ncmdumpOnReady?: () => void
}
}
+66
View File
@@ -0,0 +1,66 @@
import type { ConvertResult } from './types'
let ready = false
let initPromise: Promise<void> | null = null
export async function initWasm(): Promise<void> {
if (ready) return
if (initPromise) return initPromise
initPromise = new Promise<void>((resolve) => {
window.ncmdumpOnReady = () => {
ready = true
resolve()
}
const go = new (window as any).Go()
WebAssembly.instantiateStreaming(fetch('/ncmdump.wasm'), go.importObject).then(
(result) => {
go.run(result.instance)
}
)
})
return initPromise
}
export async function convertNcm(data: ArrayBuffer): Promise<ConvertResult> {
if (!ready) throw new Error('WASM 未就绪')
const input = new Uint8Array(data)
const result = window.ncmdumpConvert!(input)
if (result.error) throw new Error(result.error)
return {
data: new Uint8Array(result.data!),
format: result.format as 'mp3' | 'flac',
title: result.title || '',
artist: result.artist || '',
album: result.album || '',
coverUrl: result.coverUrl || '',
}
}
export async function embedMetadata(
audio: Uint8Array,
format: string,
meta: { title: string; artist: string; album: string },
coverData?: Uint8Array
): Promise<Uint8Array> {
if (!ready) throw new Error('WASM 未就绪')
const opts: any = { title: meta.title, artist: meta.artist, album: meta.album }
if (coverData) {
opts.coverData = coverData
}
const result = window.ncmdumpEmbed!(audio, format, opts)
if (result.error) throw new Error(result.error)
return new Uint8Array(result.data!)
}
export function isWasmSupported(): boolean {
return typeof WebAssembly === 'object' && typeof (window as any).Go !== 'undefined'
}
+35
View File
@@ -0,0 +1,35 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
},
animation: {
'fade-in': 'fadeIn 0.3s ease-out',
'slide-up': 'slideUp 0.4s ease-out',
'pulse-ring': 'pulseRing 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0', transform: 'translateY(4px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
slideUp: {
'0%': { opacity: '0', transform: 'translateY(12px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
pulseRing: {
'0%, 100%': { boxShadow: '0 0 0 0 rgba(99, 102, 241, 0)' },
'50%': { boxShadow: '0 0 0 8px rgba(99, 102, 241, 0.15)' },
},
},
},
},
plugins: [],
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}
+1
View File
@@ -0,0 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/types.ts","./src/wasm.ts","./src/components/FileList.tsx","./src/components/FileRow.tsx","./src/components/FolderPicker.tsx","./src/components/Header.tsx","./src/components/StatusBar.tsx"],"version":"5.9.3"}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
},
build: {
outDir: '../cmd/server/static/dist',
emptyOutDir: true,
},
})