chore: bump version to 0.7.1
This commit is contained in:
67
DEVELOPMENT.md
Normal file
67
DEVELOPMENT.md
Normal 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
21
LICENSE
Normal 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
230
README.md
Normal 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) 文件
|
||||||
|
|
||||||
3
backend/.env.example
Normal file
3
backend/.env.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# 歌词服务器配置示例
|
||||||
|
LYRICS_SERVER=http://123.57.93.143:28883
|
||||||
|
LYRICS_AUTHORIZATION=fzt_tom
|
||||||
7
backend/.gitignore
vendored
Normal file
7
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
__pycache__
|
||||||
|
lyrics
|
||||||
|
.DS_Store
|
||||||
|
build
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
venv
|
||||||
10
backend/config.py
Normal file
10
backend/config.py
Normal 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
86
backend/main.py
Normal 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)
|
||||||
9
backend/modules/__init__.py
Normal file
9
backend/modules/__init__.py
Normal 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 后端模块包
|
||||||
|
"""
|
||||||
118
backend/modules/apple_music.py
Normal file
118
backend/modules/apple_music.py
Normal 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)}
|
||||||
219
backend/modules/background_tasks.py
Normal file
219
backend/modules/background_tasks.py
Normal 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
435
backend/modules/lyrics.py
Normal 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
205
backend/modules/routes.py
Normal 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连接已清理")
|
||||||
53
backend/modules/websocket_manager.py
Normal file
53
backend/modules/websocket_manager.py
Normal 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
0
backend/pyproject.toml
Normal file
6
backend/requirements.txt
Normal file
6
backend/requirements.txt
Normal 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
85
build.sh
Executable 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
104
electron-app/.gitignore
vendored
Normal 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
254
electron-app/loading.html
Normal 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
217
electron-app/main.js
Normal 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('已有实例在运行,已将其窗口聚焦');
|
||||||
|
}
|
||||||
|
});
|
||||||
228
electron-app/modules/backend-service.js
Normal file
228
electron-app/modules/backend-service.js
Normal 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
|
||||||
|
};
|
||||||
76
electron-app/modules/i18n.js
Normal file
76
electron-app/modules/i18n.js
Normal 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
|
||||||
73
electron-app/modules/logger.js
Normal file
73
electron-app/modules/logger.js
Normal 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
|
||||||
|
};
|
||||||
875
electron-app/modules/window-manager.js
Normal file
875
electron-app/modules/window-manager.js
Normal 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
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
67
electron-app/package.json
Normal 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
97
electron-app/preload.js
Normal 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
28
electron-app/run.sh
Executable 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
98
electron-app/unlock.html
Normal 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>
|
||||||
144
electron-app/windows/search-window.js
Normal file
144
electron-app/windows/search-window.js
Normal 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
24
frontend/.gitignore
vendored
Normal 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
3
frontend/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
20
frontend/index.html
Normal file
20
frontend/index.html
Normal 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
1627
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
frontend/package.json
Normal file
21
frontend/package.json
Normal 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
1
frontend/public/vite.svg
Normal 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
39
frontend/src/App.vue
Normal 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>
|
||||||
1
frontend/src/assets/vue.svg
Normal file
1
frontend/src/assets/vue.svg
Normal 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 |
596
frontend/src/components/LyricWindow.vue
Normal file
596
frontend/src/components/LyricWindow.vue
Normal 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>
|
||||||
357
frontend/src/components/MusicControlBar.vue
Normal file
357
frontend/src/components/MusicControlBar.vue
Normal 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
237
frontend/src/i18n/index.js
Normal 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
18
frontend/src/main.js
Normal 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')
|
||||||
15
frontend/src/store/index.js
Normal file
15
frontend/src/store/index.js
Normal 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
86
frontend/src/style.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
119
frontend/src/utils/musicControl.js
Normal file
119
frontend/src/utils/musicControl.js
Normal 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
|
||||||
|
};
|
||||||
103
frontend/src/utils/positionTracker.js
Normal file
103
frontend/src/utils/positionTracker.js
Normal 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;
|
||||||
33
frontend/src/utils/request.js
Normal file
33
frontend/src/utils/request.js
Normal 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;
|
||||||
236
frontend/src/utils/websocketService.js
Normal file
236
frontend/src/utils/websocketService.js
Normal 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服务器URL(ws://或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;
|
||||||
413
frontend/src/views/SearchLyrics.vue
Normal file
413
frontend/src/views/SearchLyrics.vue
Normal 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
19
frontend/vite.config.js
Normal 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
53
node_modules/.package-lock.json
generated
vendored
Normal 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
15
node_modules/fs-extra/LICENSE
generated
vendored
Normal 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
292
node_modules/fs-extra/README.md
generated
vendored
Normal 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`.
|
||||||
|
|
||||||
|
[](https://www.npmjs.org/package/fs-extra)
|
||||||
|
[](https://github.com/jprichardson/node-fs-extra/blob/master/LICENSE)
|
||||||
|
[](https://github.com/jprichardson/node-fs-extra/actions/workflows/ci.yml?query=branch%3Amaster)
|
||||||
|
[](https://www.npmjs.org/package/fs-extra)
|
||||||
|
[](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`.
|
||||||
|
|
||||||
|
[](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
171
node_modules/fs-extra/lib/copy/copy-sync.js
generated
vendored
Normal 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
182
node_modules/fs-extra/lib/copy/copy.js
generated
vendored
Normal 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
7
node_modules/fs-extra/lib/copy/index.js
generated
vendored
Normal 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
39
node_modules/fs-extra/lib/empty/index.js
generated
vendored
Normal 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
66
node_modules/fs-extra/lib/ensure/file.js
generated
vendored
Normal 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
23
node_modules/fs-extra/lib/ensure/index.js
generated
vendored
Normal 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
64
node_modules/fs-extra/lib/ensure/link.js
generated
vendored
Normal 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
101
node_modules/fs-extra/lib/ensure/symlink-paths.js
generated
vendored
Normal 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
34
node_modules/fs-extra/lib/ensure/symlink-type.js
generated
vendored
Normal 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
67
node_modules/fs-extra/lib/ensure/symlink.js
generated
vendored
Normal 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
68
node_modules/fs-extra/lib/esm.mjs
generated
vendored
Normal 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
146
node_modules/fs-extra/lib/fs/index.js
generated
vendored
Normal 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
16
node_modules/fs-extra/lib/index.js
generated
vendored
Normal 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
16
node_modules/fs-extra/lib/json/index.js
generated
vendored
Normal 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
11
node_modules/fs-extra/lib/json/jsonfile.js
generated
vendored
Normal 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
12
node_modules/fs-extra/lib/json/output-json-sync.js
generated
vendored
Normal 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
12
node_modules/fs-extra/lib/json/output-json.js
generated
vendored
Normal 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
14
node_modules/fs-extra/lib/mkdirs/index.js
generated
vendored
Normal 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
27
node_modules/fs-extra/lib/mkdirs/make-dir.js
generated
vendored
Normal 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
21
node_modules/fs-extra/lib/mkdirs/utils.js
generated
vendored
Normal 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
7
node_modules/fs-extra/lib/move/index.js
generated
vendored
Normal 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
55
node_modules/fs-extra/lib/move/move-sync.js
generated
vendored
Normal 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
59
node_modules/fs-extra/lib/move/move.js
generated
vendored
Normal 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
31
node_modules/fs-extra/lib/output-file/index.js
generated
vendored
Normal 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
12
node_modules/fs-extra/lib/path-exists/index.js
generated
vendored
Normal 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
17
node_modules/fs-extra/lib/remove/index.js
generated
vendored
Normal 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
158
node_modules/fs-extra/lib/util/stat.js
generated
vendored
Normal 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
36
node_modules/fs-extra/lib/util/utimes.js
generated
vendored
Normal 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
71
node_modules/fs-extra/package.json
generated
vendored
Normal 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
15
node_modules/graceful-fs/LICENSE
generated
vendored
Normal 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
143
node_modules/graceful-fs/README.md
generated
vendored
Normal 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
23
node_modules/graceful-fs/clone.js
generated
vendored
Normal 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
448
node_modules/graceful-fs/graceful-fs.js
generated
vendored
Normal 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
118
node_modules/graceful-fs/legacy-streams.js
generated
vendored
Normal 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
53
node_modules/graceful-fs/package.json
generated
vendored
Normal 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
355
node_modules/graceful-fs/polyfills.js
generated
vendored
Normal 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
171
node_modules/jsonfile/CHANGELOG.md
generated
vendored
Normal 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
15
node_modules/jsonfile/LICENSE
generated
vendored
Normal 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
230
node_modules/jsonfile/README.md
generated
vendored
Normal 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._
|
||||||
|
|
||||||
|
[](https://www.npmjs.org/package/jsonfile)
|
||||||
|
[](http://travis-ci.org/jprichardson/node-jsonfile)
|
||||||
|
[](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
88
node_modules/jsonfile/index.js
generated
vendored
Normal 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
40
node_modules/jsonfile/package.json
generated
vendored
Normal 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
14
node_modules/jsonfile/utils.js
generated
vendored
Normal 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
20
node_modules/universalify/LICENSE
generated
vendored
Normal 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
76
node_modules/universalify/README.md
generated
vendored
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# universalify
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
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
24
node_modules/universalify/index.js
generated
vendored
Normal 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
34
node_modules/universalify/package.json
generated
vendored
Normal 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
Reference in New Issue
Block a user