chore: bump version to 0.7.1

This commit is contained in:
ethan.chen
2025-05-27 14:16:48 +08:00
commit 63eda91fd6
103 changed files with 15868 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

67
DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,67 @@
<!--
* @Date: 2025-05-27 13:40:13
* @LastEditors: 陈子健
* @LastEditTime: 2025-05-27 14:11:15
* @FilePath: /lyroc/DEVELOPMENT.md
-->
# Development Schedule | 开发日程表
[English](#english) | [中文](#chinese)
<a name="english"></a>
## English
### Version Management
The project follows [Semantic Versioning](https://semver.org/) (MAJOR.MINOR.PATCH):
- MAJOR version: Incompatible API changes
- MINOR version: Backwards-compatible functionality
- PATCH version: Backwards-compatible bug fixes
Version numbers are maintained in the root `VERSION` file and should be synchronized across all components:
- Frontend (package.json)
- Backend (pyproject.toml)
- Electron app (package.json)
To update version:
1. Update the `VERSION` file
2. Run `npm run version` to sync version across all components
3. Commit changes with message: "chore: bump version to x.y.z"
### Phase 1: Core Features
- [ ] 代码审查和重构
- [ ] 依赖更新
- [ ] 安全审计
- [ ] 社区互动
---
<a name="chinese"></a>
## 中文
### 版本管理
项目遵循[语义化版本](https://semver.org/)规范 (主版本号.次版本号.修订号)
- 主版本号:不兼容的 API 修改
- 次版本号:向下兼容的功能性新增
- 修订号:向下兼容的问题修正
版本号统一维护在根目录的 `VERSION` 文件中,并需要在所有组件间保持同步:
- 前端 (package.json)
- 后端 (pyproject.toml)
- Electron 应用 (package.json)
更新版本步骤:
1. 更新 `VERSION` 文件
2. 运行 `npm run version` 同步所有组件的版本号
3. 提交更改,提交信息格式为:"chore: bump version to x.y.z"
### 开发计划
- [ ] 设置界面开发,可以配置歌词显示
- [ ] 歌词多API开发对应网易云权限问题

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 ethan.chen
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

230
README.md Normal file
View File

@@ -0,0 +1,230 @@
# Lyroc - Apple Music 歌词显示 | Apple Music Lyrics Display
[English](#english) | [中文](#chinese)
<a name="english"></a>
## English
Lyroc is an elegant macOS application that displays real-time lyrics for Apple Music. It provides a clean and beautiful interface for you to easily view lyrics of the currently playing song.
### Features
- 🎵 Real-time Apple Music lyrics display
- 🎨 Clean and beautiful user interface
- 🌍 Multi-language support
- 🖥️ Native macOS experience
### Tech Stack
- Frontend: Vue 3 + Vite
- Desktop: Electron
- Backend: Python + FastApi
- Internationalization: Vue I18n
### System Requirements
- macOS operating system
- Apple Music application
### Installation
#### From Release
1. Visit the [Releases](https://github.com/tomchen1991/lyroc/releases) page
2. Download the latest `.dmg` file
3. Double-click the downloaded file and drag the app to your Applications folder
#### From Source
1. Clone the repository
```bash
git clone https://github.com/tomchen1991/lyroc.git
cd lyroc
```
2. Configure backend virtual environment
```bash
cd backend
python -m venv venv
source venv/bin/activate # on macOS/Linux
# OR for Windows:
# .\venv\Scripts\activate
pip install -r requirements.txt
```
3. Install frontend dependencies
```bash
cd frontend
npm install
npm run build
```
4. Install Electron app dependencies
```bash
cd ../electron-app
npm install
```
5. Build the application
```bash
npm run build
```
The built application can be found in the `electron-app/dist` directory.
### Usage
1. Launch Apple Music and play music
2. Open the Lyroc application
3. Lyrics will automatically display on screen
### Development
#### Backend Development
```bash
cd backend
python -m venv venv
source venv/bin/activate # on macOS/Linux
# OR for Windows:
# .\venv\Scripts\activate
python main.py
```
#### Frontend Development
```bash
cd frontend
npm install
npm run dev
```
#### Electron Development
```bash
cd electron-app
npm install
npm start
```
### Contributing
Issues and Pull Requests are welcome! For more information about development schedule and guidelines, please check [DEVELOPMENT.md](DEVELOPMENT.md).
### License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details
---
<a name="chinese"></a>
## 中文
Lyroc 是一个优雅的 macOS 应用程序,用于显示 Apple Music 的实时歌词。它提供了一个简洁美观的界面,让您能够轻松查看正在播放歌曲的歌词。
### 功能特点
- 🎵 实时显示 Apple Music 歌词
- 🎨 简洁美观的用户界面
- 🌍 多语言支持
- 🖥️ 原生 macOS 应用体验
### 技术栈
- 前端Vue 3 + Vite
- 桌面端Electron
- 后端Python + FastApi
- 国际化Vue I18n
### 系统要求
- macOS 操作系统
- Apple Music 应用
### 安装说明
#### 从发布版本安装
1. 访问 [Releases](https://github.com/tomchen1991/lyroc/releases) 页面
2. 下载最新版本的 `.dmg` 文件
3. 双击下载的文件,将应用拖到 Applications 文件夹
#### 从源码构建
1. 克隆仓库
```bash
git clone https://github.com/tomchen1991/lyroc.git
cd lyroc
```
2. 配置后端虚拟环境
```bash
cd backend
python -m venv venv
source venv/bin/activate # 在 macOS/Linux 上
# 或者在 Windows 上:
# .\venv\Scripts\activate
pip install -r requirements.txt
```
3. 安装前端依赖
```bash
cd frontend
npm install
npm run build
```
4. 安装 Electron 应用依赖
```bash
cd ../electron-app
npm install
```
5. 构建应用
```bash
npm run build
```
构建完成后,可以在 `electron-app/dist` 目录下找到打包好的应用。
### 使用方法
1. 启动 Apple Music 并播放音乐
2. 打开 Lyroc 应用
3. 歌词将自动显示在屏幕上
### 开发
#### 后端开发
```bash
cd backend
python -m venv venv
source venv/bin/activate # 在 macOS/Linux 上
# 或者在 Windows 上:
# .\venv\Scripts\activate
python main.py
```
#### 前端开发
```bash
cd frontend
npm install
npm run dev
```
#### Electron 开发
```bash
cd electron-app
npm install
npm start
```
### 贡献
欢迎提交 Issue 和 Pull Request有关开发日程和指南的更多信息请查看 [DEVELOPMENT.md](DEVELOPMENT.md)。
### 许可证
本项目采用 MIT 许可证 - 详见 [LICENSE](LICENSE) 文件

1
VERSION Normal file
View File

@@ -0,0 +1 @@
0.7.1

3
backend/.env.example Normal file
View File

@@ -0,0 +1,3 @@
# 歌词服务器配置示例
LYRICS_SERVER=http://123.57.93.143:28883
LYRICS_AUTHORIZATION=fzt_tom

7
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
__pycache__
lyrics
.DS_Store
build
dist
.env
venv

10
backend/config.py Normal file
View File

@@ -0,0 +1,10 @@
'''
Date: 2025-04-24 16:32:55
LastEditors: 陈子健
LastEditTime: 2025-05-26 17:13:51
FilePath: /mac-lyric-vue/backend/config.py
'''
import os
APP_SUPPORT_DIR = os.path.expanduser('~/Library/Application Support/lyroc')
os.makedirs(APP_SUPPORT_DIR, exist_ok=True)

86
backend/main.py Normal file
View File

@@ -0,0 +1,86 @@
'''
Date: 2025-05-06 09:26:51
LastEditors: 陈子健
LastEditTime: 2025-05-26 17:14:17
FilePath: /mac-lyric-vue/backend/main.py
'''
"""
lyroc 后端主程序
负责初始化应用和启动服务器
"""
import uvicorn
import os
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
# 导入自定义模块
from modules.websocket_manager import ConnectionManager
from modules.background_tasks import start_background_task, cancel_all_tasks, background_tasks
from modules.routes import register_routes
# 配置日志
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# 应用支持目录和歌词目录在lyrics模块中已定义
# Lifespan 上下文管理器,替代 on_event
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("正在启动API服务...")
try:
# 启动时创建后台任务
start_background_task(manager)
logger.info("后台任务已启动")
except Exception as e:
logger.error(f"启动后台任务失败: {e}")
yield # 应用正常运行
# 关闭时清理任务
try:
cancel_all_tasks()
logger.info("后台任务已关闭")
except Exception as e:
logger.error(f"关闭后台任务失败: {e}")
# 创建FastAPI应用
app = FastAPI(lifespan=lifespan)
# 确保在应用启动时直接启动任务(作为后备方案)
@app.on_event("startup")
async def startup_event():
# 仅当通过 lifespan 未启动任务时才启动
if not background_tasks:
logger.info("通过startup_event启动后台任务...")
try:
start_background_task(manager)
logger.info("后台任务通过startup_event成功启动")
except Exception as e:
logger.error(f"通过startup_event启动后台任务失败: {e}")
# 配置CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 创建WebSocket连接管理器
manager = ConnectionManager()
# 注册API路由
register_routes(app, manager)
if __name__ == "__main__":
# 从环境变量获取端口号如果未设置则默认使用5000
port = int(os.environ.get("PORT", 5000))
logger.info(f"启动后端服务器在端口: {port}")
uvicorn.run("main:app", host="127.0.0.1", port=port, reload=True)

View File

@@ -0,0 +1,9 @@
'''
Date: 2025-05-06 09:25:16
LastEditors: 陈子健
LastEditTime: 2025-05-26 17:14:27
FilePath: /mac-lyric-vue/backend/modules/__init__.py
'''
"""
lyroc 后端模块包
"""

View File

@@ -0,0 +1,118 @@
"""
Apple Music 控制和状态获取模块
负责与 Apple Music 应用交互的所有功能
"""
import subprocess
import logging
logger = logging.getLogger(__name__)
def run_applescript(script: str):
"""运行 AppleScript 并返回结果"""
result = subprocess.run([
"osascript", "-e", script
], capture_output=True, text=True)
return result.stdout.strip()
def get_music_status():
"""获取 Apple Music 播放状态"""
script = '''
tell application "Music"
if it is running then
set playerState to (get player state) as string
if playerState is "stopped" then
return "stopped"
else
set trackName to name of current track
set artistName to artist of current track
set pos to player position
set dur to duration of current track
return playerState & "|||" & trackName & "|||" & artistName & "|||" & (pos as text) & "|||" & (dur as text)
end if
else
return "notrunning"
end if
end tell
'''
try:
out = run_applescript(script)
if out is None:
logger.error("AppleScript执行失败返回了None")
return {"status": "error", "error": "AppleScript execution failed"}
if out == "notrunning":
return {"status": "notrunning"}
if out == "stopped":
return {"status": "stopped"}
try:
player_state, track_name, artist, pos, dur = out.split("|||")
return {
"status": player_state,
"track_name": track_name,
"artist": artist,
"position": float(pos),
"duration": float(dur)
}
except Exception as e:
return {"status": "error", "error": str(e), "raw": out}
except Exception as e:
logger.error(f"获取音乐状态时发生异常: {e}")
return {"status": "error", "error": str(e)}
def control_music(action, position=None):
"""控制Apple Music播放"""
try:
if action == "playpause":
script = '''
tell application "Music"
if it is running then
playpause
return "ok"
else
return "notrunning"
end if
end tell
'''
elif action == "previous":
script = '''
tell application "Music"
if it is running then
previous track
return "ok"
else
return "notrunning"
end if
end tell
'''
elif action == "next":
script = '''
tell application "Music"
if it is running then
next track
return "ok"
else
return "notrunning"
end if
end tell
'''
elif action == "seek" and position is not None:
script = f'''
tell application "Music"
if it is running then
set player position to {position}
return "ok"
else
return "notrunning"
end if
end tell
'''
else:
return {"status": "error", "message": "未知的命令"}
result = run_applescript(script)
if result == "ok":
return {"status": "success"}
else:
return {"status": "error", "message": result}
except Exception as e:
return {"status": "error", "message": str(e)}

View File

@@ -0,0 +1,219 @@
"""
后台任务模块
负责创建和管理定期检查Apple Music状态并推送更新的后台任务
"""
import time
import asyncio
import logging
from .apple_music import get_music_status
from .lyrics import get_lyrics_data
from .websocket_manager import ConnectionManager
logger = logging.getLogger(__name__)
# 存储后台任务引用
background_tasks = set()
async def fetch_and_update_lyrics(status, manager):
"""异步获取歌词并发送更新"""
try:
# 异步获取歌词
logger.info(f"正在获取歌词...")
lyrics = get_lyrics_data(status)
logger.info(f"歌词: {lyrics}")
# 获取到歌词后,再发送一个歌词更新消息
if lyrics and isinstance(lyrics, dict): # 确保lyrics是一个非空的字典
lyrics_update = {
"type": "lyric_update", # 使用不同的消息类型
"timestamp": time.time(),
"lyrics": lyrics
}
await manager.broadcast(lyrics_update)
logger.info("歌词获取完成,已发送更新")
except Exception as e:
logger.error(f"获取歌词并发送更新时出错: {e}")
async def update_music_info(manager: ConnectionManager):
"""定期检查音乐状态并向所有客户端推送更新"""
logger.info("后台任务update_music_info开始运行...")
# 记录上一次状态
last_position = -1
last_track_name = None
last_lyrics = None
last_playback_status = None # 记录上一次的播放状态
# 配置更新间隔
check_interval = 0.05 # 内部检查间隔仍保持较短
# 设定重试次数
consecutive_errors = 0
max_consecutive_errors = 10
while True:
try:
current_time = time.time()
# 获取当前状态
status = get_music_status()
if status is None:
logger.error("获取音乐状态失败返回None")
await asyncio.sleep(1)
continue
current_status = status.get("status", "unknown")
# 错误状态处理
if current_status == "error":
logger.error(f"获取音乐状态返回错误: {status.get('error', 'Unknown error')}")
await asyncio.sleep(1)
continue
# 状态变化处理
if current_status != last_playback_status and last_playback_status is not None:
logger.info(f"播放状态变化: {last_playback_status} -> {current_status}")
# 确定消息类型
message_type = "status_change"
if current_status == "playing":
message_type = "playback_resumed"
elif current_status == "paused":
message_type = "playback_paused"
elif current_status == "stopped":
message_type = "playback_stopped"
elif current_status == "notrunning":
message_type = "app_not_running"
# 向客户端通知状态变化
status_update = {
"type": message_type,
"timestamp": current_time,
"status": status
}
await manager.broadcast(status_update)
last_playback_status = current_status
consecutive_errors = 0
await asyncio.sleep(check_interval)
continue
# 如果没有状态变化,但是还没有记录过状态,进行初始化
if last_playback_status is None:
last_playback_status = current_status
# 如果应用未运行或已停止,无需进一步处理
if current_status in ["stopped", "notrunning"]:
await asyncio.sleep(check_interval)
continue
# 保存当前播放状态用于下次比较
last_playback_status = current_status
# 如果已停止,无需进一步处理
if current_status in ["stopped", "notrunning"]:
await asyncio.sleep(check_interval)
continue
# 以下是正常播放状态下的处理
position = status.get("position", -1)
track_name = status.get("track_name")
# 安全检查:如果缺少关键数据,跳过本次循环
if track_name is None:
logger.debug("歌曲名称为空,跳过本次更新")
await asyncio.sleep(check_interval)
continue
# 检查是否需要发送更新
track_changed = track_name != last_track_name
# 如果歌曲变化,获取新歌词
if track_changed:
try:
# 立即发送歌曲变化通知(不含歌词)
logger.info(f"歌曲变化: {last_track_name} -> {track_name}")
# 先发送一个不含歌词的track_change消息
initial_update = {
"type": "track_change",
"timestamp": current_time,
"status": status,
"lyrics": None # 不包含歌词
}
await manager.broadcast(initial_update)
# 更新歌曲名称记录
last_track_name = track_name
last_position = position
# 重置错误计数
consecutive_errors = 0
# 启动一个独立的异步任务来获取歌词并后续发送更新
# 这样主循环可以继续运行,不会阻塞
asyncio.create_task(fetch_and_update_lyrics(status, manager))
except Exception as e:
logger.error(f"处理歌曲变化时出错: {e}")
await asyncio.sleep(0.5)
# 检查歌词是否需要更新(播放位置变化明显)
elif abs(last_position - position) > 0.5: # 如果播放位置变化超过0.5秒
try:
lyrics = get_lyrics_data(status)
# 只有当歌词内容变化时才发送更新
current_lyric = lyrics.get("current_lyric", "") if lyrics and isinstance(lyrics, dict) else ""
current_lyric_time = lyrics.get("current_lyric_time", "") if lyrics and isinstance(lyrics, dict) else ""
last_current_lyric_time = last_lyrics.get("current_lyric_time", "") if last_lyrics and isinstance(last_lyrics, dict) else ""
if current_lyric_time != last_current_lyric_time:
if current_lyric is None:
current_lyric = ""
logger.debug(f"歌词更新: {current_lyric[:20]}...")
combined_data = {
"type": "lyric_change",
"timestamp": current_time,
"status": status,
"lyrics": lyrics
}
await manager.broadcast(combined_data)
last_lyrics = lyrics
last_position = position
# 重置错误计数
consecutive_errors = 0
except Exception as e:
logger.error(f"处理歌词更新时出错: {e}")
await asyncio.sleep(0.5)
# 更新上一次位置
if track_changed or (abs(last_position - position) > 0.5):
last_position = position
# 等待下一次检查
await asyncio.sleep(check_interval)
except Exception as e:
logger.error(f"更新音乐信息时出错: {e}")
consecutive_errors += 1
if consecutive_errors >= max_consecutive_errors:
logger.error(f"连续错误次数超过{max_consecutive_errors},退出后台任务")
break
await asyncio.sleep(1) # 出错时等待1秒再重试
def start_background_task(manager: ConnectionManager):
"""启动后台任务并添加到跟踪集合中"""
task = asyncio.create_task(update_music_info(manager))
background_tasks.add(task)
task.add_done_callback(background_tasks.discard)
return task
def cancel_all_tasks():
"""取消所有后台任务"""
for task in background_tasks:
task.cancel()

435
backend/modules/lyrics.py Normal file
View File

@@ -0,0 +1,435 @@
"""
歌词处理模块
负责歌词的解析、格式化、存储和获取
"""
import os
import re
import logging
import requests
import sqlite3
from urllib.parse import quote
from Crypto.Cipher import AES
import base64
import random
import codecs
import json
logger = logging.getLogger(__name__)
# 配置应用支持目录
APP_SUPPORT_DIR = os.path.expanduser('~/Library/Application Support/lyroc')
os.makedirs(APP_SUPPORT_DIR, exist_ok=True)
# SQLite数据库配置
DB_PATH = os.path.join(APP_SUPPORT_DIR, 'lyroc.db')
def init_db():
"""初始化SQLite数据库"""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# 创建歌词表
cursor.execute('''
CREATE TABLE IF NOT EXISTS lyrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
track_name TEXT NOT NULL,
artist TEXT NOT NULL,
lyrics_content TEXT NOT NULL,
source TEXT DEFAULT 'netease',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(track_name, artist)
)
''')
conn.commit()
conn.close()
logger.debug(f"数据库初始化完成: {DB_PATH}")
# 初始化数据库
init_db()
def clear_lyrics_cache():
"""清空歌词缓存"""
global _lyrics_cache
_lyrics_cache = {
"track_name": None,
"artist": None,
"lyrics_text": None,
"source": None,
"deleted": False
}
# 歌词缓存
clear_lyrics_cache()
# 网易云音乐API配置
NETEASE_API_BASE = "https://music.163.com/weapi"
NETEASE_SEARCH_URL = f"{NETEASE_API_BASE}/search/get"
NETEASE_LYRIC_URL = f"{NETEASE_API_BASE}/song/lyric"
# 加密相关配置
MODULUS = '00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7'
NONCE = '0CoJUm6Qyw8W8jud'
PUBKEY = '010001'
IV = '0102030405060708'
class LyricLine:
def __init__(self, time, text):
self.time = time # 时间(秒)
self.text = text # 歌词文本
def load_lyrics_from_db(track_name, artist):
"""从数据库加载歌词"""
try:
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute('''
SELECT lyrics_content FROM lyrics WHERE track_name = ? AND artist = ?
''', (track_name, artist))
result = cursor.fetchone()
conn.close()
if result:
return result[0]
except Exception as e:
logger.error(f"加载数据库歌词时出错: {e}", exc_info=True)
return None
def parse_time(time_str):
"""解析时间标记 [mm:ss.xxx]"""
try:
# 移除方括号
time_str = time_str.strip('[]')
# 分离分钟和秒
minutes, seconds = time_str.split(':')
# 转换为秒
total_seconds = float(minutes) * 60 + float(seconds)
return total_seconds
except:
return 0
def format_lrc_lyrics(lyrics_text, current_position=None):
"""格式化 LRC 歌词,找出当前、下一句和下下句歌词"""
if not lyrics_text:
return None, None, None, None
# 解析歌词行
lyric_lines = []
for line in lyrics_text.split('\n'):
line = line.strip()
if not line:
continue
# 查找所有时间标记
time_tags = re.findall(r'\[([0-9:.]+)\]', line)
if not time_tags:
continue
text = re.sub(r'\[[0-9:.]+\]', '', line).strip()
if not text:
continue
# 一行可能有多个时间标记
for time_tag in time_tags:
try:
time_seconds = parse_time(time_tag)
lyric_lines.append({"time": time_seconds, "text": text})
except Exception as e:
logger.error(f"解析时间标记出错: {time_tag}, 错误: {e}")
# 按时间排序
lyric_lines.sort(key=lambda x: x["time"])
# 如果没有有效歌词,返回空
if not lyric_lines:
return None, None, None, None
if not current_position:
# 如果没有提供当前位置,返回前三句歌词
current = lyric_lines[0]["text"] if len(lyric_lines) > 0 else None
next_ = lyric_lines[1]["text"] if len(lyric_lines) > 1 else None
next_next = lyric_lines[2]["text"] if len(lyric_lines) > 2 else None
return current, next_, next_next, lyric_lines[0]["time"]
# 找出当前歌词
current_index = -1
for i, line in enumerate(lyric_lines):
if line["time"] > current_position:
if i > 0:
current_index = i - 1
break
# 处理当前位置超过最后一行歌词的情况
if current_index == -1 and lyric_lines:
# 如果遍历完所有歌词都没找到大于当前位置的,说明当前位置超过了最后一行歌词
if current_position >= lyric_lines[-1]["time"]:
# 返回最后三句歌词,将最后一句作为当前歌词
last_index = len(lyric_lines) - 1
if last_index >= 2:
return lyric_lines[last_index]["text"], None, None, lyric_lines[last_index]["time"]
elif last_index == 1:
return lyric_lines[1]["text"], None, None, lyric_lines[1]["time"]
else:
return lyric_lines[0]["text"], None, None, lyric_lines[0]["time"]
# 如果找到了当前歌词
if current_index >= 0:
current = lyric_lines[current_index]["text"]
next_ = lyric_lines[current_index + 1]["text"] if current_index + 1 < len(lyric_lines) else None
next_next = lyric_lines[current_index + 2]["text"] if current_index + 2 < len(lyric_lines) else None
return current, next_, next_next, lyric_lines[current_index]["time"]
# 如果当前位置在所有歌词之前
if lyric_lines:
return None, lyric_lines[0]["text"], lyric_lines[1]["text"] if len(lyric_lines) > 1 else None, lyric_lines[0]["time"]
return None, None, None, None
def get_lyrics_data(status):
"""获取当前歌词(先获取状态,再从缓存或存储中获取歌词)"""
global _lyrics_cache
# 如果没有播放,清空缓存并返回空
if status.get("status") not in ["playing", "paused"]:
clear_lyrics_cache()
return {
"current_lyric_time": None,
"current_lyric": None,
"next_lyric": None,
"next_next_lyric": None,
"track_name": None,
"artist_name": None
}
track_name = status.get("track_name")
artist = status.get("artist")
position = status.get("position", 0)
# 检查是否切换了歌曲
if _lyrics_cache["track_name"] != track_name or _lyrics_cache["artist"] != artist:
logger.debug(f"歌曲切换,重新获取歌词: {track_name} - {artist}")
# 清空缓存
_lyrics_cache["track_name"] = track_name
_lyrics_cache["artist"] = artist
_lyrics_cache["lyrics_text"] = None
_lyrics_cache["deleted"] = False
# 1. 先尝试从数据库获取
lyrics_text = load_lyrics_from_db(track_name, artist)
if lyrics_text:
_lyrics_cache["lyrics_text"] = lyrics_text
_lyrics_cache["source"] = "db"
# 2. 如果本地没有且不是被删除的歌词尝试从网易云音乐API获取歌词
elif not _lyrics_cache["lyrics_text"] and not _lyrics_cache.get("deleted", False):
lyrics_text = search_lyrics(track_name, artist)
if lyrics_text:
# 保存到数据库
save_lyrics(track_name, artist, lyrics_text)
_lyrics_cache["lyrics_text"] = lyrics_text
_lyrics_cache["source"] = "netease"
# 如果有缓存的歌词,直接解析
if _lyrics_cache["lyrics_text"]:
if _lyrics_cache.get("deleted", False):
return {
"current_lyric_time": None,
"current_lyric": None,
"next_lyric": None,
"next_next_lyric": None,
}
# 根据播放位置解析当前歌词
current, next_, next_next, current_time = format_lrc_lyrics(_lyrics_cache["lyrics_text"], position)
if current or next_ or next_next:
return {
"current_lyric_time": current_time,
"current_lyric": current,
"next_lyric": next_,
"next_next_lyric": next_next,
"track_name": track_name,
"artist_name": artist,
"lyrics_source": _lyrics_cache["source"] # 标记歌词来源
}
# 都没找到返回空
return {
"current_lyric_time": None,
"current_lyric": None,
"next_lyric": None,
"next_next_lyric": None,
"track_name": track_name,
"artist_name": artist,
"lyrics_source": "none" # 标记歌词来源
}
def create_secret_key(size):
"""生成随机密钥"""
return ''.join(random.choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') for _ in range(size))
def aes_encrypt(text, key):
"""AES加密"""
pad = 16 - len(text) % 16
text = text + chr(pad) * pad
encryptor = AES.new(key.encode('utf-8'), AES.MODE_CBC, IV.encode('utf-8'))
encrypt_text = encryptor.encrypt(text.encode('utf-8'))
encrypt_text = base64.b64encode(encrypt_text).decode('utf-8')
return encrypt_text
def rsa_encrypt(text, pubkey, modulus):
"""RSA加密"""
text = text[::-1]
rs = pow(int(codecs.encode(text.encode('utf-8'), 'hex'), 16), int(pubkey, 16), int(modulus, 16))
return format(rs, 'x').zfill(256)
headers = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36',
'Referer': 'https://music.163.com/',
'Origin': 'https://music.163.com',
'Content-Type': 'application/x-www-form-urlencoded'
}
def encrypt_request(text):
"""加密请求数据"""
secret_key = create_secret_key(16)
params = aes_encrypt(text, NONCE)
params = aes_encrypt(params, secret_key)
encSecKey = rsa_encrypt(secret_key, PUBKEY, MODULUS)
return {
'params': params,
'encSecKey': encSecKey
}
def get_lyrics_from_netease(track_name, artist_name):
# 构建搜索关键词
search_keyword = f"{track_name} {artist_name}"
# 搜索歌曲
data = {
's': search_keyword,
'type': 1, # 1: 单曲, 10: 专辑, 100: 歌手, 1000: 歌单
'limit': 5,
'offset': 0,
'hlpretag': '<span class="s-fc7">',
'hlposttag': '</span>',
'total': True,
'csrf_token': ''
}
encrypted_data = encrypt_request(json.dumps(data))
response = requests.post(NETEASE_SEARCH_URL, data=encrypted_data, headers=headers)
logger.debug(f"网易云音乐搜索响应: {response.text}")
response.raise_for_status()
result = response.json()
return result
def get_lyrics_from_id(id):
"""从网易云音乐获取歌词"""
# 获取歌词
data = {
'id': id,
'lv': 1, # 获取歌词
'kv': 1, # 获取翻译
'tv': -1, # 不获取罗马音
'csrf_token': ''
}
encrypted_data = encrypt_request(json.dumps(data))
response = requests.post(NETEASE_LYRIC_URL, data=encrypted_data, headers=headers)
response.raise_for_status()
result = response.json()
return result
def search_lyrics(track_name, artist_name):
"""从网易云音乐搜索并获取歌词"""
try:
result = get_lyrics_from_netease(track_name, artist_name)
if result['code'] == 200 and result['result']['songCount'] > 0:
song = result['result']['songs'][0]
id = song['id']
lrc_result = get_lyrics_from_id(id)
if lrc_result['code'] == 200 and 'lrc' in lrc_result:
logger.debug(f"网易云音乐歌词: {lrc_result['lrc']['lyric']}")
return lrc_result['lrc']['lyric']
return None
except Exception as e:
logger.error(f"从网易云音乐获取歌词时出错: {e}", exc_info=True)
return None
def format_lyrics(lyrics):
"""格式化歌词文本,保留时间信息"""
if not lyrics:
logger.debug("歌词内容为空")
return []
lyric_lines = []
for line in lyrics.split('\n'):
line = line.strip()
if not line:
continue
# 查找所有时间标记
time_tags = re.findall(r'\[([0-9:.]+)\]', line)
if not time_tags:
continue
text = re.sub(r'\[[0-9:.]+\]', '', line).strip()
if not text:
continue
# 可能一行有多个时间标记
for time_tag in time_tags:
try:
time_seconds = parse_time(time_tag)
lyric_lines.append(LyricLine(time_seconds, text))
except Exception as e:
logger.error(f"解析时间标记出错: {time_tag}, 错误: {e}")
# 按时间排序
sorted_lyrics = sorted(lyric_lines, key=lambda x: x.time)
logger.debug(f"解析完成,共 {len(sorted_lyrics)} 行歌词")
return sorted_lyrics
def save_lyrics(track_name, artist, lyrics):
"""保存歌词到本地文件,如果已存在则更新"""
try:
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute('''
INSERT INTO lyrics (track_name, artist, lyrics_content)
VALUES (?, ?, ?)
ON CONFLICT(track_name, artist)
DO UPDATE SET
lyrics_content = excluded.lyrics_content,
created_at = CURRENT_TIMESTAMP
''', (track_name, artist, lyrics))
conn.commit()
conn.close()
logger.debug(f"歌词已保存/更新到数据库: {track_name} - {artist}")
except Exception as e:
logger.error(f"保存歌词时出错: {e}", exc_info=True)
def delete_lyrics(track_name, artist):
"""从数据库中删除歌词"""
try:
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute('''
DELETE FROM lyrics WHERE track_name = ? AND artist = ?
''', (track_name, artist))
conn.commit()
deleted = cursor.rowcount > 0
conn.close()
if deleted:
logger.info(f"已删除歌词: {track_name} - {artist}")
# 标记这首歌的歌词已被删除,避免重新获取
_lyrics_cache["deleted"] = True
return True
else:
logger.warning(f"未找到要删除的歌词: {track_name} - {artist}")
return False
except Exception as e:
logger.error(f"删除歌词时出错: {e}", exc_info=True)
return False

205
backend/modules/routes.py Normal file
View File

@@ -0,0 +1,205 @@
"""
路由模块
包含所有API端点的定义和处理逻辑
"""
import asyncio
import time
import logging
from fastapi import WebSocket, WebSocketDisconnect
from pydantic import BaseModel
from .apple_music import get_music_status, control_music
from .lyrics import get_lyrics_data, delete_lyrics, get_lyrics_from_netease, get_lyrics_from_id, save_lyrics, clear_lyrics_cache
from .websocket_manager import ConnectionManager
logger = logging.getLogger(__name__)
# 音乐控制请求模型
class ControlRequest(BaseModel):
action: str # 'playpause', 'previous', 'next', 'seek'
position: float = None # 仅当 action=='seek' 时有效
# 手动搜索歌词请求模型
class SearchLyricsRequest(BaseModel):
track_name: str
artist: str
# 获取指定ID的歌词请求模型
class GetLyricsFromIdRequest(BaseModel):
id: int
track_name: str
artist: str
def register_routes(app, manager: ConnectionManager):
"""注册所有API路由到FastAPI应用"""
# 保留向下兼容的HTTP接口
@app.get("/status")
def status_endpoint():
"""获取 Apple Music 播放状态"""
return get_music_status()
@app.get("/lyrics")
def lyrics_endpoint():
"""获取当前歌词"""
status = get_music_status()
return get_lyrics_data(status)
@app.delete("/lyrics")
def delete_lyrics_endpoint(track_name: str, artist: str):
"""删除指定歌曲的歌词"""
success = delete_lyrics(track_name, artist)
if success:
return {"status": "success", "message": "歌词已删除"}
else:
return {"status": "error", "message": "未找到要删除的歌词"}
@app.post("/lyrics/search")
def search_lyrics_endpoint(request: SearchLyricsRequest):
"""手动搜索歌词"""
try:
# 搜索歌词
result = get_lyrics_from_netease(request.track_name, request.artist)
if result['code'] == 200 and result['result']['songCount'] > 0:
songs = result['result']['songs']
return {
"status": "success",
"songs": songs
}
else:
return {
"status": "error",
"message": "未找到歌词"
}
except Exception as e:
logger.error(f"搜索歌词时出错: {e}", exc_info=True)
return {
"status": "error",
"message": f"搜索歌词时出错: {str(e)}"
}
@app.post("/lyrics/getLyricsFromId")
def get_lyrics_from_id_endpoint(request: GetLyricsFromIdRequest):
"""获取指定ID的歌词"""
result = get_lyrics_from_id(request.id)
if result['code'] == 200 and 'lrc' in result:
logger.debug(f"网易云音乐歌词: {result['lrc']['lyric']}")
lyrics_text = result['lrc']['lyric']
# 保存到数据库
save_lyrics(request.track_name, request.artist, lyrics_text)
clear_lyrics_cache()
return {
"status": "success",
}
# 添加播放控制接口
@app.post("/control")
async def control_endpoint(request: ControlRequest):
"""控制Apple Music播放"""
result = control_music(request.action, request.position)
# 如果控制成功广播更新的状态给所有WebSocket客户端
if result["status"] == "success":
# 延迟一下让Apple Music有时间执行命令
await asyncio.sleep(0.1)
status = get_music_status()
lyrics = get_lyrics_data(status)
combined_data = {
"type": "position_update", # 添加消息类型
"timestamp": time.time(),
"status": status,
"lyrics": lyrics
}
# 异步广播更新
asyncio.create_task(manager.broadcast(combined_data))
return result
# WebSocket端点
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
"""WebSocket连接处理合并推送歌词和状态"""
await manager.connect(websocket)
try:
# 连接建立后立即发送一次数据
status = get_music_status()
lyrics = get_lyrics_data(status)
logger.info(f"发送初始状态数据: 当前歌曲={status.get('track_name', '')}, 歌词可用={lyrics is not None}")
combined_data = {
"type": "track_change", # 确保添加消息类型
"timestamp": time.time(),
"status": status,
"lyrics": lyrics
}
await websocket.send_json(combined_data)
# 发送欢迎消息
await websocket.send_json({"type": "info", "message": "WebSocket连接已建立"})
# 连接状态标志,用于安全终止循环
is_connected = True
# 持续接收并处理客户端消息
while is_connected:
try:
# 接收客户端消息,设置超时避免无限阻塞
data = await asyncio.wait_for(websocket.receive_json(), timeout=1.0)
logger.info(f"收到WebSocket消息: {data}")
# 处理不同类型的消息
if isinstance(data, dict):
# 处理请求状态消息
if data.get("type") == "request_status":
logger.info("客户端请求状态更新")
status = get_music_status()
lyrics = get_lyrics_data(status)
update_data = {
"type": "track_change", # 确保添加消息类型
"timestamp": time.time(),
"status": status,
"lyrics": lyrics
}
await websocket.send_json(update_data)
# 处理ping消息
elif data.get("type") == "ping":
logger.debug("收到客户端ping")
await websocket.send_json({
"type": "pong",
"timestamp": time.time()
})
except WebSocketDisconnect:
# 连接已断开,退出循环
logger.info("WebSocket连接断开")
is_connected = False
break
except asyncio.TimeoutError:
# 接收超时,但连接可能仍然有效,继续循环
continue
except Exception as e:
# 其他错误,记录后继续(除非是连接断开)
if "disconnect" in str(e).lower() or "not connected" in str(e).lower():
logger.info(f"WebSocket连接可能已断开: {e}")
is_connected = False
break
else:
logger.error(f"处理WebSocket消息时出错: {e}")
# 继续监听下一条消息,不断开连接
continue
except WebSocketDisconnect:
logger.info("WebSocket连接断开 (外层异常)")
except Exception as e:
logger.error(f"WebSocket错误: {e}")
finally:
# 确保连接从管理器中移除
await manager.disconnect(websocket)
logger.info("WebSocket连接已清理")

View File

@@ -0,0 +1,53 @@
"""
WebSocket连接管理模块
负责管理WebSocket连接和广播消息
"""
import asyncio
import logging
from typing import List
from fastapi import WebSocket
logger = logging.getLogger(__name__)
class ConnectionManager:
def __init__(self):
self.active_connections: List[WebSocket] = []
self._lock = asyncio.Lock() # 添加锁以保护连接列表
async def connect(self, websocket: WebSocket):
await websocket.accept()
async with self._lock:
# 检查连接是否已经存在,如果存在则不重复添加
if websocket not in self.active_connections:
self.active_connections.append(websocket)
logger.info(f"WebSocket客户端连接当前连接数: {len(self.active_connections)}")
async def disconnect(self, websocket: WebSocket):
async with self._lock:
# 安全移除连接,如果不存在则忽略
if websocket in self.active_connections:
self.active_connections.remove(websocket)
logger.info(f"WebSocket客户端断开当前连接数: {len(self.active_connections)}")
async def broadcast(self, data):
"""向所有连接的客户端广播数据"""
async with self._lock:
# 复制列表以避免在迭代时修改
connections = self.active_connections.copy()
# 在锁外处理发送操作
disconnect_list = []
for connection in connections:
try:
await connection.send_json(data)
except Exception as e:
logger.error(f"发送消息失败: {e}")
disconnect_list.append(connection)
# 移除断开的连接
if disconnect_list:
async with self._lock:
for conn in disconnect_list:
if conn in self.active_connections:
self.active_connections.remove(conn)
logger.info(f"移除断开的连接,剩余连接数: {len(self.active_connections)}")

0
backend/pyproject.toml Normal file
View File

6
backend/requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
fastapi>=0.95.0
uvicorn[standard]>=0.22.0
websockets>=10.4
pydantic>=1.10.7
requests>=2.31.0
pycryptodome>=3.22.0

85
build.sh Executable file
View File

@@ -0,0 +1,85 @@
#!/bin/bash
###
# @Date: 2025-04-25 13:45:35
# @LastEditors: 陈子健
# @LastEditTime: 2025-05-26 17:14:22
# @FilePath: /mac-lyric-vue/build.sh
###
# lyroc 打包脚本
# 该脚本将前后端整合并打包为Electron应用
# 显示彩色输出
GREEN='\033[0;32m'
BLUE='\033[0;34m'
RED='\033[0;31m'
NC='\033[0m' # No Color
echo -e "${BLUE}开始构建 lyroc 应用...${NC}"
# 检查基本命令是否存在
command -v node >/dev/null 2>&1 || { echo -e "${RED}错误: node 未安装${NC}" >&2; exit 1; }
command -v npm >/dev/null 2>&1 || { echo -e "${RED}错误: npm 未安装${NC}" >&2; exit 1; }
command -v python3 >/dev/null 2>&1 || { echo -e "${RED}错误: python3 未安装${NC}" >&2; exit 1; }
# 项目根目录
ROOT_DIR="$(pwd)"
FRONTEND_DIR="$ROOT_DIR/frontend"
BACKEND_DIR="$ROOT_DIR/backend"
ELECTRON_DIR="$ROOT_DIR/electron-app"
# 1. 构建前端
echo -e "${GREEN}[1/4] 构建前端...${NC}"
cd "$FRONTEND_DIR" || exit 1
npm install
npm run build
if [ $? -ne 0 ]; then
echo -e "${RED}前端构建失败${NC}"
exit 1
fi
echo -e "${GREEN}前端构建成功${NC}"
# 2. 确认后端依赖已安装
echo -e "${GREEN}[2/4] 配置后端...${NC}"
cd "$BACKEND_DIR" || exit 1
# 创建虚拟环境并安装依赖
python3 -m pip install --upgrade pip
python3 -m pip install -r requirements.txt
if [ $? -ne 0 ]; then
echo -e "${RED}后端依赖安装失败${NC}"
exit 1
fi
echo -e "${GREEN}后端配置完成${NC}"
# 3. 配置Electron应用
echo -e "${GREEN}[3/4] 配置Electron应用...${NC}"
cd "$ELECTRON_DIR" || exit 1
# npm install
# if [ $? -ne 0 ]; then
# echo -e "${RED}Electron依赖安装失败${NC}"
# exit 1
# fi
echo -e "${GREEN}Electron配置完成${NC}"
# 4. 创建图标目录
echo -e "${GREEN}[4/4] 准备应用图标...${NC}"
mkdir -p "$ELECTRON_DIR/build"
# 如果没有图标,使用默认图标或生成一个简单图标
if [ ! -f "$ELECTRON_DIR/build/icon.icns" ]; then
echo -e "${BLUE}使用默认图标${NC}"
# 这里可以复制一个默认图标或使用命令生成简单图标
fi
# 5. 打包应用
echo -e "${GREEN}开始打包Electron应用...${NC}"
cd "$ELECTRON_DIR" || exit 1
npm run build
if [ $? -ne 0 ]; then
echo -e "${RED}Electron打包失败${NC}"
exit 1
fi
echo -e "${GREEN}应用打包成功!${NC}"
echo -e "${BLUE}应用位于: ${ELECTRON_DIR}/dist${NC}"

104
electron-app/.gitignore vendored Normal file
View File

@@ -0,0 +1,104 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# node-waf configuration
.lock-wscript
# Dependency directories
node_modules/
jspm_packages/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# Build outputs
dist/
out/
build/
# macOS specific
.DS_Store
.AppleDouble
.LSOverride
._*
# Thumbnails
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
# Temporary files
*.tmp
*.swp
*.swo
# VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Electron specific
.electron-builder.env
dist_electron/
# Python
__pycache__/
*.py[cod]
*$py.class
.pytest_cache/
.coverage
htmlcov/
# Virtual Environment
.venv/
venv/
ENV/
# Local dev configurations
config.local.js

254
electron-app/loading.html Normal file
View File

@@ -0,0 +1,254 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>lyroc 启动中...</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
background: linear-gradient(135deg, #1e1e28, #2d2d3a);
color: #fff;
overflow: hidden;
user-select: none;
border-radius: 12px;
opacity: 1;
transition: opacity 0.8s ease-out;
}
body.fade-out {
opacity: 0;
}
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
max-width: 280px;
}
.logo {
width: 80px;
height: 80px;
margin-bottom: 20px;
animation: pulse 2s infinite;
}
.title {
font-size: 18px;
margin-bottom: 30px;
font-weight: 300;
letter-spacing: 1px;
}
.progress-container {
width: 100%;
height: 6px;
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
overflow: hidden;
margin-bottom: 8px;
}
.progress-bar {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #00c6fb 0%, #005bea 100%);
border-radius: 3px;
transition: width 0.4s ease;
}
.progress-info {
display: flex;
width: 100%;
justify-content: space-between;
margin-bottom: 5px;
align-items: center;
}
.progress-percentage {
font-size: 16px;
color: rgba(255, 255, 255, 0.9);
font-weight: 600;
font-variant-numeric: tabular-nums;
min-width: 50px;
text-align: center;
}
.status {
font-size: 12px;
color: rgba(255, 255, 255, 0.6);
margin-top: 10px;
min-height: 16px;
}
.progress-label {
font-size: 16px;
color: rgba(255, 255, 255, 0.9);
font-weight: 600;
font-variant-numeric: tabular-nums;
min-width: 50px;
text-align: left;
margin-right: 10px;
}
@keyframes pulse {
0% { opacity: 0.8; transform: scale(0.95); }
50% { opacity: 1; transform: scale(1); }
100% { opacity: 0.8; transform: scale(0.95); }
}
.logo-placeholder {
width: 80px;
height: 80px;
border-radius: 50%;
background: linear-gradient(135deg, #00c6fb, #005bea);
margin-bottom: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 30px;
font-weight: bold;
color: white;
text-shadow: 1px 1px 3px rgba(0,0,0,0.3);
animation: pulse 2s infinite;
}
</style>
</head>
<body>
<div class="container">
<div class="logo-placeholder">lyroc</div>
<h1 class="title" id="loading-title">lyroc 启动中</h1>
<div class="progress-info">
<div class="progress-label" id="progress-label">进度</div>
<span class="progress-percentage" id="progress-percentage">0%</span>
</div>
<div class="progress-container">
<div class="progress-bar" id="progress"></div>
</div>
<div class="status" id="status">正在初始化应用...</div>
</div>
<script>
// 监听来自主进程的消息
const { ipcRenderer } = require('electron');
// 获取DOM元素
const progressBar = document.getElementById('progress');
const statusText = document.getElementById('status');
const progressPercentageText = document.getElementById('progress-percentage');
const loadingTitle = document.getElementById('loading-title');
const progressLabel = document.getElementById('progress-label');
// 设置初始进度
let currentProgress = 0;
// 语言配置
const translations = {
en: {
title: 'lyroc Starting',
progress: 'Progress',
initializing: 'Initializing application...',
ready: 'Ready'
},
zh: {
title: 'lyroc 启动中',
progress: '进度',
initializing: '正在初始化应用...',
ready: '准备就绪'
}
};
// 设置语言
function setLanguage(lang) {
const t = translations[lang] || translations.en;
loadingTitle.textContent = t.title;
progressLabel.textContent = t.progress;
if (currentProgress === 0) {
statusText.textContent = t.initializing;
}
}
// 监听语言切换事件
ipcRenderer.on('change-language', (_, lang) => {
console.log('加载窗口收到语言切换事件:', lang);
setLanguage(lang);
});
// 监听进度更新事件
ipcRenderer.on('update-progress', (event, data) => {
// 更新进度条
currentProgress = data.progress;
progressBar.style.width = `${currentProgress}%`;
progressPercentageText.textContent = `${Math.round(currentProgress)}%`;
// 更新状态文本
if (data.message) {
statusText.textContent = data.message;
}
// 如果进度达到100%,添加淡出效果
if (currentProgress >= 100) {
// 清除自动增长定时器
clearInterval(autoIncreaseInterval);
// 延迟一会儿后添加淡出效果
setTimeout(() => {
document.body.classList.add('fade-out');
// 通知主进程可以关闭窗口了
setTimeout(() => {
ipcRenderer.send('loading-finished');
}, 800); // 与CSS中的淡出时间一致
}, 1000);
}
});
// 动画平滑过渡到目标进度
function animateProgress(target, duration) {
const start = currentProgress;
const startTime = performance.now();
function updateFrame(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const current = start + (target - start) * progress;
progressBar.style.width = `${current}%`;
progressPercentageText.textContent = `${Math.round(current)}%`;
if (progress < 1) {
requestAnimationFrame(updateFrame);
}
}
requestAnimationFrame(updateFrame);
}
// 自动增加一点进度,给用户感觉应用正在加载
let autoIncreaseInterval = setInterval(() => {
if (currentProgress < 90) {
const increment = Math.random() * 0.5;
currentProgress += increment;
progressBar.style.width = `${currentProgress}%`;
progressPercentageText.textContent = `${Math.round(currentProgress)}%`;
}
}, 400);
// 页面加载完成时,设置初始进度
window.addEventListener('DOMContentLoaded', () => {
progressBar.style.width = '10%';
progressPercentageText.textContent = '10%';
currentProgress = 10;
});
</script>
</body>
</html>

217
electron-app/main.js Normal file
View File

@@ -0,0 +1,217 @@
/**
* lyroc 主程序文件
* 使用模块化结构,只负责应用的生命周期管理和组件集成
*/
const { app, protocol, ipcMain } = require('electron');
const path = require('path');
const fs = require('fs');
const isDev = require('electron-is-dev');
// 导入自定义模块
const windowManager = require('./modules/window-manager');
const backendService = require('./modules/backend-service');
const logger = require('./modules/logger');
const searchWindowManager = require('./windows/search-window');
// 应用全局状态
global.appReady = false;
/**
* 初始化应用
*/
async function initApp() {
try {
console.log('初始化应用...');
// 确保只有一个实例在运行
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
console.log('另一个实例已经在运行');
app.quit();
return;
}
// 创建加载窗口
const loadingWindow = windowManager.createLoadingWindow();
windowManager.updateLoadingProgress(10, 'initializing');
// 注册文件协议处理器
protocol.registerFileProtocol('file', (request, callback) => {
const url = request.url.replace('file://', '');
try {
return callback(decodeURIComponent(url));
} catch (error) {
console.error('协议处理器错误:', error);
}
});
// 检查系统环境
console.log('运行模式:', isDev ? '开发' : '生产');
// 清理可能运行的Python进程
await backendService.cleanupExistingProcesses();
// 检查端口可用性
await backendService.checkPort();
windowManager.updateLoadingProgress(30, 'allocatingPorts');
// 获取前端随机端口
await backendService.getFrontendPort();
// 启动Python后端
windowManager.updateLoadingProgress(40, 'loadingBackend');
await backendService.startPythonBackend(isDev);
// 更新加载进度
windowManager.updateLoadingProgress(60, 'loadingFrontend');
// 初始化窗口配置
windowManager.initConfig(app.getPath('userData'));
// 创建窗口
windowManager.updateLoadingProgress(70, 'connecting');
const mainWindow = windowManager.createWindow(
null,
backendService.getFrontendPortNumber(),
backendService.getBackendPort(),
isDev
);
// 更新加载进度
windowManager.updateLoadingProgress(80, 'connecting');
// 设置窗口事件
windowManager.setupWindowEvents();
// 设置IPC通信
windowManager.setupIPC(
backendService.getBackendPort(),
backendService.getFrontendPortNumber()
);
// 处理搜索窗口相关事件
ipcMain.on('open-search-window', () => {
console.log('收到打开搜索窗口请求');
searchWindowManager.createWindow();
});
ipcMain.on('close-search-window', () => {
console.log('收到关闭搜索窗口请求');
searchWindowManager.closeWindow();
});
// 处理发送数据到搜索窗口的请求
ipcMain.on('send-to-search-window', (_, data) => {
console.log('收到发送到搜索窗口的数据:', data);
searchWindowManager.receiveMainWindowData(data);
});
// 处理搜索窗口请求主窗口数据
ipcMain.on('request-main-window-data', () => {
console.log('收到搜索窗口数据请求');
// 获取主窗口数据并发送
const mainWindow = windowManager.mainWindow();
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('get-main-window-data');
}
});
// 更新加载进度
windowManager.updateLoadingProgress(90, 'loadingLyrics');
// 监听主窗口内容加载完成事件
mainWindow.webContents.once('did-finish-load', () => {
windowManager.updateLoadingProgress(100, 'ready');
// 短暂延迟后关闭加载窗口
setTimeout(() => {
// 不需要在这里显示主窗口,动画结束时会自动显示
windowManager.closeLoadingWindow();
}, 500);
// 设置应用就绪标志
global.appReady = true;
});
} catch (err) {
console.error('应用初始化失败:', err);
app.quit();
}
}
// Electron 应用就绪时
app.on('ready', () => {
// 设置日志记录
const logPath = logger.setupLogging(app.getPath('userData'));
// 添加日志以显示Electron路径
console.log('Electron应用已准备就绪');
console.log('应用路径:', app.getAppPath());
console.log('__dirname:', __dirname);
console.log('用户数据路径:', app.getPath('userData'));
console.log('日志文件路径:', logPath);
// 检查资源路径
const resourcePath = isDev
? path.join(__dirname, '..', 'backend')
: path.join(process.resourcesPath, 'backend');
console.log('后端资源路径:', resourcePath);
console.log('该路径存在:', fs.existsSync(resourcePath));
// 隐藏dock栏图标
if (process.platform === 'darwin' && app.dock) {
app.dock.hide();
console.log('已隐藏Dock图标');
}
// 启动应用初始化
initApp()
.then(() => {
console.log('应用初始化完成');
})
.catch(err => {
console.error('初始化应用失败:', err);
});
});
// 所有窗口关闭时退出应用
app.on('window-all-closed', function() {
if (process.platform !== 'darwin') {
app.quit();
}
});
// 应用激活时
app.on('activate', function() {
if (!windowManager.mainWindow()) {
// 初始化窗口配置
windowManager.initConfig(app.getPath('userData'));
// 重新创建窗口
const mainWindow = windowManager.createWindow(
null,
backendService.getFrontendPortNumber(),
backendService.getBackendPort(),
isDev
);
// 设置窗口事件
windowManager.setupWindowEvents();
}
});
// 应用退出前
app.on('before-quit', () => {
// 清理Python进程
backendService.stopPythonBackend();
});
// 监听第二实例启动事件
app.on('second-instance', (event, commandLine, workingDirectory) => {
// 当尝试启动第二个实例时,让主窗口获得焦点
const mainWindow = windowManager.mainWindow();
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
console.log('已有实例在运行,已将其窗口聚焦');
}
});

View File

@@ -0,0 +1,228 @@
/**
* 后端服务管理模块
* 负责启动、管理和监控Python后端服务
*/
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
const findProcess = require('find-process');
const getPort = require('get-port');
// 存储Python进程引用
let pythonProcess = null;
let backendPort = 5000;
let frontendPort = 5173;
/**
* 获取可用的后端端口
* @returns {Promise<number>} 可用端口号
*/
async function checkPort() {
try {
// 获取可用端口首选5000
backendPort = await getPort({ port: 5000 });
console.log(`后端将使用端口: ${backendPort}`);
return backendPort;
} catch (err) {
console.error('获取可用端口失败:', err);
throw err;
}
}
/**
* 获取可用的前端端口
* @returns {Promise<number>} 可用端口号
*/
async function getFrontendPort() {
try {
// 获取完全随机的可用端口,不指定首选端口
frontendPort = await getPort();
console.log(`前端将使用随机端口: ${frontendPort}`);
return frontendPort;
} catch (err) {
console.error('获取前端端口失败:', err);
throw err;
}
}
/**
* 清理已存在的Python进程
* @returns {Promise<void>}
*/
async function cleanupExistingProcesses() {
try {
const processList = await findProcess('port', 5000);
for (const proc of processList) {
if (proc.name.includes('python')) {
console.log(`杀死已存在的Python进程: PID ${proc.pid}`);
process.kill(proc.pid, 'SIGKILL');
}
}
} catch (err) {
console.error('清理进程失败:', err);
}
}
/**
* 启动Python后端服务
* @param {boolean} isDev 是否为开发模式
* @returns {Promise<void>}
*/
async function startPythonBackend(isDev) {
try {
// 在开发模式和生产模式下使用不同的路径
let pythonPath;
let scriptPath;
if (isDev) {
// 开发模式:使用项目中的虚拟环境
const venvPath = path.join(__dirname, '..', '..', 'backend', 'venv');
pythonPath = process.platform === 'win32'
? path.join(venvPath, 'Scripts', 'python.exe')
: path.join(venvPath, 'bin', 'python3');
scriptPath = path.join(__dirname, '..', '..', 'backend', 'main.py');
} else {
// 生产模式:使用打包的虚拟环境
const venvPath = path.join(process.resourcesPath, 'backend', 'venv');
pythonPath = process.platform === 'win32'
? path.join(venvPath, 'Scripts', 'python.exe')
: path.join(venvPath, 'bin', 'python3');
scriptPath = path.join(process.resourcesPath, 'backend', 'main.py');
}
console.log(`启动Python后端: ${scriptPath} 在端口 ${backendPort}`);
console.log(`后端脚本路径存在: ${fs.existsSync(scriptPath)}`);
console.log(`Python解释器路径: ${pythonPath}`);
// 检查Python解释器是否存在
if (!fs.existsSync(pythonPath)) {
throw new Error(`Python解释器不存在: ${pythonPath}`);
}
// 打印Python脚本目录内容
try {
const scriptDir = path.dirname(scriptPath);
console.log(`后端目录内容: ${fs.readdirSync(scriptDir).join(', ')}`);
} catch (err) {
console.error(`无法读取后端目录: ${err}`);
}
// 启动子进程
pythonProcess = spawn(pythonPath, [scriptPath], {
env: { ...process.env, PORT: backendPort.toString() },
stdio: 'pipe' // 确保可以读取标准输出和错误
});
// 输出Python进程的日志
pythonProcess.stdout.on('data', (data) => {
const output = data.toString().trim();
console.log(`Python后端输出: ${output}`);
});
pythonProcess.stderr.on('data', (data) => {
const message = data.toString().trim();
// 判断是否是错误日志还是普通日志
if (message.includes('ERROR') || message.includes('CRITICAL') || message.includes('WARN')) {
console.error(`Python后端错误: ${message}`);
} else {
console.log(`Python后端日志: ${message}`);
}
});
pythonProcess.on('close', (code) => {
console.log(`Python后端退出退出码: ${code}`);
// 如果应用仍在运行,尝试重启后端
if (global.appReady && code !== 0) {
console.log('尝试重启Python后端...');
setTimeout(() => startPythonBackend(isDev), 1000);
}
});
// 等待后端启动 - 改为检测后端是否真正启动完成而非固定等待时间
return new Promise((resolve, reject) => {
// 后端启动超时时间,开发模式较短,生产模式较长
const maxTimeout = isDev ? 15000 : 30000;
const startTime = Date.now();
let backendReady = false;
// 添加额外的日志解析以检测后端就绪状态
pythonProcess.stdout.on('data', (data) => {
const output = data.toString().trim();
console.log(`Python后端输出: ${output}`);
// 检测后端就绪信号uvicorn输出"Application startup complete"表示应用已启动)
if (output.includes('Application startup complete') || output.includes('INFO: Application startup complete')) {
console.log('检测到后端应用启动完成信号');
backendReady = true;
// 再等待短暂时间确保所有服务都初始化完成
setTimeout(() => {
console.log('后端已完全启动,准备创建前端窗口');
resolve();
}, 500);
}
});
// 实现HTTP测试以检测后端是否正常响应
const testBackendConnection = () => {
const http = require('http');
const testUrl = `http://127.0.0.1:${backendPort}/`;
// 如果已经检测到后端就绪,不再继续测试
if (backendReady) return;
// 检查是否超时
if (Date.now() - startTime > maxTimeout) {
console.warn(`后端启动超时(${maxTimeout}ms),将继续尝试启动前端`);
resolve();
return;
}
http.get(testUrl, (res) => {
if (res.statusCode === 200 || res.statusCode === 404) {
// 404也表示服务器在运行只是路径不存在
console.log('通过HTTP检测确认后端已启动');
backendReady = true;
resolve();
} else {
console.log(`后端响应状态码: ${res.statusCode},继续等待...`);
// 短时间后再次测试
setTimeout(testBackendConnection, 1000);
}
}).on('error', (err) => {
console.log(`后端连接测试失败: ${err.message}, 继续等待...`);
// 短时间后再次测试
setTimeout(testBackendConnection, 1000);
});
};
// 启动后端连接测试
setTimeout(testBackendConnection, 1000);
});
} catch (err) {
console.error('启动Python后端失败:', err);
throw err;
}
}
/**
* 停止Python后端
*/
function stopPythonBackend() {
if (pythonProcess) {
console.log('终止Python后端进程...');
pythonProcess.kill();
pythonProcess = null;
}
}
module.exports = {
checkPort,
getFrontendPort,
cleanupExistingProcesses,
startPythonBackend,
stopPythonBackend,
getBackendPort: () => backendPort,
getFrontendPortNumber: () => frontendPort,
getPythonProcess: () => pythonProcess
};

View File

@@ -0,0 +1,76 @@
/*
* @Date: 2025-05-23 13:26:08
* @LastEditors: 陈子健
* @LastEditTime: 2025-05-26 17:14:59
* @FilePath: /mac-lyric-vue/electron-app/modules/i18n.js
*/
const i18next = require('i18next')
const LanguageDetector = require('i18next-browser-languagedetector')
const resources = {
en: {
translation: {
app: {
title: 'lyroc',
settings: 'Settings',
language: 'Language',
theme: 'Theme',
about: 'About'
},
menu: {
file: 'File',
edit: 'Edit',
view: 'View',
help: 'Help',
lockWindow: 'Lock Window',
refresh: 'Refresh',
openDevTools: 'Open DevTools',
language: 'Language',
chinese: 'Chinese',
english: 'English',
deleteLyrics: 'Delete Local Lyrics',
searchLyrics: 'Search Lyrics',
quit: 'Quit'
}
}
},
zh: {
translation: {
app: {
title: 'Mac歌词',
settings: '设置',
language: '语言',
theme: '主题',
about: '关于'
},
menu: {
file: '文件',
edit: '编辑',
view: '视图',
help: '帮助',
lockWindow: '锁定窗口',
refresh: '刷新',
openDevTools: '打开调试工具',
language: '切换语言',
chinese: '中文',
english: '英文',
deleteLyrics: '删除本地歌词',
searchLyrics: '搜索歌词',
quit: '退出'
}
}
}
}
i18next
.use(LanguageDetector)
.init({
resources,
fallbackLng: 'en',
detection: {
order: ['navigator', 'htmlTag'],
caches: ['localStorage']
}
})
module.exports = i18next

View File

@@ -0,0 +1,73 @@
/**
* 日志记录模块
* 负责应用日志记录和输出重定向
*/
const path = require('path');
const fs = require('fs');
/**
* 设置应用日志系统
* @param {string} userDataPath 用户数据目录路径
* @returns {string} 日志文件路径
*/
function setupLogging(userDataPath) {
const logPath = path.join(
userDataPath,
'logs',
`app-${new Date().toISOString().replace(/:/g, '-')}.log`
);
// 确保日志目录存在
const logDir = path.dirname(logPath);
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
}
console.log(`日志文件将保存在: ${logPath}`);
// 创建日志文件写入流
const logStream = fs.createWriteStream(logPath, { flags: 'a' });
// 重定向console输出到文件
const originalConsoleLog = console.log;
const originalConsoleError = console.error;
console.log = function(...args) {
const message = args.map(arg =>
typeof arg === 'object' ? JSON.stringify(arg) : arg
).join(' ');
const timestamp = new Date().toISOString();
const logMessage = `[${timestamp}] [INFO] ${message}\n`;
logStream.write(logMessage);
originalConsoleLog.apply(console, args);
};
console.error = function(...args) {
const message = args.map(arg =>
typeof arg === 'object' ? JSON.stringify(arg) : arg
).join(' ');
const timestamp = new Date().toISOString();
const logMessage = `[${timestamp}] [ERROR] ${message}\n`;
logStream.write(logMessage);
originalConsoleError.apply(console, args);
};
// 记录未捕获的异常
process.on('uncaughtException', (error) => {
console.error('未捕获的异常:', error);
});
process.on('unhandledRejection', (reason, promise) => {
console.error('未处理的Promise拒绝:', reason);
});
return logPath;
}
module.exports = {
setupLogging
};

View File

@@ -0,0 +1,875 @@
/**
* 窗口管理模块
* 负责创建、配置和管理主窗口
*/
const { BrowserWindow, screen, Menu, ipcMain, shell } = require('electron');
const path = require('path');
const fs = require('fs');
const i18next = require('./i18n');
// 全局变量
let mainWindow = null;
let unlockWindow = null; // 解锁窗口
let contextMenu = null; // 存储右键菜单实例
// 窗口配置文件路径
let configPath = null;
/**
* 初始化窗口配置
* @param {string} userDataPath 用户数据目录路径
*/
function initConfig(userDataPath) {
configPath = path.join(userDataPath, 'window-config.json');
}
/**
* 加载窗口配置
* @returns {Object|null} 窗口配置对象或null
*/
function loadWindowConfig() {
try {
if (fs.existsSync(configPath)) {
const data = fs.readFileSync(configPath, 'utf8');
const config = JSON.parse(data);
console.log('加载窗口配置成功:', config);
// 如果配置中有语言设置更新i18next
if (config.language) {
console.log('更新i18next语言:', config.language);
i18next.changeLanguage(config.language);
}
return config;
}
} catch (error) {
console.error('加载窗口配置失败:', error);
}
return null;
}
/**
* 保存窗口配置
* @param {Object} bounds 窗口边界信息
*/
function saveWindowConfig(bounds) {
try {
// 合并窗口位置和语言设置
const config = {
...bounds,
language: i18next.language
};
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
console.log('保存窗口配置成功:', config);
} catch (error) {
console.error('保存窗口配置失败:', error);
}
}
/**
* 处理静态资源路径
* @param {string} htmlContent HTML内容
* @returns {string} 修正后的HTML内容
*/
function fixStaticAssetsPaths(htmlContent) {
// 将绝对路径(/assets/)替换为相对路径(./assets/)
const fixedContent = htmlContent
.replace(/src="\/assets\//g, 'src="./assets/')
.replace(/href="\/assets\//g, 'href="./assets/')
.replace(/href="\/favicon.svg"/g, 'href="./favicon.svg"');
console.log('已修复资源路径引用');
return fixedContent;
}
/**
* 创建主窗口
* @param {string} frontendUrl 前端URL
* @param {number} frontendPort 前端端口
* @param {number} backendPort 后端端口
* @param {boolean} isDev 是否为开发模式
* @returns {BrowserWindow} 创建的主窗口
*/
function createWindow(frontendUrl, frontendPort, backendPort, isDev) {
console.log('开始创建主窗口...');
// 加载窗口配置
const config = loadWindowConfig();
console.log('加载窗口配置成功:', config);
// 提取窗口尺寸和位置
let windowWidth = 500;
let windowHeight = 230;
let x, y;
let searchWindow = null;
if (config) {
windowWidth = config.width || windowWidth;
windowHeight = config.height || windowHeight;
x = config.x;
y = config.y;
}
// 检查坐标是否有效 (针对多显示器情况)
const displays = screen.getAllDisplays();
console.log(`系统有 ${displays.length} 个显示器`);
let foundValidDisplay = false;
if (x !== undefined && y !== undefined) {
// 检查坐标是否在任一显示器范围内
for (const display of displays) {
const { bounds } = display;
if (
x >= bounds.x - windowWidth &&
x <= bounds.x + bounds.width &&
y >= bounds.y - windowHeight &&
y <= bounds.y + bounds.height
) {
foundValidDisplay = true;
break;
}
}
// 如果坐标无效,使用主显示器中央
if (!foundValidDisplay) {
const primaryDisplay = screen.getPrimaryDisplay();
x = Math.floor((primaryDisplay.workAreaSize.width - windowWidth) / 2);
y = Math.floor((primaryDisplay.workAreaSize.height - windowHeight) / 2);
}
} else {
// 如果没有保存的坐标,使用主显示器中央
const primaryDisplay = screen.getPrimaryDisplay();
x = Math.floor((primaryDisplay.workAreaSize.width - windowWidth) / 2);
y = Math.floor((primaryDisplay.workAreaSize.height - windowHeight) / 2);
}
console.log(`设置窗口位置: x=${x}, y=${y}, 屏幕尺寸: ${screen.getPrimaryDisplay().workAreaSize.width}x${screen.getPrimaryDisplay().workAreaSize.height}`);
// 创建浏览器窗口
mainWindow = new BrowserWindow({
width: windowWidth,
height: windowHeight,
x: x,
y: y,
frame: false,
transparent: true,
backgroundColor: '#00000000',
hasShadow: false, // 禁用窗口阴影以防止透明边缘出现灰色
titleBarStyle: 'hidden',
alwaysOnTop: true,
show: false, // 初始不显示,等待加载完成后显示
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, '..', 'preload.js'),
}
});
// 确保窗口在屏幕上可见,设置居中位置
mainWindow.setPosition(x, y);
console.log(`设置窗口位置: x=${x}, y=${y}, 屏幕尺寸: ${screen.getPrimaryDisplay().workAreaSize.width}x${screen.getPrimaryDisplay().workAreaSize.height}`);
console.log('窗口创建成功,现在设置窗口事件...');
// 确保窗口及其内容完全透明
mainWindow.webContents.on('did-finish-load', () => {
// 设置背景色为透明
mainWindow.webContents.insertCSS(`
html, body {
background-color: transparent !important;
background: transparent !important;
}
`);
});
// 设置窗口永远在最上层使用screen-saver级别确保在所有空间可见
mainWindow.setAlwaysOnTop(true, 'screen-saver', 1);
// 在macOS上隐藏流量灯按钮
if (process.platform === 'darwin') {
// 完全隐藏所有默认窗口控件
mainWindow.setWindowButtonVisibility(false);
mainWindow.setMovable(true);
// macOS 窗口设置
try {
// 部分 Electron 版本可能不支持这个 API
if (typeof mainWindow.setConstraints === 'function') {
mainWindow.setConstraints({ minWidth: null, minHeight: null });
}
} catch (e) {
console.log('无法设置窗口约束,使用默认设置');
}
}
// 加载前端页面
if (isDev) {
// 开发模式: 加载本地开发服务器
frontendUrl = `http://localhost:${frontendPort}/`;
// 检查开发服务器是否在运行
const http = require('http');
const checkDevServer = () => {
http.get(frontendUrl, (res) => {
if (res.statusCode === 200) {
mainWindow.loadURL(frontendUrl);
} else {
loadBuiltFiles(mainWindow);
}
}).on('error', () => {
loadBuiltFiles(mainWindow);
});
};
// 尝试连接开发服务器
checkDevServer();
} else {
loadBuiltFiles(mainWindow);
}
// 设置后端URL
global.backendUrl = `http://127.0.0.1:${backendPort}`;
// 创建右键菜单
contextMenu = Menu.buildFromTemplate(getMenuTemplate());
// 添加右键菜单事件监听
mainWindow.webContents.on('context-menu', (e, params) => {
e.preventDefault();
contextMenu.popup({ window: mainWindow });
});
// 阻止窗口导航到外部链接
mainWindow.webContents.on('will-navigate', (event, url) => {
if (!url.startsWith(frontendUrl)) {
event.preventDefault();
shell.openExternal(url);
}
});
// 创建关闭按钮window
createUnlockWindow()
return mainWindow;
/**
* 获取翻译后的菜单模板
* @returns {Array} 菜单模板数组
*/
function getMenuTemplate() {
return [
{
label: i18next.t('menu.lockWindow'),
click: () => {
console.log('锁定窗口 - 启用点击穿透');
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.blur()
mainWindow.webContents.send('toggle-lock-window', true);
showUnlockWindow()
}
}
},
{ type: 'separator' },
{
label: i18next.t('menu.refresh'),
click: () => { mainWindow.reload(); }
},
{
label: i18next.t('menu.openDevTools'),
click: () => { mainWindow.webContents.openDevTools(); }
},
{
label: i18next.t('menu.language'),
submenu: [
{
label: i18next.t('menu.chinese'),
click: () => {
i18next.changeLanguage('zh');
mainWindow.webContents.send('change-language', 'zh');
searchWindow && searchWindow.webContents.send('change-language', 'zh');
updateContextMenu();
}
},
{
label: i18next.t('menu.english'),
click: () => {
i18next.changeLanguage('en');
mainWindow.webContents.send('change-language', 'en');
searchWindow && searchWindow.webContents.send('change-language', 'en');
updateContextMenu();
}
}
]
},
{
label: i18next.t('menu.deleteLyrics'),
click: () => {
mainWindow.webContents.send('delete-current-lyrics');
}
},
{
label: i18next.t('menu.searchLyrics'),
click: () => {
console.log('右键菜单:点击搜索歌词');
const searchWindowManager = require('../windows/search-window');
searchWindow = searchWindowManager.createWindow(loadBuiltFiles);
}
},
{ type: 'separator' },
{
label: i18next.t('menu.quit'),
click: () => { require('electron').app.quit(); }
}
];
}
/**
* 更新右键菜单
*/
function updateContextMenu() {
if (mainWindow && !mainWindow.isDestroyed()) {
contextMenu = Menu.buildFromTemplate(getMenuTemplate());
}
}
// 加载构建好的前端文件
function loadBuiltFiles(window, search) {
// 修正前端资源路径匹配package.json中的配置
const frontendDistPath = isDev
? path.join(__dirname, '..', '..', 'frontend', 'dist')
: path.join(process.resourcesPath, 'app');
console.log(`加载已构建的前端文件: ${frontendDistPath}`);
try {
// 检查路径是否存在
if (!fs.existsSync(frontendDistPath)) {
console.error(`前端资源路径不存在: ${frontendDistPath}`);
// 尝试列出可能的路径
if (!isDev) {
console.log('资源路径内容:');
console.log(fs.readdirSync(process.resourcesPath));
}
}
// 读取index.html文件内容
const indexPath = path.join(frontendDistPath, 'index.html');
if (!fs.existsSync(indexPath)) {
console.error(`HTML文件不存在: ${indexPath}`);
return;
}
let htmlContent = fs.readFileSync(indexPath, 'utf8');
// 修复资源路径
htmlContent = fixStaticAssetsPaths(htmlContent);
// 将修复后的HTML内容写入临时文件
const tempIndexPath = path.join(frontendDistPath, '_fixed_index.html');
fs.writeFileSync(tempIndexPath, htmlContent, 'utf8');
console.log(`已创建临时HTML文件: ${tempIndexPath}`);
// 加载修复后的HTML文件
window.loadFile(tempIndexPath, { search });
} catch (err) {
console.error('加载前端文件失败:', err);
}
}
}
/**
* 创建解锁窗口
* @returns {BrowserWindow} 创建的解锁窗口
*/
function createUnlockWindow() {
// 获取主窗口的位置和尺寸
let x, y;
if (mainWindow && !mainWindow.isDestroyed()) {
const mainBounds = mainWindow.getBounds();
// 将解锁按钮定位在歌词窗口上方
x = mainBounds.x + mainBounds.width / 2 - 24;
y = mainBounds.y + 24;
} else {
// 如果主窗口不可用,用屏幕中心位置
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
x = Math.floor(width / 2 - 24);
y = Math.floor(height / 2 - 24);
}
// 创建解锁窗口
unlockWindow = new BrowserWindow({
width: 48,
height: 48,
x: x,
y: y,
frame: false,
show: false,
resizable: false,
transparent: true,
alwaysOnTop: true,
backgroundColor: '#00000000',
webPreferences: {
nodeIntegration: true,
contextIsolation: false
}
});
// 加载HTML文件
unlockWindow.loadFile(path.join(__dirname, '..', 'unlock.html'));
// 禁用右键菜单
unlockWindow.webContents.on('context-menu', e => {
e.preventDefault();
});
// 设置窗口永远在最上层使用screen-saver级别确保在所有空间可见
unlockWindow.setAlwaysOnTop(true, 'screen-saver', 1);
// 在macOS上设置可见于所有工作区
if (process.platform === 'darwin') {
unlockWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
}
return unlockWindow;
}
function showUnlockWindow() {
if (unlockWindow) {
if (mainWindow && !mainWindow.isDestroyed()) {
const mainBounds = mainWindow.getBounds();
// 将解锁按钮定位在歌词窗口上方
const x = mainBounds.x + mainBounds.width / 2 - 24;
const y = mainBounds.y + 24;
unlockWindow.setPosition(x, y);
unlockWindow.showInactive()
mainWindow.setIgnoreMouseEvents(true);
}
}
}
function hideUnlockWindow() {
if (unlockWindow) {
unlockWindow.hide()
}
}
/**
* 创建加载窗口
* @returns {BrowserWindow} 创建的加载窗口
*/
function createLoadingWindow() {
// 获取主显示器尺寸
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
// 创建加载窗口
loadingWindow = new BrowserWindow({
width: 320,
height: 280,
x: Math.floor((width - 320) / 2),
y: Math.floor((height - 280) / 2),
frame: false,
resizable: false,
transparent: true,
alwaysOnTop: true,
webPreferences: {
nodeIntegration: true,
contextIsolation: false
}
});
// 加载HTML文件
loadingWindow.loadFile(path.join(__dirname, '..', 'loading.html'));
// 禁用右键菜单
loadingWindow.webContents.on('context-menu', e => {
e.preventDefault();
});
// 窗口准备好时发送当前语言设置
loadingWindow.webContents.on('did-finish-load', () => {
// 先尝试从配置中读取语言设置
const config = loadWindowConfig();
console.log('加载窗口配置:', config);
const currentLang = config?.language || i18next.language;
console.log('发送当前语言到加载窗口:', currentLang);
loadingWindow.webContents.send('change-language', currentLang);
});
return loadingWindow;
}
/**
* 更新加载进度
* @param {number} progress 进度百分比 (0-100)
* @param {string} message 状态消息
*/
function updateLoadingProgress(progress, message) {
if (loadingWindow && !loadingWindow.isDestroyed()) {
// 获取当前语言的消息
const messages = {
en: {
initializing: 'Initializing application...',
loadingBackend: 'Loading backend service...',
allocatingPorts: 'Allocating ports...',
loadingFrontend: 'Loading frontend resources...',
connecting: 'Connecting to Apple Music...',
ready: 'Ready',
loadingLyrics: 'Loading lyrics...'
},
zh: {
initializing: '正在初始化应用...',
loadingBackend: '正在加载后端服务...',
allocatingPorts: '正在分配端口...',
loadingFrontend: '正在加载前端资源...',
connecting: '正在连接 Apple Music...',
ready: '准备就绪',
loadingLyrics: '正在加载歌词...'
}
};
// 获取当前语言
const currentLang = i18next.language;
const t = messages[currentLang] || messages.en;
// 如果消息是预定义的键,则使用翻译
const translatedMessage = t[message] || message;
loadingWindow.webContents.send('update-progress', {
progress,
message: translatedMessage
});
}
}
/**
* 关闭加载窗口
*/
function closeLoadingWindow() {
if (loadingWindow && !loadingWindow.isDestroyed()) {
// 设置最终进度为100%
updateLoadingProgress(100, 'ready');
// 获取主窗口和加载窗口的位置和大小
const loadingBounds = loadingWindow.getBounds();
// 确保主窗口已创建
if (!mainWindow) {
// 如果主窗口不存在,直接关闭加载窗口
loadingWindow.close();
loadingWindow = null;
return;
}
const mainBounds = mainWindow.getBounds();
// 动画的总帧数和当前帧
const totalFrames = 40; // 动画时长约为40帧大约600-800ms
let currentFrame = 0;
// 窗口属性的起始和结束值
const startProps = {
x: loadingBounds.x,
y: loadingBounds.y,
width: loadingBounds.width,
height: loadingBounds.height,
opacity: 1
};
const endProps = {
x: mainBounds.x,
y: mainBounds.y,
width: mainBounds.width,
height: mainBounds.height,
opacity: 0.3
};
// 创建动画计时器
const animationTimer = setInterval(() => {
currentFrame++;
// 使用缓动函数计算当前帧的属性值
const progress = easeOutCubic(currentFrame / totalFrames);
// 计算当前帧的窗口位置和大小
const newBounds = {
x: Math.round(startProps.x + (endProps.x - startProps.x) * progress),
y: Math.round(startProps.y + (endProps.y - startProps.y) * progress),
width: Math.round(startProps.width + (endProps.width - startProps.width) * progress),
height: Math.round(startProps.height + (endProps.height - startProps.height) * progress)
};
// 计算当前帧的不透明度
const newOpacity = startProps.opacity + (endProps.opacity - startProps.opacity) * progress;
// 应用新的边界和不透明度
loadingWindow.setBounds(newBounds);
loadingWindow.setOpacity(newOpacity);
// 动画完成后执行淡出并显示主窗口
if (currentFrame >= totalFrames) {
clearInterval(animationTimer);
// 先显示主窗口,让它可以在背景中显示
if (mainWindow && !mainWindow.isVisible()) {
mainWindow.show();
}
// 执行额外的淡出动画
let fadeFrame = 0;
const fadeTotalFrames = 20; // 约300ms的淡出时间
const fadeTimer = setInterval(() => {
fadeFrame++;
// 使用easeInCubic让淡出渐渐加速
const fadeProgress = fadeFrame / fadeTotalFrames;
const fadeOpacity = Math.max(0, endProps.opacity * (1 - fadeProgress));
loadingWindow.setOpacity(fadeOpacity);
if (fadeFrame >= fadeTotalFrames) {
clearInterval(fadeTimer);
loadingWindow.close();
loadingWindow = null;
}
}, 15);
}
}, 16); // 约60fps的刷新率
// 缓动函数,使动画更自然
function easeOutCubic(t) {
return 1 - Math.pow(1 - t, 3);
}
}
}
/**
* 设置窗口事件监听
*/
function setupWindowEvents() {
if (!mainWindow) {
console.error('无法设置窗口事件:窗口未创建');
return;
}
// 使用轮询方法检测鼠标是否在窗口上
let isMouseOverWindow = false;
let pollInterval = null;
// 开始轮询检测鼠标位置
const startMouseDetection = () => {
if (pollInterval) {
clearInterval(pollInterval);
}
pollInterval = setInterval(() => {
if (!mainWindow) {
// 如果窗口已关闭,停止轮询
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
return;
}
try {
// 获取鼠标位置
const mousePos = screen.getCursorScreenPoint();
// 获取窗口位置和大小
const bounds = mainWindow.getBounds();
// 检查鼠标是否在窗口内
const isInWindow = (
mousePos.x >= bounds.x &&
mousePos.x <= bounds.x + bounds.width &&
mousePos.y >= bounds.y &&
mousePos.y <= bounds.y + bounds.height
);
// 状态变化时才发送事件和更新点击穿透
if (isInWindow !== isMouseOverWindow) {
isMouseOverWindow = isInWindow;
// 通知渲染进程 - 无论是开发还是生产模式都需要
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('mouse-hover-change', isMouseOverWindow);
}
}
} catch (err) {
console.error('鼠标检测错误:', err);
}
}, 100); // 每100毫秒检测一次可根据需要调整
};
// 创建窗口后立即开始检测
startMouseDetection();
// 监听窗口位置变化事件,保存新位置
mainWindow.on('moved', function() {
if (!mainWindow) return;
const bounds = mainWindow.getBounds();
saveWindowConfig(bounds);
});
// 窗口关闭前保存位置
mainWindow.on('close', function() {
if (!mainWindow) return;
const bounds = mainWindow.getBounds();
saveWindowConfig(bounds);
});
// 窗口关闭时清除轮询
mainWindow.on('closed', () => {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
});
// 确保窗口在所有空间都可见macOS特有
if (process.platform === 'darwin') {
// 设置窗口可见于所有工作区
mainWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
}
}
/**
* 设置IPC通信
* @param {number} backendPort 后端服务端口
* @param {number} frontendPort 前端服务端口
*/
function setupIPC(backendPort, frontendPort) {
// IPC 处理函数,提供详细输出便于调试
ipcMain.handle('get-backend-url', () => {
const url = `http://127.0.0.1:${backendPort}`;
console.log(`向前端提供后端URL: ${url}`);
return url;
});
// 处理IPC事件获取前端URL
ipcMain.handle('get-frontend-url', () => {
return `http://localhost:${frontendPort}`;
});
// 监听窗口位置和大小变化
ipcMain.on('save-window-bounds', (event, bounds) => {
saveWindowConfig(bounds);
});
// 监听最小化窗口请求
ipcMain.on('minimize-window', () => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.minimize();
}
});
// 监听退出应用请求
ipcMain.on('quit-app', () => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.close();
}
});
// 监听获取端口请求
ipcMain.handle('get-ports', async () => {
return {
backendPort,
frontendPort
};
});
// 监听加载窗口完成事件
ipcMain.on('loading-finished', () => {
closeLoadingWindow();
});
// 处理窗口拖动 - 使用系统原生拖动
ipcMain.on('start-window-drag', () => {
if (process.platform === 'darwin') {
// 使用BrowserWindow.startWindowDrag() API (Electron 14+)
try {
console.log('使用Electron原生窗口拖动API');
mainWindow.webContents.executeJavaScript('document.documentElement.style.cursor = "grab";')
.catch(err => console.error('设置光标样式失败:', err));
// 使用较新的原生API
if (typeof mainWindow.startWindowDrag === 'function') {
mainWindow.startWindowDrag();
} else {
// 回退到老方法
mainWindow.setMovable(true);
mainWindow.moveTop();
}
} catch (error) {
console.error('窗口拖动错误:', error);
}
}
});
// 处理窗口锁定状态设置
ipcMain.on('set-window-lock-state', (_, locked) => {
console.log('设置窗口锁定状态:', locked);
// 确保锁定状态下窗口始终忽略鼠标事件
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.setIgnoreMouseEvents(locked);
}
});
// 处理锁定时close window的渲染
ipcMain.on('set-close-window-state', (_, showLock) => {
console.log('设置关闭按钮', showLock);
if (showLock) {
// 展示解锁窗口
showUnlockWindow();
} else {
// 关闭解锁窗口
hideUnlockWindow()
}
})
// 监听解锁窗口的解锁事件
ipcMain.on('unlock-window', () => {
console.log('收到解锁窗口事件');
// 通知主窗口解锁
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.setIgnoreMouseEvents(false);
mainWindow.webContents.send('toggle-lock-window', false);
}
// 关闭解锁窗口
hideUnlockWindow()
});
// 处理语言切换
ipcMain.on('change-language', (_, lang) => {
console.log('收到语言切换请求:', lang);
i18next.changeLanguage(lang);
// 通知所有窗口语言已更改
BrowserWindow.getAllWindows().forEach(window => {
if (!window.isDestroyed()) {
window.webContents.send('change-language', lang);
}
});
// 更新右键菜单
updateContextMenu();
// 保存配置
if (mainWindow && !mainWindow.isDestroyed()) {
const bounds = mainWindow.getBounds();
saveWindowConfig(bounds);
}
});
}
module.exports = {
initConfig,
createWindow,
createLoadingWindow,
updateLoadingProgress,
closeLoadingWindow,
setupWindowEvents,
setupIPC,
mainWindow: () => mainWindow,
saveWindowConfig
};

4214
electron-app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

67
electron-app/package.json Normal file
View File

@@ -0,0 +1,67 @@
{
"name": "lyroc-electron",
"version": "0.7.1",
"description": "lyroc - Apple Music 歌词显示",
"main": "main.js",
"scripts": {
"start": "electron .",
"build": "electron-builder",
"postinstall": "electron-builder install-app-deps"
},
"author": "fzt_tom",
"license": "MIT",
"devDependencies": {
"electron": "^29.0.0",
"electron-builder": "^24.9.1"
},
"dependencies": {
"electron-is-dev": "^2.0.0",
"find-process": "^1.4.7",
"get-port": "^5.1.1",
"i18next": "^23.10.0",
"i18next-browser-languagedetector": "^7.2.0"
},
"build": {
"appId": "com.lyroc.app",
"productName": "lyroc",
"mac": {
"category": "public.app-category.music",
"target": "dmg",
"icon": "build/icon.icns",
"darkModeSupport": true,
"extraResources": [
{
"from": "../backend",
"to": "backend",
"filter": [
"**/*",
"!**/__pycache__",
"!**/.pytest_cache",
"!**/.venv"
]
},
{
"from": "../frontend/dist",
"to": "app"
}
]
},
"dmg": {
"contents": [
{
"x": 130,
"y": 220
},
{
"x": 410,
"y": 220,
"type": "link",
"path": "/Applications"
}
]
},
"extraMetadata": {
"main": "main.js"
}
}
}

97
electron-app/preload.js Normal file
View File

@@ -0,0 +1,97 @@
/*
* @Date: 2025-05-06 17:45:54
* @LastEditors: 陈子健
* @LastEditTime: 2025-05-23 14:14:05
* @FilePath: /mac-lyric-vue/electron-app/preload.js
*/
const { contextBridge, ipcRenderer } = require('electron');
// 暴露安全的API供渲染进程使用
contextBridge.exposeInMainWorld('electronAPI', {
// 获取后端URL
getBackendUrl: () => ipcRenderer.invoke('get-backend-url'),
// 获取前端URL
getFrontendUrl: () => ipcRenderer.invoke('get-frontend-url'),
// 处理窗口拖动 - 改为使用原生拖动
startWindowDrag: () => {
ipcRenderer.send('start-window-drag');
},
// 鼠标悬停状态变化事件
onMouseHoverChange: (callback) => {
// 清除旧的监听器,避免重复
ipcRenderer.removeAllListeners('mouse-hover-change');
// 添加新的监听器
ipcRenderer.on('mouse-hover-change', (_, isHovering) => callback(isHovering));
// 返回一个清理函数
return () => {
ipcRenderer.removeAllListeners('mouse-hover-change');
};
},
// 监听窗口锁定状态变化事件
onToggleLockWindow: (callback) => {
ipcRenderer.removeAllListeners('toggle-lock-window');
ipcRenderer.on('toggle-lock-window', (_, isLocked) => callback(isLocked));
return () => {
ipcRenderer.removeAllListeners('toggle-lock-window');
};
},
// 保持窗口的锁定状态
setWindowLockState: (isLocked) => {
ipcRenderer.send('set-window-lock-state', isLocked);
},
// 渲染close window
setCloseWindowState: (showWindow) => {
ipcRenderer.send('set-close-window-state', showWindow);
},
// 监听删除歌词事件
onDeleteCurrentLyrics: (callback) => {
ipcRenderer.on('delete-current-lyrics', () => {
callback();
});
},
// 监听主窗口数据
onMainWindowData: (callback) => {
ipcRenderer.on('main-window-data', (_, data) => {
callback(data);
});
},
// 监听获取主窗口数据请求
onGetMainWindowData: (callback) => {
ipcRenderer.on('get-main-window-data', () => {
callback();
});
},
// 发送数据到搜索窗口
sendToSearchWindow: (data) => {
ipcRenderer.send('send-to-search-window', data);
},
closeSearchWindow: () => ipcRenderer.send('close-search-window'),
// 简化的语言设置API
getLanguage: () => ipcRenderer.invoke('get-language'),
setLanguage: (lang) => ipcRenderer.invoke('set-language', lang),
onLanguageChange: (callback) => {
ipcRenderer.removeAllListeners('change-language');
ipcRenderer.on('change-language', (_, lang) => callback(lang));
return () => {
ipcRenderer.removeAllListeners('change-language');
};
},
// 发送语言切换事件
changeLanguage: (lang) => {
ipcRenderer.send('change-language', lang);
}
});

28
electron-app/run.sh Executable file
View File

@@ -0,0 +1,28 @@
#!/bin/bash
###
# @Date: 2025-04-30 15:42:44
# @LastEditors: 陈子健
# @LastEditTime: 2025-04-30 15:45:29
# @FilePath: /mac-lyric-vue/electron-app/run.sh
###
echo "===== MacLyric启动脚本 ====="
echo "正在构建前端..."
# 进入前端目录并构建项目
cd ../frontend
npm run build
if [ $? -ne 0 ]; then
echo "前端构建失败,请检查错误"
exit 1
fi
echo "前端构建成功!"
echo "启动应用中..."
# 进入electron目录并启动应用
cd ../electron-app
npm start
exit $?

98
electron-app/unlock.html Normal file
View File

@@ -0,0 +1,98 @@
<!--
* @Date: 2025-05-06 18:27:32
* @LastEditors: 陈子健
* @LastEditTime: 2025-05-07 10:24:07
* @FilePath: /mac-lyric-vue/electron-app/unlock.html
-->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>解锁窗口</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
margin: 0;
padding: 0;
background-color: transparent;
overflow: hidden;
user-select: none;
}
.unlock-container {
width: 48px;
height: 48px;
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(0, 0, 0, 0.4);
border-radius: 50%;
cursor: pointer;
transition: background-color 0.3s ease;
}
.unlock-container:hover {
background-color: rgba(0, 0, 0, 0.6);
}
.lock-icon {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
transition: opacity 0.3s ease;
}
.icon-locked {
opacity: 1;
}
.icon-unlocked {
opacity: 0;
}
.unlock-container:hover .icon-locked {
opacity: 0;
}
.unlock-container:hover .icon-unlocked {
opacity: 1;
}
svg {
width: 24px;
height: 24px;
}
</style>
</head>
<body>
<div class="unlock-container" id="unlockBtn">
<div class="lock-icon icon-locked">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<path fill="white" d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zM9 6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9V6zm9 14H6V10h12v10zm-6-3c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z"/>
</svg>
</div>
<div class="lock-icon icon-unlocked">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<path fill="white" d="M12 17c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm6-9h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6h2c0-1.66 1.34-3 3-3s3 1.34 3 3v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm0 12H6V10h12v10z"/>
</svg>
</div>
</div>
<script>
// 引入electron模块
const { ipcRenderer } = require('electron');
// 直接将点击事件添加到整个文档
document.addEventListener('click', () => {
console.log('解锁按钮被点击');
// 发送解锁事件到主进程
ipcRenderer.send('unlock-window');
});
</script>
</body>
</html>

View File

@@ -0,0 +1,144 @@
/*
* @Date: 2025-05-22 15:38:30
* @LastEditors: 陈子健
* @LastEditTime: 2025-05-23 14:27:20
* @FilePath: /mac-lyric-vue/electron-app/windows/search-window.js
*/
/*
* @Date: 2025-05-22 15:38:30
* @LastEditors: 陈子健
* @LastEditTime: 2025-05-22 15:47:23
* @FilePath: /mac-lyric-vue/electron-app/windows/search-window.js
*/
const { BrowserWindow, ipcMain } = require('electron');
const path = require('path');
const i18next = require('../modules/i18n');
class SearchWindowManager {
constructor() {
this.window = null;
}
createWindow(loadBuiltFiles) {
if (this.window) {
this.window.focus();
return;
}
this.window = new BrowserWindow({
width: 600, // 调整宽度以匹配前端设计
height: 600,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, '../preload.js'),
webSecurity: true
},
frame: false,
transparent: true,
backgroundColor: '#80000000', // 半透明黑色背景
hasShadow: true, // 启用窗口阴影
resizable: false,
alwaysOnTop: true,
show: false,
center: true, // 窗口居中
skipTaskbar: true, // 不在任务栏显示
titleBarStyle: 'hidden'
});
// 加载搜索页面
if (process.env.NODE_ENV === 'development') {
const url = 'http://localhost:5173';
console.log('开发环境:加载搜索页面', url);
this.window.loadURL(url).catch(err => {
console.error('加载搜索页面失败:', err);
});
} else {
console.log('生产环境:加载搜索页面');
loadBuiltFiles(this.window, '?search=true');
}
// 添加更多调试日志
this.window.webContents.on('will-navigate', (event, url) => {
console.log('窗口即将导航到:', url);
});
this.window.webContents.on('did-navigate', (event, url) => {
console.log('窗口已导航到:', url);
});
this.window.webContents.on('did-navigate-in-page', (event, url) => {
console.log('窗口页面内导航到:', url);
});
// 窗口准备好时显示
this.window.once('ready-to-show', () => {
console.log('搜索窗口准备就绪,准备显示');
this.window.show();
// 请求主窗口数据
console.log('请求主窗口数据');
ipcMain.emit('request-main-window-data');
// 发送当前语言到搜索窗口
const currentLang = i18next.language;
console.log('发送当前语言到搜索窗口:', currentLang);
this.window.webContents.send('change-language', currentLang);
});
// 添加调试日志
this.window.webContents.on('did-finish-load', () => {
console.log('搜索窗口加载完成');
// 确保窗口可见
this.window.show();
this.window.focus();
this.window.webContents.insertCSS(`
body {
-webkit-app-region: drag;
}
input, button, .no-drag {
-webkit-app-region: no-drag;
}
`);
});
this.window.webContents.on('did-fail-load', (event, errorCode, errorDescription) => {
console.error('搜索窗口加载失败:', errorCode, errorDescription);
});
// 窗口关闭时清理引用
this.window.on('closed', () => {
console.log('搜索窗口已关闭');
this.window = null;
});
// 开发环境下打开开发者工具
if (process.env.NODE_ENV === 'development') {
this.window.webContents.openDevTools();
}
return this.window;
}
// 接收主窗口数据的方法
receiveMainWindowData(data) {
if (this.window && !this.window.isDestroyed()) {
console.log('向搜索窗口发送数据:', data);
this.window.webContents.send('main-window-data', data);
}
}
closeWindow() {
if (this.window) {
this.window.close();
this.window = null;
}
}
focusWindow() {
if (this.window) {
this.window.focus();
}
}
}
module.exports = new SearchWindowManager();

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
frontend/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

20
frontend/index.html Normal file
View File

@@ -0,0 +1,20 @@
<!--
* @Date: 2025-04-25 13:53:48
* @LastEditors: 陈子健
* @LastEditTime: 2025-04-25 13:53:51
* @FilePath: /mac-lyric-vue/frontend/index.html
-->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MacLyric - Apple Music 歌词</title>
<meta name="description" content="MacLyric - 轻量级 Apple Music 歌词显示应用" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1627
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
frontend/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "frontend",
"private": true,
"version": "0.7.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.13",
"pinia": "^2.2.6",
"axios": "^1.7.7",
"vue-i18n": "^9.9.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.2",
"vite": "^6.3.1"
}
}

1
frontend/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

39
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,39 @@
<!--
* @Date: 2025-05-06 15:27:12
* @LastEditors: 陈子健
* @LastEditTime: 2025-05-22 16:08:16
* @FilePath: /mac-lyric-vue/frontend/src/App.vue
-->
<!-- macOS歌词窗口应用 -->
<template>
<div class="app-container">
<LyricWindow v-if="showLyricWindow" />
<LyricSearch v-else />
</div>
</template>
<script setup>
import { computed } from 'vue'
import LyricWindow from './components/LyricWindow.vue'
import LyricSearch from './views/SearchLyrics.vue'
const showLyricWindow = computed(() => {
return !window.location.search.includes('search')
})
</script>
<style>
.app-container {
width: 100%;
height: 100%;
padding: 0;
margin: 0;
display: flex;
justify-content: flex-start;
align-items: flex-start;
background-color: transparent;
}
body {
background: #1a1b22;
}
</style>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,596 @@
<template>
<div class="lyric-window" ref="lyricWindow" :class="{ 'no-focus': !isHovering || isLocked }">
<!-- 将控制栏包装在一个容器中用于更好的悬停控制 -->
<div class="control-bar-container" :style="{ visibility: isLocked ? 'hidden' : 'visible' }">
<MusicControlBar
:is-focused="isHovering && !isLocked"
@previous="onPreviousTrack"
@play-pause="onTogglePlayPause"
@next="onNextTrack"
@seek="onSeekTo"
/>
</div>
<div class="lyrics-container">
<div class="lyrics-text">
<div class="track-info" v-if="trackName">
<span class="track-name">{{ trackName }}</span>
<span class="artist-name">{{ artistName }}</span>
</div>
<div class="no-playback" v-if="playbackStatus === 'notrunning' || playbackStatus === 'stopped'">
<div v-if="playbackStatus === 'notrunning'">
{{ $t('message.appleMusicNotRunning') }}
</div>
<div v-else>
{{ $t('message.waitingForPlayback') }}
</div>
</div>
<template v-else>
<div class="lyric-line highlight">
<span v-if="isLoadingLyrics" class="loading-text">{{ $t('message.loading') }}</span>
<span v-else>{{ currentLyric || '...' }}</span>
</div>
<div class="lyric-line next">{{ nextLyric || '...' }}</div>
<div class="lyric-line next-next">{{ nextNextLyric || '...' }}</div>
</template>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { togglePlayPause, previousTrack, nextTrack, seekTo } from '../utils/musicControl';
import websocketService from '../utils/websocketService';
import positionTracker from '../utils/positionTracker';
// 导入MusicControlBar组件
import MusicControlBar from './MusicControlBar.vue';
import { useLyricsStore } from '../store';
import { storeToRefs } from 'pinia';
const { t } = useI18n();
// 歌词数据
const currentLyric = ref(null);
const nextLyric = ref(null);
const nextNextLyric = ref(null);
const lyricsStore = useLyricsStore();
const trackName = storeToRefs(lyricsStore).trackName;
const artistName = storeToRefs(lyricsStore).artist;
const isLoadingLyrics = ref(false); // 歌词加载状态
// 后端API基础URL
const baseUrl = storeToRefs(lyricsStore).baseUrl;
// 鼠标悬停状态
const isHovering = ref(false);
// 锁定状态
const isLocked = ref(false);
// 引用歌词窗口DOM元素
const lyricWindow = ref(null);
// 从位置跟踪器获取状态值
const playbackStatus = positionTracker.playbackStatus;
// 播放控制处理函数
const onTogglePlayPause = async () => {
try {
await togglePlayPause();
} catch (error) {
console.error(t('message.playPauseError'), error);
}
};
const onPreviousTrack = async () => {
try {
await previousTrack();
} catch (error) {
console.error(t('message.previousTrackError'), error);
}
};
const onNextTrack = async () => {
try {
await nextTrack();
} catch (error) {
console.error(t('message.nextTrackError'), error);
}
};
const onSeekTo = async (newPosition) => {
try {
await seekTo(newPosition);
} catch (error) {
console.error(t('message.seekError'), error);
}
};
// 注册WebSocket消息处理器
function setupWebSocketHandlers() {
// 处理信息类型消息
websocketService.registerHandler('info', (data) => {
console.log('服务器消息:', data.message);
});
// 处理心跳响应
websocketService.registerHandler('pong', (data) => {
console.log('收到服务器心跳响应');
});
// 处理轨道变化消息 - 完整更新
websocketService.registerHandler('track_change', (data) => {
console.log('收到轨道变化:', data);
// 更新所有数据
if (data.status) {
positionTracker.updateState(
data.status.status,
data.status.position,
data.status.duration
);
}
if (data.lyrics) {
// 有歌词数据,重置加载状态
isLoadingLyrics.value = false;
console.log('更新歌词:', data.lyrics);
currentLyric.value = data.lyrics.current_lyric || '';
nextLyric.value = data.lyrics.next_lyric || '';
nextNextLyric.value = data.lyrics.next_next_lyric || '';
if (data.lyrics.track_name) {
trackName.value = data.lyrics.track_name;
}
if (data.lyrics.artist_name) {
artistName.value = data.lyrics.artist_name;
}
} else {
// 没有歌词数据,设置加载状态
console.log('歌曲切换,但没有歌词数据,显示加载中...');
isLoadingLyrics.value = true;
// 清空歌词显示
currentLyric.value = '';
nextLyric.value = '';
nextNextLyric.value = '';
// 可以保留歌曲信息如果status中有的话
if (data.status) {
trackName.value = data.status.track_name || trackName.value;
artistName.value = data.status.artist || artistName.value;
}
}
});
// 处理歌词变化消息 - 只更新歌词和相关状态
websocketService.registerHandler('lyric_change', (data) => {
console.log('收到歌词变化');
// 更新时间相关状态
if (data.status) {
positionTracker.updateState(
data.status.status,
data.status.position,
data.status.duration
);
}
// 更新歌词
if (data.lyrics) {
// 有歌词数据,重置加载状态
isLoadingLyrics.value = false;
currentLyric.value = data.lyrics.current_lyric || '';
nextLyric.value = data.lyrics.next_lyric || '';
nextNextLyric.value = data.lyrics.next_next_lyric || '';
}
});
// 处理新增的歌词更新消息类型 - 专门用于接收后期加载的歌词
websocketService.registerHandler('lyric_update', (data) => {
console.log('收到歌词更新消息');
if (data.lyrics) {
// 加载完成,重置加载状态
isLoadingLyrics.value = false;
// 更新歌词内容
currentLyric.value = data.lyrics.current_lyric || '';
nextLyric.value = data.lyrics.next_lyric || '';
nextNextLyric.value = data.lyrics.next_next_lyric || '';
}
});
// 处理播放停止消息 - 更新播放状态
websocketService.registerHandler('playback_stopped', (data) => {
console.log('收到播放停止消息');
// 更新播放状态
positionTracker.updateState(
data.status ? data.status.status : 'stopped',
null,
null
);
});
// 处理播放暂停消息 - 线控暂停
websocketService.registerHandler('playback_paused', (data) => {
console.log('收到播放暂停消息 (线控暂停)');
// 更新播放状态
positionTracker.updateState(
'paused',
data.status?.position,
null
);
});
// 处理播放恢复消息 - 从暂停恢复
websocketService.registerHandler('playback_resumed', (data) => {
console.log('收到播放恢复消息');
// 更新播放状态
positionTracker.updateState(
'playing',
data.status?.position,
null
);
});
// 处理Apple Music未运行消息
websocketService.registerHandler('app_not_running', (data) => {
console.log('收到Apple Music未运行消息');
// 更新播放状态为notrunning
positionTracker.updateState(
'notrunning',
0,
0
);
// 清空歌曲信息
trackName.value = null;
artistName.value = null;
// 清空歌词显示
currentLyric.value = '';
nextLyric.value = '';
nextNextLyric.value = '';
});
// 处理位置更新消息 - 只更新播放位置
websocketService.registerHandler('position_update', (data) => {
console.log('收到位置更新消息');
// 更新位置信息
if (data.status && data.status.position !== undefined) {
positionTracker.updateState(
playbackStatus.value,
data.status.position,
null
);
}
});
}
// 监视锁定状态变化,确保正确应用点击穿透
watch(isLocked, (newValue) => {
console.log('锁定状态变化:', newValue);
if (window.electronAPI) {
// 向主进程报告窗口锁定状态
if (window.electronAPI.setWindowLockState) {
window.electronAPI.setWindowLockState(newValue);
console.log(newValue ? '已向主进程报告窗口锁定' : '已向主进程报告窗口解锁');
}
}
});
// 处理鼠标进入窗口
const handleMouseEnter = () => {
isHovering.value = true;
};
// 处理鼠标离开窗口
const handleMouseLeave = () => {
isHovering.value = false;
};
// 组件挂载时初始化
onMounted(() => {
console.log('组件已挂载');
// 注册WebSocket消息处理器
setupWebSocketHandlers();
// 检查是否在Electron环境中获取后端URL
if (window.electronAPI) {
window.electronAPI.getBackendUrl()
.then(url => {
console.log('从Electron获取后端URL:', url);
baseUrl.value = url;
const wsUrl = url.replace('http://', 'ws://') + '/ws';
console.log('WebSocket URL设置为:', wsUrl);
// 设置WebSocket URL并连接
websocketService.setWsUrl(wsUrl);
websocketService.connect();
// 使用Electron提供的悬停状态移除本地事件监听
window.electronAPI.onMouseHoverChange((hovering) => {
isHovering.value = hovering;
console.log('鼠标悬停状态变化:', hovering);
if (isLocked.value) {
if (window.electronAPI.setCloseWindowState) {
window.electronAPI.setCloseWindowState(isHovering.value)
}
}
});
// 监听锁定窗口事件
if (window.electronAPI.onToggleLockWindow) {
window.electronAPI.onToggleLockWindow((locked) => {
isLocked.value = locked
});
}
// 监听删除歌词事件
if (window.electronAPI.onDeleteCurrentLyrics) {
window.electronAPI.onDeleteCurrentLyrics(() => {
onDeleteLyrics();
});
}
// 监听获取主窗口数据请求
if (window.electronAPI.onGetMainWindowData) {
window.electronAPI.onGetMainWindowData(() => {
console.log('收到获取主窗口数据请求');
window.electronAPI.sendToSearchWindow({
baseUrl: baseUrl.value,
trackName: trackName.value,
artist: artistName.value
});
});
}
})
.catch(err => {
console.error('获取后端URL失败使用默认URL', err);
websocketService.connect();
// 设置鼠标移入移出事件仅在非Electron环境或Electron API失败时使用
setupMouseEvents();
});
} else {
websocketService.connect();
// 设置鼠标移入移出事件仅在非Electron环境下使用
setupMouseEvents();
}
// 设置定期检查连接状态的间隔
const connectionCheckInterval = setInterval(() => {
websocketService.checkAndRestoreConnection();
}, 10000);
// 组件卸载时清理资源
onUnmounted(() => {
console.log('组件将卸载');
// 清理WebSocket连接
websocketService.disconnect();
// 清理位置跟踪器
positionTracker.cleanup();
// 清理连接检查定时器
clearInterval(connectionCheckInterval);
// 移除鼠标事件监听器
const lyricWindowEl = lyricWindow.value;
if (lyricWindowEl) {
lyricWindowEl.removeEventListener('mouseenter', handleMouseEnter);
lyricWindowEl.removeEventListener('mouseleave', handleMouseLeave);
}
});
});
// 设置鼠标事件的辅助函数,避免逻辑重复
function setupMouseEvents() {
const lyricWindowEl = lyricWindow.value;
if (lyricWindowEl) {
lyricWindowEl.addEventListener('mouseenter', handleMouseEnter);
lyricWindowEl.addEventListener('mouseleave', handleMouseLeave);
}
}
import request from '../utils/request';
// 删除歌词
const onDeleteLyrics = async () => {
if (!trackName.value || !artistName.value) return;
try {
const result = await request({
url: '/lyrics',
method: 'DELETE',
params: {
track_name: trackName.value,
artist: artistName.value
}
});
if (result.status === 'success') {
console.log('歌词已删除');
// 清空当前歌词显示
currentLyric.value = '';
nextLyric.value = '';
nextNextLyric.value = '';
} else {
console.error('删除歌词失败:', result.message);
}
} catch (error) {
console.error('删除歌词时出错:', error);
}
};
</script>
<style scoped>
* {
user-select: none;
}
.lyric-window {
width: 100%;
height: 100%;
color: #fff;
padding: 0;
margin: 0;
border-radius: 10px;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
background: rgba(30, 30, 40);
position: relative;
overflow: hidden;
-webkit-app-region: drag; /* 使整个窗口可拖动 */
transition: background-color 0.3s ease;
}
/* 当鼠标不在窗口上时,背景完全透明 */
.lyric-window.no-focus {
background: rgba(0, 0, 0, 0); /* 完全透明的背景 */
}
/* 控制按钮等交互元素不可拖动 */
:deep(.control-btn), :deep(.progress-bar) {
-webkit-app-region: no-drag;
}
/* 控制栏容器样式 */
.control-bar-container {
width: 100%;
-webkit-app-region: drag; /* 允许控制栏区域拖动窗口 */
transition: opacity 0.3s ease;
}
/* 当鼠标不在窗口上时隐藏控制栏容器 */
.no-focus .control-bar-container {
opacity: 0;
}
.lyrics-container {
position: relative;
height: 140px;
display: flex;
align-items: center;
justify-content: center;
}
.lyrics-text {
position: relative;
z-index: 2;
width: 100%;
text-align: center;
color: #fff;
}
.track-info {
font-size: 18px;
color: #aaf;
margin-bottom: 4px;
text-shadow: none;
transition: text-shadow 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
white-space: nowrap;
}
.track-name {
font-weight: bold;
margin-right: 10px;
font-size: 20px; /* 增大歌曲名 */
}
.artist-name {
font-style: italic;
}
.lyric-line {
font-size: 22px;
line-height: 1.5;
margin: 2px 0;
color: #fff;
opacity: 0.85;
transition: color 0.2s, opacity 0.2s, text-shadow 0.2s ease;
white-space: nowrap;
}
/* Make text fully opaque when window loses focus */
.no-focus .lyric-line {
opacity: 1;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.9), /* 增加阴影深度 */
0 0 8px rgba(0, 0, 0, 0.8), /* 增加阴影强度 */
0 1px 15px rgba(0, 0, 0, 0.7); /* 添加额外的阴影层 */
}
.lyric-line.highlight {
color: #00e6ff;
font-size: 28px;
font-weight: bold;
opacity: 1;
}
.no-focus .lyric-line.highlight {
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.95), /* 增加阴影深度 */
0 0 10px rgba(0, 0, 0, 0.8), /* 增加阴影强度 */
0 1px 20px rgba(0, 0, 0, 0.7); /* 添加额外的阴影层 */
}
.lyric-line.next {
color: #fff;
opacity: 0.65;
}
.no-focus .lyric-line.next {
opacity: 1;
}
.lyric-line.next-next {
color: #fff;
opacity: 0.45;
}
.no-focus .lyric-line.next-next {
opacity: 1;
}
.no-playback {
font-size: 24px;
color: rgba(255, 255, 255, 0.5);
margin: 20px 0;
}
.no-focus .no-playback {
color: rgba(255, 255, 255, 1);
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8), 0 0 8px rgba(0, 0, 0, 0.6);
}
.lyric-line .loading-text {
color: rgba(255, 255, 255, 0.7);
font-style: italic;
animation: pulse 1.5s infinite ease-in-out;
}
@keyframes pulse {
0% { opacity: 0.5; }
50% { opacity: 1; }
100% { opacity: 0.5; }
}
.no-focus .loading-text {
color: rgba(255, 255, 255, 0.9);
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8), 0 0 8px rgba(0, 0, 0, 0.6);
}
</style>

View File

@@ -0,0 +1,357 @@
<template>
<div class="music-control-bar">
<button class="control-btn" @click="previousTrack" :disabled="isAppleMusicNotRunning"></button>
<button class="control-btn" @click="togglePlayPause" :disabled="isAppleMusicNotRunning">
{{ playbackStatus === 'playing' ? '⏸' : '▶' }}
</button>
<button class="control-btn" @click="nextTrack" :disabled="isAppleMusicNotRunning"></button>
<div
class="progress-container"
:class="{ 'disabled': isAppleMusicNotRunning }"
>
<div
class="progress-bar"
ref="progressBarRef"
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@mouseleave="handleMouseLeave"
@mouseenter="handleMouseEnter"
>
<div class="progress-bg"></div>
<div class="progress-fill" :style="{ width: progressPercentage + '%' }"></div>
<div
class="progress-handle"
:class="{ 'active': isDragging || isHovering }"
:style="{ left: (isDragging ? dragPosition : progressPercentage) + '%' }"
></div>
<div
class="progress-tooltip"
v-show="isDragging || isHovering"
:style="{ left: (isDragging ? dragPosition : hoverPosition) + '%' }"
>
{{ formatTime(isDragging ? dragTimePosition : hoverTimePosition) }}
</div>
</div>
</div>
<div class="time-display" v-if="duration > 0">
{{ formatTime(position) }} / {{ formatTime(duration) }}
</div>
</div>
</template>
<script setup>
import { computed, ref, onMounted, onUnmounted } from 'vue';
import { togglePlayPause as togglePlayPauseAPI, previousTrack as previousTrackAPI, nextTrack as nextTrackAPI, seekTo as seekToAPI, formatTime } from '../utils/musicControl';
import positionTracker from '../utils/positionTracker';
// 定义组件接收的属性
const props = defineProps({
// 是否处于聚焦状态
isFocused: {
type: Boolean,
default: true
}
});
// 从位置跟踪器获取状态
const position = positionTracker.position;
const duration = positionTracker.duration;
const playbackStatus = positionTracker.playbackStatus;
// 计算播放进度百分比
const progressPercentage = computed(() => {
return positionTracker.getProgressPercentage();
});
// 是否Apple Music未运行
const isAppleMusicNotRunning = computed(() => {
return playbackStatus.value === 'notrunning';
});
// 播放控制函数 - 现在直接在组件内部实现而非发出事件
async function togglePlayPause() {
try {
await togglePlayPauseAPI();
} catch (error) {
console.error('播放/暂停控制失败:', error);
}
}
async function previousTrack() {
try {
await previousTrackAPI();
} catch (error) {
console.error('切换上一曲失败:', error);
}
}
async function nextTrack() {
try {
await nextTrackAPI();
} catch (error) {
console.error('切换下一曲失败:', error);
}
}
// 跳转到进度条点击位置
function seekTo(event) {
if (duration.value <= 0) return;
// 计算点击位置占进度条的比例
const progressBar = event.target.closest('.progress-bar');
const clickX = event.clientX - progressBar.getBoundingClientRect().left;
const percentage = clickX / progressBar.clientWidth;
// 计算对应的播放位置
const newPosition = percentage * duration.value;
// 直接调用seek函数而不是发出事件
seekToAPI(newPosition)
.catch(error => console.error('进度跳转失败:', error));
}
// 进度条事件处理
const progressBarRef = ref(null);
const isDragging = ref(false);
const isHovering = ref(false);
const dragPosition = ref(0);
const hoverPosition = ref(0);
const dragTimePosition = ref(0);
const hoverTimePosition = ref(0);
function handleMouseDown(event) {
isDragging.value = true;
dragPosition.value = getMousePosition(event);
dragTimePosition.value = getTimePosition(dragPosition.value);
}
function handleMouseMove(event) {
if (isDragging.value) {
dragPosition.value = getMousePosition(event);
dragTimePosition.value = getTimePosition(dragPosition.value);
} else {
hoverPosition.value = getMousePosition(event);
hoverTimePosition.value = getTimePosition(hoverPosition.value);
}
}
function handleMouseLeave() {
isHovering.value = false;
}
function handleMouseEnter(event) {
isHovering.value = true;
hoverPosition.value = getMousePosition(event);
hoverTimePosition.value = getHoverTimePosition(hoverPosition.value);
}
function getMousePosition(event) {
const progressBar = progressBarRef.value;
const clickX = event.clientX - progressBar.getBoundingClientRect().left;
const percentage = clickX / progressBar.clientWidth;
return percentage * 100;
}
function getTimePosition(position) {
return position / 100 * duration.value;
}
function getHoverTimePosition(position) {
return position / 100 * duration.value;
}
onMounted(() => {
// 添加全局鼠标事件监听,以支持拖动功能
document.addEventListener('mousemove', handleGlobalMouseMove);
document.addEventListener('mouseup', handleGlobalMouseUp);
})
onUnmounted(() => {
// 移除全局鼠标事件监听,避免内存泄漏
document.removeEventListener('mousemove', handleGlobalMouseMove);
document.removeEventListener('mouseup', handleGlobalMouseUp);
})
// 处理全局鼠标移动事件
function handleGlobalMouseMove(event) {
if (isDragging.value) {
// 计算新的拖动位置
const progressBar = progressBarRef.value;
const rect = progressBar.getBoundingClientRect();
let percentage = (event.clientX - rect.left) / rect.width;
// 限制在0-100%范围内
percentage = Math.min(Math.max(percentage, 0), 1);
dragPosition.value = percentage * 100;
dragTimePosition.value = percentage * duration.value;
}
}
// 处理全局鼠标释放事件
function handleGlobalMouseUp() {
if (isDragging.value) {
// 拖动结束,跳转到新位置
const newPosition = dragTimePosition.value;
seekToAPI(newPosition)
.catch(error => console.error('进度跳转失败:', error));
// 重置拖动状态
isDragging.value = false;
}
}
</script>
<style scoped>
.music-control-bar {
height: 80px;
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
position: relative;
z-index: 2;
padding: 0 20px;
transition: all 0.3s ease;
opacity: 1;
}
.control-btn {
background: rgba(255,255,255,0.12);
border: none;
border-radius: 6px;
color: #fff;
font-size: 22px;
padding: 8px 16px;
margin: 0 4px;
cursor: pointer;
transition: background 0.2s, opacity 0.3s;
width: 55px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.control-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* 播放暂停按钮的特殊样式 */
.play-pause-btn {
font-size: 22px;
line-height: 1;
}
.play-icon {
font-size: 24px;
}
.pause-icon {
font-size: 20px;
letter-spacing: -2px;
}
.control-btn:hover {
background: rgba(255,255,255,0.25);
}
.progress-container {
flex: 1;
height: 8px;
background: rgba(255, 255, 255, 0.15);
border-radius: 4px;
margin-left: 20px;
position: relative;
max-width: 140px;
cursor: pointer;
transition: all 0.2s ease;
}
.progress-container:hover {
height: 10px;
background: rgba(255, 255, 255, 0.2);
}
.progress-container.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.progress-bar {
height: 100%;
width: 100%;
position: relative;
border-radius: 4px;
}
.progress-bg {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
border-radius: 4px;
}
.progress-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 0%; /* 通过动态样式控制 */
background: linear-gradient(90deg, #00c6fb 0%, #005bea 100%);
border-radius: 4px;
box-shadow: 0 0 5px rgba(0, 198, 251, 0.5);
transition: width 0.1s ease-out;
}
.progress-handle {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 14px;
height: 14px;
background: #fff;
border-radius: 50%;
cursor: pointer;
box-shadow: 0 0 6px rgba(0, 0, 0, 0.4);
opacity: 0;
transition: opacity 0.2s ease, width 0.2s ease, height 0.2s ease, background 0.2s ease;
z-index: 2;
}
.progress-container:hover .progress-handle,
.progress-handle.active {
opacity: 1;
}
.progress-handle.active {
width: 16px;
height: 16px;
background: #00e6ff;
}
.progress-tooltip {
position: absolute;
top: -28px;
transform: translateX(-50%);
font-size: 12px;
font-weight: bold;
color: #fff;
background: rgba(0, 0, 0, 0.7);
padding: 3px 6px;
border-radius: 4px;
white-space: nowrap;
z-index: 3;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
pointer-events: none; /* 避免鼠标事件干扰 */
}
.time-display {
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
margin-left: 10px;
white-space: nowrap;
}
</style>

237
frontend/src/i18n/index.js Normal file
View File

@@ -0,0 +1,237 @@
import { createI18n } from 'vue-i18n'
const messages = {
en: {
message: {
// Common
hello: 'Hello',
settings: 'Settings',
language: 'Language',
theme: 'Theme',
about: 'About',
save: 'Save',
cancel: 'Cancel',
confirm: 'Confirm',
delete: 'Delete',
edit: 'Edit',
search: 'Search',
loading: 'Loading...',
success: 'Success',
error: 'Error',
warning: 'Warning',
info: 'Information',
back: 'Back',
select: 'Select',
// Navigation
home: 'Home',
lyrics: 'Lyrics',
playlist: 'Playlist',
favorites: 'Favorites',
history: 'History',
// Settings
general: 'General',
appearance: 'Appearance',
notifications: 'Notifications',
hotkeys: 'Hotkeys',
advanced: 'Advanced',
darkMode: 'Dark Mode',
lightMode: 'Light Mode',
systemDefault: 'System Default',
fontSize: 'Font Size',
fontFamily: 'Font Family',
opacity: 'Opacity',
position: 'Position',
alwaysOnTop: 'Always on Top',
showInDock: 'Show in Dock',
startWithSystem: 'Start with System',
minimizeToTray: 'Minimize to Tray',
// Lyrics
noLyrics: 'No lyrics available',
searchingLyrics: 'Searching lyrics...',
lyricsNotFound: 'Lyrics not found',
manualSearch: 'Manual Search',
autoSearch: 'Auto Search',
syncWithMusic: 'Sync with Music',
showTranslation: 'Show Translation',
hideTranslation: 'Hide Translation',
copyLyrics: 'Copy Lyrics',
shareLyrics: 'Share Lyrics',
appleMusicNotRunning: 'Apple Music is not running, please start the Apple Music app...',
waitingForPlayback: 'Waiting for playback...',
playPauseError: 'Play/Pause control failed:',
previousTrackError: 'Previous track failed:',
nextTrackError: 'Next track failed:',
seekError: 'Seek failed:',
// Search
searchLyrics: 'Search Lyrics',
trackName: 'Track Name',
artist: 'Artist',
enterTrackName: 'Enter track name',
enterArtistName: 'Enter artist name',
searching: 'Searching...',
searchResults: 'Search Results',
noLyricsFound: 'No lyrics found',
enterTrackOrArtist: 'Please enter track name or artist name',
searchError: 'Error searching lyrics:',
searchFailed: 'Search failed, please try again later',
// Playlist
addToPlaylist: 'Add to Playlist',
removeFromPlaylist: 'Remove from Playlist',
createPlaylist: 'Create Playlist',
deletePlaylist: 'Delete Playlist',
renamePlaylist: 'Rename Playlist',
emptyPlaylist: 'Empty Playlist',
playAll: 'Play All',
shuffle: 'Shuffle',
repeat: 'Repeat',
repeatOne: 'Repeat One',
repeatAll: 'Repeat All',
// Notifications
newVersion: 'New Version Available',
updateNow: 'Update Now',
later: 'Later',
downloadComplete: 'Download Complete',
installationReady: 'Installation Ready',
restartRequired: 'Restart Required',
// Errors
networkError: 'Network Error',
serverError: 'Server Error',
tryAgain: 'Try Again',
checkConnection: 'Check Connection',
invalidInput: 'Invalid Input',
permissionDenied: 'Permission Denied'
}
},
zh: {
message: {
// Common
hello: '你好',
settings: '设置',
language: '语言',
theme: '主题',
about: '关于',
save: '保存',
cancel: '取消',
confirm: '确认',
delete: '删除',
edit: '编辑',
search: '搜索',
loading: '加载中...',
success: '成功',
error: '错误',
warning: '警告',
info: '信息',
back: '返回',
select: '选择',
// Navigation
home: '首页',
lyrics: '歌词',
playlist: '播放列表',
favorites: '收藏',
history: '历史记录',
// Settings
general: '常规',
appearance: '外观',
notifications: '通知',
hotkeys: '快捷键',
advanced: '高级',
darkMode: '深色模式',
lightMode: '浅色模式',
systemDefault: '跟随系统',
fontSize: '字体大小',
fontFamily: '字体',
opacity: '透明度',
position: '位置',
alwaysOnTop: '窗口置顶',
showInDock: '显示在程序坞',
startWithSystem: '开机启动',
minimizeToTray: '最小化到托盘',
// Lyrics
noLyrics: '暂无歌词',
searchingLyrics: '正在搜索歌词...',
lyricsNotFound: '未找到歌词',
manualSearch: '手动搜索',
autoSearch: '自动搜索',
syncWithMusic: '与音乐同步',
showTranslation: '显示翻译',
hideTranslation: '隐藏翻译',
copyLyrics: '复制歌词',
shareLyrics: '分享歌词',
appleMusicNotRunning: 'Apple Music 未运行,请启动 Apple Music 应用...',
waitingForPlayback: '等待播放...',
playPauseError: '播放/暂停控制失败:',
previousTrackError: '切换上一曲失败:',
nextTrackError: '切换下一曲失败:',
seekError: '进度跳转失败:',
// Search
searchLyrics: '搜索歌词',
trackName: '歌曲名',
artist: '艺术家',
enterTrackName: '输入歌曲名',
enterArtistName: '输入艺术家名',
searching: '搜索中...',
searchResults: '搜索结果',
noLyricsFound: '未找到相关歌词',
enterTrackOrArtist: '请输入歌曲名或艺术家名',
searchError: '搜索歌词时出错:',
searchFailed: '搜索失败,请稍后重试',
// Playlist
addToPlaylist: '添加到播放列表',
removeFromPlaylist: '从播放列表移除',
createPlaylist: '创建播放列表',
deletePlaylist: '删除播放列表',
renamePlaylist: '重命名播放列表',
emptyPlaylist: '播放列表为空',
playAll: '播放全部',
shuffle: '随机播放',
repeat: '循环播放',
repeatOne: '单曲循环',
repeatAll: '列表循环',
// Notifications
newVersion: '发现新版本',
updateNow: '立即更新',
later: '稍后',
downloadComplete: '下载完成',
installationReady: '准备安装',
restartRequired: '需要重启',
// Errors
networkError: '网络错误',
serverError: '服务器错误',
tryAgain: '重试',
checkConnection: '检查网络连接',
invalidInput: '输入无效',
permissionDenied: '权限被拒绝'
}
}
}
const i18n = createI18n({
legacy: false, // 使用Composition API
locale: 'en', // 默认语言
fallbackLocale: 'en', // 回退语言
messages
})
// 监听语言切换事件
if (window.electronAPI) {
window.electronAPI.onLanguageChange((lang) => {
console.log('收到语言切换事件:', lang);
i18n.global.locale.value = lang;
});
}
export default i18n

18
frontend/src/main.js Normal file
View File

@@ -0,0 +1,18 @@
/*
* @Date: 2025-04-25 13:51:09
* @LastEditors: 陈子健
* @LastEditTime: 2025-05-23 13:55:43
* @FilePath: /mac-lyric-vue/frontend/src/main.js
*/
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import { createPinia } from 'pinia'
import i18n from './i18n'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(i18n)
app.mount('#app')

View File

@@ -0,0 +1,15 @@
/*
* @Date: 2025-05-22 11:50:44
* @LastEditors: 陈子健
* @LastEditTime: 2025-05-22 15:17:39
* @FilePath: /mac-lyric-vue/frontend/src/store/index.js
*/
import { defineStore } from 'pinia'
export const useLyricsStore = defineStore('lyrics', {
state: () => ({
trackName: '',
artist: '',
baseUrl: 'http://127.0.0.1:5000'
}),
})

86
frontend/src/style.css Normal file
View File

@@ -0,0 +1,86 @@
/* 全局样式重置 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-user-select: none;
user-select: none;
}
html, body, #app {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
overflow: hidden;
font-family: sans-serif;
background-color: transparent !important;
display: flex;
align-items: flex-start;
justify-content: flex-start;
position: relative;
}
body {
color: rgba(255, 255, 255, 0.87);
background-color: transparent !important;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* 隐藏滚动条 */
::-webkit-scrollbar {
display: none;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
@media (prefers-color-scheme: light) {
body {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

View File

@@ -0,0 +1,119 @@
/**
* 音乐播放控制模块
* 负责与后端交互控制Apple Music播放
*/
/**
* 发送播放控制请求到后端
* @param {string} action - 控制动作 ('playpause'|'previous'|'next'|'seek')
* @param {number} [position] - 当seek时的目标位置
* @returns {Promise<Object>} - 请求结果
*/
import request from './request';
async function sendControlCommand(action, position = null) {
try {
const payload = { action };
// 如果是seek操作且提供了position参数
if (action === 'seek' && position !== null) {
payload.position = position;
}
const response = await request({
url: '/control',
method: 'POST',
data: payload
});
if (!response.ok) {
throw new Error(`控制请求失败: ${response.status} ${response.statusText}`);
}
const result = await response.json();
// 检查结果状态如果Apple Music未运行提供明确的错误信息
if (result.status === 'error' && result.message === 'notrunning') {
console.warn('Apple Music未运行无法执行控制命令');
throw new Error('Apple Music未运行请先启动Apple Music应用');
}
return result;
} catch (error) {
console.error(`${action}控制请求错误:`, error);
throw error;
}
}
/**
* 切换播放/暂停状态
* @returns {Promise<Object>} - 请求结果
*/
function togglePlayPause() {
return sendControlCommand('playpause');
}
/**
* 切换到上一首曲目
* @returns {Promise<Object>} - 请求结果
*/
function previousTrack() {
return sendControlCommand('previous');
}
/**
* 切换到下一首曲目
* @returns {Promise<Object>} - 请求结果
*/
function nextTrack(baseUrl) {
return sendControlCommand('next');
}
/**
* 跳转到指定播放位置
* @param {number} position - 目标位置(秒)
* @returns {Promise<Object>} - 请求结果
*/
function seekTo(position) {
return sendControlCommand('seek', position);
}
/**
* 根据进度条点击位置计算新的播放位置并进行跳转
* @param {Event} event - 点击事件对象
* @param {number} duration - 当前曲目总时长(秒)
* @returns {Promise<Object>|null} - 请求结果或null如果duration无效
*/
function seekToByClick(event, duration) {
if (duration <= 0) return null;
// 计算点击位置占进度条的比例
const progressBar = event.target.closest('.progress-bar');
const clickX = event.clientX - progressBar.getBoundingClientRect().left;
const percentage = clickX / progressBar.clientWidth;
// 计算对应的播放位置
const newPosition = percentage * duration;
return seekTo(newPosition);
}
/**
* 格式化秒数为mm:ss格式
* @param {number} seconds - 秒数
* @returns {string} - 格式化后的时间字符串
*/
function formatTime(seconds) {
if (!seconds) return '00:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
export {
togglePlayPause,
previousTrack,
nextTrack,
seekTo,
seekToByClick,
formatTime
};

View File

@@ -0,0 +1,103 @@
/**
* 播放位置跟踪模块
* 负责在WebSocket更新之间本地更新播放位置
*/
import { ref } from 'vue';
class PositionTracker {
constructor() {
this.position = ref(0);
this.duration = ref(0);
this.playbackStatus = ref('stopped');
this.updateTimer = null;
this.updateInterval = 100; // 每100毫秒更新一次位置
}
/**
* 更新播放状态和位置信息
* @param {string} status - 播放状态 ('playing'|'paused'|'stopped'|'notrunning')
* @param {number} pos - 当前播放位置(秒)
* @param {number} dur - 曲目总时长(秒)
*/
updateState(status, pos, dur) {
this.playbackStatus.value = status;
if (pos !== undefined && pos !== null) {
this.position.value = pos;
}
if (dur !== undefined && dur !== null) {
this.duration.value = dur;
}
// 如果状态变化,管理定时器
if (status === 'playing') {
this.startPositionUpdate();
} else {
this.stopPositionUpdate();
}
}
/**
* 本地更新播放位置
*/
updatePositionLocally() {
if (this.playbackStatus.value === 'playing') {
// 每次更新位置(基于更新间隔计算增量)
const increment = this.updateInterval / 1000; // 转换为秒
this.position.value += increment;
// 确保不超过总时长
if (this.position.value > this.duration.value && this.duration.value > 0) {
this.position.value = this.duration.value;
}
}
}
/**
* 启动位置更新定时器
*/
startPositionUpdate() {
// 先停止可能存在的定时器
this.stopPositionUpdate();
// 创建新的定时器
this.updateTimer = setInterval(() => {
this.updatePositionLocally();
}, this.updateInterval);
console.log('开始本地位置更新计时器');
}
/**
* 停止位置更新定时器
*/
stopPositionUpdate() {
if (this.updateTimer) {
clearInterval(this.updateTimer);
this.updateTimer = null;
console.log('停止本地位置更新计时器');
}
}
/**
* 计算播放进度百分比
* @returns {number} 播放进度百分比 (0-100)
*/
getProgressPercentage() {
if (this.duration.value <= 0) return 0;
return (this.position.value / this.duration.value) * 100;
}
/**
* 清理资源
*/
cleanup() {
this.stopPositionUpdate();
}
}
// 创建单例实例
const positionTracker = new PositionTracker();
export default positionTracker;

View File

@@ -0,0 +1,33 @@
/*
* @Date: 2025-05-22 13:56:20
* @LastEditors: 陈子健
* @LastEditTime: 2025-05-22 17:08:29
* @FilePath: /mac-lyric-vue/frontend/src/utils/request.js
*/
import axios from 'axios';
import { useLyricsStore } from '../store';
const request = (options) => {
const lyricsStore = useLyricsStore();
const config = {
baseURL: `${lyricsStore.baseUrl}`,
url: `${options.url}`,
method: options.method,
data: options.data,
params: options.params
};
const axiosInstance = axios.create();
axiosInstance.interceptors.request.use(config => {
console.log('config', config);
return config;
});
axiosInstance.interceptors.response.use(response => {
if (response.status === 200) {
return response.data;
}
return response;
});
return axiosInstance(config);
};
export default request;

View File

@@ -0,0 +1,236 @@
/**
* WebSocket通信服务模块
* 负责与后端WebSocket通信、连接管理和消息处理
*/
import { ref } from 'vue';
class WebSocketService {
constructor() {
this.socket = null;
this.wsUrl = ref('ws://127.0.0.1:5000/ws');
this.reconnectInterval = 1000; // 重连间隔(毫秒)
this.reconnectTimer = null;
this.pingInterval = null;
this.messageHandlers = new Map(); // 消息处理器映射
this.isConnecting = false;
}
/**
* 设置WebSocket服务器URL
* @param {string} url - WebSocket服务器URLws://或wss://协议)
*/
setWsUrl(url) {
this.wsUrl.value = url;
console.log('WebSocket URL已设置为:', url);
}
/**
* 注册消息处理器
* @param {string} messageType - 消息类型
* @param {Function} handler - 处理函数,接收消息数据参数
*/
registerHandler(messageType, handler) {
this.messageHandlers.set(messageType, handler);
}
/**
* 移除消息处理器
* @param {string} messageType - 要移除处理器的消息类型
*/
removeHandler(messageType) {
this.messageHandlers.delete(messageType);
}
/**
* 清除所有消息处理器
*/
clearHandlers() {
this.messageHandlers.clear();
}
/**
* 连接到WebSocket服务器
*/
connect() {
if (this.isConnecting || (this.socket && this.socket.readyState === WebSocket.OPEN)) {
console.log('WebSocket已连接或正在连接中不需要重新连接');
return;
}
this.isConnecting = true;
try {
console.log('尝试连接WebSocket:', this.wsUrl.value);
this.socket = new WebSocket(this.wsUrl.value);
this.socket.onopen = () => {
console.log('WebSocket连接成功');
this.isConnecting = false;
// 发送初始化请求,请求当前状态
this.sendMessage({ type: 'request_status' });
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
// 启动定期ping保持连接活跃
this.startPingInterval();
};
this.socket.onmessage = (event) => {
this.handleMessage(event);
};
this.socket.onclose = (event) => {
console.log(`WebSocket连接关闭代码: ${event.code},原因: ${event.reason}`);
this.isConnecting = false;
this.socket = null;
// 清理ping定时器
if (this.pingInterval) {
clearInterval(this.pingInterval);
this.pingInterval = null;
}
// 尝试重连
this.scheduleReconnect();
};
this.socket.onerror = (error) => {
console.error('WebSocket错误:', error);
this.isConnecting = false;
};
} catch (error) {
console.error('创建WebSocket连接错误:', error);
this.isConnecting = false;
// 连接失败时也应设置重连
this.scheduleReconnect();
}
}
/**
* 安排重新连接
*/
scheduleReconnect() {
if (!this.reconnectTimer) {
this.reconnectTimer = setTimeout(() => {
console.log('尝试重新连接WebSocket...');
this.reconnectTimer = null;
this.connect();
}, this.reconnectInterval);
}
}
/**
* 启动定期发送ping消息的定时器
*/
startPingInterval() {
// 清理现有的ping定时器
if (this.pingInterval) {
clearInterval(this.pingInterval);
}
// 每10秒发送一次ping消息保持连接
this.pingInterval = setInterval(() => {
this.sendPing();
}, 10000);
}
/**
* 发送ping消息
*/
sendPing() {
if (this.socket?.readyState === WebSocket.OPEN) {
console.log('发送ping消息保持连接');
this.sendMessage({ type: 'ping' });
}
}
/**
* 发送消息到WebSocket服务器
* @param {Object} data - 要发送的消息对象
* @returns {boolean} - 是否成功发送
*/
sendMessage(data) {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
console.warn('WebSocket未连接无法发送消息');
return false;
}
try {
this.socket.send(JSON.stringify(data));
return true;
} catch (error) {
console.error('发送WebSocket消息失败:', error);
return false;
}
}
/**
* 处理接收到的WebSocket消息
* @param {MessageEvent} event - WebSocket消息事件
*/
handleMessage(event) {
try {
const data = JSON.parse(event.data);
console.log('收到WebSocket消息类型:', data.type);
// 调用对应类型的消息处理器
if (data.type && this.messageHandlers.has(data.type)) {
this.messageHandlers.get(data.type)(data);
} else {
console.log('没有对应的处理器,消息类型:', data.type);
}
} catch (error) {
console.error('处理WebSocket消息错误:', error, '原始数据:', event.data);
}
}
/**
* 检查并恢复WebSocket连接
*/
checkAndRestoreConnection() {
console.log('检查WebSocket连接状态');
if (!this.socket || this.socket.readyState === WebSocket.CLOSED || this.socket.readyState === WebSocket.CLOSING) {
console.log('WebSocket未连接或已关闭尝试重新连接');
this.connect();
}
}
/**
* 关闭WebSocket连接
*/
disconnect() {
// 清理ping定时器
if (this.pingInterval) {
clearInterval(this.pingInterval);
this.pingInterval = null;
}
// 清理重连定时器
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
// 关闭WebSocket连接
if (this.socket) {
try {
this.socket.close();
} catch (error) {
console.error('关闭WebSocket连接错误:', error);
}
this.socket = null;
}
}
}
// 创建单例实例
const websocketService = new WebSocketService();
export default websocketService;

View File

@@ -0,0 +1,413 @@
<template>
<div class="search-page">
<div class="search-container">
<div class="search-form" v-if="!showResult">
<h2 class="search-title">{{ $t('message.searchLyrics') }}</h2>
<div class="form-group">
<label>{{ $t('message.trackName') }}</label>
<input
type="text"
v-model="trackName"
:placeholder="$t('message.enterTrackName')"
@keyup.enter="searchLyrics"
class="search-input"
>
</div>
<div class="form-group">
<label>{{ $t('message.artist') }}</label>
<input
type="text"
v-model="artist"
:placeholder="$t('message.enterArtistName')"
@keyup.enter="searchLyrics"
class="search-input"
>
</div>
<button
class="search-btn"
@click="searchLyrics"
:disabled="isSearching"
>
{{ isSearching ? $t('message.searching') : $t('message.search') }}
</button>
</div>
<!-- 搜索结果列表 -->
<div class="search-results" v-else>
<div class="search-results-header">
<h2>{{ $t('message.searchResults') }}</h2>
<button class="return-btn" @click="returnToSearch">
{{ $t('message.back') }}
</button>
</div>
<div class="results-list">
<div
v-for="(result, index) in searchResults"
:key="index"
class="result-item"
:class="{ 'selected': selectedIndex === index }"
@click="selectResult(index)"
>
<div class="result-info">
<div class="track-name">{{ result.name }}</div>
<div class="artist-name">{{ getArtistName(result.artists) }}</div>
</div>
<div class="result-actions">
<button
class="select-btn"
@click.stop="selectResult(index)"
>
{{ $t('message.select') }}
</button>
</div>
</div>
<div v-if="searchResults.length === 0">
<p>{{ $t('message.noLyricsFound') }}</p>
</div>
</div>
<!-- 错误提示 -->
<div class="error-message" v-if="error">
{{ error }}
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const trackName = ref('');
const artist = ref('');
const isSearching = ref(false);
const error = ref('');
const hasSearched = ref(false);
const selectedIndex = ref(-1);
const searchResults = ref([]);
const getArtistName = (artists) => {
return artists.map(artist => artist.name).join(', ');
};
import request from '../utils/request';
import { useLyricsStore } from '../store';
const lyricsStore = useLyricsStore();
const showResult = ref(false);
const searchLyrics = async () => {
if (!trackName.value && !artist.value) {
error.value = t('message.enterTrackOrArtist');
return;
}
isSearching.value = true;
error.value = '';
hasSearched.value = true;
selectedIndex.value = -1;
searchResults.value = [];
try {
const result = await request({
url: '/lyrics/search',
method: 'POST',
data: {
track_name: trackName.value,
artist: artist.value
}
});
console.log('result', result);
if (result.status === 'success') {
searchResults.value = result.songs;
showResult.value = true;
} else {
error.value = result.message || t('message.noLyricsFound');
}
} catch (err) {
console.error(t('message.searchError'), err);
error.value = t('message.searchFailed');
} finally {
isSearching.value = false;
}
};
const returnToSearch = () => {
showResult.value = false;
}
const selectResult = (index) => {
request({
url: '/lyrics/getLyricsFromId',
method: 'POST',
data: {
id: searchResults.value[index].id,
track_name: lyricsStore.trackName,
artist: lyricsStore.artist
}
});
};
const closeWindow = () => {
if (window.electronAPI) {
window.electronAPI.closeSearchWindow();
}
};
onMounted(() => {
// 监听主窗口数据
if (window.electronAPI && window.electronAPI.onMainWindowData) {
window.electronAPI.onMainWindowData((data) => {
console.log('收到主窗口数据:', data);
trackName.value = data.trackName;
artist.value = data.artist;
lyricsStore.$patch({
'trackName': data.trackName,
'artist': data.artist,
'baseUrl': data.baseUrl
})
});
}
});
</script>
<style scoped>
.search-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #1a1a24;
}
.search-container {
background: #1e1e28;
padding: 30px;
border-radius: 12px;
width: 600px;
height: 600px;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
display: flex;
justify-content: center;
align-items: center;
}
.search-header {
display: flex;
justify-content: space-between;
align-items: center;
}
h2.search-title {
color: #fff;
margin: 0;
font-size: 24px;
}
.close-btn {
background: none;
border: none;
color: #666;
font-size: 24px;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.2s ease;
}
.close-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.search-form {
display: flex;
flex-direction: column;
width: 60%;
gap: 20px;
margin-bottom: 30px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
label {
color: #aaf;
font-size: 14px;
}
.search-input {
padding: 12px;
border: 1px solid #333;
border-radius: 6px;
background: #2a2a35;
color: #fff;
font-size: 16px;
transition: all 0.2s ease;
}
.search-input:focus {
outline: none;
border-color: #00e6ff;
box-shadow: 0 0 0 2px rgba(0, 230, 255, 0.2);
}
.search-btn {
padding: 12px;
border: none;
border-radius: 6px;
background: #00e6ff;
color: #000;
font-weight: bold;
font-size: 16px;
cursor: pointer;
transition: all 0.2s ease;
}
.search-btn:hover:not(:disabled) {
background: #00c4e0;
transform: translateY(-1px);
}
.search-btn:disabled {
background: #666;
cursor: not-allowed;
}
.search-results {
margin-top: 20px;
width: 100%;
height: 100%;
}
.search-results-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
}
h2 {
color: #fff;
font-size: 18px;
}
.return-btn {
background: none;
border: none;
color: #666;
font-size: 18px;
cursor: pointer;
width: 100px;
line-height: 18px;
height: 40px;
border-radius: 6px;
white-space: nowrap;
}
.results-list {
display: flex;
flex-direction: column;
gap: 10px;
overflow-y: auto;
}
.result-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
background: #2a2a35;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.result-item:hover {
background: #333340;
}
.result-item.selected {
background: #333340;
border: 1px solid #00e6ff;
}
.result-info {
flex: 1;
}
.track-name {
color: #fff;
font-size: 16px;
margin-bottom: 4px;
}
.artist-name {
color: #aaf;
font-size: 14px;
}
.result-actions {
margin-left: 15px;
}
.select-btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
background: #00e6ff;
color: #000;
font-weight: bold;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
}
.select-btn:hover {
background: #00c4e0;
}
.no-results {
text-align: center;
color: #666;
padding: 30px 0;
}
.error-message {
margin-top: 20px;
padding: 12px;
background: rgba(255, 107, 107, 0.1);
border: 1px solid #ff6b6b;
border-radius: 6px;
color: #ff6b6b;
text-align: center;
}
/* 自定义滚动条样式 */
.results-list::-webkit-scrollbar {
width: 8px;
}
.results-list::-webkit-scrollbar-track {
background: #1e1e28;
border-radius: 4px;
}
.results-list::-webkit-scrollbar-thumb {
background: #333340;
border-radius: 4px;
}
.results-list::-webkit-scrollbar-thumb:hover {
background: #444450;
}
</style>

19
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
server: {
host: '127.0.0.1',
port: 5173,
strictPort: false, // 当端口被占用时自动尝试下一个可用端口
cors: true
},
build: {
outDir: 'dist',
assetsDir: 'assets',
// 使用相对路径而不是绝对路径
base: './'
}
})

53
node_modules/.package-lock.json generated vendored Normal file
View File

@@ -0,0 +1,53 @@
{
"name": "lyroc",
"version": "0.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"node_modules/fs-extra": {
"version": "11.3.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz",
"integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=14.14"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/jsonfile": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
"integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
}
}
}

15
node_modules/fs-extra/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,15 @@
(The MIT License)
Copyright (c) 2011-2024 JP Richardson
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files
(the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify,
merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

292
node_modules/fs-extra/README.md generated vendored Normal file
View File

@@ -0,0 +1,292 @@
Node.js: fs-extra
=================
`fs-extra` adds file system methods that aren't included in the native `fs` module and adds promise support to the `fs` methods. It also uses [`graceful-fs`](https://github.com/isaacs/node-graceful-fs) to prevent `EMFILE` errors. It should be a drop in replacement for `fs`.
[![npm Package](https://img.shields.io/npm/v/fs-extra.svg)](https://www.npmjs.org/package/fs-extra)
[![License](https://img.shields.io/npm/l/fs-extra.svg)](https://github.com/jprichardson/node-fs-extra/blob/master/LICENSE)
[![build status](https://img.shields.io/github/actions/workflow/status/jprichardson/node-fs-extra/ci.yml?branch=master)](https://github.com/jprichardson/node-fs-extra/actions/workflows/ci.yml?query=branch%3Amaster)
[![downloads per month](http://img.shields.io/npm/dm/fs-extra.svg)](https://www.npmjs.org/package/fs-extra)
[![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com)
Why?
----
I got tired of including `mkdirp`, `rimraf`, and `ncp` in most of my projects.
Installation
------------
npm install fs-extra
Usage
-----
### CommonJS
`fs-extra` is a drop in replacement for native `fs`. All methods in `fs` are attached to `fs-extra`. All `fs` methods return promises if the callback isn't passed.
You don't ever need to include the original `fs` module again:
```js
const fs = require('fs') // this is no longer necessary
```
you can now do this:
```js
const fs = require('fs-extra')
```
or if you prefer to make it clear that you're using `fs-extra` and not `fs`, you may want
to name your `fs` variable `fse` like so:
```js
const fse = require('fs-extra')
```
you can also keep both, but it's redundant:
```js
const fs = require('fs')
const fse = require('fs-extra')
```
### ESM
There is also an `fs-extra/esm` import, that supports both default and named exports. However, note that `fs` methods are not included in `fs-extra/esm`; you still need to import `fs` and/or `fs/promises` seperately:
```js
import { readFileSync } from 'fs'
import { readFile } from 'fs/promises'
import { outputFile, outputFileSync } from 'fs-extra/esm'
```
Default exports are supported:
```js
import fs from 'fs'
import fse from 'fs-extra/esm'
// fse.readFileSync is not a function; must use fs.readFileSync
```
but you probably want to just use regular `fs-extra` instead of `fs-extra/esm` for default exports:
```js
import fs from 'fs-extra'
// both fs and fs-extra methods are defined
```
Sync vs Async vs Async/Await
-------------
Most methods are async by default. All async methods will return a promise if the callback isn't passed.
Sync methods on the other hand will throw if an error occurs.
Also Async/Await will throw an error if one occurs.
Example:
```js
const fs = require('fs-extra')
// Async with promises:
fs.copy('/tmp/myfile', '/tmp/mynewfile')
.then(() => console.log('success!'))
.catch(err => console.error(err))
// Async with callbacks:
fs.copy('/tmp/myfile', '/tmp/mynewfile', err => {
if (err) return console.error(err)
console.log('success!')
})
// Sync:
try {
fs.copySync('/tmp/myfile', '/tmp/mynewfile')
console.log('success!')
} catch (err) {
console.error(err)
}
// Async/Await:
async function copyFiles () {
try {
await fs.copy('/tmp/myfile', '/tmp/mynewfile')
console.log('success!')
} catch (err) {
console.error(err)
}
}
copyFiles()
```
Methods
-------
### Async
- [copy](docs/copy.md)
- [emptyDir](docs/emptyDir.md)
- [ensureFile](docs/ensureFile.md)
- [ensureDir](docs/ensureDir.md)
- [ensureLink](docs/ensureLink.md)
- [ensureSymlink](docs/ensureSymlink.md)
- [mkdirp](docs/ensureDir.md)
- [mkdirs](docs/ensureDir.md)
- [move](docs/move.md)
- [outputFile](docs/outputFile.md)
- [outputJson](docs/outputJson.md)
- [pathExists](docs/pathExists.md)
- [readJson](docs/readJson.md)
- [remove](docs/remove.md)
- [writeJson](docs/writeJson.md)
### Sync
- [copySync](docs/copy-sync.md)
- [emptyDirSync](docs/emptyDir-sync.md)
- [ensureFileSync](docs/ensureFile-sync.md)
- [ensureDirSync](docs/ensureDir-sync.md)
- [ensureLinkSync](docs/ensureLink-sync.md)
- [ensureSymlinkSync](docs/ensureSymlink-sync.md)
- [mkdirpSync](docs/ensureDir-sync.md)
- [mkdirsSync](docs/ensureDir-sync.md)
- [moveSync](docs/move-sync.md)
- [outputFileSync](docs/outputFile-sync.md)
- [outputJsonSync](docs/outputJson-sync.md)
- [pathExistsSync](docs/pathExists-sync.md)
- [readJsonSync](docs/readJson-sync.md)
- [removeSync](docs/remove-sync.md)
- [writeJsonSync](docs/writeJson-sync.md)
**NOTE:** You can still use the native Node.js methods. They are promisified and copied over to `fs-extra`. See [notes on `fs.read()`, `fs.write()`, & `fs.writev()`](docs/fs-read-write-writev.md)
### What happened to `walk()` and `walkSync()`?
They were removed from `fs-extra` in v2.0.0. If you need the functionality, `walk` and `walkSync` are available as separate packages, [`klaw`](https://github.com/jprichardson/node-klaw) and [`klaw-sync`](https://github.com/manidlou/node-klaw-sync).
Third Party
-----------
### CLI
[fse-cli](https://www.npmjs.com/package/@atao60/fse-cli) allows you to run `fs-extra` from a console or from [npm](https://www.npmjs.com) scripts.
### TypeScript
If you like TypeScript, you can use `fs-extra` with it: https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/fs-extra
### File / Directory Watching
If you want to watch for changes to files or directories, then you should use [chokidar](https://github.com/paulmillr/chokidar).
### Obtain Filesystem (Devices, Partitions) Information
[fs-filesystem](https://github.com/arthurintelligence/node-fs-filesystem) allows you to read the state of the filesystem of the host on which it is run. It returns information about both the devices and the partitions (volumes) of the system.
### Misc.
- [fs-extra-debug](https://github.com/jdxcode/fs-extra-debug) - Send your fs-extra calls to [debug](https://npmjs.org/package/debug).
- [mfs](https://github.com/cadorn/mfs) - Monitor your fs-extra calls.
Hacking on fs-extra
-------------------
Wanna hack on `fs-extra`? Great! Your help is needed! [fs-extra is one of the most depended upon Node.js packages](http://nodei.co/npm/fs-extra.png?downloads=true&downloadRank=true&stars=true). This project
uses [JavaScript Standard Style](https://github.com/feross/standard) - if the name or style choices bother you,
you're gonna have to get over it :) If `standard` is good enough for `npm`, it's good enough for `fs-extra`.
[![js-standard-style](https://cdn.rawgit.com/feross/standard/master/badge.svg)](https://github.com/feross/standard)
What's needed?
- First, take a look at existing issues. Those are probably going to be where the priority lies.
- More tests for edge cases. Specifically on different platforms. There can never be enough tests.
- Improve test coverage.
Note: If you make any big changes, **you should definitely file an issue for discussion first.**
### Running the Test Suite
fs-extra contains hundreds of tests.
- `npm run lint`: runs the linter ([standard](http://standardjs.com/))
- `npm run unit`: runs the unit tests
- `npm run unit-esm`: runs tests for `fs-extra/esm` exports
- `npm test`: runs the linter and all tests
When running unit tests, set the environment variable `CROSS_DEVICE_PATH` to the absolute path of an empty directory on another device (like a thumb drive) to enable cross-device move tests.
### Windows
If you run the tests on the Windows and receive a lot of symbolic link `EPERM` permission errors, it's
because on Windows you need elevated privilege to create symbolic links. You can add this to your Windows's
account by following the instructions here: http://superuser.com/questions/104845/permission-to-make-symbolic-links-in-windows-7
However, I didn't have much luck doing this.
Since I develop on Mac OS X, I use VMWare Fusion for Windows testing. I create a shared folder that I map to a drive on Windows.
I open the `Node.js command prompt` and run as `Administrator`. I then map the network drive running the following command:
net use z: "\\vmware-host\Shared Folders"
I can then navigate to my `fs-extra` directory and run the tests.
Naming
------
I put a lot of thought into the naming of these functions. Inspired by @coolaj86's request. So he deserves much of the credit for raising the issue. See discussion(s) here:
* https://github.com/jprichardson/node-fs-extra/issues/2
* https://github.com/flatiron/utile/issues/11
* https://github.com/ryanmcgrath/wrench-js/issues/29
* https://github.com/substack/node-mkdirp/issues/17
First, I believe that in as many cases as possible, the [Node.js naming schemes](http://nodejs.org/api/fs.html) should be chosen. However, there are problems with the Node.js own naming schemes.
For example, `fs.readFile()` and `fs.readdir()`: the **F** is capitalized in *File* and the **d** is not capitalized in *dir*. Perhaps a bit pedantic, but they should still be consistent. Also, Node.js has chosen a lot of POSIX naming schemes, which I believe is great. See: `fs.mkdir()`, `fs.rmdir()`, `fs.chown()`, etc.
We have a dilemma though. How do you consistently name methods that perform the following POSIX commands: `cp`, `cp -r`, `mkdir -p`, and `rm -rf`?
My perspective: when in doubt, err on the side of simplicity. A directory is just a hierarchical grouping of directories and files. Consider that for a moment. So when you want to copy it or remove it, in most cases you'll want to copy or remove all of its contents. When you want to create a directory, if the directory that it's suppose to be contained in does not exist, then in most cases you'll want to create that too.
So, if you want to remove a file or a directory regardless of whether it has contents, just call `fs.remove(path)`. If you want to copy a file or a directory whether it has contents, just call `fs.copy(source, destination)`. If you want to create a directory regardless of whether its parent directories exist, just call `fs.mkdirs(path)` or `fs.mkdirp(path)`.
Credit
------
`fs-extra` wouldn't be possible without using the modules from the following authors:
- [Isaac Shlueter](https://github.com/isaacs)
- [Charlie McConnel](https://github.com/avianflu)
- [James Halliday](https://github.com/substack)
- [Andrew Kelley](https://github.com/andrewrk)
License
-------
Licensed under MIT
Copyright (c) 2011-2024 [JP Richardson](https://github.com/jprichardson)
[1]: http://nodejs.org/docs/latest/api/fs.html
[jsonfile]: https://github.com/jprichardson/node-jsonfile

171
node_modules/fs-extra/lib/copy/copy-sync.js generated vendored Normal file
View File

@@ -0,0 +1,171 @@
'use strict'
const fs = require('graceful-fs')
const path = require('path')
const mkdirsSync = require('../mkdirs').mkdirsSync
const utimesMillisSync = require('../util/utimes').utimesMillisSync
const stat = require('../util/stat')
function copySync (src, dest, opts) {
if (typeof opts === 'function') {
opts = { filter: opts }
}
opts = opts || {}
opts.clobber = 'clobber' in opts ? !!opts.clobber : true // default to true for now
opts.overwrite = 'overwrite' in opts ? !!opts.overwrite : opts.clobber // overwrite falls back to clobber
// Warn about using preserveTimestamps on 32-bit node
if (opts.preserveTimestamps && process.arch === 'ia32') {
process.emitWarning(
'Using the preserveTimestamps option in 32-bit node is not recommended;\n\n' +
'\tsee https://github.com/jprichardson/node-fs-extra/issues/269',
'Warning', 'fs-extra-WARN0002'
)
}
const { srcStat, destStat } = stat.checkPathsSync(src, dest, 'copy', opts)
stat.checkParentPathsSync(src, srcStat, dest, 'copy')
if (opts.filter && !opts.filter(src, dest)) return
const destParent = path.dirname(dest)
if (!fs.existsSync(destParent)) mkdirsSync(destParent)
return getStats(destStat, src, dest, opts)
}
function getStats (destStat, src, dest, opts) {
const statSync = opts.dereference ? fs.statSync : fs.lstatSync
const srcStat = statSync(src)
if (srcStat.isDirectory()) return onDir(srcStat, destStat, src, dest, opts)
else if (srcStat.isFile() ||
srcStat.isCharacterDevice() ||
srcStat.isBlockDevice()) return onFile(srcStat, destStat, src, dest, opts)
else if (srcStat.isSymbolicLink()) return onLink(destStat, src, dest, opts)
else if (srcStat.isSocket()) throw new Error(`Cannot copy a socket file: ${src}`)
else if (srcStat.isFIFO()) throw new Error(`Cannot copy a FIFO pipe: ${src}`)
throw new Error(`Unknown file: ${src}`)
}
function onFile (srcStat, destStat, src, dest, opts) {
if (!destStat) return copyFile(srcStat, src, dest, opts)
return mayCopyFile(srcStat, src, dest, opts)
}
function mayCopyFile (srcStat, src, dest, opts) {
if (opts.overwrite) {
fs.unlinkSync(dest)
return copyFile(srcStat, src, dest, opts)
} else if (opts.errorOnExist) {
throw new Error(`'${dest}' already exists`)
}
}
function copyFile (srcStat, src, dest, opts) {
fs.copyFileSync(src, dest)
if (opts.preserveTimestamps) handleTimestamps(srcStat.mode, src, dest)
return setDestMode(dest, srcStat.mode)
}
function handleTimestamps (srcMode, src, dest) {
// Make sure the file is writable before setting the timestamp
// otherwise open fails with EPERM when invoked with 'r+'
// (through utimes call)
if (fileIsNotWritable(srcMode)) makeFileWritable(dest, srcMode)
return setDestTimestamps(src, dest)
}
function fileIsNotWritable (srcMode) {
return (srcMode & 0o200) === 0
}
function makeFileWritable (dest, srcMode) {
return setDestMode(dest, srcMode | 0o200)
}
function setDestMode (dest, srcMode) {
return fs.chmodSync(dest, srcMode)
}
function setDestTimestamps (src, dest) {
// The initial srcStat.atime cannot be trusted
// because it is modified by the read(2) system call
// (See https://nodejs.org/api/fs.html#fs_stat_time_values)
const updatedSrcStat = fs.statSync(src)
return utimesMillisSync(dest, updatedSrcStat.atime, updatedSrcStat.mtime)
}
function onDir (srcStat, destStat, src, dest, opts) {
if (!destStat) return mkDirAndCopy(srcStat.mode, src, dest, opts)
return copyDir(src, dest, opts)
}
function mkDirAndCopy (srcMode, src, dest, opts) {
fs.mkdirSync(dest)
copyDir(src, dest, opts)
return setDestMode(dest, srcMode)
}
function copyDir (src, dest, opts) {
const dir = fs.opendirSync(src)
try {
let dirent
while ((dirent = dir.readSync()) !== null) {
copyDirItem(dirent.name, src, dest, opts)
}
} finally {
dir.closeSync()
}
}
function copyDirItem (item, src, dest, opts) {
const srcItem = path.join(src, item)
const destItem = path.join(dest, item)
if (opts.filter && !opts.filter(srcItem, destItem)) return
const { destStat } = stat.checkPathsSync(srcItem, destItem, 'copy', opts)
return getStats(destStat, srcItem, destItem, opts)
}
function onLink (destStat, src, dest, opts) {
let resolvedSrc = fs.readlinkSync(src)
if (opts.dereference) {
resolvedSrc = path.resolve(process.cwd(), resolvedSrc)
}
if (!destStat) {
return fs.symlinkSync(resolvedSrc, dest)
} else {
let resolvedDest
try {
resolvedDest = fs.readlinkSync(dest)
} catch (err) {
// dest exists and is a regular file or directory,
// Windows may throw UNKNOWN error. If dest already exists,
// fs throws error anyway, so no need to guard against it here.
if (err.code === 'EINVAL' || err.code === 'UNKNOWN') return fs.symlinkSync(resolvedSrc, dest)
throw err
}
if (opts.dereference) {
resolvedDest = path.resolve(process.cwd(), resolvedDest)
}
if (stat.isSrcSubdir(resolvedSrc, resolvedDest)) {
throw new Error(`Cannot copy '${resolvedSrc}' to a subdirectory of itself, '${resolvedDest}'.`)
}
// prevent copy if src is a subdir of dest since unlinking
// dest in this case would result in removing src contents
// and therefore a broken symlink would be created.
if (stat.isSrcSubdir(resolvedDest, resolvedSrc)) {
throw new Error(`Cannot overwrite '${resolvedDest}' with '${resolvedSrc}'.`)
}
return copyLink(resolvedSrc, dest)
}
}
function copyLink (resolvedSrc, dest) {
fs.unlinkSync(dest)
return fs.symlinkSync(resolvedSrc, dest)
}
module.exports = copySync

182
node_modules/fs-extra/lib/copy/copy.js generated vendored Normal file
View File

@@ -0,0 +1,182 @@
'use strict'
const fs = require('../fs')
const path = require('path')
const { mkdirs } = require('../mkdirs')
const { pathExists } = require('../path-exists')
const { utimesMillis } = require('../util/utimes')
const stat = require('../util/stat')
async function copy (src, dest, opts = {}) {
if (typeof opts === 'function') {
opts = { filter: opts }
}
opts.clobber = 'clobber' in opts ? !!opts.clobber : true // default to true for now
opts.overwrite = 'overwrite' in opts ? !!opts.overwrite : opts.clobber // overwrite falls back to clobber
// Warn about using preserveTimestamps on 32-bit node
if (opts.preserveTimestamps && process.arch === 'ia32') {
process.emitWarning(
'Using the preserveTimestamps option in 32-bit node is not recommended;\n\n' +
'\tsee https://github.com/jprichardson/node-fs-extra/issues/269',
'Warning', 'fs-extra-WARN0001'
)
}
const { srcStat, destStat } = await stat.checkPaths(src, dest, 'copy', opts)
await stat.checkParentPaths(src, srcStat, dest, 'copy')
const include = await runFilter(src, dest, opts)
if (!include) return
// check if the parent of dest exists, and create it if it doesn't exist
const destParent = path.dirname(dest)
const dirExists = await pathExists(destParent)
if (!dirExists) {
await mkdirs(destParent)
}
await getStatsAndPerformCopy(destStat, src, dest, opts)
}
async function runFilter (src, dest, opts) {
if (!opts.filter) return true
return opts.filter(src, dest)
}
async function getStatsAndPerformCopy (destStat, src, dest, opts) {
const statFn = opts.dereference ? fs.stat : fs.lstat
const srcStat = await statFn(src)
if (srcStat.isDirectory()) return onDir(srcStat, destStat, src, dest, opts)
if (
srcStat.isFile() ||
srcStat.isCharacterDevice() ||
srcStat.isBlockDevice()
) return onFile(srcStat, destStat, src, dest, opts)
if (srcStat.isSymbolicLink()) return onLink(destStat, src, dest, opts)
if (srcStat.isSocket()) throw new Error(`Cannot copy a socket file: ${src}`)
if (srcStat.isFIFO()) throw new Error(`Cannot copy a FIFO pipe: ${src}`)
throw new Error(`Unknown file: ${src}`)
}
async function onFile (srcStat, destStat, src, dest, opts) {
if (!destStat) return copyFile(srcStat, src, dest, opts)
if (opts.overwrite) {
await fs.unlink(dest)
return copyFile(srcStat, src, dest, opts)
}
if (opts.errorOnExist) {
throw new Error(`'${dest}' already exists`)
}
}
async function copyFile (srcStat, src, dest, opts) {
await fs.copyFile(src, dest)
if (opts.preserveTimestamps) {
// Make sure the file is writable before setting the timestamp
// otherwise open fails with EPERM when invoked with 'r+'
// (through utimes call)
if (fileIsNotWritable(srcStat.mode)) {
await makeFileWritable(dest, srcStat.mode)
}
// Set timestamps and mode correspondingly
// Note that The initial srcStat.atime cannot be trusted
// because it is modified by the read(2) system call
// (See https://nodejs.org/api/fs.html#fs_stat_time_values)
const updatedSrcStat = await fs.stat(src)
await utimesMillis(dest, updatedSrcStat.atime, updatedSrcStat.mtime)
}
return fs.chmod(dest, srcStat.mode)
}
function fileIsNotWritable (srcMode) {
return (srcMode & 0o200) === 0
}
function makeFileWritable (dest, srcMode) {
return fs.chmod(dest, srcMode | 0o200)
}
async function onDir (srcStat, destStat, src, dest, opts) {
// the dest directory might not exist, create it
if (!destStat) {
await fs.mkdir(dest)
}
const promises = []
// loop through the files in the current directory to copy everything
for await (const item of await fs.opendir(src)) {
const srcItem = path.join(src, item.name)
const destItem = path.join(dest, item.name)
promises.push(
runFilter(srcItem, destItem, opts).then(include => {
if (include) {
// only copy the item if it matches the filter function
return stat.checkPaths(srcItem, destItem, 'copy', opts).then(({ destStat }) => {
// If the item is a copyable file, `getStatsAndPerformCopy` will copy it
// If the item is a directory, `getStatsAndPerformCopy` will call `onDir` recursively
return getStatsAndPerformCopy(destStat, srcItem, destItem, opts)
})
}
})
)
}
await Promise.all(promises)
if (!destStat) {
await fs.chmod(dest, srcStat.mode)
}
}
async function onLink (destStat, src, dest, opts) {
let resolvedSrc = await fs.readlink(src)
if (opts.dereference) {
resolvedSrc = path.resolve(process.cwd(), resolvedSrc)
}
if (!destStat) {
return fs.symlink(resolvedSrc, dest)
}
let resolvedDest = null
try {
resolvedDest = await fs.readlink(dest)
} catch (e) {
// dest exists and is a regular file or directory,
// Windows may throw UNKNOWN error. If dest already exists,
// fs throws error anyway, so no need to guard against it here.
if (e.code === 'EINVAL' || e.code === 'UNKNOWN') return fs.symlink(resolvedSrc, dest)
throw e
}
if (opts.dereference) {
resolvedDest = path.resolve(process.cwd(), resolvedDest)
}
if (stat.isSrcSubdir(resolvedSrc, resolvedDest)) {
throw new Error(`Cannot copy '${resolvedSrc}' to a subdirectory of itself, '${resolvedDest}'.`)
}
// do not copy if src is a subdir of dest since unlinking
// dest in this case would result in removing src contents
// and therefore a broken symlink would be created.
if (stat.isSrcSubdir(resolvedDest, resolvedSrc)) {
throw new Error(`Cannot overwrite '${resolvedDest}' with '${resolvedSrc}'.`)
}
// copy the link
await fs.unlink(dest)
return fs.symlink(resolvedSrc, dest)
}
module.exports = copy

7
node_modules/fs-extra/lib/copy/index.js generated vendored Normal file
View File

@@ -0,0 +1,7 @@
'use strict'
const u = require('universalify').fromPromise
module.exports = {
copy: u(require('./copy')),
copySync: require('./copy-sync')
}

39
node_modules/fs-extra/lib/empty/index.js generated vendored Normal file
View File

@@ -0,0 +1,39 @@
'use strict'
const u = require('universalify').fromPromise
const fs = require('../fs')
const path = require('path')
const mkdir = require('../mkdirs')
const remove = require('../remove')
const emptyDir = u(async function emptyDir (dir) {
let items
try {
items = await fs.readdir(dir)
} catch {
return mkdir.mkdirs(dir)
}
return Promise.all(items.map(item => remove.remove(path.join(dir, item))))
})
function emptyDirSync (dir) {
let items
try {
items = fs.readdirSync(dir)
} catch {
return mkdir.mkdirsSync(dir)
}
items.forEach(item => {
item = path.join(dir, item)
remove.removeSync(item)
})
}
module.exports = {
emptyDirSync,
emptydirSync: emptyDirSync,
emptyDir,
emptydir: emptyDir
}

66
node_modules/fs-extra/lib/ensure/file.js generated vendored Normal file
View File

@@ -0,0 +1,66 @@
'use strict'
const u = require('universalify').fromPromise
const path = require('path')
const fs = require('../fs')
const mkdir = require('../mkdirs')
async function createFile (file) {
let stats
try {
stats = await fs.stat(file)
} catch { }
if (stats && stats.isFile()) return
const dir = path.dirname(file)
let dirStats = null
try {
dirStats = await fs.stat(dir)
} catch (err) {
// if the directory doesn't exist, make it
if (err.code === 'ENOENT') {
await mkdir.mkdirs(dir)
await fs.writeFile(file, '')
return
} else {
throw err
}
}
if (dirStats.isDirectory()) {
await fs.writeFile(file, '')
} else {
// parent is not a directory
// This is just to cause an internal ENOTDIR error to be thrown
await fs.readdir(dir)
}
}
function createFileSync (file) {
let stats
try {
stats = fs.statSync(file)
} catch { }
if (stats && stats.isFile()) return
const dir = path.dirname(file)
try {
if (!fs.statSync(dir).isDirectory()) {
// parent is not a directory
// This is just to cause an internal ENOTDIR error to be thrown
fs.readdirSync(dir)
}
} catch (err) {
// If the stat call above failed because the directory doesn't exist, create it
if (err && err.code === 'ENOENT') mkdir.mkdirsSync(dir)
else throw err
}
fs.writeFileSync(file, '')
}
module.exports = {
createFile: u(createFile),
createFileSync
}

23
node_modules/fs-extra/lib/ensure/index.js generated vendored Normal file
View File

@@ -0,0 +1,23 @@
'use strict'
const { createFile, createFileSync } = require('./file')
const { createLink, createLinkSync } = require('./link')
const { createSymlink, createSymlinkSync } = require('./symlink')
module.exports = {
// file
createFile,
createFileSync,
ensureFile: createFile,
ensureFileSync: createFileSync,
// link
createLink,
createLinkSync,
ensureLink: createLink,
ensureLinkSync: createLinkSync,
// symlink
createSymlink,
createSymlinkSync,
ensureSymlink: createSymlink,
ensureSymlinkSync: createSymlinkSync
}

64
node_modules/fs-extra/lib/ensure/link.js generated vendored Normal file
View File

@@ -0,0 +1,64 @@
'use strict'
const u = require('universalify').fromPromise
const path = require('path')
const fs = require('../fs')
const mkdir = require('../mkdirs')
const { pathExists } = require('../path-exists')
const { areIdentical } = require('../util/stat')
async function createLink (srcpath, dstpath) {
let dstStat
try {
dstStat = await fs.lstat(dstpath)
} catch {
// ignore error
}
let srcStat
try {
srcStat = await fs.lstat(srcpath)
} catch (err) {
err.message = err.message.replace('lstat', 'ensureLink')
throw err
}
if (dstStat && areIdentical(srcStat, dstStat)) return
const dir = path.dirname(dstpath)
const dirExists = await pathExists(dir)
if (!dirExists) {
await mkdir.mkdirs(dir)
}
await fs.link(srcpath, dstpath)
}
function createLinkSync (srcpath, dstpath) {
let dstStat
try {
dstStat = fs.lstatSync(dstpath)
} catch {}
try {
const srcStat = fs.lstatSync(srcpath)
if (dstStat && areIdentical(srcStat, dstStat)) return
} catch (err) {
err.message = err.message.replace('lstat', 'ensureLink')
throw err
}
const dir = path.dirname(dstpath)
const dirExists = fs.existsSync(dir)
if (dirExists) return fs.linkSync(srcpath, dstpath)
mkdir.mkdirsSync(dir)
return fs.linkSync(srcpath, dstpath)
}
module.exports = {
createLink: u(createLink),
createLinkSync
}

101
node_modules/fs-extra/lib/ensure/symlink-paths.js generated vendored Normal file
View File

@@ -0,0 +1,101 @@
'use strict'
const path = require('path')
const fs = require('../fs')
const { pathExists } = require('../path-exists')
const u = require('universalify').fromPromise
/**
* Function that returns two types of paths, one relative to symlink, and one
* relative to the current working directory. Checks if path is absolute or
* relative. If the path is relative, this function checks if the path is
* relative to symlink or relative to current working directory. This is an
* initiative to find a smarter `srcpath` to supply when building symlinks.
* This allows you to determine which path to use out of one of three possible
* types of source paths. The first is an absolute path. This is detected by
* `path.isAbsolute()`. When an absolute path is provided, it is checked to
* see if it exists. If it does it's used, if not an error is returned
* (callback)/ thrown (sync). The other two options for `srcpath` are a
* relative url. By default Node's `fs.symlink` works by creating a symlink
* using `dstpath` and expects the `srcpath` to be relative to the newly
* created symlink. If you provide a `srcpath` that does not exist on the file
* system it results in a broken symlink. To minimize this, the function
* checks to see if the 'relative to symlink' source file exists, and if it
* does it will use it. If it does not, it checks if there's a file that
* exists that is relative to the current working directory, if does its used.
* This preserves the expectations of the original fs.symlink spec and adds
* the ability to pass in `relative to current working direcotry` paths.
*/
async function symlinkPaths (srcpath, dstpath) {
if (path.isAbsolute(srcpath)) {
try {
await fs.lstat(srcpath)
} catch (err) {
err.message = err.message.replace('lstat', 'ensureSymlink')
throw err
}
return {
toCwd: srcpath,
toDst: srcpath
}
}
const dstdir = path.dirname(dstpath)
const relativeToDst = path.join(dstdir, srcpath)
const exists = await pathExists(relativeToDst)
if (exists) {
return {
toCwd: relativeToDst,
toDst: srcpath
}
}
try {
await fs.lstat(srcpath)
} catch (err) {
err.message = err.message.replace('lstat', 'ensureSymlink')
throw err
}
return {
toCwd: srcpath,
toDst: path.relative(dstdir, srcpath)
}
}
function symlinkPathsSync (srcpath, dstpath) {
if (path.isAbsolute(srcpath)) {
const exists = fs.existsSync(srcpath)
if (!exists) throw new Error('absolute srcpath does not exist')
return {
toCwd: srcpath,
toDst: srcpath
}
}
const dstdir = path.dirname(dstpath)
const relativeToDst = path.join(dstdir, srcpath)
const exists = fs.existsSync(relativeToDst)
if (exists) {
return {
toCwd: relativeToDst,
toDst: srcpath
}
}
const srcExists = fs.existsSync(srcpath)
if (!srcExists) throw new Error('relative srcpath does not exist')
return {
toCwd: srcpath,
toDst: path.relative(dstdir, srcpath)
}
}
module.exports = {
symlinkPaths: u(symlinkPaths),
symlinkPathsSync
}

34
node_modules/fs-extra/lib/ensure/symlink-type.js generated vendored Normal file
View File

@@ -0,0 +1,34 @@
'use strict'
const fs = require('../fs')
const u = require('universalify').fromPromise
async function symlinkType (srcpath, type) {
if (type) return type
let stats
try {
stats = await fs.lstat(srcpath)
} catch {
return 'file'
}
return (stats && stats.isDirectory()) ? 'dir' : 'file'
}
function symlinkTypeSync (srcpath, type) {
if (type) return type
let stats
try {
stats = fs.lstatSync(srcpath)
} catch {
return 'file'
}
return (stats && stats.isDirectory()) ? 'dir' : 'file'
}
module.exports = {
symlinkType: u(symlinkType),
symlinkTypeSync
}

67
node_modules/fs-extra/lib/ensure/symlink.js generated vendored Normal file
View File

@@ -0,0 +1,67 @@
'use strict'
const u = require('universalify').fromPromise
const path = require('path')
const fs = require('../fs')
const { mkdirs, mkdirsSync } = require('../mkdirs')
const { symlinkPaths, symlinkPathsSync } = require('./symlink-paths')
const { symlinkType, symlinkTypeSync } = require('./symlink-type')
const { pathExists } = require('../path-exists')
const { areIdentical } = require('../util/stat')
async function createSymlink (srcpath, dstpath, type) {
let stats
try {
stats = await fs.lstat(dstpath)
} catch { }
if (stats && stats.isSymbolicLink()) {
const [srcStat, dstStat] = await Promise.all([
fs.stat(srcpath),
fs.stat(dstpath)
])
if (areIdentical(srcStat, dstStat)) return
}
const relative = await symlinkPaths(srcpath, dstpath)
srcpath = relative.toDst
const toType = await symlinkType(relative.toCwd, type)
const dir = path.dirname(dstpath)
if (!(await pathExists(dir))) {
await mkdirs(dir)
}
return fs.symlink(srcpath, dstpath, toType)
}
function createSymlinkSync (srcpath, dstpath, type) {
let stats
try {
stats = fs.lstatSync(dstpath)
} catch { }
if (stats && stats.isSymbolicLink()) {
const srcStat = fs.statSync(srcpath)
const dstStat = fs.statSync(dstpath)
if (areIdentical(srcStat, dstStat)) return
}
const relative = symlinkPathsSync(srcpath, dstpath)
srcpath = relative.toDst
type = symlinkTypeSync(relative.toCwd, type)
const dir = path.dirname(dstpath)
const exists = fs.existsSync(dir)
if (exists) return fs.symlinkSync(srcpath, dstpath, type)
mkdirsSync(dir)
return fs.symlinkSync(srcpath, dstpath, type)
}
module.exports = {
createSymlink: u(createSymlink),
createSymlinkSync
}

68
node_modules/fs-extra/lib/esm.mjs generated vendored Normal file
View File

@@ -0,0 +1,68 @@
import _copy from './copy/index.js'
import _empty from './empty/index.js'
import _ensure from './ensure/index.js'
import _json from './json/index.js'
import _mkdirs from './mkdirs/index.js'
import _move from './move/index.js'
import _outputFile from './output-file/index.js'
import _pathExists from './path-exists/index.js'
import _remove from './remove/index.js'
// NOTE: Only exports fs-extra's functions; fs functions must be imported from "node:fs" or "node:fs/promises"
export const copy = _copy.copy
export const copySync = _copy.copySync
export const emptyDirSync = _empty.emptyDirSync
export const emptydirSync = _empty.emptydirSync
export const emptyDir = _empty.emptyDir
export const emptydir = _empty.emptydir
export const createFile = _ensure.createFile
export const createFileSync = _ensure.createFileSync
export const ensureFile = _ensure.ensureFile
export const ensureFileSync = _ensure.ensureFileSync
export const createLink = _ensure.createLink
export const createLinkSync = _ensure.createLinkSync
export const ensureLink = _ensure.ensureLink
export const ensureLinkSync = _ensure.ensureLinkSync
export const createSymlink = _ensure.createSymlink
export const createSymlinkSync = _ensure.createSymlinkSync
export const ensureSymlink = _ensure.ensureSymlink
export const ensureSymlinkSync = _ensure.ensureSymlinkSync
export const readJson = _json.readJson
export const readJSON = _json.readJSON
export const readJsonSync = _json.readJsonSync
export const readJSONSync = _json.readJSONSync
export const writeJson = _json.writeJson
export const writeJSON = _json.writeJSON
export const writeJsonSync = _json.writeJsonSync
export const writeJSONSync = _json.writeJSONSync
export const outputJson = _json.outputJson
export const outputJSON = _json.outputJSON
export const outputJsonSync = _json.outputJsonSync
export const outputJSONSync = _json.outputJSONSync
export const mkdirs = _mkdirs.mkdirs
export const mkdirsSync = _mkdirs.mkdirsSync
export const mkdirp = _mkdirs.mkdirp
export const mkdirpSync = _mkdirs.mkdirpSync
export const ensureDir = _mkdirs.ensureDir
export const ensureDirSync = _mkdirs.ensureDirSync
export const move = _move.move
export const moveSync = _move.moveSync
export const outputFile = _outputFile.outputFile
export const outputFileSync = _outputFile.outputFileSync
export const pathExists = _pathExists.pathExists
export const pathExistsSync = _pathExists.pathExistsSync
export const remove = _remove.remove
export const removeSync = _remove.removeSync
export default {
..._copy,
..._empty,
..._ensure,
..._json,
..._mkdirs,
..._move,
..._outputFile,
..._pathExists,
..._remove
}

146
node_modules/fs-extra/lib/fs/index.js generated vendored Normal file
View File

@@ -0,0 +1,146 @@
'use strict'
// This is adapted from https://github.com/normalize/mz
// Copyright (c) 2014-2016 Jonathan Ong me@jongleberry.com and Contributors
const u = require('universalify').fromCallback
const fs = require('graceful-fs')
const api = [
'access',
'appendFile',
'chmod',
'chown',
'close',
'copyFile',
'cp',
'fchmod',
'fchown',
'fdatasync',
'fstat',
'fsync',
'ftruncate',
'futimes',
'glob',
'lchmod',
'lchown',
'lutimes',
'link',
'lstat',
'mkdir',
'mkdtemp',
'open',
'opendir',
'readdir',
'readFile',
'readlink',
'realpath',
'rename',
'rm',
'rmdir',
'stat',
'statfs',
'symlink',
'truncate',
'unlink',
'utimes',
'writeFile'
].filter(key => {
// Some commands are not available on some systems. Ex:
// fs.cp was added in Node.js v16.7.0
// fs.statfs was added in Node v19.6.0, v18.15.0
// fs.glob was added in Node.js v22.0.0
// fs.lchown is not available on at least some Linux
return typeof fs[key] === 'function'
})
// Export cloned fs:
Object.assign(exports, fs)
// Universalify async methods:
api.forEach(method => {
exports[method] = u(fs[method])
})
// We differ from mz/fs in that we still ship the old, broken, fs.exists()
// since we are a drop-in replacement for the native module
exports.exists = function (filename, callback) {
if (typeof callback === 'function') {
return fs.exists(filename, callback)
}
return new Promise(resolve => {
return fs.exists(filename, resolve)
})
}
// fs.read(), fs.write(), fs.readv(), & fs.writev() need special treatment due to multiple callback args
exports.read = function (fd, buffer, offset, length, position, callback) {
if (typeof callback === 'function') {
return fs.read(fd, buffer, offset, length, position, callback)
}
return new Promise((resolve, reject) => {
fs.read(fd, buffer, offset, length, position, (err, bytesRead, buffer) => {
if (err) return reject(err)
resolve({ bytesRead, buffer })
})
})
}
// Function signature can be
// fs.write(fd, buffer[, offset[, length[, position]]], callback)
// OR
// fs.write(fd, string[, position[, encoding]], callback)
// We need to handle both cases, so we use ...args
exports.write = function (fd, buffer, ...args) {
if (typeof args[args.length - 1] === 'function') {
return fs.write(fd, buffer, ...args)
}
return new Promise((resolve, reject) => {
fs.write(fd, buffer, ...args, (err, bytesWritten, buffer) => {
if (err) return reject(err)
resolve({ bytesWritten, buffer })
})
})
}
// Function signature is
// s.readv(fd, buffers[, position], callback)
// We need to handle the optional arg, so we use ...args
exports.readv = function (fd, buffers, ...args) {
if (typeof args[args.length - 1] === 'function') {
return fs.readv(fd, buffers, ...args)
}
return new Promise((resolve, reject) => {
fs.readv(fd, buffers, ...args, (err, bytesRead, buffers) => {
if (err) return reject(err)
resolve({ bytesRead, buffers })
})
})
}
// Function signature is
// s.writev(fd, buffers[, position], callback)
// We need to handle the optional arg, so we use ...args
exports.writev = function (fd, buffers, ...args) {
if (typeof args[args.length - 1] === 'function') {
return fs.writev(fd, buffers, ...args)
}
return new Promise((resolve, reject) => {
fs.writev(fd, buffers, ...args, (err, bytesWritten, buffers) => {
if (err) return reject(err)
resolve({ bytesWritten, buffers })
})
})
}
// fs.realpath.native sometimes not available if fs is monkey-patched
if (typeof fs.realpath.native === 'function') {
exports.realpath.native = u(fs.realpath.native)
} else {
process.emitWarning(
'fs.realpath.native is not a function. Is fs being monkey-patched?',
'Warning', 'fs-extra-WARN0003'
)
}

16
node_modules/fs-extra/lib/index.js generated vendored Normal file
View File

@@ -0,0 +1,16 @@
'use strict'
module.exports = {
// Export promiseified graceful-fs:
...require('./fs'),
// Export extra methods:
...require('./copy'),
...require('./empty'),
...require('./ensure'),
...require('./json'),
...require('./mkdirs'),
...require('./move'),
...require('./output-file'),
...require('./path-exists'),
...require('./remove')
}

16
node_modules/fs-extra/lib/json/index.js generated vendored Normal file
View File

@@ -0,0 +1,16 @@
'use strict'
const u = require('universalify').fromPromise
const jsonFile = require('./jsonfile')
jsonFile.outputJson = u(require('./output-json'))
jsonFile.outputJsonSync = require('./output-json-sync')
// aliases
jsonFile.outputJSON = jsonFile.outputJson
jsonFile.outputJSONSync = jsonFile.outputJsonSync
jsonFile.writeJSON = jsonFile.writeJson
jsonFile.writeJSONSync = jsonFile.writeJsonSync
jsonFile.readJSON = jsonFile.readJson
jsonFile.readJSONSync = jsonFile.readJsonSync
module.exports = jsonFile

11
node_modules/fs-extra/lib/json/jsonfile.js generated vendored Normal file
View File

@@ -0,0 +1,11 @@
'use strict'
const jsonFile = require('jsonfile')
module.exports = {
// jsonfile exports
readJson: jsonFile.readFile,
readJsonSync: jsonFile.readFileSync,
writeJson: jsonFile.writeFile,
writeJsonSync: jsonFile.writeFileSync
}

12
node_modules/fs-extra/lib/json/output-json-sync.js generated vendored Normal file
View File

@@ -0,0 +1,12 @@
'use strict'
const { stringify } = require('jsonfile/utils')
const { outputFileSync } = require('../output-file')
function outputJsonSync (file, data, options) {
const str = stringify(data, options)
outputFileSync(file, str, options)
}
module.exports = outputJsonSync

12
node_modules/fs-extra/lib/json/output-json.js generated vendored Normal file
View File

@@ -0,0 +1,12 @@
'use strict'
const { stringify } = require('jsonfile/utils')
const { outputFile } = require('../output-file')
async function outputJson (file, data, options = {}) {
const str = stringify(data, options)
await outputFile(file, str, options)
}
module.exports = outputJson

14
node_modules/fs-extra/lib/mkdirs/index.js generated vendored Normal file
View File

@@ -0,0 +1,14 @@
'use strict'
const u = require('universalify').fromPromise
const { makeDir: _makeDir, makeDirSync } = require('./make-dir')
const makeDir = u(_makeDir)
module.exports = {
mkdirs: makeDir,
mkdirsSync: makeDirSync,
// alias
mkdirp: makeDir,
mkdirpSync: makeDirSync,
ensureDir: makeDir,
ensureDirSync: makeDirSync
}

27
node_modules/fs-extra/lib/mkdirs/make-dir.js generated vendored Normal file
View File

@@ -0,0 +1,27 @@
'use strict'
const fs = require('../fs')
const { checkPath } = require('./utils')
const getMode = options => {
const defaults = { mode: 0o777 }
if (typeof options === 'number') return options
return ({ ...defaults, ...options }).mode
}
module.exports.makeDir = async (dir, options) => {
checkPath(dir)
return fs.mkdir(dir, {
mode: getMode(options),
recursive: true
})
}
module.exports.makeDirSync = (dir, options) => {
checkPath(dir)
return fs.mkdirSync(dir, {
mode: getMode(options),
recursive: true
})
}

21
node_modules/fs-extra/lib/mkdirs/utils.js generated vendored Normal file
View File

@@ -0,0 +1,21 @@
// Adapted from https://github.com/sindresorhus/make-dir
// Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (sindresorhus.com)
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
'use strict'
const path = require('path')
// https://github.com/nodejs/node/issues/8987
// https://github.com/libuv/libuv/pull/1088
module.exports.checkPath = function checkPath (pth) {
if (process.platform === 'win32') {
const pathHasInvalidWinCharacters = /[<>:"|?*]/.test(pth.replace(path.parse(pth).root, ''))
if (pathHasInvalidWinCharacters) {
const error = new Error(`Path contains invalid characters: ${pth}`)
error.code = 'EINVAL'
throw error
}
}
}

7
node_modules/fs-extra/lib/move/index.js generated vendored Normal file
View File

@@ -0,0 +1,7 @@
'use strict'
const u = require('universalify').fromPromise
module.exports = {
move: u(require('./move')),
moveSync: require('./move-sync')
}

55
node_modules/fs-extra/lib/move/move-sync.js generated vendored Normal file
View File

@@ -0,0 +1,55 @@
'use strict'
const fs = require('graceful-fs')
const path = require('path')
const copySync = require('../copy').copySync
const removeSync = require('../remove').removeSync
const mkdirpSync = require('../mkdirs').mkdirpSync
const stat = require('../util/stat')
function moveSync (src, dest, opts) {
opts = opts || {}
const overwrite = opts.overwrite || opts.clobber || false
const { srcStat, isChangingCase = false } = stat.checkPathsSync(src, dest, 'move', opts)
stat.checkParentPathsSync(src, srcStat, dest, 'move')
if (!isParentRoot(dest)) mkdirpSync(path.dirname(dest))
return doRename(src, dest, overwrite, isChangingCase)
}
function isParentRoot (dest) {
const parent = path.dirname(dest)
const parsedPath = path.parse(parent)
return parsedPath.root === parent
}
function doRename (src, dest, overwrite, isChangingCase) {
if (isChangingCase) return rename(src, dest, overwrite)
if (overwrite) {
removeSync(dest)
return rename(src, dest, overwrite)
}
if (fs.existsSync(dest)) throw new Error('dest already exists.')
return rename(src, dest, overwrite)
}
function rename (src, dest, overwrite) {
try {
fs.renameSync(src, dest)
} catch (err) {
if (err.code !== 'EXDEV') throw err
return moveAcrossDevice(src, dest, overwrite)
}
}
function moveAcrossDevice (src, dest, overwrite) {
const opts = {
overwrite,
errorOnExist: true,
preserveTimestamps: true
}
copySync(src, dest, opts)
return removeSync(src)
}
module.exports = moveSync

59
node_modules/fs-extra/lib/move/move.js generated vendored Normal file
View File

@@ -0,0 +1,59 @@
'use strict'
const fs = require('../fs')
const path = require('path')
const { copy } = require('../copy')
const { remove } = require('../remove')
const { mkdirp } = require('../mkdirs')
const { pathExists } = require('../path-exists')
const stat = require('../util/stat')
async function move (src, dest, opts = {}) {
const overwrite = opts.overwrite || opts.clobber || false
const { srcStat, isChangingCase = false } = await stat.checkPaths(src, dest, 'move', opts)
await stat.checkParentPaths(src, srcStat, dest, 'move')
// If the parent of dest is not root, make sure it exists before proceeding
const destParent = path.dirname(dest)
const parsedParentPath = path.parse(destParent)
if (parsedParentPath.root !== destParent) {
await mkdirp(destParent)
}
return doRename(src, dest, overwrite, isChangingCase)
}
async function doRename (src, dest, overwrite, isChangingCase) {
if (!isChangingCase) {
if (overwrite) {
await remove(dest)
} else if (await pathExists(dest)) {
throw new Error('dest already exists.')
}
}
try {
// Try w/ rename first, and try copy + remove if EXDEV
await fs.rename(src, dest)
} catch (err) {
if (err.code !== 'EXDEV') {
throw err
}
await moveAcrossDevice(src, dest, overwrite)
}
}
async function moveAcrossDevice (src, dest, overwrite) {
const opts = {
overwrite,
errorOnExist: true,
preserveTimestamps: true
}
await copy(src, dest, opts)
return remove(src)
}
module.exports = move

31
node_modules/fs-extra/lib/output-file/index.js generated vendored Normal file
View File

@@ -0,0 +1,31 @@
'use strict'
const u = require('universalify').fromPromise
const fs = require('../fs')
const path = require('path')
const mkdir = require('../mkdirs')
const pathExists = require('../path-exists').pathExists
async function outputFile (file, data, encoding = 'utf-8') {
const dir = path.dirname(file)
if (!(await pathExists(dir))) {
await mkdir.mkdirs(dir)
}
return fs.writeFile(file, data, encoding)
}
function outputFileSync (file, ...args) {
const dir = path.dirname(file)
if (!fs.existsSync(dir)) {
mkdir.mkdirsSync(dir)
}
fs.writeFileSync(file, ...args)
}
module.exports = {
outputFile: u(outputFile),
outputFileSync
}

12
node_modules/fs-extra/lib/path-exists/index.js generated vendored Normal file
View File

@@ -0,0 +1,12 @@
'use strict'
const u = require('universalify').fromPromise
const fs = require('../fs')
function pathExists (path) {
return fs.access(path).then(() => true).catch(() => false)
}
module.exports = {
pathExists: u(pathExists),
pathExistsSync: fs.existsSync
}

17
node_modules/fs-extra/lib/remove/index.js generated vendored Normal file
View File

@@ -0,0 +1,17 @@
'use strict'
const fs = require('graceful-fs')
const u = require('universalify').fromCallback
function remove (path, callback) {
fs.rm(path, { recursive: true, force: true }, callback)
}
function removeSync (path) {
fs.rmSync(path, { recursive: true, force: true })
}
module.exports = {
remove: u(remove),
removeSync
}

158
node_modules/fs-extra/lib/util/stat.js generated vendored Normal file
View File

@@ -0,0 +1,158 @@
'use strict'
const fs = require('../fs')
const path = require('path')
const u = require('universalify').fromPromise
function getStats (src, dest, opts) {
const statFunc = opts.dereference
? (file) => fs.stat(file, { bigint: true })
: (file) => fs.lstat(file, { bigint: true })
return Promise.all([
statFunc(src),
statFunc(dest).catch(err => {
if (err.code === 'ENOENT') return null
throw err
})
]).then(([srcStat, destStat]) => ({ srcStat, destStat }))
}
function getStatsSync (src, dest, opts) {
let destStat
const statFunc = opts.dereference
? (file) => fs.statSync(file, { bigint: true })
: (file) => fs.lstatSync(file, { bigint: true })
const srcStat = statFunc(src)
try {
destStat = statFunc(dest)
} catch (err) {
if (err.code === 'ENOENT') return { srcStat, destStat: null }
throw err
}
return { srcStat, destStat }
}
async function checkPaths (src, dest, funcName, opts) {
const { srcStat, destStat } = await getStats(src, dest, opts)
if (destStat) {
if (areIdentical(srcStat, destStat)) {
const srcBaseName = path.basename(src)
const destBaseName = path.basename(dest)
if (funcName === 'move' &&
srcBaseName !== destBaseName &&
srcBaseName.toLowerCase() === destBaseName.toLowerCase()) {
return { srcStat, destStat, isChangingCase: true }
}
throw new Error('Source and destination must not be the same.')
}
if (srcStat.isDirectory() && !destStat.isDirectory()) {
throw new Error(`Cannot overwrite non-directory '${dest}' with directory '${src}'.`)
}
if (!srcStat.isDirectory() && destStat.isDirectory()) {
throw new Error(`Cannot overwrite directory '${dest}' with non-directory '${src}'.`)
}
}
if (srcStat.isDirectory() && isSrcSubdir(src, dest)) {
throw new Error(errMsg(src, dest, funcName))
}
return { srcStat, destStat }
}
function checkPathsSync (src, dest, funcName, opts) {
const { srcStat, destStat } = getStatsSync(src, dest, opts)
if (destStat) {
if (areIdentical(srcStat, destStat)) {
const srcBaseName = path.basename(src)
const destBaseName = path.basename(dest)
if (funcName === 'move' &&
srcBaseName !== destBaseName &&
srcBaseName.toLowerCase() === destBaseName.toLowerCase()) {
return { srcStat, destStat, isChangingCase: true }
}
throw new Error('Source and destination must not be the same.')
}
if (srcStat.isDirectory() && !destStat.isDirectory()) {
throw new Error(`Cannot overwrite non-directory '${dest}' with directory '${src}'.`)
}
if (!srcStat.isDirectory() && destStat.isDirectory()) {
throw new Error(`Cannot overwrite directory '${dest}' with non-directory '${src}'.`)
}
}
if (srcStat.isDirectory() && isSrcSubdir(src, dest)) {
throw new Error(errMsg(src, dest, funcName))
}
return { srcStat, destStat }
}
// recursively check if dest parent is a subdirectory of src.
// It works for all file types including symlinks since it
// checks the src and dest inodes. It starts from the deepest
// parent and stops once it reaches the src parent or the root path.
async function checkParentPaths (src, srcStat, dest, funcName) {
const srcParent = path.resolve(path.dirname(src))
const destParent = path.resolve(path.dirname(dest))
if (destParent === srcParent || destParent === path.parse(destParent).root) return
let destStat
try {
destStat = await fs.stat(destParent, { bigint: true })
} catch (err) {
if (err.code === 'ENOENT') return
throw err
}
if (areIdentical(srcStat, destStat)) {
throw new Error(errMsg(src, dest, funcName))
}
return checkParentPaths(src, srcStat, destParent, funcName)
}
function checkParentPathsSync (src, srcStat, dest, funcName) {
const srcParent = path.resolve(path.dirname(src))
const destParent = path.resolve(path.dirname(dest))
if (destParent === srcParent || destParent === path.parse(destParent).root) return
let destStat
try {
destStat = fs.statSync(destParent, { bigint: true })
} catch (err) {
if (err.code === 'ENOENT') return
throw err
}
if (areIdentical(srcStat, destStat)) {
throw new Error(errMsg(src, dest, funcName))
}
return checkParentPathsSync(src, srcStat, destParent, funcName)
}
function areIdentical (srcStat, destStat) {
return destStat.ino && destStat.dev && destStat.ino === srcStat.ino && destStat.dev === srcStat.dev
}
// return true if dest is a subdir of src, otherwise false.
// It only checks the path strings.
function isSrcSubdir (src, dest) {
const srcArr = path.resolve(src).split(path.sep).filter(i => i)
const destArr = path.resolve(dest).split(path.sep).filter(i => i)
return srcArr.every((cur, i) => destArr[i] === cur)
}
function errMsg (src, dest, funcName) {
return `Cannot ${funcName} '${src}' to a subdirectory of itself, '${dest}'.`
}
module.exports = {
// checkPaths
checkPaths: u(checkPaths),
checkPathsSync,
// checkParent
checkParentPaths: u(checkParentPaths),
checkParentPathsSync,
// Misc
isSrcSubdir,
areIdentical
}

36
node_modules/fs-extra/lib/util/utimes.js generated vendored Normal file
View File

@@ -0,0 +1,36 @@
'use strict'
const fs = require('../fs')
const u = require('universalify').fromPromise
async function utimesMillis (path, atime, mtime) {
// if (!HAS_MILLIS_RES) return fs.utimes(path, atime, mtime, callback)
const fd = await fs.open(path, 'r+')
let closeErr = null
try {
await fs.futimes(fd, atime, mtime)
} finally {
try {
await fs.close(fd)
} catch (e) {
closeErr = e
}
}
if (closeErr) {
throw closeErr
}
}
function utimesMillisSync (path, atime, mtime) {
const fd = fs.openSync(path, 'r+')
fs.futimesSync(fd, atime, mtime)
return fs.closeSync(fd)
}
module.exports = {
utimesMillis: u(utimesMillis),
utimesMillisSync
}

71
node_modules/fs-extra/package.json generated vendored Normal file
View File

@@ -0,0 +1,71 @@
{
"name": "fs-extra",
"version": "11.3.0",
"description": "fs-extra contains methods that aren't included in the vanilla Node.js fs package. Such as recursive mkdir, copy, and remove.",
"engines": {
"node": ">=14.14"
},
"homepage": "https://github.com/jprichardson/node-fs-extra",
"repository": {
"type": "git",
"url": "https://github.com/jprichardson/node-fs-extra"
},
"keywords": [
"fs",
"file",
"file system",
"copy",
"directory",
"extra",
"mkdirp",
"mkdir",
"mkdirs",
"recursive",
"json",
"read",
"write",
"extra",
"delete",
"remove",
"touch",
"create",
"text",
"output",
"move",
"promise"
],
"author": "JP Richardson <jprichardson@gmail.com>",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"devDependencies": {
"klaw": "^2.1.1",
"klaw-sync": "^3.0.2",
"minimist": "^1.1.1",
"mocha": "^10.1.0",
"nyc": "^15.0.0",
"proxyquire": "^2.0.1",
"read-dir-files": "^0.1.1",
"standard": "^17.0.0"
},
"main": "./lib/index.js",
"exports": {
".": "./lib/index.js",
"./esm": "./lib/esm.mjs"
},
"files": [
"lib/",
"!lib/**/__tests__/"
],
"scripts": {
"lint": "standard",
"test-find": "find ./lib/**/__tests__ -name *.test.js | xargs mocha",
"test": "npm run lint && npm run unit && npm run unit-esm",
"unit": "nyc node test.js",
"unit-esm": "node test.mjs"
},
"sideEffects": false
}

15
node_modules/graceful-fs/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,15 @@
The ISC License
Copyright (c) 2011-2022 Isaac Z. Schlueter, Ben Noordhuis, and Contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

143
node_modules/graceful-fs/README.md generated vendored Normal file
View File

@@ -0,0 +1,143 @@
# graceful-fs
graceful-fs functions as a drop-in replacement for the fs module,
making various improvements.
The improvements are meant to normalize behavior across different
platforms and environments, and to make filesystem access more
resilient to errors.
## Improvements over [fs module](https://nodejs.org/api/fs.html)
* Queues up `open` and `readdir` calls, and retries them once
something closes if there is an EMFILE error from too many file
descriptors.
* fixes `lchmod` for Node versions prior to 0.6.2.
* implements `fs.lutimes` if possible. Otherwise it becomes a noop.
* ignores `EINVAL` and `EPERM` errors in `chown`, `fchown` or
`lchown` if the user isn't root.
* makes `lchmod` and `lchown` become noops, if not available.
* retries reading a file if `read` results in EAGAIN error.
On Windows, it retries renaming a file for up to one second if `EACCESS`
or `EPERM` error occurs, likely because antivirus software has locked
the directory.
## USAGE
```javascript
// use just like fs
var fs = require('graceful-fs')
// now go and do stuff with it...
fs.readFile('some-file-or-whatever', (err, data) => {
// Do stuff here.
})
```
## Sync methods
This module cannot intercept or handle `EMFILE` or `ENFILE` errors from sync
methods. If you use sync methods which open file descriptors then you are
responsible for dealing with any errors.
This is a known limitation, not a bug.
## Global Patching
If you want to patch the global fs module (or any other fs-like
module) you can do this:
```javascript
// Make sure to read the caveat below.
var realFs = require('fs')
var gracefulFs = require('graceful-fs')
gracefulFs.gracefulify(realFs)
```
This should only ever be done at the top-level application layer, in
order to delay on EMFILE errors from any fs-using dependencies. You
should **not** do this in a library, because it can cause unexpected
delays in other parts of the program.
## Changes
This module is fairly stable at this point, and used by a lot of
things. That being said, because it implements a subtle behavior
change in a core part of the node API, even modest changes can be
extremely breaking, and the versioning is thus biased towards
bumping the major when in doubt.
The main change between major versions has been switching between
providing a fully-patched `fs` module vs monkey-patching the node core
builtin, and the approach by which a non-monkey-patched `fs` was
created.
The goal is to trade `EMFILE` errors for slower fs operations. So, if
you try to open a zillion files, rather than crashing, `open`
operations will be queued up and wait for something else to `close`.
There are advantages to each approach. Monkey-patching the fs means
that no `EMFILE` errors can possibly occur anywhere in your
application, because everything is using the same core `fs` module,
which is patched. However, it can also obviously cause undesirable
side-effects, especially if the module is loaded multiple times.
Implementing a separate-but-identical patched `fs` module is more
surgical (and doesn't run the risk of patching multiple times), but
also imposes the challenge of keeping in sync with the core module.
The current approach loads the `fs` module, and then creates a
lookalike object that has all the same methods, except a few that are
patched. It is safe to use in all versions of Node from 0.8 through
7.0.
### v4
* Do not monkey-patch the fs module. This module may now be used as a
drop-in dep, and users can opt into monkey-patching the fs builtin
if their app requires it.
### v3
* Monkey-patch fs, because the eval approach no longer works on recent
node.
* fixed possible type-error throw if rename fails on windows
* verify that we *never* get EMFILE errors
* Ignore ENOSYS from chmod/chown
* clarify that graceful-fs must be used as a drop-in
### v2.1.0
* Use eval rather than monkey-patching fs.
* readdir: Always sort the results
* win32: requeue a file if error has an OK status
### v2.0
* A return to monkey patching
* wrap process.cwd
### v1.1
* wrap readFile
* Wrap fs.writeFile.
* readdir protection
* Don't clobber the fs builtin
* Handle fs.read EAGAIN errors by trying again
* Expose the curOpen counter
* No-op lchown/lchmod if not implemented
* fs.rename patch only for win32
* Patch fs.rename to handle AV software on Windows
* Close #4 Chown should not fail on einval or eperm if non-root
* Fix isaacs/fstream#1 Only wrap fs one time
* Fix #3 Start at 1024 max files, then back off on EMFILE
* lutimes that doens't blow up on Linux
* A full on-rewrite using a queue instead of just swallowing the EMFILE error
* Wrap Read/Write streams as well
### 1.0
* Update engines for node 0.6
* Be lstat-graceful on Windows
* first

23
node_modules/graceful-fs/clone.js generated vendored Normal file
View File

@@ -0,0 +1,23 @@
'use strict'
module.exports = clone
var getPrototypeOf = Object.getPrototypeOf || function (obj) {
return obj.__proto__
}
function clone (obj) {
if (obj === null || typeof obj !== 'object')
return obj
if (obj instanceof Object)
var copy = { __proto__: getPrototypeOf(obj) }
else
var copy = Object.create(null)
Object.getOwnPropertyNames(obj).forEach(function (key) {
Object.defineProperty(copy, key, Object.getOwnPropertyDescriptor(obj, key))
})
return copy
}

448
node_modules/graceful-fs/graceful-fs.js generated vendored Normal file
View File

@@ -0,0 +1,448 @@
var fs = require('fs')
var polyfills = require('./polyfills.js')
var legacy = require('./legacy-streams.js')
var clone = require('./clone.js')
var util = require('util')
/* istanbul ignore next - node 0.x polyfill */
var gracefulQueue
var previousSymbol
/* istanbul ignore else - node 0.x polyfill */
if (typeof Symbol === 'function' && typeof Symbol.for === 'function') {
gracefulQueue = Symbol.for('graceful-fs.queue')
// This is used in testing by future versions
previousSymbol = Symbol.for('graceful-fs.previous')
} else {
gracefulQueue = '___graceful-fs.queue'
previousSymbol = '___graceful-fs.previous'
}
function noop () {}
function publishQueue(context, queue) {
Object.defineProperty(context, gracefulQueue, {
get: function() {
return queue
}
})
}
var debug = noop
if (util.debuglog)
debug = util.debuglog('gfs4')
else if (/\bgfs4\b/i.test(process.env.NODE_DEBUG || ''))
debug = function() {
var m = util.format.apply(util, arguments)
m = 'GFS4: ' + m.split(/\n/).join('\nGFS4: ')
console.error(m)
}
// Once time initialization
if (!fs[gracefulQueue]) {
// This queue can be shared by multiple loaded instances
var queue = global[gracefulQueue] || []
publishQueue(fs, queue)
// Patch fs.close/closeSync to shared queue version, because we need
// to retry() whenever a close happens *anywhere* in the program.
// This is essential when multiple graceful-fs instances are
// in play at the same time.
fs.close = (function (fs$close) {
function close (fd, cb) {
return fs$close.call(fs, fd, function (err) {
// This function uses the graceful-fs shared queue
if (!err) {
resetQueue()
}
if (typeof cb === 'function')
cb.apply(this, arguments)
})
}
Object.defineProperty(close, previousSymbol, {
value: fs$close
})
return close
})(fs.close)
fs.closeSync = (function (fs$closeSync) {
function closeSync (fd) {
// This function uses the graceful-fs shared queue
fs$closeSync.apply(fs, arguments)
resetQueue()
}
Object.defineProperty(closeSync, previousSymbol, {
value: fs$closeSync
})
return closeSync
})(fs.closeSync)
if (/\bgfs4\b/i.test(process.env.NODE_DEBUG || '')) {
process.on('exit', function() {
debug(fs[gracefulQueue])
require('assert').equal(fs[gracefulQueue].length, 0)
})
}
}
if (!global[gracefulQueue]) {
publishQueue(global, fs[gracefulQueue]);
}
module.exports = patch(clone(fs))
if (process.env.TEST_GRACEFUL_FS_GLOBAL_PATCH && !fs.__patched) {
module.exports = patch(fs)
fs.__patched = true;
}
function patch (fs) {
// Everything that references the open() function needs to be in here
polyfills(fs)
fs.gracefulify = patch
fs.createReadStream = createReadStream
fs.createWriteStream = createWriteStream
var fs$readFile = fs.readFile
fs.readFile = readFile
function readFile (path, options, cb) {
if (typeof options === 'function')
cb = options, options = null
return go$readFile(path, options, cb)
function go$readFile (path, options, cb, startTime) {
return fs$readFile(path, options, function (err) {
if (err && (err.code === 'EMFILE' || err.code === 'ENFILE'))
enqueue([go$readFile, [path, options, cb], err, startTime || Date.now(), Date.now()])
else {
if (typeof cb === 'function')
cb.apply(this, arguments)
}
})
}
}
var fs$writeFile = fs.writeFile
fs.writeFile = writeFile
function writeFile (path, data, options, cb) {
if (typeof options === 'function')
cb = options, options = null
return go$writeFile(path, data, options, cb)
function go$writeFile (path, data, options, cb, startTime) {
return fs$writeFile(path, data, options, function (err) {
if (err && (err.code === 'EMFILE' || err.code === 'ENFILE'))
enqueue([go$writeFile, [path, data, options, cb], err, startTime || Date.now(), Date.now()])
else {
if (typeof cb === 'function')
cb.apply(this, arguments)
}
})
}
}
var fs$appendFile = fs.appendFile
if (fs$appendFile)
fs.appendFile = appendFile
function appendFile (path, data, options, cb) {
if (typeof options === 'function')
cb = options, options = null
return go$appendFile(path, data, options, cb)
function go$appendFile (path, data, options, cb, startTime) {
return fs$appendFile(path, data, options, function (err) {
if (err && (err.code === 'EMFILE' || err.code === 'ENFILE'))
enqueue([go$appendFile, [path, data, options, cb], err, startTime || Date.now(), Date.now()])
else {
if (typeof cb === 'function')
cb.apply(this, arguments)
}
})
}
}
var fs$copyFile = fs.copyFile
if (fs$copyFile)
fs.copyFile = copyFile
function copyFile (src, dest, flags, cb) {
if (typeof flags === 'function') {
cb = flags
flags = 0
}
return go$copyFile(src, dest, flags, cb)
function go$copyFile (src, dest, flags, cb, startTime) {
return fs$copyFile(src, dest, flags, function (err) {
if (err && (err.code === 'EMFILE' || err.code === 'ENFILE'))
enqueue([go$copyFile, [src, dest, flags, cb], err, startTime || Date.now(), Date.now()])
else {
if (typeof cb === 'function')
cb.apply(this, arguments)
}
})
}
}
var fs$readdir = fs.readdir
fs.readdir = readdir
var noReaddirOptionVersions = /^v[0-5]\./
function readdir (path, options, cb) {
if (typeof options === 'function')
cb = options, options = null
var go$readdir = noReaddirOptionVersions.test(process.version)
? function go$readdir (path, options, cb, startTime) {
return fs$readdir(path, fs$readdirCallback(
path, options, cb, startTime
))
}
: function go$readdir (path, options, cb, startTime) {
return fs$readdir(path, options, fs$readdirCallback(
path, options, cb, startTime
))
}
return go$readdir(path, options, cb)
function fs$readdirCallback (path, options, cb, startTime) {
return function (err, files) {
if (err && (err.code === 'EMFILE' || err.code === 'ENFILE'))
enqueue([
go$readdir,
[path, options, cb],
err,
startTime || Date.now(),
Date.now()
])
else {
if (files && files.sort)
files.sort()
if (typeof cb === 'function')
cb.call(this, err, files)
}
}
}
}
if (process.version.substr(0, 4) === 'v0.8') {
var legStreams = legacy(fs)
ReadStream = legStreams.ReadStream
WriteStream = legStreams.WriteStream
}
var fs$ReadStream = fs.ReadStream
if (fs$ReadStream) {
ReadStream.prototype = Object.create(fs$ReadStream.prototype)
ReadStream.prototype.open = ReadStream$open
}
var fs$WriteStream = fs.WriteStream
if (fs$WriteStream) {
WriteStream.prototype = Object.create(fs$WriteStream.prototype)
WriteStream.prototype.open = WriteStream$open
}
Object.defineProperty(fs, 'ReadStream', {
get: function () {
return ReadStream
},
set: function (val) {
ReadStream = val
},
enumerable: true,
configurable: true
})
Object.defineProperty(fs, 'WriteStream', {
get: function () {
return WriteStream
},
set: function (val) {
WriteStream = val
},
enumerable: true,
configurable: true
})
// legacy names
var FileReadStream = ReadStream
Object.defineProperty(fs, 'FileReadStream', {
get: function () {
return FileReadStream
},
set: function (val) {
FileReadStream = val
},
enumerable: true,
configurable: true
})
var FileWriteStream = WriteStream
Object.defineProperty(fs, 'FileWriteStream', {
get: function () {
return FileWriteStream
},
set: function (val) {
FileWriteStream = val
},
enumerable: true,
configurable: true
})
function ReadStream (path, options) {
if (this instanceof ReadStream)
return fs$ReadStream.apply(this, arguments), this
else
return ReadStream.apply(Object.create(ReadStream.prototype), arguments)
}
function ReadStream$open () {
var that = this
open(that.path, that.flags, that.mode, function (err, fd) {
if (err) {
if (that.autoClose)
that.destroy()
that.emit('error', err)
} else {
that.fd = fd
that.emit('open', fd)
that.read()
}
})
}
function WriteStream (path, options) {
if (this instanceof WriteStream)
return fs$WriteStream.apply(this, arguments), this
else
return WriteStream.apply(Object.create(WriteStream.prototype), arguments)
}
function WriteStream$open () {
var that = this
open(that.path, that.flags, that.mode, function (err, fd) {
if (err) {
that.destroy()
that.emit('error', err)
} else {
that.fd = fd
that.emit('open', fd)
}
})
}
function createReadStream (path, options) {
return new fs.ReadStream(path, options)
}
function createWriteStream (path, options) {
return new fs.WriteStream(path, options)
}
var fs$open = fs.open
fs.open = open
function open (path, flags, mode, cb) {
if (typeof mode === 'function')
cb = mode, mode = null
return go$open(path, flags, mode, cb)
function go$open (path, flags, mode, cb, startTime) {
return fs$open(path, flags, mode, function (err, fd) {
if (err && (err.code === 'EMFILE' || err.code === 'ENFILE'))
enqueue([go$open, [path, flags, mode, cb], err, startTime || Date.now(), Date.now()])
else {
if (typeof cb === 'function')
cb.apply(this, arguments)
}
})
}
}
return fs
}
function enqueue (elem) {
debug('ENQUEUE', elem[0].name, elem[1])
fs[gracefulQueue].push(elem)
retry()
}
// keep track of the timeout between retry() calls
var retryTimer
// reset the startTime and lastTime to now
// this resets the start of the 60 second overall timeout as well as the
// delay between attempts so that we'll retry these jobs sooner
function resetQueue () {
var now = Date.now()
for (var i = 0; i < fs[gracefulQueue].length; ++i) {
// entries that are only a length of 2 are from an older version, don't
// bother modifying those since they'll be retried anyway.
if (fs[gracefulQueue][i].length > 2) {
fs[gracefulQueue][i][3] = now // startTime
fs[gracefulQueue][i][4] = now // lastTime
}
}
// call retry to make sure we're actively processing the queue
retry()
}
function retry () {
// clear the timer and remove it to help prevent unintended concurrency
clearTimeout(retryTimer)
retryTimer = undefined
if (fs[gracefulQueue].length === 0)
return
var elem = fs[gracefulQueue].shift()
var fn = elem[0]
var args = elem[1]
// these items may be unset if they were added by an older graceful-fs
var err = elem[2]
var startTime = elem[3]
var lastTime = elem[4]
// if we don't have a startTime we have no way of knowing if we've waited
// long enough, so go ahead and retry this item now
if (startTime === undefined) {
debug('RETRY', fn.name, args)
fn.apply(null, args)
} else if (Date.now() - startTime >= 60000) {
// it's been more than 60 seconds total, bail now
debug('TIMEOUT', fn.name, args)
var cb = args.pop()
if (typeof cb === 'function')
cb.call(null, err)
} else {
// the amount of time between the last attempt and right now
var sinceAttempt = Date.now() - lastTime
// the amount of time between when we first tried, and when we last tried
// rounded up to at least 1
var sinceStart = Math.max(lastTime - startTime, 1)
// backoff. wait longer than the total time we've been retrying, but only
// up to a maximum of 100ms
var desiredDelay = Math.min(sinceStart * 1.2, 100)
// it's been long enough since the last retry, do it again
if (sinceAttempt >= desiredDelay) {
debug('RETRY', fn.name, args)
fn.apply(null, args.concat([startTime]))
} else {
// if we can't do this job yet, push it to the end of the queue
// and let the next iteration check again
fs[gracefulQueue].push(elem)
}
}
// schedule our next run if one isn't already scheduled
if (retryTimer === undefined) {
retryTimer = setTimeout(retry, 0)
}
}

118
node_modules/graceful-fs/legacy-streams.js generated vendored Normal file
View File

@@ -0,0 +1,118 @@
var Stream = require('stream').Stream
module.exports = legacy
function legacy (fs) {
return {
ReadStream: ReadStream,
WriteStream: WriteStream
}
function ReadStream (path, options) {
if (!(this instanceof ReadStream)) return new ReadStream(path, options);
Stream.call(this);
var self = this;
this.path = path;
this.fd = null;
this.readable = true;
this.paused = false;
this.flags = 'r';
this.mode = 438; /*=0666*/
this.bufferSize = 64 * 1024;
options = options || {};
// Mixin options into this
var keys = Object.keys(options);
for (var index = 0, length = keys.length; index < length; index++) {
var key = keys[index];
this[key] = options[key];
}
if (this.encoding) this.setEncoding(this.encoding);
if (this.start !== undefined) {
if ('number' !== typeof this.start) {
throw TypeError('start must be a Number');
}
if (this.end === undefined) {
this.end = Infinity;
} else if ('number' !== typeof this.end) {
throw TypeError('end must be a Number');
}
if (this.start > this.end) {
throw new Error('start must be <= end');
}
this.pos = this.start;
}
if (this.fd !== null) {
process.nextTick(function() {
self._read();
});
return;
}
fs.open(this.path, this.flags, this.mode, function (err, fd) {
if (err) {
self.emit('error', err);
self.readable = false;
return;
}
self.fd = fd;
self.emit('open', fd);
self._read();
})
}
function WriteStream (path, options) {
if (!(this instanceof WriteStream)) return new WriteStream(path, options);
Stream.call(this);
this.path = path;
this.fd = null;
this.writable = true;
this.flags = 'w';
this.encoding = 'binary';
this.mode = 438; /*=0666*/
this.bytesWritten = 0;
options = options || {};
// Mixin options into this
var keys = Object.keys(options);
for (var index = 0, length = keys.length; index < length; index++) {
var key = keys[index];
this[key] = options[key];
}
if (this.start !== undefined) {
if ('number' !== typeof this.start) {
throw TypeError('start must be a Number');
}
if (this.start < 0) {
throw new Error('start must be >= zero');
}
this.pos = this.start;
}
this.busy = false;
this._queue = [];
if (this.fd === null) {
this._open = fs.open;
this._queue.push([this._open, this.path, this.flags, this.mode, undefined]);
this.flush();
}
}
}

53
node_modules/graceful-fs/package.json generated vendored Normal file
View File

@@ -0,0 +1,53 @@
{
"name": "graceful-fs",
"description": "A drop-in replacement for fs, making various improvements.",
"version": "4.2.11",
"repository": {
"type": "git",
"url": "https://github.com/isaacs/node-graceful-fs"
},
"main": "graceful-fs.js",
"directories": {
"test": "test"
},
"scripts": {
"preversion": "npm test",
"postversion": "npm publish",
"postpublish": "git push origin --follow-tags",
"test": "nyc --silent node test.js | tap -c -",
"posttest": "nyc report"
},
"keywords": [
"fs",
"module",
"reading",
"retry",
"retries",
"queue",
"error",
"errors",
"handling",
"EMFILE",
"EAGAIN",
"EINVAL",
"EPERM",
"EACCESS"
],
"license": "ISC",
"devDependencies": {
"import-fresh": "^2.0.0",
"mkdirp": "^0.5.0",
"rimraf": "^2.2.8",
"tap": "^16.3.4"
},
"files": [
"fs.js",
"graceful-fs.js",
"legacy-streams.js",
"polyfills.js",
"clone.js"
],
"tap": {
"reporter": "classic"
}
}

355
node_modules/graceful-fs/polyfills.js generated vendored Normal file
View File

@@ -0,0 +1,355 @@
var constants = require('constants')
var origCwd = process.cwd
var cwd = null
var platform = process.env.GRACEFUL_FS_PLATFORM || process.platform
process.cwd = function() {
if (!cwd)
cwd = origCwd.call(process)
return cwd
}
try {
process.cwd()
} catch (er) {}
// This check is needed until node.js 12 is required
if (typeof process.chdir === 'function') {
var chdir = process.chdir
process.chdir = function (d) {
cwd = null
chdir.call(process, d)
}
if (Object.setPrototypeOf) Object.setPrototypeOf(process.chdir, chdir)
}
module.exports = patch
function patch (fs) {
// (re-)implement some things that are known busted or missing.
// lchmod, broken prior to 0.6.2
// back-port the fix here.
if (constants.hasOwnProperty('O_SYMLINK') &&
process.version.match(/^v0\.6\.[0-2]|^v0\.5\./)) {
patchLchmod(fs)
}
// lutimes implementation, or no-op
if (!fs.lutimes) {
patchLutimes(fs)
}
// https://github.com/isaacs/node-graceful-fs/issues/4
// Chown should not fail on einval or eperm if non-root.
// It should not fail on enosys ever, as this just indicates
// that a fs doesn't support the intended operation.
fs.chown = chownFix(fs.chown)
fs.fchown = chownFix(fs.fchown)
fs.lchown = chownFix(fs.lchown)
fs.chmod = chmodFix(fs.chmod)
fs.fchmod = chmodFix(fs.fchmod)
fs.lchmod = chmodFix(fs.lchmod)
fs.chownSync = chownFixSync(fs.chownSync)
fs.fchownSync = chownFixSync(fs.fchownSync)
fs.lchownSync = chownFixSync(fs.lchownSync)
fs.chmodSync = chmodFixSync(fs.chmodSync)
fs.fchmodSync = chmodFixSync(fs.fchmodSync)
fs.lchmodSync = chmodFixSync(fs.lchmodSync)
fs.stat = statFix(fs.stat)
fs.fstat = statFix(fs.fstat)
fs.lstat = statFix(fs.lstat)
fs.statSync = statFixSync(fs.statSync)
fs.fstatSync = statFixSync(fs.fstatSync)
fs.lstatSync = statFixSync(fs.lstatSync)
// if lchmod/lchown do not exist, then make them no-ops
if (fs.chmod && !fs.lchmod) {
fs.lchmod = function (path, mode, cb) {
if (cb) process.nextTick(cb)
}
fs.lchmodSync = function () {}
}
if (fs.chown && !fs.lchown) {
fs.lchown = function (path, uid, gid, cb) {
if (cb) process.nextTick(cb)
}
fs.lchownSync = function () {}
}
// on Windows, A/V software can lock the directory, causing this
// to fail with an EACCES or EPERM if the directory contains newly
// created files. Try again on failure, for up to 60 seconds.
// Set the timeout this long because some Windows Anti-Virus, such as Parity
// bit9, may lock files for up to a minute, causing npm package install
// failures. Also, take care to yield the scheduler. Windows scheduling gives
// CPU to a busy looping process, which can cause the program causing the lock
// contention to be starved of CPU by node, so the contention doesn't resolve.
if (platform === "win32") {
fs.rename = typeof fs.rename !== 'function' ? fs.rename
: (function (fs$rename) {
function rename (from, to, cb) {
var start = Date.now()
var backoff = 0;
fs$rename(from, to, function CB (er) {
if (er
&& (er.code === "EACCES" || er.code === "EPERM" || er.code === "EBUSY")
&& Date.now() - start < 60000) {
setTimeout(function() {
fs.stat(to, function (stater, st) {
if (stater && stater.code === "ENOENT")
fs$rename(from, to, CB);
else
cb(er)
})
}, backoff)
if (backoff < 100)
backoff += 10;
return;
}
if (cb) cb(er)
})
}
if (Object.setPrototypeOf) Object.setPrototypeOf(rename, fs$rename)
return rename
})(fs.rename)
}
// if read() returns EAGAIN, then just try it again.
fs.read = typeof fs.read !== 'function' ? fs.read
: (function (fs$read) {
function read (fd, buffer, offset, length, position, callback_) {
var callback
if (callback_ && typeof callback_ === 'function') {
var eagCounter = 0
callback = function (er, _, __) {
if (er && er.code === 'EAGAIN' && eagCounter < 10) {
eagCounter ++
return fs$read.call(fs, fd, buffer, offset, length, position, callback)
}
callback_.apply(this, arguments)
}
}
return fs$read.call(fs, fd, buffer, offset, length, position, callback)
}
// This ensures `util.promisify` works as it does for native `fs.read`.
if (Object.setPrototypeOf) Object.setPrototypeOf(read, fs$read)
return read
})(fs.read)
fs.readSync = typeof fs.readSync !== 'function' ? fs.readSync
: (function (fs$readSync) { return function (fd, buffer, offset, length, position) {
var eagCounter = 0
while (true) {
try {
return fs$readSync.call(fs, fd, buffer, offset, length, position)
} catch (er) {
if (er.code === 'EAGAIN' && eagCounter < 10) {
eagCounter ++
continue
}
throw er
}
}
}})(fs.readSync)
function patchLchmod (fs) {
fs.lchmod = function (path, mode, callback) {
fs.open( path
, constants.O_WRONLY | constants.O_SYMLINK
, mode
, function (err, fd) {
if (err) {
if (callback) callback(err)
return
}
// prefer to return the chmod error, if one occurs,
// but still try to close, and report closing errors if they occur.
fs.fchmod(fd, mode, function (err) {
fs.close(fd, function(err2) {
if (callback) callback(err || err2)
})
})
})
}
fs.lchmodSync = function (path, mode) {
var fd = fs.openSync(path, constants.O_WRONLY | constants.O_SYMLINK, mode)
// prefer to return the chmod error, if one occurs,
// but still try to close, and report closing errors if they occur.
var threw = true
var ret
try {
ret = fs.fchmodSync(fd, mode)
threw = false
} finally {
if (threw) {
try {
fs.closeSync(fd)
} catch (er) {}
} else {
fs.closeSync(fd)
}
}
return ret
}
}
function patchLutimes (fs) {
if (constants.hasOwnProperty("O_SYMLINK") && fs.futimes) {
fs.lutimes = function (path, at, mt, cb) {
fs.open(path, constants.O_SYMLINK, function (er, fd) {
if (er) {
if (cb) cb(er)
return
}
fs.futimes(fd, at, mt, function (er) {
fs.close(fd, function (er2) {
if (cb) cb(er || er2)
})
})
})
}
fs.lutimesSync = function (path, at, mt) {
var fd = fs.openSync(path, constants.O_SYMLINK)
var ret
var threw = true
try {
ret = fs.futimesSync(fd, at, mt)
threw = false
} finally {
if (threw) {
try {
fs.closeSync(fd)
} catch (er) {}
} else {
fs.closeSync(fd)
}
}
return ret
}
} else if (fs.futimes) {
fs.lutimes = function (_a, _b, _c, cb) { if (cb) process.nextTick(cb) }
fs.lutimesSync = function () {}
}
}
function chmodFix (orig) {
if (!orig) return orig
return function (target, mode, cb) {
return orig.call(fs, target, mode, function (er) {
if (chownErOk(er)) er = null
if (cb) cb.apply(this, arguments)
})
}
}
function chmodFixSync (orig) {
if (!orig) return orig
return function (target, mode) {
try {
return orig.call(fs, target, mode)
} catch (er) {
if (!chownErOk(er)) throw er
}
}
}
function chownFix (orig) {
if (!orig) return orig
return function (target, uid, gid, cb) {
return orig.call(fs, target, uid, gid, function (er) {
if (chownErOk(er)) er = null
if (cb) cb.apply(this, arguments)
})
}
}
function chownFixSync (orig) {
if (!orig) return orig
return function (target, uid, gid) {
try {
return orig.call(fs, target, uid, gid)
} catch (er) {
if (!chownErOk(er)) throw er
}
}
}
function statFix (orig) {
if (!orig) return orig
// Older versions of Node erroneously returned signed integers for
// uid + gid.
return function (target, options, cb) {
if (typeof options === 'function') {
cb = options
options = null
}
function callback (er, stats) {
if (stats) {
if (stats.uid < 0) stats.uid += 0x100000000
if (stats.gid < 0) stats.gid += 0x100000000
}
if (cb) cb.apply(this, arguments)
}
return options ? orig.call(fs, target, options, callback)
: orig.call(fs, target, callback)
}
}
function statFixSync (orig) {
if (!orig) return orig
// Older versions of Node erroneously returned signed integers for
// uid + gid.
return function (target, options) {
var stats = options ? orig.call(fs, target, options)
: orig.call(fs, target)
if (stats) {
if (stats.uid < 0) stats.uid += 0x100000000
if (stats.gid < 0) stats.gid += 0x100000000
}
return stats;
}
}
// ENOSYS means that the fs doesn't support the op. Just ignore
// that, because it doesn't matter.
//
// if there's no getuid, or if getuid() is something other
// than 0, and the error is EINVAL or EPERM, then just ignore
// it.
//
// This specific case is a silent failure in cp, install, tar,
// and most other unix tools that manage permissions.
//
// When running as root, or if other types of errors are
// encountered, then it's strict.
function chownErOk (er) {
if (!er)
return true
if (er.code === "ENOSYS")
return true
var nonroot = !process.getuid || process.getuid() !== 0
if (nonroot) {
if (er.code === "EINVAL" || er.code === "EPERM")
return true
}
return false
}
}

171
node_modules/jsonfile/CHANGELOG.md generated vendored Normal file
View File

@@ -0,0 +1,171 @@
6.1.0 / 2020-10-31
------------------
- Add `finalEOL` option to disable writing final EOL ([#115](https://github.com/jprichardson/node-jsonfile/issues/115), [#137](https://github.com/jprichardson/node-jsonfile/pull/137))
- Update dependency ([#138](https://github.com/jprichardson/node-jsonfile/pull/138))
6.0.1 / 2020-03-07
------------------
- Update dependency ([#130](https://github.com/jprichardson/node-jsonfile/pull/130))
- Fix code style ([#129](https://github.com/jprichardson/node-jsonfile/pull/129))
6.0.0 / 2020-02-24
------------------
- **BREAKING:** Drop support for Node 6 & 8 ([#128](https://github.com/jprichardson/node-jsonfile/pull/128))
- **BREAKING:** Do not allow passing `null` as options to `readFile()` or `writeFile()` ([#128](https://github.com/jprichardson/node-jsonfile/pull/128))
- Refactor internals ([#128](https://github.com/jprichardson/node-jsonfile/pull/128))
5.0.0 / 2018-09-08
------------------
- **BREAKING:** Drop Node 4 support
- **BREAKING:** If no callback is passed to an asynchronous method, a promise is now returned ([#109](https://github.com/jprichardson/node-jsonfile/pull/109))
- Cleanup docs
4.0.0 / 2017-07-12
------------------
- **BREAKING:** Remove global `spaces` option.
- **BREAKING:** Drop support for Node 0.10, 0.12, and io.js.
- Remove undocumented `passParsingErrors` option.
- Added `EOL` override option to `writeFile` when using `spaces`. [#89]
3.0.1 / 2017-07-05
------------------
- Fixed bug in `writeFile` when there was a serialization error & no callback was passed. In previous versions, an empty file would be written; now no file is written.
3.0.0 / 2017-04-25
------------------
- Changed behavior of `throws` option for `readFileSync`; now does not throw filesystem errors when `throws` is `false`
2.4.0 / 2016-09-15
------------------
### Changed
- added optional support for `graceful-fs` [#62]
2.3.1 / 2016-05-13
------------------
- fix to support BOM. [#45][#45]
2.3.0 / 2016-04-16
------------------
- add `throws` to `readFile()`. See [#39][#39]
- add support for any arbitrary `fs` module. Useful with [mock-fs](https://www.npmjs.com/package/mock-fs)
2.2.3 / 2015-10-14
------------------
- include file name in parse error. See: https://github.com/jprichardson/node-jsonfile/pull/34
2.2.2 / 2015-09-16
------------------
- split out tests into separate files
- fixed `throws` when set to `true` in `readFileSync()`. See: https://github.com/jprichardson/node-jsonfile/pull/33
2.2.1 / 2015-06-25
------------------
- fixed regression when passing in string as encoding for options in `writeFile()` and `writeFileSync()`. See: https://github.com/jprichardson/node-jsonfile/issues/28
2.2.0 / 2015-06-25
------------------
- added `options.spaces` to `writeFile()` and `writeFileSync()`
2.1.2 / 2015-06-22
------------------
- fixed if passed `readFileSync(file, 'utf8')`. See: https://github.com/jprichardson/node-jsonfile/issues/25
2.1.1 / 2015-06-19
------------------
- fixed regressions if `null` is passed for options. See: https://github.com/jprichardson/node-jsonfile/issues/24
2.1.0 / 2015-06-19
------------------
- cleanup: JavaScript Standard Style, rename files, dropped terst for assert
- methods now support JSON revivers/replacers
2.0.1 / 2015-05-24
------------------
- update license attribute https://github.com/jprichardson/node-jsonfile/pull/21
2.0.0 / 2014-07-28
------------------
* added `\n` to end of file on write. [#14](https://github.com/jprichardson/node-jsonfile/pull/14)
* added `options.throws` to `readFileSync()`
* dropped support for Node v0.8
1.2.0 / 2014-06-29
------------------
* removed semicolons
* bugfix: passed `options` to `fs.readFile` and `fs.readFileSync`. This technically changes behavior, but
changes it according to docs. [#12][#12]
1.1.1 / 2013-11-11
------------------
* fixed catching of callback bug (ffissore / #5)
1.1.0 / 2013-10-11
------------------
* added `options` param to methods, (seanodell / #4)
1.0.1 / 2013-09-05
------------------
* removed `homepage` field from package.json to remove NPM warning
1.0.0 / 2013-06-28
------------------
* added `.npmignore`, #1
* changed spacing default from `4` to `2` to follow Node conventions
0.0.1 / 2012-09-10
------------------
* Initial release.
[#89]: https://github.com/jprichardson/node-jsonfile/pull/89
[#45]: https://github.com/jprichardson/node-jsonfile/issues/45 "Reading of UTF8-encoded (w/ BOM) files fails"
[#44]: https://github.com/jprichardson/node-jsonfile/issues/44 "Extra characters in written file"
[#43]: https://github.com/jprichardson/node-jsonfile/issues/43 "Prettyfy json when written to file"
[#42]: https://github.com/jprichardson/node-jsonfile/pull/42 "Moved fs.readFileSync within the try/catch"
[#41]: https://github.com/jprichardson/node-jsonfile/issues/41 "Linux: Hidden file not working"
[#40]: https://github.com/jprichardson/node-jsonfile/issues/40 "autocreate folder doesn't work from Path-value"
[#39]: https://github.com/jprichardson/node-jsonfile/pull/39 "Add `throws` option for readFile (async)"
[#38]: https://github.com/jprichardson/node-jsonfile/pull/38 "Update README.md writeFile[Sync] signature"
[#37]: https://github.com/jprichardson/node-jsonfile/pull/37 "support append file"
[#36]: https://github.com/jprichardson/node-jsonfile/pull/36 "Add typescript definition file."
[#35]: https://github.com/jprichardson/node-jsonfile/pull/35 "Add typescript definition file."
[#34]: https://github.com/jprichardson/node-jsonfile/pull/34 "readFile JSON parse error includes filename"
[#33]: https://github.com/jprichardson/node-jsonfile/pull/33 "fix throw->throws typo in readFileSync()"
[#32]: https://github.com/jprichardson/node-jsonfile/issues/32 "readFile & readFileSync can possible have strip-comments as an option?"
[#31]: https://github.com/jprichardson/node-jsonfile/pull/31 "[Modify] Support string include is unicode escape string"
[#30]: https://github.com/jprichardson/node-jsonfile/issues/30 "How to use Jsonfile package in Meteor.js App?"
[#29]: https://github.com/jprichardson/node-jsonfile/issues/29 "writefile callback if no error?"
[#28]: https://github.com/jprichardson/node-jsonfile/issues/28 "writeFile options argument broken "
[#27]: https://github.com/jprichardson/node-jsonfile/pull/27 "Use svg instead of png to get better image quality"
[#26]: https://github.com/jprichardson/node-jsonfile/issues/26 "Breaking change to fs-extra"
[#25]: https://github.com/jprichardson/node-jsonfile/issues/25 "support string encoding param for read methods"
[#24]: https://github.com/jprichardson/node-jsonfile/issues/24 "readFile: Passing in null options with a callback throws an error"
[#23]: https://github.com/jprichardson/node-jsonfile/pull/23 "Add appendFile and appendFileSync"
[#22]: https://github.com/jprichardson/node-jsonfile/issues/22 "Default value for spaces in readme.md is outdated"
[#21]: https://github.com/jprichardson/node-jsonfile/pull/21 "Update license attribute"
[#20]: https://github.com/jprichardson/node-jsonfile/issues/20 "Add simple caching functionallity"
[#19]: https://github.com/jprichardson/node-jsonfile/pull/19 "Add appendFileSync method"
[#18]: https://github.com/jprichardson/node-jsonfile/issues/18 "Add updateFile and updateFileSync methods"
[#17]: https://github.com/jprichardson/node-jsonfile/issues/17 "seem read & write sync has sequentially problem"
[#16]: https://github.com/jprichardson/node-jsonfile/pull/16 "export spaces defaulted to null"
[#15]: https://github.com/jprichardson/node-jsonfile/issues/15 "`jsonfile.spaces` should default to `null`"
[#14]: https://github.com/jprichardson/node-jsonfile/pull/14 "Add EOL at EOF"
[#13]: https://github.com/jprichardson/node-jsonfile/issues/13 "Add a final newline"
[#12]: https://github.com/jprichardson/node-jsonfile/issues/12 "readFile doesn't accept options"
[#11]: https://github.com/jprichardson/node-jsonfile/pull/11 "Added try,catch to readFileSync"
[#10]: https://github.com/jprichardson/node-jsonfile/issues/10 "No output or error from writeFile"
[#9]: https://github.com/jprichardson/node-jsonfile/pull/9 "Change 'js' to 'jf' in example."
[#8]: https://github.com/jprichardson/node-jsonfile/pull/8 "Updated forgotten module.exports to me."
[#7]: https://github.com/jprichardson/node-jsonfile/pull/7 "Add file name in error message"
[#6]: https://github.com/jprichardson/node-jsonfile/pull/6 "Use graceful-fs when possible"
[#5]: https://github.com/jprichardson/node-jsonfile/pull/5 "Jsonfile doesn't behave nicely when used inside a test suite."
[#4]: https://github.com/jprichardson/node-jsonfile/pull/4 "Added options parameter to writeFile and writeFileSync"
[#3]: https://github.com/jprichardson/node-jsonfile/issues/3 "test2"
[#2]: https://github.com/jprichardson/node-jsonfile/issues/2 "homepage field must be a string url. Deleted."
[#1]: https://github.com/jprichardson/node-jsonfile/pull/1 "adding an `.npmignore` file"

15
node_modules/jsonfile/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,15 @@
(The MIT License)
Copyright (c) 2012-2015, JP Richardson <jprichardson@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files
(the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify,
merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

230
node_modules/jsonfile/README.md generated vendored Normal file
View File

@@ -0,0 +1,230 @@
Node.js - jsonfile
================
Easily read/write JSON files in Node.js. _Note: this module cannot be used in the browser._
[![npm Package](https://img.shields.io/npm/v/jsonfile.svg?style=flat-square)](https://www.npmjs.org/package/jsonfile)
[![build status](https://secure.travis-ci.org/jprichardson/node-jsonfile.svg)](http://travis-ci.org/jprichardson/node-jsonfile)
[![windows Build status](https://img.shields.io/appveyor/ci/jprichardson/node-jsonfile/master.svg?label=windows%20build)](https://ci.appveyor.com/project/jprichardson/node-jsonfile/branch/master)
<a href="https://github.com/feross/standard"><img src="https://cdn.rawgit.com/feross/standard/master/sticker.svg" alt="Standard JavaScript" width="100"></a>
Why?
----
Writing `JSON.stringify()` and then `fs.writeFile()` and `JSON.parse()` with `fs.readFile()` enclosed in `try/catch` blocks became annoying.
Installation
------------
npm install --save jsonfile
API
---
* [`readFile(filename, [options], callback)`](#readfilefilename-options-callback)
* [`readFileSync(filename, [options])`](#readfilesyncfilename-options)
* [`writeFile(filename, obj, [options], callback)`](#writefilefilename-obj-options-callback)
* [`writeFileSync(filename, obj, [options])`](#writefilesyncfilename-obj-options)
----
### readFile(filename, [options], callback)
`options` (`object`, default `undefined`): Pass in any [`fs.readFile`](https://nodejs.org/api/fs.html#fs_fs_readfile_path_options_callback) options or set `reviver` for a [JSON reviver](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse).
- `throws` (`boolean`, default: `true`). If `JSON.parse` throws an error, pass this error to the callback.
If `false`, returns `null` for the object.
```js
const jsonfile = require('jsonfile')
const file = '/tmp/data.json'
jsonfile.readFile(file, function (err, obj) {
if (err) console.error(err)
console.dir(obj)
})
```
You can also use this method with promises. The `readFile` method will return a promise if you do not pass a callback function.
```js
const jsonfile = require('jsonfile')
const file = '/tmp/data.json'
jsonfile.readFile(file)
.then(obj => console.dir(obj))
.catch(error => console.error(error))
```
----
### readFileSync(filename, [options])
`options` (`object`, default `undefined`): Pass in any [`fs.readFileSync`](https://nodejs.org/api/fs.html#fs_fs_readfilesync_path_options) options or set `reviver` for a [JSON reviver](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse).
- `throws` (`boolean`, default: `true`). If an error is encountered reading or parsing the file, throw the error. If `false`, returns `null` for the object.
```js
const jsonfile = require('jsonfile')
const file = '/tmp/data.json'
console.dir(jsonfile.readFileSync(file))
```
----
### writeFile(filename, obj, [options], callback)
`options`: Pass in any [`fs.writeFile`](https://nodejs.org/api/fs.html#fs_fs_writefile_file_data_options_callback) options or set `replacer` for a [JSON replacer](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify). Can also pass in `spaces`, or override `EOL` string or set `finalEOL` flag as `false` to not save the file with `EOL` at the end.
```js
const jsonfile = require('jsonfile')
const file = '/tmp/data.json'
const obj = { name: 'JP' }
jsonfile.writeFile(file, obj, function (err) {
if (err) console.error(err)
})
```
Or use with promises as follows:
```js
const jsonfile = require('jsonfile')
const file = '/tmp/data.json'
const obj = { name: 'JP' }
jsonfile.writeFile(file, obj)
.then(res => {
console.log('Write complete')
})
.catch(error => console.error(error))
```
**formatting with spaces:**
```js
const jsonfile = require('jsonfile')
const file = '/tmp/data.json'
const obj = { name: 'JP' }
jsonfile.writeFile(file, obj, { spaces: 2 }, function (err) {
if (err) console.error(err)
})
```
**overriding EOL:**
```js
const jsonfile = require('jsonfile')
const file = '/tmp/data.json'
const obj = { name: 'JP' }
jsonfile.writeFile(file, obj, { spaces: 2, EOL: '\r\n' }, function (err) {
if (err) console.error(err)
})
```
**disabling the EOL at the end of file:**
```js
const jsonfile = require('jsonfile')
const file = '/tmp/data.json'
const obj = { name: 'JP' }
jsonfile.writeFile(file, obj, { spaces: 2, finalEOL: false }, function (err) {
if (err) console.log(err)
})
```
**appending to an existing JSON file:**
You can use `fs.writeFile` option `{ flag: 'a' }` to achieve this.
```js
const jsonfile = require('jsonfile')
const file = '/tmp/mayAlreadyExistedData.json'
const obj = { name: 'JP' }
jsonfile.writeFile(file, obj, { flag: 'a' }, function (err) {
if (err) console.error(err)
})
```
----
### writeFileSync(filename, obj, [options])
`options`: Pass in any [`fs.writeFileSync`](https://nodejs.org/api/fs.html#fs_fs_writefilesync_file_data_options) options or set `replacer` for a [JSON replacer](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify). Can also pass in `spaces`, or override `EOL` string or set `finalEOL` flag as `false` to not save the file with `EOL` at the end.
```js
const jsonfile = require('jsonfile')
const file = '/tmp/data.json'
const obj = { name: 'JP' }
jsonfile.writeFileSync(file, obj)
```
**formatting with spaces:**
```js
const jsonfile = require('jsonfile')
const file = '/tmp/data.json'
const obj = { name: 'JP' }
jsonfile.writeFileSync(file, obj, { spaces: 2 })
```
**overriding EOL:**
```js
const jsonfile = require('jsonfile')
const file = '/tmp/data.json'
const obj = { name: 'JP' }
jsonfile.writeFileSync(file, obj, { spaces: 2, EOL: '\r\n' })
```
**disabling the EOL at the end of file:**
```js
const jsonfile = require('jsonfile')
const file = '/tmp/data.json'
const obj = { name: 'JP' }
jsonfile.writeFileSync(file, obj, { spaces: 2, finalEOL: false })
```
**appending to an existing JSON file:**
You can use `fs.writeFileSync` option `{ flag: 'a' }` to achieve this.
```js
const jsonfile = require('jsonfile')
const file = '/tmp/mayAlreadyExistedData.json'
const obj = { name: 'JP' }
jsonfile.writeFileSync(file, obj, { flag: 'a' })
```
License
-------
(MIT License)
Copyright 2012-2016, JP Richardson <jprichardson@gmail.com>

88
node_modules/jsonfile/index.js generated vendored Normal file
View File

@@ -0,0 +1,88 @@
let _fs
try {
_fs = require('graceful-fs')
} catch (_) {
_fs = require('fs')
}
const universalify = require('universalify')
const { stringify, stripBom } = require('./utils')
async function _readFile (file, options = {}) {
if (typeof options === 'string') {
options = { encoding: options }
}
const fs = options.fs || _fs
const shouldThrow = 'throws' in options ? options.throws : true
let data = await universalify.fromCallback(fs.readFile)(file, options)
data = stripBom(data)
let obj
try {
obj = JSON.parse(data, options ? options.reviver : null)
} catch (err) {
if (shouldThrow) {
err.message = `${file}: ${err.message}`
throw err
} else {
return null
}
}
return obj
}
const readFile = universalify.fromPromise(_readFile)
function readFileSync (file, options = {}) {
if (typeof options === 'string') {
options = { encoding: options }
}
const fs = options.fs || _fs
const shouldThrow = 'throws' in options ? options.throws : true
try {
let content = fs.readFileSync(file, options)
content = stripBom(content)
return JSON.parse(content, options.reviver)
} catch (err) {
if (shouldThrow) {
err.message = `${file}: ${err.message}`
throw err
} else {
return null
}
}
}
async function _writeFile (file, obj, options = {}) {
const fs = options.fs || _fs
const str = stringify(obj, options)
await universalify.fromCallback(fs.writeFile)(file, str, options)
}
const writeFile = universalify.fromPromise(_writeFile)
function writeFileSync (file, obj, options = {}) {
const fs = options.fs || _fs
const str = stringify(obj, options)
// not sure if fs.writeFileSync returns anything, but just in case
return fs.writeFileSync(file, str, options)
}
const jsonfile = {
readFile,
readFileSync,
writeFile,
writeFileSync
}
module.exports = jsonfile

40
node_modules/jsonfile/package.json generated vendored Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "jsonfile",
"version": "6.1.0",
"description": "Easily read/write JSON files.",
"repository": {
"type": "git",
"url": "git@github.com:jprichardson/node-jsonfile.git"
},
"keywords": [
"read",
"write",
"file",
"json",
"fs",
"fs-extra"
],
"author": "JP Richardson <jprichardson@gmail.com>",
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
},
"devDependencies": {
"mocha": "^8.2.0",
"rimraf": "^2.4.0",
"standard": "^16.0.1"
},
"main": "index.js",
"files": [
"index.js",
"utils.js"
],
"scripts": {
"lint": "standard",
"test": "npm run lint && npm run unit",
"unit": "mocha"
}
}

14
node_modules/jsonfile/utils.js generated vendored Normal file
View File

@@ -0,0 +1,14 @@
function stringify (obj, { EOL = '\n', finalEOL = true, replacer = null, spaces } = {}) {
const EOF = finalEOL ? EOL : ''
const str = JSON.stringify(obj, replacer, spaces)
return str.replace(/\n/g, EOL) + EOF
}
function stripBom (content) {
// we do this because JSON.parse would convert it to a utf8 string if encoding wasn't specified
if (Buffer.isBuffer(content)) content = content.toString('utf8')
return content.replace(/^\uFEFF/, '')
}
module.exports = { stringify, stripBom }

20
node_modules/universalify/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,20 @@
(The MIT License)
Copyright (c) 2017, Ryan Zimmerman <opensrc@ryanzim.com>
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the 'Software'), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

76
node_modules/universalify/README.md generated vendored Normal file
View File

@@ -0,0 +1,76 @@
# universalify
![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/RyanZim/universalify/ci.yml?branch=master)
![Coveralls github branch](https://img.shields.io/coveralls/github/RyanZim/universalify/master.svg)
![npm](https://img.shields.io/npm/dm/universalify.svg)
![npm](https://img.shields.io/npm/l/universalify.svg)
Make a callback- or promise-based function support both promises and callbacks.
Uses the native promise implementation.
## Installation
```bash
npm install universalify
```
## API
### `universalify.fromCallback(fn)`
Takes a callback-based function to universalify, and returns the universalified function.
Function must take a callback as the last parameter that will be called with the signature `(error, result)`. `universalify` does not support calling the callback with three or more arguments, and does not ensure that the callback is only called once.
```js
function callbackFn (n, cb) {
setTimeout(() => cb(null, n), 15)
}
const fn = universalify.fromCallback(callbackFn)
// Works with Promises:
fn('Hello World!')
.then(result => console.log(result)) // -> Hello World!
.catch(error => console.error(error))
// Works with Callbacks:
fn('Hi!', (error, result) => {
if (error) return console.error(error)
console.log(result)
// -> Hi!
})
```
### `universalify.fromPromise(fn)`
Takes a promise-based function to universalify, and returns the universalified function.
Function must return a valid JS promise. `universalify` does not ensure that a valid promise is returned.
```js
function promiseFn (n) {
return new Promise(resolve => {
setTimeout(() => resolve(n), 15)
})
}
const fn = universalify.fromPromise(promiseFn)
// Works with Promises:
fn('Hello World!')
.then(result => console.log(result)) // -> Hello World!
.catch(error => console.error(error))
// Works with Callbacks:
fn('Hi!', (error, result) => {
if (error) return console.error(error)
console.log(result)
// -> Hi!
})
```
## License
MIT

24
node_modules/universalify/index.js generated vendored Normal file
View File

@@ -0,0 +1,24 @@
'use strict'
exports.fromCallback = function (fn) {
return Object.defineProperty(function (...args) {
if (typeof args[args.length - 1] === 'function') fn.apply(this, args)
else {
return new Promise((resolve, reject) => {
args.push((err, res) => (err != null) ? reject(err) : resolve(res))
fn.apply(this, args)
})
}
}, 'name', { value: fn.name })
}
exports.fromPromise = function (fn) {
return Object.defineProperty(function (...args) {
const cb = args[args.length - 1]
if (typeof cb !== 'function') return fn.apply(this, args)
else {
args.pop()
fn.apply(this, args).then(r => cb(null, r), cb)
}
}, 'name', { value: fn.name })
}

34
node_modules/universalify/package.json generated vendored Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "universalify",
"version": "2.0.1",
"description": "Make a callback- or promise-based function support both promises and callbacks.",
"keywords": [
"callback",
"native",
"promise"
],
"homepage": "https://github.com/RyanZim/universalify#readme",
"bugs": "https://github.com/RyanZim/universalify/issues",
"license": "MIT",
"author": "Ryan Zimmerman <opensrc@ryanzim.com>",
"files": [
"index.js"
],
"repository": {
"type": "git",
"url": "git+https://github.com/RyanZim/universalify.git"
},
"scripts": {
"test": "standard && nyc --reporter text --reporter lcovonly tape test/*.js | colortape"
},
"devDependencies": {
"colortape": "^0.1.2",
"coveralls": "^3.0.1",
"nyc": "^15.0.0",
"standard": "^14.3.1",
"tape": "^5.0.1"
},
"engines": {
"node": ">= 10.0.0"
}
}

Some files were not shown because too many files have changed in this diff Show More