0.1.0
@@ -1,9 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
2
.gitattributes
vendored
@@ -1,2 +0,0 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
26
.gitignore
vendored
@@ -1,26 +0,0 @@
|
||||
# CakePHP 3
|
||||
|
||||
/vendor/*
|
||||
/config/app.php
|
||||
|
||||
/tmp/cache/models/*
|
||||
!/tmp/cache/models/empty
|
||||
/tmp/cache/persistent/*
|
||||
!/tmp/cache/persistent/empty
|
||||
/tmp/cache/views/*
|
||||
!/tmp/cache/views/empty
|
||||
/tmp/sessions/*
|
||||
!/tmp/sessions/empty
|
||||
/tmp/tests/*
|
||||
!/tmp/tests/empty
|
||||
|
||||
/logs/*
|
||||
!/logs/empty
|
||||
|
||||
# CakePHP 2
|
||||
|
||||
/app/tmp/*
|
||||
/app/Config/core.php
|
||||
/app/Config/database.php
|
||||
/vendors/*
|
||||
node_modules
|
||||
3
.npmrc
@@ -1,3 +0,0 @@
|
||||
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||
shamefully-hoist=true
|
||||
@@ -1,6 +0,0 @@
|
||||
out
|
||||
dist
|
||||
pnpm-lock.yaml
|
||||
LICENSE.md
|
||||
tsconfig.json
|
||||
tsconfig.*.json
|
||||
@@ -1,4 +0,0 @@
|
||||
singleQuote: true
|
||||
semi: false
|
||||
printWidth: 100
|
||||
trailingComma: none
|
||||
3
.vscode/extensions.json
vendored
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"recommendations": ["dbaeumer.vscode-eslint"]
|
||||
}
|
||||
39
.vscode/launch.json
vendored
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Debug Main Process",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"cwd": "${workspaceRoot}",
|
||||
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite",
|
||||
"windows": {
|
||||
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd"
|
||||
},
|
||||
"runtimeArgs": ["--sourcemap"],
|
||||
"env": {
|
||||
"REMOTE_DEBUGGING_PORT": "9222"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Debug Renderer Process",
|
||||
"port": 9222,
|
||||
"request": "attach",
|
||||
"type": "chrome",
|
||||
"webRoot": "${workspaceFolder}/src/renderer",
|
||||
"timeout": 60000,
|
||||
"presentation": {
|
||||
"hidden": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "Debug All",
|
||||
"configurations": ["Debug Main Process", "Debug Renderer Process"],
|
||||
"presentation": {
|
||||
"order": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
11
.vscode/settings.json
vendored
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
}
|
||||
55
README.md
@@ -1,55 +0,0 @@
|
||||
# LanMontainDesktop
|
||||
|
||||
一个使用 Electron 打包的桌面应用:前端采用 Vue 3(Renderer),主进程内置 Elysia.js 作为本地后端服务(Main)。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Electron + electron-vite(主进程/构建/开发)
|
||||
- Vue 3 + Vite + TypeScript(渲染进程 UI)
|
||||
- Elysia.js + @elysiajs/node(主进程内的本地后端 API)
|
||||
|
||||
## 架构说明
|
||||
|
||||
这个项目不是传统意义上“浏览器前端 + 远程后端”的部署形态,而是:
|
||||
|
||||
- 主进程(Electron Main)负责创建窗口,并启动 Elysia.js(HTTP Server 绑定到 127.0.0.1 的随机端口)。
|
||||
- 预加载(Preload)通过 `ipcRenderer.invoke('eiysia:request', ...)` 把“类 HTTP 请求”转发到主进程里的 Elysia 路由。
|
||||
- 渲染进程(Vue 3 Renderer)通过 `window.api.call({ method, path, body })` 调用后端接口(例如 `/apps/list`、`/apps/launch`、`/open/external`)。
|
||||
|
||||
## 目录结构(关键)
|
||||
|
||||
- `src/main/`:Electron 主进程入口(创建窗口、启动 Elysia 服务)
|
||||
- `src/preload/`:Preload 桥接层(暴露 `window.api`)
|
||||
- `src/renderer/`:Vue 3 渲染进程(UI 与交互)
|
||||
- `src/eiysia/`:Elysia.js “后端”路由与启动逻辑
|
||||
|
||||
## 推荐 IDE
|
||||
|
||||
- VSCode + Volar(Vue Language Features)+ ESLint + Prettier
|
||||
|
||||
## 开发与构建
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
$ pnpm install
|
||||
```
|
||||
|
||||
### 开发
|
||||
|
||||
```bash
|
||||
$ pnpm dev
|
||||
```
|
||||
|
||||
### 构建
|
||||
|
||||
```bash
|
||||
# For windows
|
||||
$ pnpm build:win
|
||||
|
||||
# For macOS
|
||||
$ pnpm build:mac
|
||||
|
||||
# For Linux
|
||||
$ pnpm build:linux
|
||||
```
|
||||
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
build/icon.icns
BIN
build/icon.ico
|
Before Width: | Height: | Size: 121 KiB |
BIN
build/icon.png
|
Before Width: | Height: | Size: 35 KiB |
@@ -1,3 +0,0 @@
|
||||
provider: generic
|
||||
url: https://example.com/auto-updates
|
||||
updaterCacheDirName: desktop-updater
|
||||
@@ -1,46 +0,0 @@
|
||||
appId: com.electron.app
|
||||
productName: desktop
|
||||
directories:
|
||||
buildResources: build
|
||||
files:
|
||||
- '!**/.vscode/*'
|
||||
- '!src/*'
|
||||
- '!electron.vite.config.{js,ts,mjs,cjs}'
|
||||
- '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
|
||||
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
|
||||
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
- '**/*.node'
|
||||
win:
|
||||
executableName: desktop
|
||||
nsis:
|
||||
artifactName: ${name}-${version}-setup.${ext}
|
||||
shortcutName: ${productName}
|
||||
uninstallDisplayName: ${productName}
|
||||
createDesktopShortcut: always
|
||||
mac:
|
||||
entitlementsInherit: build/entitlements.mac.plist
|
||||
extendInfo:
|
||||
- NSCameraUsageDescription: Application requests access to the device's camera.
|
||||
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
|
||||
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
|
||||
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
|
||||
notarize: false
|
||||
dmg:
|
||||
artifactName: ${name}-${version}.${ext}
|
||||
linux:
|
||||
target:
|
||||
- AppImage
|
||||
- snap
|
||||
- deb
|
||||
maintainer: electronjs.org
|
||||
category: Utility
|
||||
appImage:
|
||||
artifactName: ${name}-${version}.${ext}
|
||||
npmRebuild: false
|
||||
publish:
|
||||
provider: generic
|
||||
url: https://example.com/auto-updates
|
||||
electronDownload:
|
||||
mirror: https://npmmirror.com/mirrors/electron/
|
||||
@@ -1,22 +0,0 @@
|
||||
import { resolve } from 'path'
|
||||
import { defineConfig } from 'electron-vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external: ['better-sqlite3']
|
||||
}
|
||||
}
|
||||
},
|
||||
preload: {},
|
||||
renderer: {
|
||||
resolve: {
|
||||
alias: {
|
||||
'@renderer': resolve('src/renderer/src')
|
||||
}
|
||||
},
|
||||
plugins: [vue()]
|
||||
}
|
||||
})
|
||||
@@ -1,9 +0,0 @@
|
||||
import { defineConfig } from 'eslint/config'
|
||||
import tseslint from '@electron-toolkit/eslint-config-ts'
|
||||
import eslintConfigPrettier from '@electron-toolkit/eslint-config-prettier'
|
||||
|
||||
export default defineConfig(
|
||||
{ ignores: ['**/node_modules', '**/dist', '**/out', '**/*.vue'] },
|
||||
tseslint.configs.recommended,
|
||||
eslintConfigPrettier
|
||||
)
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill="currentColor" d="M5.102 3a.5.5 0 0 0-.433.25l-2.598 4.5a.5.5 0 0 0 0 .5l2.504 4.337a.827.827 0 0 0 1.504-.165l2.89-9.146a1.826 1.826 0 0 1 3.322-.363l2.504 4.337a1.5 1.5 0 0 1 0 1.5l-2.598 4.5a1.5 1.5 0 0 1-1.299.75H8.502a.5.5 0 0 1 0-1h2.396a.5.5 0 0 0 .433-.25l2.598-4.5a.5.5 0 0 0 0-.5l-2.504-4.337a.826.826 0 0 0-1.503.164l-2.89 9.146a1.827 1.827 0 0 1-3.323.364L1.205 8.75a1.5 1.5 0 0 1 0-1.5l2.598-4.5A1.5 1.5 0 0 1 5.102 2h2.4a.5.5 0 0 1 0 1z"/></svg>
|
||||
|
Before Width: | Height: | Size: 553 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path fill="currentColor" d="M9.08 18a3 3 0 0 0 .67-1.238l3.4-12.754A.01.01 0 0 1 13.158 4l.001-.004l-.006.004c.066-.203-.009-.53-.313-.602c-.357-.084-.555.092-.635.285l-.02.068l-3.402 12.753a2.015 2.015 0 0 1-3.652.556l-3.863-6.126a1.75 1.75 0 0 1 0-1.868l3.941-6.25A1.75 1.75 0 0 1 6.69 2h4.23a3 3 0 0 0-.667 1.235L6.85 15.99l-.005.007l-.002.006l.004-.002c-.067.203.008.53.312.602c.355.084.553-.09.634-.282q.013-.036.023-.074l3.401-12.753a2.01 2.01 0 0 1 3.645-.554l.003.005l.002.004l3.92 6.297a1.43 1.43 0 0 1 0 1.51l-3.996 6.42a1.75 1.75 0 0 1-1.486.825z"/></svg>
|
||||
|
Before Width: | Height: | Size: 650 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path fill="currentColor" d="M4.5 17a1.5 1.5 0 0 1-1.493-1.355L3 15.501v-11a1.5 1.5 0 0 1 1.356-1.493L4.5 3H9a1.5 1.5 0 0 1 1.493 1.355l.007.145v.254l2.189-2.269a1.5 1.5 0 0 1 2.007-.138l.116.101l2.757 2.725a1.5 1.5 0 0 1 .111 2.011l-.103.116l-2.311 2.2h.234a1.5 1.5 0 0 1 1.493 1.356L17 11v4.5a1.5 1.5 0 0 1-1.355 1.493L15.5 17zm5-6.5H4v5a.5.5 0 0 0 .326.47l.084.023l.09.008h5zm6 0h-5V16h5a.5.5 0 0 0 .492-.41L16 15.5V11a.5.5 0 0 0-.41-.491zm-5-2.79V9.5h1.79zM9 4H4.5a.5.5 0 0 0-.492.411L4 4.501v5h5.5v-5a.5.5 0 0 0-.326-.469L9.09 4.01zm5.122-.826a.5.5 0 0 0-.645-.053l-.068.06l-2.616 2.713a.5.5 0 0 0-.057.623l.063.078l2.616 2.615a.5.5 0 0 0 .62.07l.078-.061l2.758-2.627a.5.5 0 0 0 .054-.638l-.059-.069z"/></svg>
|
||||
|
Before Width: | Height: | Size: 797 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path fill="currentColor" d="M16 10A6 6 0 0 0 5.528 6H7.5a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5v-3a.5.5 0 0 1 1 0v1.601a7 7 0 1 1-1.98 4.361a.5.5 0 0 1 .998.076A6 6 0 1 0 16 10"/></svg>
|
||||
|
Before Width: | Height: | Size: 265 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path fill="currentColor" d="M12.5 17a.5.5 0 0 0 0-1H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h6.5a.5.5 0 0 0 0-1H6a3 3 0 0 0-3 3v8a3 3 0 0 0 3 3zm1.146-10.854a.5.5 0 0 1 .708 0l3.5 3.5a.5.5 0 0 1 0 .708l-3.5 3.5a.5.5 0 0 1-.708-.708l2.647-2.646H7.5a.5.5 0 0 1 0-1h8.793l-2.647-2.646a.5.5 0 0 1 0-.708"/></svg>
|
||||
|
Before Width: | Height: | Size: 383 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path fill="currentColor" d="M10 2a8 8 0 1 1-3.613 15.14l-.121-.065l-3.645.91a.5.5 0 0 1-.62-.441v-.082l.014-.083l.91-3.644l-.063-.12a8 8 0 0 1-.83-2.887l-.025-.382L2 10q.001-.823.16-1.599V8.4a.506.506 0 0 1 .615-.394c.307.08.413.394.353.671l-.012.049a7.04 7.04 0 0 0 .778 4.7a.5.5 0 0 1 .063.272l-.014.094l-.756 3.021l3.024-.754a.5.5 0 0 1 .188-.01l.091.021l.087.039A7 7 0 1 0 4.255 6H6.5a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5v-3a.5.5 0 0 1 1 0v1.208A7.98 7.98 0 0 1 10 2m0 5.5a.5.5 0 0 0-1 0v4a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 0-1H10z"/></svg>
|
||||
|
Before Width: | Height: | Size: 621 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path fill="currentColor" d="M8.731 17.174a2.5 2.5 0 0 1 0-3.536l4.9-4.9a2.5 2.5 0 0 1 3.536 0l.8.8a7.995 7.995 0 1 0-8.441 8.44zm7.737-7.734l2.095 2.1a1.5 1.5 0 0 1 0 2.122l-3.6 3.6l-4.216-4.217l3.6-3.6a1.5 1.5 0 0 1 2.122 0zm-2.212 8.523l-4.216-4.217l-.6.6a1.5 1.5 0 0 0 0 2.122l2.1 2.095a1.5 1.5 0 0 0 1.117.438h4.092a.5.5 0 0 0 0-1h-2.531z"/></svg>
|
||||
|
Before Width: | Height: | Size: 435 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path fill="currentColor" d="M8.732 17.173a2.5 2.5 0 0 1-.288-.353A7.01 7.01 0 0 1 3 10a7.01 7.01 0 0 1 7-7a7.01 7.01 0 0 1 6.819 5.441q.19.13.354.29l.8.8A8 8 0 0 0 10 2a8.01 8.01 0 0 0-8 8a8 8 0 0 0 7.535 7.976zm.707-.707a1.5 1.5 0 0 1 0-2.122l4.9-4.9a1.5 1.5 0 0 1 2.122 0l2.095 2.095a1.5 1.5 0 0 1 0 2.122L14.216 18h2.531a.5.5 0 0 1 0 1h-4.092a1.5 1.5 0 0 1-1.121-.438zm5.612-6.319l-3.6 3.6l2.8 2.8l3.6-3.6a.5.5 0 0 0 0-.708l-2.095-2.095a.5.5 0 0 0-.705.003m-1.5 7.108l-2.8-2.8l-.6.6a.5.5 0 0 0 0 .708l2.095 2.095a.5.5 0 0 0 .708 0z"/></svg>
|
||||
|
Before Width: | Height: | Size: 627 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path fill="currentColor" d="M6.255 3.579a.478.478 0 0 0-.706-.227a8 8 0 0 0-2.2 2.2a.478.478 0 0 0 .228.706a.53.53 0 0 0 .638-.2a7 7 0 0 1 1.84-1.84a.53.53 0 0 0 .2-.639m2.44-.458a.53.53 0 0 1-.593-.31a.478.478 0 0 1 .338-.659a8 8 0 0 1 3.115-.001c.298.059.454.38.338.66a.53.53 0 0 1-.594.31a7 7 0 0 0-2.603 0m7.727 3.135a.53.53 0 0 1-.638-.2a7 7 0 0 0-1.842-1.842a.53.53 0 0 1-.201-.638a.478.478 0 0 1 .706-.228A8 8 0 0 1 16.65 5.55c.17.253.054.59-.227.706m.766 5.64a.53.53 0 0 1-.309-.593a7 7 0 0 0 0-2.606a.53.53 0 0 1 .31-.593c.28-.117.6.04.66.338a8 8 0 0 1 0 3.116a.478.478 0 0 1-.66.338m-9.086 5.292a.53.53 0 0 1 .594-.31a7 7 0 0 0 2.305.05v1.01a8 8 0 0 1-2.56-.09a.478.478 0 0 1-.339-.66m-4.525-3.446a.53.53 0 0 1 .638.2a7 7 0 0 0 1.84 1.841a.53.53 0 0 1 .2.638a.478.478 0 0 1-.706.228a8 8 0 0 1-2.2-2.202a.478.478 0 0 1 .228-.705M2.81 8.106a.53.53 0 0 1 .31.593a7 7 0 0 0 0 2.601a.53.53 0 0 1-.31.594a.478.478 0 0 1-.66-.338a8 8 0 0 1 0-3.112a.478.478 0 0 1 .66-.338m10.045 2.04a.5.5 0 0 0-.852.354l-.003 8a.5.5 0 0 0 .9.301l1.994-2.647l3.497.776a.5.5 0 0 0 .46-.843z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path fill="currentColor" d="M10 2a8 8 0 1 1 0 16a8 8 0 0 1 0-16m0 1a7 7 0 1 0 0 14a7 7 0 0 0 0-14m-.5 2a.5.5 0 0 1 .492.41L10 5.5V10h2.5a.5.5 0 0 1 .09.992L12.5 11h-3a.5.5 0 0 1-.492-.41L9 10.5v-5a.5.5 0 0 1 .5-.5"/></svg>
|
||||
|
Before Width: | Height: | Size: 306 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path fill="currentColor" d="M6.636 2.287A1 1 0 0 0 5 3.059v13.998c0 .927 1.15 1.355 1.756.655l3.524-4.073a1.5 1.5 0 0 1 1.134-.518h5.592c.938 0 1.36-1.176.636-1.772z"/></svg>
|
||||
|
Before Width: | Height: | Size: 258 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path fill="currentColor" d="M5 3.059a1 1 0 0 1 1.636-.772l11.006 9.062c.724.596.302 1.772-.636 1.772h-5.592a1.5 1.5 0 0 0-1.134.518l-3.524 4.073c-.606.7-1.756.271-1.756-.655zm12.006 9.062L6 3.059v13.998l3.524-4.072a2.5 2.5 0 0 1 1.89-.864z"/></svg>
|
||||
|
Before Width: | Height: | Size: 332 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path fill="currentColor" d="M4 5c0-1.007.875-1.755 1.904-2.223C6.978 2.289 8.427 2 10 2s3.022.289 4.096.777C15.125 3.245 16 3.993 16 5v4.041a3 3 0 0 0-1 0V6.698a5 5 0 0 1-.904.525C13.022 7.711 11.573 8 10 8s-3.022-.289-4.096-.777A5 5 0 0 1 5 6.698V15c0 .374.356.875 1.318 1.313c.916.416 2.218.687 3.682.687q.533 0 1.031-.046c.047.334.146.663.3.975Q10.686 18 10 18c-1.573 0-3.022-.289-4.096-.777C4.875 16.755 4 16.007 4 15zm1 0c0 .374.356.875 1.318 1.313C7.234 6.729 8.536 7 10 7s2.766-.27 3.682-.687C14.644 5.875 15 5.373 15 5c0-.374-.356-.875-1.318-1.313C12.766 3.271 11.464 3 10 3s-2.766.27-3.682.687C5.356 4.125 5 4.627 5 5m12.5 7a2 2 0 1 1-4 0a2 2 0 0 1 4 0m1.5 4.5c0 1.245-1 2.5-3.5 2.5S12 17.75 12 16.5a1.5 1.5 0 0 1 1.5-1.5h4a1.5 1.5 0 0 1 1.5 1.5"/></svg>
|
||||
|
Before Width: | Height: | Size: 847 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path fill="currentColor" d="M10 7a3 3 0 1 0 0 6a3 3 0 0 0 0-6m-2 3a2 2 0 1 1 4 0a2 2 0 0 1-4 0m-.5-8a.5.5 0 0 1 .5.5V4h1.5V2.5a.5.5 0 0 1 1 0V4H12V2.5a.5.5 0 0 1 1 0V4h.5A2.5 2.5 0 0 1 16 6.5V7h1.5a.5.5 0 0 1 0 1H16v1.5h1.5a.5.5 0 0 1 0 1H16V12h1.5a.5.5 0 0 1 0 1H16v.5a2.5 2.5 0 0 1-2.5 2.5H13v1.5a.5.5 0 0 1-1 0V16h-1.5v1.5a.5.5 0 0 1-1 0V16H8v1.5a.5.5 0 0 1-1 0V16h-.5A2.5 2.5 0 0 1 4 13.5V13H2.5a.5.5 0 0 1 0-1H4v-1.5H2.5a.5.5 0 0 1 0-1H4V8H2.5a.5.5 0 0 1 0-1H4v-.5A2.5 2.5 0 0 1 6.5 4H7V2.5a.5.5 0 0 1 .5-.5M15 6.5A1.5 1.5 0 0 0 13.5 5h-7A1.5 1.5 0 0 0 5 6.5v7A1.5 1.5 0 0 0 6.5 15h7a1.5 1.5 0 0 0 1.5-1.5z"/></svg>
|
||||
|
Before Width: | Height: | Size: 704 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path fill="currentColor" d="M11.197 2.44a1.5 1.5 0 0 1 2.121 0l4.243 4.242a1.5 1.5 0 0 1 0 2.121L9.364 17H14.5a.5.5 0 1 1 0 1H7.82a1.5 1.5 0 0 1-1.14-.437L2.437 13.32a1.5 1.5 0 0 1 0-2.121zM9.781 15.168l-4.95-4.95l-1.687 1.687a.5.5 0 0 0 0 .707l4.243 4.243a.5.5 0 0 0 .707 0z"/></svg>
|
||||
|
Before Width: | Height: | Size: 368 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path fill="currentColor" d="M11.197 2.44a1.5 1.5 0 0 1 2.121 0l4.243 4.242a1.5 1.5 0 0 1 0 2.121L9.364 17H14.5a.5.5 0 1 1 0 1H7.82a1.5 1.5 0 0 1-1.14-.437L2.437 13.32a1.5 1.5 0 0 1 0-2.121zm1.414.706a.5.5 0 0 0-.707 0L5.538 9.512l4.95 4.95l6.366-6.366a.5.5 0 0 0 0-.707zM9.781 15.17l-4.95-4.95l-1.687 1.687a.5.5 0 0 0 0 .707l4.243 4.243a.5.5 0 0 0 .707 0z"/></svg>
|
||||
|
Before Width: | Height: | Size: 448 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path fill="currentColor" d="M2.44 11.2a1.5 1.5 0 0 0 0 2.122l4.242 4.242a1.5 1.5 0 0 0 2.121 0l.72-.72a5.5 5.5 0 0 1-.369-1.045l-1.058 1.058a.5.5 0 0 1-.707 0l-4.243-4.242a.5.5 0 0 1 0-.707l1.69-1.69l4.165 4.164a5.5 5.5 0 0 1 7.843-4.859l.72-.72a1.5 1.5 0 0 0 0-2.121l-4.242-4.243a1.5 1.5 0 0 0-2.122 0zM14.5 19a4.5 4.5 0 1 0 0-9a4.5 4.5 0 0 0 0 9"/></svg>
|
||||
|
Before Width: | Height: | Size: 440 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path fill="currentColor" d="M2.44 11.2a1.5 1.5 0 0 0 0 2.122l4.242 4.242a1.5 1.5 0 0 0 2.121 0l.72-.72a5.5 5.5 0 0 1-.369-1.045l-1.058 1.058a.5.5 0 0 1-.707 0l-4.243-4.242a.5.5 0 0 1 0-.707l1.69-1.69l4.165 4.164q.015-.645.17-1.245L5.543 9.51l6.364-6.364a.5.5 0 0 1 .707 0l4.242 4.243a.5.5 0 0 1 0 .707L15.8 9.154a5.5 5.5 0 0 1 1.045.37l.72-.72a1.5 1.5 0 0 0 0-2.122l-4.242-4.243a1.5 1.5 0 0 0-2.122 0zM14.5 19a4.5 4.5 0 1 0 0-9a4.5 4.5 0 0 0 0 9"/></svg>
|
||||
|
Before Width: | Height: | Size: 538 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path fill="currentColor" d="M10 4a6 6 0 1 1-5.982 5.538a.5.5 0 1 0-.998-.076Q3 9.73 3 10a7 7 0 1 0 2-4.899V3.5a.5.5 0 0 0-1 0v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 0-1H5.528A5.98 5.98 0 0 1 10 4m0 2.5a.5.5 0 0 0-1 0v4a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 0-1H10z"/></svg>
|
||||
|
Before Width: | Height: | Size: 340 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path fill="currentColor" d="M3.5 4A1.5 1.5 0 0 0 2 5.5v8A1.5 1.5 0 0 0 3.5 15h13a1.5 1.5 0 0 0 1.5-1.5v-8A1.5 1.5 0 0 0 16.5 4zm2.755 3.252a.752.752 0 1 1-1.505 0a.752.752 0 0 1 1.505 0m6 0a.752.752 0 1 1-1.505 0a.752.752 0 0 1 1.505 0M5 12.5a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5m9.502-4.495a.752.752 0 1 1 0-1.505a.752.752 0 0 1 0 1.505m-7.504 2.5a.752.752 0 1 1 0-1.505a.752.752 0 0 1 0 1.505m3.757-.753a.752.752 0 1 1-1.505 0a.752.752 0 0 1 1.505 0m2.252.753a.752.752 0 1 1 0-1.505a.752.752 0 0 1 0 1.505M9.255 7.252a.752.752 0 1 1-1.505 0a.752.752 0 0 1 1.505 0"/></svg>
|
||||
|
Before Width: | Height: | Size: 675 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path fill="currentColor" d="M5 12.5a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5m6.502-4.495a.752.752 0 1 0 0-1.505a.752.752 0 0 0 0 1.505m3.753-.753a.752.752 0 1 1-1.505 0a.752.752 0 0 1 1.505 0m-9.753.753a.752.752 0 1 0 0-1.505a.752.752 0 0 0 0 1.505M7.75 9.752a.752.752 0 1 1-1.505 0a.752.752 0 0 1 1.505 0m2.252.753a.752.752 0 1 0 0-1.505a.752.752 0 0 0 0 1.505m3.757-.753a.752.752 0 1 1-1.504 0a.752.752 0 0 1 1.504 0M8.503 8.005a.752.752 0 1 0 0-1.505a.752.752 0 0 0 0 1.505M2 5.5A1.5 1.5 0 0 1 3.5 4h13A1.5 1.5 0 0 1 18 5.5v8a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 2 13.5zM3.5 5a.5.5 0 0 0-.5.5v8a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5v-8a.5.5 0 0 0-.5-.5z"/></svg>
|
||||
|
Before Width: | Height: | Size: 763 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path fill="currentColor" d="M5 2a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2zM4 4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1zm9-2a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2zm-1 2a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1z"/></svg>
|
||||
|
Before Width: | Height: | Size: 395 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path fill="currentColor" d="M12.92 2.873a2.975 2.975 0 0 1 4.207 4.207l-.67.67l.336.336a2 2 0 0 1 0 2.828l-.94.94a.5.5 0 0 1-.707-.708l.94-.939a1 1 0 0 0 0-1.414l-.336-.336l-7.98 7.981a2.5 2.5 0 0 1-1.235.678l-3.926.873a.5.5 0 0 1-.597-.597l.878-3.95c.1-.452.328-.867.655-1.194z"/></svg>
|
||||
|
Before Width: | Height: | Size: 371 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path fill="currentColor" d="M17.18 2.926a2.975 2.975 0 0 0-4.26-.054l-9.375 9.375a2.44 2.44 0 0 0-.655 1.194l-.878 3.95a.5.5 0 0 0 .597.597l3.926-.873a2.5 2.5 0 0 0 1.234-.678l7.98-7.98l.337.336a1 1 0 0 1 0 1.414l-.94.94a.5.5 0 0 0 .708.706l.939-.94a2 2 0 0 0 0-2.828l-.336-.336l.67-.67a2.975 2.975 0 0 0 .052-4.153m-3.553.653a1.975 1.975 0 0 1 2.793 2.793L7.062 15.73a1.5 1.5 0 0 1-.744.409l-3.16.702l.708-3.183a1.43 1.43 0 0 1 .387-.704z"/></svg>
|
||||
|
Before Width: | Height: | Size: 532 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path fill="currentColor" d="M8.5 9A1.5 1.5 0 0 0 10 7.5v-4A1.5 1.5 0 0 0 8.5 2h-6A1.5 1.5 0 0 0 1 3.5v4A1.5 1.5 0 0 0 2.5 9zm0 1h-6q-.257 0-.5-.05v4.3A2.75 2.75 0 0 0 4.75 17h10.5A2.75 2.75 0 0 0 18 14.25v-6.5A2.75 2.75 0 0 0 15.25 5H11v2.5A2.5 2.5 0 0 1 8.5 10m3.854.646L15 13.293V11.5a.5.5 0 0 1 1 0v3.003a.5.5 0 0 1-.5.497h-3a.5.5 0 0 1 0-1h1.793l-2.647-2.646a.5.5 0 0 1 .708-.708"/></svg>
|
||||
|
Before Width: | Height: | Size: 476 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path fill="currentColor" d="M8.5 9A1.5 1.5 0 0 0 10 7.5v-4A1.5 1.5 0 0 0 8.5 2h-6A1.5 1.5 0 0 0 1 3.5v4a1.5 1.5 0 0 0 1 1.415l.019.006c.15.051.313.079.481.079zm6.75-3H11V5h4.25A2.75 2.75 0 0 1 18 7.75v6.5A2.75 2.75 0 0 1 15.25 17H4.75A2.75 2.75 0 0 1 2 14.25v-4.3q.243.05.5.05H3v4.25c0 .966.784 1.75 1.75 1.75h10.5A1.75 1.75 0 0 0 17 14.25v-6.5A1.75 1.75 0 0 0 15.25 6M14 12.293l-2.646-2.647a.5.5 0 0 0-.708.708L13.293 13H11.5a.5.5 0 0 0 0 1h3a.5.5 0 0 0 .5-.497V10.5a.5.5 0 0 0-1 0z"/></svg>
|
||||
|
Before Width: | Height: | Size: 576 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path fill="currentColor" d="M13.325 2.617a2 2 0 0 0-3.203.52l-1.73 3.459a1.5 1.5 0 0 1-.784.721l-3.59 1.436a1 1 0 0 0-.335 1.636L6.293 13L3 16.292V17h.707L7 13.706l2.61 2.61a1 1 0 0 0 1.636-.335l1.436-3.59a1.5 1.5 0 0 1 .722-.784l3.458-1.73a2 2 0 0 0 .52-3.203z"/></svg>
|
||||
|
Before Width: | Height: | Size: 354 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path fill="currentColor" d="M10.122 3.137a2 2 0 0 1 3.203-.52l4.057 4.057a2 2 0 0 1-.52 3.203l-3.458 1.73a1.5 1.5 0 0 0-.722.784l-1.436 3.59a1 1 0 0 1-1.636.336L7 13.707l-3.293 3.292H3v-.707L6.293 13l-2.61-2.61a1 1 0 0 1 .335-1.636l3.59-1.436a1.5 1.5 0 0 0 .785-.721zm2.496.187a1 1 0 0 0-1.601.26l-1.73 3.459A2.5 2.5 0 0 1 7.98 8.246L4.39 9.682l5.927 5.928l1.436-3.59a2.5 2.5 0 0 1 1.204-1.308l3.458-1.73a1 1 0 0 0 .26-1.6z"/></svg>
|
||||
|
Before Width: | Height: | Size: 516 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path fill="currentColor" d="M17.22 8.687a1.498 1.498 0 0 1 0 2.626l-9.997 5.499A1.5 1.5 0 0 1 5 15.499V4.501a1.5 1.5 0 0 1 2.223-1.313zm-.482 1.75a.5.5 0 0 0 0-.875L6.741 4.063A.5.5 0 0 0 6 4.501v10.998a.5.5 0 0 0 .741.438z"/></svg>
|
||||
|
Before Width: | Height: | Size: 316 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path fill="currentColor" d="M16 8a2 2 0 1 1 0 4H4a2 2 0 1 1 0-4zm1 2a1 1 0 0 0-1-1H4a1 1 0 0 0 0 2h12a1 1 0 0 0 1-1M8 2a2 2 0 1 1 0 4H4a2 2 0 1 1 0-4zm1 2a1 1 0 0 0-1-1H4a1 1 0 0 0 0 2h4a1 1 0 0 0 1-1m5 12a2 2 0 0 0-2-2H4a2 2 0 1 0 0 4h8a2 2 0 0 0 2-2m-2-1a1 1 0 1 1 0 2H4a1 1 0 1 1 0-2z"/></svg>
|
||||
|
Before Width: | Height: | Size: 380 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path fill="currentColor" d="M7.75 17.25a.75.75 0 0 0 1.5 0V2.75a.75.75 0 0 0-1.5 0zm3 0a.75.75 0 0 0 1.5 0V2.75a.75.75 0 0 0-1.5 0z"/></svg>
|
||||
|
Before Width: | Height: | Size: 224 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path fill="currentColor" d="M8 17.5a.5.5 0 0 0 1 0v-15a.5.5 0 0 0-1 0zm3 0a.5.5 0 0 0 1 0v-15a.5.5 0 0 0-1 0z"/></svg>
|
||||
|
Before Width: | Height: | Size: 202 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path fill="currentColor" d="M1.911 7.383a8.5 8.5 0 0 1 1.78-3.08a.5.5 0 0 1 .54-.135l1.918.686a1 1 0 0 0 1.32-.762l.366-2.006a.5.5 0 0 1 .388-.4a8.5 8.5 0 0 1 3.554 0a.5.5 0 0 1 .388.4l.366 2.006a1 1 0 0 0 1.32.762l1.919-.686a.5.5 0 0 1 .54.136a8.5 8.5 0 0 1 1.78 3.079a.5.5 0 0 1-.153.535l-1.555 1.32a1 1 0 0 0 0 1.524l1.555 1.32a.5.5 0 0 1 .152.535a8.5 8.5 0 0 1-1.78 3.08a.5.5 0 0 1-.54.135l-1.918-.686a1 1 0 0 0-1.32.762l-.366 2.007a.5.5 0 0 1-.388.399a8.5 8.5 0 0 1-3.554 0a.5.5 0 0 1-.388-.4l-.366-2.006a1 1 0 0 0-1.32-.762l-1.918.686a.5.5 0 0 1-.54-.136a8.5 8.5 0 0 1-1.78-3.079a.5.5 0 0 1 .152-.535l1.555-1.32a1 1 0 0 0 0-1.524l-1.555-1.32a.5.5 0 0 1-.152-.535m1.06-.006l1.294 1.098a2 2 0 0 1 0 3.05l-1.293 1.098c.292.782.713 1.51 1.244 2.152l1.596-.57q.155-.055.315-.085a2 2 0 0 1 2.326 1.609l.304 1.669a7.6 7.6 0 0 0 2.486 0l.304-1.67a1.998 1.998 0 0 1 2.641-1.524l1.596.571a7.5 7.5 0 0 0 1.245-2.152l-1.294-1.098a1.998 1.998 0 0 1 0-3.05l1.294-1.098a7.5 7.5 0 0 0-1.245-2.152l-1.596.57a2 2 0 0 1-2.64-1.524l-.305-1.669a7.6 7.6 0 0 0-2.486 0l-.304 1.669a2 2 0 0 1-2.64 1.525l-1.597-.571a7.5 7.5 0 0 0-1.244 2.152M7.502 10a2.5 2.5 0 1 1 5 0a2.5 2.5 0 0 1-5 0m1 0a1.5 1.5 0 1 0 3 0a1.5 1.5 0 0 0-3 0"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path fill="currentColor" d="M9.5 7v3h-3V7zm0 7v-3h-3v3zm4-7v3h-3V7zm0 7v-3h-3v3zM7 4V2.5A1.5 1.5 0 0 1 8.5 1h3A1.5 1.5 0 0 1 13 2.5V4h4.5a.5.5 0 0 1 .5.5v10a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 2 14.5v-10a.5.5 0 0 1 .5-.5zm1-1.5V4h4V2.5a.5.5 0 0 0-.5-.5h-3a.5.5 0 0 0-.5.5m-5 12A1.5 1.5 0 0 0 4.5 16h11a1.5 1.5 0 0 0 1.5-1.5V5H3z"/></svg>
|
||||
|
Before Width: | Height: | Size: 425 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path fill="currentColor" d="M5 4a3 3 0 0 0-3 3v6a3 3 0 0 0 3 3h5a3 3 0 0 0 3-3v-.321l3.037 2.097a1.25 1.25 0 0 0 1.96-1.029V6.252a1.25 1.25 0 0 0-1.96-1.028L13 7.32V7a3 3 0 0 0-3-3zm8 4.536l3.605-2.49a.25.25 0 0 1 .392.206v7.495a.25.25 0 0 1-.392.206L13 11.463zM3 7a2 2 0 0 1 2-2h5a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
|
||||
|
Before Width: | Height: | Size: 421 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path fill="currentColor" d="m17.331 3.461l.11.102l.102.11a1.93 1.93 0 0 1-.103 2.606l-3.603 3.617a1.9 1.9 0 0 1-.794.477l-1.96.591a.84.84 0 0 1-1.047-.567a.85.85 0 0 1 .005-.503l.621-1.942c.093-.289.252-.55.465-.765l3.612-3.625a1.904 1.904 0 0 1 2.592-.1M12.891 4H4.5A2.5 2.5 0 0 0 2 6.5v2.264a18 18 0 0 1 1.72-1.411c.647-.458 1.342-.86 1.979-1.026c.322-.085.662-.118.987-.042c.34.08.633.272.846.582c.463.674.126 1.404-.194 1.924c-.167.272-.374.556-.576.834l-.023.03c-.211.292-.421.582-.609.881c-.158.285-.2.622-.13.865q.054.174.166.265c.073.061.19.119.379.136c.33.03.759-.083 1.286-.272a.5.5 0 1 1 .338.94c-.52.188-1.14.38-1.714.328a1.66 1.66 0 0 1-.928-.363a1.5 1.5 0 0 1-.486-.753c-.16-.546-.05-1.165.224-1.648l.005-.009l.006-.01c.21-.337.443-.657.655-.948l.01-.014c.213-.291.399-.547.545-.785c.326-.53.298-.724.222-.835a.4.4 0 0 0-.25-.175c-.113-.026-.278-.024-.505.036c-.46.12-1.038.438-1.654.875c-.853.604-1.701 1.38-2.299 1.985V13.5A2.5 2.5 0 0 0 4.5 16h11a2.5 2.5 0 0 0 2.5-2.5V7.134l-3.455 3.468c-.338.34-.755.59-1.213.728l-1.96.591a1.84 1.84 0 0 1-2.295-1.238a1.85 1.85 0 0 1 .011-1.094l.622-1.942c.14-.44.383-.839.709-1.165z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path fill="currentColor" d="m17.331 3.461l.11.102l.102.11a1.93 1.93 0 0 1-.103 2.606l-3.603 3.617a1.9 1.9 0 0 1-.794.477l-1.96.591a.84.84 0 0 1-1.047-.567a.85.85 0 0 1 .005-.503l.621-1.942c.093-.289.252-.55.465-.765l3.612-3.625a1.904 1.904 0 0 1 2.592-.1m-1.884.806l-3.611 3.626a.9.9 0 0 0-.221.363l-.533 1.664l1.672-.505c.14-.042.27-.12.374-.224l3.603-3.617a.93.93 0 0 0 .06-1.24l-.06-.065l-.064-.06a.904.904 0 0 0-1.22.058M12.891 4H5a3 3 0 0 0-3 3v6a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V7.134l-1 1.004V13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9.23c.573-.486 1.34-1.11 2.074-1.535c.41-.237.772-.39 1.062-.439c.281-.048.423.01.51.098a.33.33 0 0 1 .106.185a.6.6 0 0 1-.04.276c-.093.276-.31.602-.602 1.01l-.094.132c-.252.35-.538.747-.736 1.144c-.225.447-.392.995-.204 1.557c.17.508.498.845.926 1.011c.402.156.844.144 1.236.073c.785-.14 1.584-.552 2.02-.813a.5.5 0 0 0-.515-.858c-.399.24-1.075.578-1.681.687c-.303.054-.537.042-.698-.021c-.136-.053-.26-.153-.34-.395c-.062-.188-.03-.435.15-.793c.16-.32.396-.649.656-1.01l.093-.131c.276-.386.587-.832.737-1.273c.077-.229.122-.486.08-.753a1.32 1.32 0 0 0-.386-.736c-.397-.396-.914-.456-1.386-.376c-.462.079-.945.3-1.394.559c-.546.315-1.096.722-1.574 1.104V7a2 2 0 0 1 2-2h6.895z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1,470 +0,0 @@
|
||||
"use strict";
|
||||
const electron = require("electron");
|
||||
const path = require("path");
|
||||
const utils = require("@electron-toolkit/utils");
|
||||
const elysia = require("elysia");
|
||||
const child_process = require("child_process");
|
||||
const fs = require("fs");
|
||||
const util = require("util");
|
||||
const node = require("@elysiajs/node");
|
||||
const icon = path.join(__dirname, "../../resources/icon.png");
|
||||
function getStartMenuPaths() {
|
||||
const appData = process.env["APPDATA"];
|
||||
const programData = process.env["ProgramData"] ?? process.env["PROGRAMDATA"];
|
||||
return {
|
||||
userProgramsPath: appData ? path.join(appData, "Microsoft", "Windows", "Start Menu", "Programs") : null,
|
||||
commonProgramsPath: programData ? path.join(programData, "Microsoft", "Windows", "Start Menu", "Programs") : null
|
||||
};
|
||||
}
|
||||
function getStartMenuRoots() {
|
||||
const { userProgramsPath, commonProgramsPath } = getStartMenuPaths();
|
||||
return [userProgramsPath, commonProgramsPath].filter((p) => Boolean(p));
|
||||
}
|
||||
const execFileAsync$2 = util.promisify(child_process.execFile);
|
||||
function getWindowsPowerShellExe() {
|
||||
const systemRoot = process.env["SystemRoot"] ?? process.env["WINDIR"] ?? "C:\\Windows";
|
||||
return path.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
||||
}
|
||||
function buildPowerShellScript(roots) {
|
||||
const rootsJson = JSON.stringify(roots).replace(/'/g, "''");
|
||||
return [
|
||||
"[Console]::OutputEncoding = [System.Text.Encoding]::UTF8",
|
||||
'$ErrorActionPreference = "Stop"',
|
||||
"$out = $null",
|
||||
"try {",
|
||||
`$roots = '${rootsJson}' | ConvertFrom-Json`,
|
||||
"$wsh = New-Object -ComObject WScript.Shell",
|
||||
"$entries = @()",
|
||||
"foreach ($root in $roots) {",
|
||||
" if (-not (Test-Path -LiteralPath $root)) { continue }",
|
||||
" Get-ChildItem -LiteralPath $root -Recurse -File -ErrorAction SilentlyContinue | ForEach-Object {",
|
||||
" $ext = $_.Extension.ToLowerInvariant()",
|
||||
' if ($ext -ne ".lnk" -and $ext -ne ".url" -and $ext -ne ".appref-ms") { return }',
|
||||
' $source = "unknown"',
|
||||
' if ($root -like "*\\\\AppData\\\\Roaming*") { $source = "user" }',
|
||||
' if ($root -like "*\\\\ProgramData*") { $source = "common" }',
|
||||
' $rel = $_.FullName.Substring($root.Length).TrimStart("\\\\")',
|
||||
' if ($ext -eq ".lnk") {',
|
||||
" $sc = $wsh.CreateShortcut($_.FullName)",
|
||||
" $entries += [pscustomobject]@{",
|
||||
" id = $_.FullName",
|
||||
" name = $_.BaseName",
|
||||
' type = "lnk"',
|
||||
" filePath = $_.FullName",
|
||||
" relativePath = $rel",
|
||||
" targetPath = $sc.TargetPath",
|
||||
" arguments = $sc.Arguments",
|
||||
" workingDirectory = $sc.WorkingDirectory",
|
||||
" iconLocation = $sc.IconLocation",
|
||||
" description = $sc.Description",
|
||||
" source = $source",
|
||||
" }",
|
||||
" return",
|
||||
" }",
|
||||
' $t = "appref-ms"',
|
||||
' if ($ext -eq ".url") { $t = "url" }',
|
||||
" $entries += [pscustomobject]@{",
|
||||
" id = $_.FullName",
|
||||
" name = $_.BaseName",
|
||||
" type = $t",
|
||||
" filePath = $_.FullName",
|
||||
" relativePath = $rel",
|
||||
" source = $source",
|
||||
" }",
|
||||
" }",
|
||||
"}",
|
||||
"$usedStartApps = $false",
|
||||
"try {",
|
||||
" $startApps = Get-StartApps | Select-Object Name, AppID",
|
||||
" if ($startApps -ne $null) {",
|
||||
" $usedStartApps = $true",
|
||||
" foreach ($a in $startApps) {",
|
||||
" if ([string]::IsNullOrWhiteSpace($a.AppID)) { continue }",
|
||||
" $entries += [pscustomobject]@{",
|
||||
' id = "appsFolder:" + $a.AppID',
|
||||
" name = $a.Name",
|
||||
' type = "uwp"',
|
||||
' filePath = "shell:AppsFolder\\\\" + $a.AppID',
|
||||
' relativePath = "AppsFolder\\\\" + $a.Name',
|
||||
" appUserModelId = $a.AppID",
|
||||
' source = "appsfolder"',
|
||||
" }",
|
||||
" }",
|
||||
" }",
|
||||
"} catch { }",
|
||||
"if (-not $usedStartApps) {",
|
||||
" $shell = New-Object -ComObject Shell.Application",
|
||||
' $appsFolder = $shell.NameSpace("shell:AppsFolder")',
|
||||
" if ($appsFolder -ne $null) {",
|
||||
" $appsFolder.Items() | ForEach-Object {",
|
||||
" $aumid = $_.Path",
|
||||
" if ([string]::IsNullOrWhiteSpace($aumid)) { return }",
|
||||
" $name = $_.Name",
|
||||
" $entries += [pscustomobject]@{",
|
||||
' id = "appsFolder:" + $aumid',
|
||||
" name = $name",
|
||||
' type = "uwp"',
|
||||
' filePath = "shell:AppsFolder\\\\" + $aumid',
|
||||
' relativePath = "AppsFolder\\\\" + $name',
|
||||
" appUserModelId = $aumid",
|
||||
' source = "appsfolder"',
|
||||
" }",
|
||||
" }",
|
||||
" }",
|
||||
"}",
|
||||
"$out = [pscustomobject]@{ ok = $true; entries = $entries }",
|
||||
"} catch {",
|
||||
" $out = [pscustomobject]@{ ok = $false; error = ($_ | Out-String); entries = @() }",
|
||||
"}",
|
||||
"$out | ConvertTo-Json -Depth 6"
|
||||
].join("\n");
|
||||
}
|
||||
async function listWindowsStartMenuApps() {
|
||||
if (process.platform !== "win32") return [];
|
||||
const roots = getStartMenuRoots();
|
||||
const script = buildPowerShellScript(roots);
|
||||
const powershellExe = getWindowsPowerShellExe();
|
||||
const { stdout } = await execFileAsync$2(
|
||||
powershellExe,
|
||||
["-NoProfile", "-NonInteractive", "-Sta", "-ExecutionPolicy", "Bypass", "-Command", script],
|
||||
{ windowsHide: true, maxBuffer: 50 * 1024 * 1024, timeout: 6e4 }
|
||||
);
|
||||
const trimmed = stdout.trim();
|
||||
if (!trimmed) return [];
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed === "object" && parsed !== null && "ok" in parsed) {
|
||||
const ok = parsed.ok;
|
||||
if (ok === false) {
|
||||
const error = parsed.error;
|
||||
throw new Error(typeof error === "string" ? error : "PowerShellFailed");
|
||||
}
|
||||
}
|
||||
const rawEntries = typeof parsed === "object" && parsed !== null && "entries" in parsed ? parsed.entries : parsed;
|
||||
const list = Array.isArray(rawEntries) ? rawEntries : rawEntries ? [rawEntries] : [];
|
||||
const isEntry = (value) => {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const v = value;
|
||||
return typeof v.id === "string" && typeof v.name === "string" && typeof v.type === "string" && typeof v.filePath === "string" && typeof v.relativePath === "string";
|
||||
};
|
||||
const seen = /* @__PURE__ */ new Set();
|
||||
const result = [];
|
||||
for (const item of list) {
|
||||
if (!isEntry(item)) continue;
|
||||
if (seen.has(item.id)) continue;
|
||||
seen.add(item.id);
|
||||
result.push(item);
|
||||
}
|
||||
result.sort((a, b) => a.name.localeCompare(b.name, "zh-CN"));
|
||||
return result;
|
||||
}
|
||||
const execFileAsync$1 = util.promisify(child_process.execFile);
|
||||
function isUnderRoot(filePath, root) {
|
||||
const normalizedFile = path.resolve(filePath).toLowerCase();
|
||||
const normalizedRoot = path.resolve(root).toLowerCase();
|
||||
return normalizedFile === normalizedRoot || normalizedFile.startsWith(normalizedRoot + "\\");
|
||||
}
|
||||
async function launchStartMenuEntry(filePath) {
|
||||
if (process.platform !== "win32") return;
|
||||
if (filePath.startsWith("shell:AppsFolder\\")) {
|
||||
await execFileAsync$1("explorer.exe", [filePath], { windowsHide: true });
|
||||
return;
|
||||
}
|
||||
const roots = getStartMenuRoots();
|
||||
const allowed = roots.some((root) => isUnderRoot(filePath, root));
|
||||
if (!allowed) {
|
||||
throw new Error("PathNotAllowed");
|
||||
}
|
||||
const result = await electron.shell.openPath(filePath);
|
||||
if (result) {
|
||||
throw new Error(result);
|
||||
}
|
||||
}
|
||||
const execFileAsync = util.promisify(child_process.execFile);
|
||||
function createEiysiaApp(deps) {
|
||||
const iconCache = /* @__PURE__ */ new Map();
|
||||
const appsCacheFilePath = path.join(electron.app.getPath("userData"), "apps-cache.json");
|
||||
let cachedApps = [];
|
||||
let cacheLoadPromise = null;
|
||||
let refreshPromise = null;
|
||||
const ensureAppsCacheLoaded = async () => {
|
||||
if (cacheLoadPromise) return cacheLoadPromise;
|
||||
cacheLoadPromise = (async () => {
|
||||
try {
|
||||
const raw = await fs.promises.readFile(appsCacheFilePath, "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
const apps = Array.isArray(parsed.apps) ? parsed.apps : [];
|
||||
const parsedApps = apps.map((a) => {
|
||||
const id = typeof a.id === "string" ? a.id : "";
|
||||
const name = typeof a.name === "string" ? a.name : "";
|
||||
const filePath = typeof a.filePath === "string" ? a.filePath : "";
|
||||
const iconDataUrl = typeof a.iconDataUrl === "string" ? a.iconDataUrl : "";
|
||||
if (!id || !name || !filePath || !iconDataUrl) return null;
|
||||
return { id, name, filePath, iconDataUrl };
|
||||
});
|
||||
cachedApps = parsedApps.filter((a) => Boolean(a));
|
||||
} catch {
|
||||
cachedApps = [];
|
||||
}
|
||||
})();
|
||||
return cacheLoadPromise;
|
||||
};
|
||||
const persistAppsCache = async (apps) => {
|
||||
const payload = JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
updatedAt: Date.now(),
|
||||
apps
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
await fs.promises.writeFile(appsCacheFilePath, payload, "utf-8");
|
||||
};
|
||||
const refreshAppsCache = async () => {
|
||||
if (refreshPromise) return refreshPromise;
|
||||
refreshPromise = (async () => {
|
||||
const cachedById = new Map(cachedApps.map((a) => [a.id, a]));
|
||||
const entries = await listWindowsStartMenuApps();
|
||||
let index = 0;
|
||||
const limit = Math.max(4, Math.min(16, entries.length));
|
||||
const nextApps = new Array(entries.length);
|
||||
const workers = Array.from({ length: limit }, async () => {
|
||||
while (true) {
|
||||
const i = index;
|
||||
index += 1;
|
||||
if (i >= entries.length) return;
|
||||
const e = entries[i];
|
||||
const cached = cachedById.get(e.id);
|
||||
if (cached && cached.name === e.name && cached.filePath === e.filePath && cached.iconDataUrl) {
|
||||
nextApps[i] = cached;
|
||||
continue;
|
||||
}
|
||||
const iconDataUrl = await getIconDataUrl(e);
|
||||
nextApps[i] = { id: e.id, name: e.name, filePath: e.filePath, iconDataUrl };
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
cachedApps = nextApps.filter(Boolean);
|
||||
await persistAppsCache(cachedApps);
|
||||
})().finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
return refreshPromise;
|
||||
};
|
||||
const placeholderIconDataUrl = (name, id) => {
|
||||
const letter = (name.trim().charAt(0) || "?").toUpperCase();
|
||||
let hash = 0;
|
||||
for (let i = 0; i < id.length; i += 1) {
|
||||
hash = hash * 31 + id.charCodeAt(i) | 0;
|
||||
}
|
||||
const hue = Math.abs(hash) % 360;
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"><circle cx="32" cy="32" r="32" fill="hsl(${hue} 70% 45%)"/><text x="32" y="40" text-anchor="middle" font-family="Segoe UI, Arial" font-size="28" font-weight="700" fill="white">${letter.replace(
|
||||
/[<>&]/g,
|
||||
""
|
||||
)}</text></svg>`;
|
||||
return `data:image/svg+xml;base64,${Buffer.from(svg, "utf-8").toString("base64")}`;
|
||||
};
|
||||
const getIconDataUrl = async (entry) => {
|
||||
const cached = iconCache.get(entry.id);
|
||||
if (cached) return cached;
|
||||
if (entry.type === "uwp" || entry.filePath.startsWith("shell:")) {
|
||||
const dataUrl = placeholderIconDataUrl(entry.name, entry.id);
|
||||
iconCache.set(entry.id, dataUrl);
|
||||
return dataUrl;
|
||||
}
|
||||
if (!electron.app.isReady()) {
|
||||
const dataUrl = placeholderIconDataUrl(entry.name, entry.id);
|
||||
iconCache.set(entry.id, dataUrl);
|
||||
return dataUrl;
|
||||
}
|
||||
try {
|
||||
const tryPath = typeof entry.targetPath === "string" && entry.targetPath.trim() ? entry.targetPath : entry.filePath;
|
||||
const rawIcon = await electron.app.getFileIcon(tryPath, { size: "large" }).catch(() => electron.app.getFileIcon(tryPath, { size: "normal" }));
|
||||
let icon2 = rawIcon;
|
||||
if (!icon2.isEmpty()) {
|
||||
const { width, height } = icon2.getSize();
|
||||
const target = 64;
|
||||
if (width > 0 && height > 0 && (width < target || height < target)) {
|
||||
icon2 = icon2.resize({ width: target, height: target, quality: "best" });
|
||||
}
|
||||
}
|
||||
const dataUrl = icon2.isEmpty() ? placeholderIconDataUrl(entry.name, entry.id) : icon2.toDataURL();
|
||||
iconCache.set(entry.id, dataUrl);
|
||||
return dataUrl;
|
||||
} catch {
|
||||
const dataUrl = placeholderIconDataUrl(entry.name, entry.id);
|
||||
iconCache.set(entry.id, dataUrl);
|
||||
return dataUrl;
|
||||
}
|
||||
};
|
||||
return new elysia.Elysia().get("/health", () => ({
|
||||
ok: true,
|
||||
time: Date.now()
|
||||
})).onStart(() => {
|
||||
void ensureAppsCacheLoaded().then(() => refreshAppsCache());
|
||||
}).get("/apps/list", async () => {
|
||||
try {
|
||||
await ensureAppsCacheLoaded();
|
||||
if (cachedApps.length === 0) {
|
||||
await refreshAppsCache();
|
||||
} else {
|
||||
void refreshAppsCache();
|
||||
}
|
||||
return { apps: cachedApps, error: null };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "UnknownError";
|
||||
console.error("[apps/list] failed:", message);
|
||||
return { apps: [], error: message };
|
||||
}
|
||||
}).post("/apps/launch", async ({ body }) => {
|
||||
const payload = body;
|
||||
if (typeof payload.filePath !== "string") {
|
||||
return new Response("BadRequest", { status: 400 });
|
||||
}
|
||||
try {
|
||||
await launchStartMenuEntry(payload.filePath);
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "UnknownError";
|
||||
if (message === "PathNotAllowed") return new Response("Forbidden", { status: 403 });
|
||||
return new Response("LaunchFailed", { status: 500 });
|
||||
}
|
||||
}).post("/open/external", async ({ body }) => {
|
||||
const payload = body;
|
||||
if (typeof payload.url !== "string") {
|
||||
return new Response("BadRequest", { status: 400 });
|
||||
}
|
||||
const url = payload.url.trim();
|
||||
if (!url) return new Response("BadRequest", { status: 400 });
|
||||
const lower = url.toLowerCase();
|
||||
if (lower.startsWith("javascript:") || lower.startsWith("data:") || lower.startsWith("file:")) {
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
try {
|
||||
if (process.platform === "win32" && lower.startsWith("shell:")) {
|
||||
await execFileAsync("explorer.exe", [url], { windowsHide: true });
|
||||
return { ok: true };
|
||||
}
|
||||
await electron.shell.openExternal(url);
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return new Response("OpenFailed", { status: 500 });
|
||||
}
|
||||
}).get("/backend/port", () => ({
|
||||
port: deps.getHttpPort()
|
||||
})).post("/app/minimize", () => {
|
||||
deps.getMainWindow()?.minimize();
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
function registerEiysiaIpc(app) {
|
||||
electron.ipcMain.handle(
|
||||
"eiysia:request",
|
||||
async (_event, payload) => {
|
||||
const method = payload.method?.toUpperCase?.() ?? "GET";
|
||||
const path2 = payload.path?.startsWith("/") ? payload.path : `/${payload.path ?? ""}`;
|
||||
const headers = new Headers(payload.headers ?? {});
|
||||
let body;
|
||||
if (payload.body !== void 0) {
|
||||
if (typeof payload.body === "string" || payload.body instanceof ArrayBuffer || ArrayBuffer.isView(payload.body)) {
|
||||
body = payload.body;
|
||||
} else {
|
||||
body = JSON.stringify(payload.body);
|
||||
if (!headers.has("content-type")) {
|
||||
headers.set("content-type", "application/json");
|
||||
}
|
||||
}
|
||||
}
|
||||
const request = new Request(`http://eiysia.local${path2}`, {
|
||||
method,
|
||||
headers,
|
||||
body
|
||||
});
|
||||
const response = await app.handle(request);
|
||||
const bodyText = await response.text();
|
||||
return {
|
||||
status: response.status,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
bodyText
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
function startEiysiaHttpServer(app) {
|
||||
const serverApp = new elysia.Elysia({ adapter: node.node() }).use(app).listen({ hostname: "127.0.0.1", port: 0 });
|
||||
let stopped = false;
|
||||
const stop = () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
try {
|
||||
if (!serverApp.server) return;
|
||||
const maybe = serverApp;
|
||||
if (typeof maybe.stop === "function") maybe.stop();
|
||||
else if (typeof maybe.close === "function") maybe.close();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
};
|
||||
return {
|
||||
app: serverApp,
|
||||
port: serverApp.server?.port ?? null,
|
||||
stop
|
||||
};
|
||||
}
|
||||
electron.app.commandLine.appendSwitch("touch-events", "enabled");
|
||||
let mainWindow = null;
|
||||
let eiysiaServerStop = null;
|
||||
let eiysiaHttpPort = null;
|
||||
function createWindow() {
|
||||
const window = new electron.BrowserWindow({
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
fullscreen: true,
|
||||
...process.platform === "linux" ? { icon } : {},
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "../preload/index.js"),
|
||||
sandbox: false
|
||||
}
|
||||
});
|
||||
mainWindow = window;
|
||||
window.on("ready-to-show", () => {
|
||||
window.show();
|
||||
});
|
||||
window.webContents.setWindowOpenHandler((details) => {
|
||||
electron.shell.openExternal(details.url);
|
||||
return { action: "deny" };
|
||||
});
|
||||
if (utils.is.dev && process.env["ELECTRON_RENDERER_URL"]) {
|
||||
window.loadURL(process.env["ELECTRON_RENDERER_URL"]);
|
||||
} else {
|
||||
window.loadFile(path.join(__dirname, "../renderer/index.html"));
|
||||
}
|
||||
}
|
||||
electron.app.whenReady().then(() => {
|
||||
utils.electronApp.setAppUserModelId("com.electron");
|
||||
electron.app.on("browser-window-created", (_, window) => {
|
||||
utils.optimizer.watchWindowShortcuts(window);
|
||||
});
|
||||
const eiysia = createEiysiaApp({
|
||||
getMainWindow: () => mainWindow,
|
||||
getHttpPort: () => eiysiaHttpPort
|
||||
});
|
||||
registerEiysiaIpc(eiysia);
|
||||
const server = startEiysiaHttpServer(eiysia);
|
||||
eiysiaHttpPort = server.port;
|
||||
eiysiaServerStop = server.stop;
|
||||
createWindow();
|
||||
electron.app.on("activate", function() {
|
||||
if (electron.BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
});
|
||||
electron.app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
electron.app.quit();
|
||||
}
|
||||
});
|
||||
electron.app.on("before-quit", () => {
|
||||
eiysiaServerStop?.();
|
||||
eiysiaServerStop = null;
|
||||
eiysiaHttpPort = null;
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
"use strict";
|
||||
const electron = require("electron");
|
||||
const preload = require("@electron-toolkit/preload");
|
||||
const api = {
|
||||
request: (payload) => electron.ipcRenderer.invoke("eiysia:request", payload),
|
||||
call: async (payload) => {
|
||||
const response = await electron.ipcRenderer.invoke("eiysia:request", payload);
|
||||
const contentType = response.headers["content-type"] ?? response.headers["Content-Type"];
|
||||
if (contentType?.includes("application/json")) {
|
||||
return JSON.parse(response.bodyText);
|
||||
}
|
||||
return response.bodyText;
|
||||
}
|
||||
};
|
||||
if (process.contextIsolated) {
|
||||
try {
|
||||
electron.contextBridge.exposeInMainWorld("electron", preload.electronAPI);
|
||||
electron.contextBridge.exposeInMainWorld("api", api);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
} else {
|
||||
window.electron = preload.electronAPI;
|
||||
window.api = api;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Electron</title>
|
||||
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
|
||||
/>
|
||||
<script type="module" crossorigin src="./assets/index-CkK6wThO.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BOqh2SqZ.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
54
package.json
@@ -1,54 +0,0 @@
|
||||
{
|
||||
"name": "desktop",
|
||||
"version": "1.0.0",
|
||||
"description": "An Electron application with Vue 3 and TypeScript",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "example.com",
|
||||
"homepage": "https://electron-vite.org",
|
||||
"scripts": {
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint --cache .",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "vue-tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
|
||||
"start": "electron-vite preview",
|
||||
"dev": "electron-vite dev",
|
||||
"build": "pnpm run typecheck && electron-vite build",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"build:unpack": "pnpm run build && electron-builder --dir",
|
||||
"build:win": "pnpm run build && electron-builder --win",
|
||||
"build:mac": "electron-vite build && electron-builder --mac",
|
||||
"build:linux": "electron-vite build && electron-builder --linux"
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@elysiajs/node": "^1.4.4",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"electron-updater": "^6.3.9",
|
||||
"elysia": "^1.4.25"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^3.1.0",
|
||||
"@electron-toolkit/tsconfig": "^2.0.0",
|
||||
"@types/node": "^22.19.1",
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"electron": "^39.2.6",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-vite": "^5.0.0",
|
||||
"eslint": "^9.39.1",
|
||||
"prettier": "^3.7.4",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.6",
|
||||
"vue": "^3.5.0",
|
||||
"vue-tsc": "^2.2.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"electron",
|
||||
"better-sqlite3",
|
||||
"esbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
5245
pnpm-lock.yaml
generated
|
Before Width: | Height: | Size: 35 KiB |
@@ -1,772 +0,0 @@
|
||||
# 沉浸式时钟噪音计算与评分技术文档
|
||||
|
||||
Immersive Clock 的噪音监测系统不仅仅是一个简单的分贝计,它内置了一个基于心理声学与专注力理论的评分引擎。该引擎旨在客观、多维度地量化环境噪音对学习心流的干扰程度。
|
||||
|
||||
本文档详细解析了该系统的计算原理、核心指标定义、评分算法及完整的技术架构。
|
||||
|
||||
## 目录
|
||||
|
||||
1. [核心理念](#1-核心理念)
|
||||
2. [系统架构](#2-系统架构)
|
||||
3. [数据采集层](#3-数据采集层)
|
||||
4. [数据聚合层](#4-数据聚合层)
|
||||
5. [评分算法核心](#5-评分算法核心)
|
||||
6. [数据存储层](#6-数据存储层)
|
||||
7. [历史报告生成](#7-历史报告生成)
|
||||
8. [流服务整合](#8-流服务整合)
|
||||
9. [配置参数体系](#9-配置参数体系)
|
||||
10. [类型定义](#10-类型定义)
|
||||
|
||||
---
|
||||
|
||||
## 1. 核心理念
|
||||
|
||||
### 1.1 设计原则
|
||||
|
||||
系统认为,并非所有"响声"都是一样的。对于专注力而言:
|
||||
|
||||
- **持续的嗡嗡声**(如嘈杂的人群)比**偶尔的掉笔声**更具破坏性
|
||||
- **频繁的打断**(如每分钟都有人说话)比**单次的大声喧哗**更让人烦躁
|
||||
- **评分与校准分离**:评分使用原始 DBFS 数据,校准仅影响显示分贝
|
||||
|
||||
因此,评分系统采用了 **多维度加权扣分制**,满分 100 分,根据环境表现进行扣分。
|
||||
|
||||
### 1.2 评分与校准分离
|
||||
|
||||
项目通过"原始数据(用于评分)"与"显示数据(用于展示)"的**严格分层**,杜绝了校准值导致的评分偏差:
|
||||
|
||||
1. **评分只依赖原始 DBFS(设备输出的相对电平)**
|
||||
- 评分的三项核心指标(`p50Dbfs`、`overRatioDbfs`、`segmentCount`)都来自原始 `dbfs` 统计
|
||||
- "超阈时长占比"判定条件固定为:`dbfs > scoreThresholdDbfs`(阈值默认 `-50 dBFS`),与校准无关
|
||||
- 这意味着即使用户把"显示分贝基准"调高/调低,评分侧的 `dbfs` 不会变化,因此得分与超阈时长也不会被"调参刷分"
|
||||
|
||||
2. **校准仅影响 Display dB(UI 展示口径),不进入评分链路**
|
||||
- 校准(`baselineRms` / `baselineDb`)只用于将 `rms` 映射为 `displayDb`,用于实时显示与报告中的"噪音等级分布"等图表展示
|
||||
- 这些展示口径变化不会反向影响评分输入,也不会改变切片摘要中的 `raw.*` 字段
|
||||
|
||||
3. **统计报告中"超阈时长"取自 raw.overRatioDbfs**
|
||||
- 报告里展示的"超阈时长"是对每个切片 `raw.overRatioDbfs` 按有效采样时长加权汇总得到,仍然完全基于 DBFS
|
||||
- 相比之下,"噪音等级分布"使用的是 `display.avgDb`(校准后的显示分贝),因此它会随校准变化——这是为了更贴近用户直觉的 dB 区间划分
|
||||
|
||||
---
|
||||
|
||||
## 2. 系统架构
|
||||
|
||||
### 2.1 整体架构图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 用户界面层 │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ 实时监控组件 │ │ 噪音报告弹窗 │ │ 噪音历史列表 │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↑
|
||||
│ 订阅/发布
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 流服务层 │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ 噪音流服务 - 订阅管理、生命周期控制、设置热更新 │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↑
|
||||
│ 帧数据流
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 数据聚合层 │
|
||||
│ ┌──────────────────┐ ┌────────────────────────────────────┐ │
|
||||
│ │ 噪音帧处理器 │ │ 噪音切片聚合器 │ │
|
||||
│ │ - RMS/dBFS 计算 │ │ - 切片聚合、统计指标、评分计算 │ │
|
||||
│ │ - 50ms/帧 │ │ - 30秒/切片 │ │
|
||||
│ └──────────────────┘ └────────────────────────────────────┘ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ 实时环形缓冲区 - 保留固定时长的实时数据 │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↑
|
||||
│ 音频流
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 数据采集层 │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ 麦克风采集 - Web Audio API、滤波器、AnalyserNode │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↑
|
||||
│ 物理音频
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 数据存储层 │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ 切片存储 - localStorage、时间清理、容量限制 │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↑
|
||||
│ 历史数据
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 历史报告层 │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ 历史构建 - 课表关联、加权平均评分、覆盖率计算 │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.2 模块说明
|
||||
|
||||
| 模块 | 功能 |
|
||||
|------|------|
|
||||
| 类型定义 | 核心类型定义 |
|
||||
| 常量定义 | 分析参数常量、报告参数常量 |
|
||||
| 麦克风采集 | 音频采集 |
|
||||
| 帧处理器 | 帧处理 |
|
||||
| 切片聚合器 | 切片聚合 |
|
||||
| 环形缓冲区 | 实时数据 |
|
||||
| 流服务 | 流管理 |
|
||||
| 评分引擎 | 评分算法 |
|
||||
| 切片服务 | 存储服务 |
|
||||
| 历史构建 | 历史报告 |
|
||||
| 设置管理 | 设置管理 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据采集层
|
||||
|
||||
### 3.1 麦克风采集
|
||||
|
||||
#### 3.1.1 Web Audio API 使用
|
||||
|
||||
系统使用 Web Audio API 获取麦克风输入,构建完整的音频处理链路:
|
||||
|
||||
```
|
||||
麦克风 → MediaStream → MediaStreamAudioSourceNode
|
||||
→ 高通滤波器 (80Hz) → 低通滤波器 (8000Hz)
|
||||
→ AnalyserNode (FFT Size 2048)
|
||||
```
|
||||
|
||||
#### 3.1.2 音频滤波器配置
|
||||
|
||||
| 滤波器类型 | 截止频率 | 作用 |
|
||||
|-----------|---------|------|
|
||||
| 高通滤波器 | 80 Hz | 过滤低频噪音(如空调嗡嗡声) |
|
||||
| 低通滤波器 | 8000 Hz | 过滤高频噪音(如电子设备啸叫) |
|
||||
|
||||
#### 3.1.3 AnalyserNode 配置
|
||||
|
||||
```typescript
|
||||
analyser.fftSize = 2048; // FFT 窗口大小
|
||||
analyser.smoothingTimeConstant = 0; // 无平滑,实时响应
|
||||
```
|
||||
|
||||
#### 3.1.4 权限处理与错误处理
|
||||
|
||||
```typescript
|
||||
// 麦克风权限请求配置
|
||||
{
|
||||
audio: {
|
||||
echoCancellation: false, // 禁用回声消除
|
||||
noiseSuppression: false, // 禁用降噪
|
||||
autoGainControl: false, // 禁用自动增益
|
||||
},
|
||||
video: false
|
||||
}
|
||||
```
|
||||
|
||||
**浏览器兼容性说明:**
|
||||
- 部分浏览器/设备可能忽略上述约束设置
|
||||
- 建议在 UI 中提示用户实际生效的约束
|
||||
- 需要测试矩阵验证:Chrome/Firefox/Safari/Edge/iOS Safari/Android WebView
|
||||
|
||||
**错误处理:**
|
||||
- `NotAllowedError` / `SecurityError` → 权限拒绝
|
||||
- `AudioContext not supported` → 浏览器不支持
|
||||
|
||||
---
|
||||
|
||||
### 3.2 帧处理器
|
||||
|
||||
#### 3.2.1 采样频率
|
||||
|
||||
- **帧间隔**:50ms(约 20 fps)
|
||||
- **数据来源**:AnalyserNode.getFloatTimeDomainData()
|
||||
|
||||
#### 3.2.2 RMS(均方根)计算
|
||||
|
||||
RMS 是衡量音频信号强度的标准方法:
|
||||
|
||||
**公式:**
|
||||
$$ \text{RMS} = \sqrt{\frac{1}{N} \sum_{i=1}^{N} x_i^2} $$
|
||||
|
||||
#### 3.2.3 dBFS(分贝满刻度)转换
|
||||
|
||||
dBFS 是数字音频的标准分贝单位,范围 -100 到 0 dB:
|
||||
|
||||
**公式:**
|
||||
$$ \text{dBFS} = 20 \times \log_{10}(\text{RMS}) $$
|
||||
|
||||
**范围限制:**
|
||||
- 最小值:-100 dBFS(静音)
|
||||
- 最大值:0 dBFS(满刻度)
|
||||
|
||||
#### 3.2.4 峰值检测
|
||||
|
||||
峰值用于检测突发噪音,在 RMS 计算过程中同时记录峰值。
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据聚合层
|
||||
|
||||
### 4.1 切片聚合器
|
||||
|
||||
#### 4.1.1 切片时长
|
||||
|
||||
- **默认切片时长**:30 秒
|
||||
- **可配置范围**:≥ 1 秒
|
||||
|
||||
#### 4.1.2 统计指标计算
|
||||
|
||||
切片聚合器为每个切片计算以下统计指标:
|
||||
|
||||
| 指标 | 说明 | 计算方法 |
|
||||
|------|------|---------|
|
||||
| avgDbfs | 平均分贝 | 能量平均(线性域 RMS 平均后转回 dBFS) |
|
||||
| maxDbfs | 最大分贝 | 所有帧 dBFS 的最大值 |
|
||||
| p50Dbfs | 中位数分贝 | 线性域分位数(RMS 域计算后转回 dBFS) |
|
||||
| p95Dbfs | 95分位数分贝 | 线性域分位数(RMS 域计算后转回 dBFS) |
|
||||
| overRatioDbfs | 超阈值比例 | 超阈值时长 / 采样时长 |
|
||||
| segmentCount | 事件段数量 | 独立噪音事件次数 |
|
||||
| sampledDurationMs | 采样时长 | 有效采样时间(排除缺口) |
|
||||
| gapCount | 缺口数量 | 数据缺口次数 |
|
||||
| maxGapMs | 最大缺口时长 | 最长数据缺口时长 |
|
||||
|
||||
#### 4.1.3 能量平均计算(avgDbfs)
|
||||
|
||||
**公式:**
|
||||
$$ \text{avgDbfs} = 20 \times \log_{10}\left(\sqrt{\frac{1}{N} \sum_{i=1}^{N} 10^{\text{dBFS}_i / 10}}\right) $$
|
||||
|
||||
**物理意义:** 在线性域(RMS)上做平均,符合能量守恒定律
|
||||
|
||||
#### 4.1.4 线性域分位数计算
|
||||
|
||||
**公式:**
|
||||
$$ \text{quantileDbfs} = 20 \times \log_{10}(Q_{\text{RMS}}(p)) $$
|
||||
|
||||
其中 $Q_{\text{RMS}}(p)$ 是 RMS 域的分位数,使用线性插值计算:
|
||||
$$ Q_{\text{RMS}}(p) = x_{\lfloor i \rfloor} \times (1 - w) + x_{\lceil i \rceil} \times w $$
|
||||
|
||||
- $i = (n-1) \times p$
|
||||
- $w = i - \lfloor i \rfloor$
|
||||
|
||||
**物理意义:** 在线性域(RMS)上计算分位数,符合能量统计的严谨性
|
||||
|
||||
#### 4.1.5 超阈值比例计算(时间加权)
|
||||
|
||||
**公式:**
|
||||
$$ \text{overRatioDbfs} = \frac{\text{超阈值时长}}{\text{采样时长}} $$
|
||||
|
||||
**物理意义:** 使用实际时长而非帧数计算比例,更精确
|
||||
|
||||
#### 4.1.6 事件段检测与合并算法
|
||||
|
||||
事件段检测用于识别独立的噪音事件:
|
||||
|
||||
**合并规则:**
|
||||
- **合并窗口**:500ms(默认)
|
||||
- 如果两次超阈值事件间隔 ≤ 500ms,合并为同一事件段
|
||||
- 否则计为新的独立事件段
|
||||
|
||||
**示例:**
|
||||
```
|
||||
时间轴: 0ms 200ms 400ms 600ms 800ms 1000ms
|
||||
状态: [噪音] [噪音] [安静] [噪音] [噪音] [安静]
|
||||
合并后: └─────── 事件段1 ───────┘ └── 事件段2 ──┘
|
||||
```
|
||||
|
||||
#### 4.1.7 显示分贝映射(校准机制)
|
||||
|
||||
显示分贝用于用户界面展示,支持校准:
|
||||
|
||||
**公式(有校准):**
|
||||
$$ \text{displayDb} = \text{baselineDb} + 20 \times \log_{10}\left(\frac{\text{rms}}{\text{baselineRms}}\right) $$
|
||||
|
||||
**公式(无校准):**
|
||||
$$ \text{displayDb} = 20 \times \log_{10}\left(\frac{\text{rms}}{10^{-3}}\right) + 60 $$
|
||||
|
||||
**范围限制:** 20 dB ~ 100 dB
|
||||
|
||||
**校准流程说明:**
|
||||
1. 使用标准声源(如 60 dB 的白噪音)
|
||||
2. 测量对应的 RMS 值
|
||||
3. 设置为 baselineRms
|
||||
4. 设置对应的显示分贝为 baselineDb
|
||||
|
||||
#### 4.1.8 缺口检测与采样时长统计
|
||||
|
||||
**缺口阈值:** `max(1000ms, frameMs × 5)` = **1000ms**(默认)
|
||||
|
||||
当检测到数据缺口时,会触发切片完成并记录缺口信息。
|
||||
|
||||
#### 4.1.9 无效帧过滤
|
||||
|
||||
低于 -90 dBFS 的帧被视为静音/无效信号,不参与统计。
|
||||
|
||||
**常量说明:**
|
||||
- `INVALID_DBFS_THRESHOLD = -90`:统计意义上的"静音"阈值
|
||||
- `DBFS_MIN_POSSIBLE = -100`:物理最小可表示值(用于 clamp)
|
||||
- `DBFS_MAX_POSSIBLE = 0`:物理最大可表示值(用于 clamp)
|
||||
|
||||
---
|
||||
|
||||
### 4.2 实时环形缓冲区
|
||||
|
||||
#### 4.2.1 数据结构设计
|
||||
|
||||
环形缓冲区使用固定容量数组实现,通过起始索引和当前长度管理数据。
|
||||
|
||||
#### 4.2.2 时间窗口裁剪策略
|
||||
|
||||
**裁剪规则:** 移除时间戳早于 `当前时间 - retentionMs` 的数据点
|
||||
|
||||
---
|
||||
|
||||
## 5. 评分算法核心
|
||||
|
||||
### 5.1 三大核心指标
|
||||
|
||||
评分引擎从以下三个维度对噪音数据进行分析:
|
||||
|
||||
#### A. 持续噪音水平 (Sustained Level)
|
||||
|
||||
- **定义**:剔除突发噪音后的环境"底噪"水平
|
||||
- **算法**:使用时段内所有帧的中位数电平 (`p50Dbfs`)
|
||||
- **意义**:反映环境本身是否安静。如果环境中有持续的风扇声或交谈声,该指标会升高
|
||||
|
||||
#### B. 超阈值时长占比 (Over Threshold Ratio)
|
||||
|
||||
- **定义**:原始 `DBFS` 超过评分阈值(`scoreThresholdDbfs`)的时间比例
|
||||
- **算法**:`超阈值时长 / 采样时长`(超标判定:`dbfs > scoreThresholdDbfs`)
|
||||
- **意义**:反映环境的"纯净度"。即使是 0.1 秒的尖叫也会被精确计入,无法被平均值掩盖
|
||||
|
||||
> **提示**:评分阈值(`scoreThresholdDbfs`,单位 dBFS)与"界面报警/提示音"使用的显示分贝阈值(`maxLevelDb`,单位 dB)不是同一个概念;前者只用于评分,后者用于判定 noisy/quiet 与提示音触发。
|
||||
|
||||
#### C. 打断次数密度 (Interruption Density)
|
||||
|
||||
- **定义**:单位时间内(每分钟)发生的独立噪音事件次数
|
||||
- **智能合并算法**:
|
||||
- 系统设有 **500ms** (默认) 的合并窗口
|
||||
- 如果两次响声间隔小于该窗口(如拉椅子的一连串声音),会被合并为 **1 次打断**
|
||||
- 只有间隔较长的响声才会被计为新的打断
|
||||
- **意义**:反映环境的干扰频率。频繁的打断(如断断续续的说话声)比连续的噪音更易打断心流
|
||||
|
||||
### 5.2 评分引擎
|
||||
|
||||
#### 5.2.1 三维度评分模型
|
||||
|
||||
评分系统从三个维度对噪音进行评估:
|
||||
|
||||
| 维度 | 权重 | 指标 | 满扣分条件 |
|
||||
|------|------|------|-----------|
|
||||
| **持续噪音** | 40% | p50Dbfs | 中位数超过阈值 6 dBFS |
|
||||
| **超阈时长** | 30% | overRatioDbfs | 超阈时间占比 30% |
|
||||
| **打断频次** | 30% | segmentCount | 6 次/分钟 |
|
||||
|
||||
#### 5.2.2 评分公式
|
||||
|
||||
**总惩罚系数:**
|
||||
$$ \text{TotalPenalty} = 0.40 \times P_{\text{sustained}} + 0.30 \times P_{\text{time}} + 0.30 \times P_{\text{segment}} $$
|
||||
|
||||
**最终得分:**
|
||||
$$ \text{Score} = 100 \times (1 - \text{TotalPenalty}) $$
|
||||
|
||||
#### 5.2.3 惩罚系数计算
|
||||
|
||||
##### A. 持续噪音惩罚
|
||||
|
||||
**公式:**
|
||||
$$ P_{\text{sustained}} = \text{clamp}_{[0,1]}\left(\frac{\text{p50Dbfs} - \text{threshold}}{6}\right) $$
|
||||
|
||||
**满扣分条件:** `p50Dbfs - threshold ≥ 6 dBFS`
|
||||
|
||||
##### B. 超阈时长惩罚
|
||||
|
||||
**公式:**
|
||||
$$ P_{\text{time}} = \text{clamp}_{[0,1]}\left(\frac{\text{overRatioDbfs}}{0.3}\right) $$
|
||||
|
||||
**满扣分条件:** `overRatioDbfs ≥ 30%`
|
||||
|
||||
##### C. 打断频次惩罚
|
||||
|
||||
**公式:**
|
||||
$$ P_{\text{segment}} = \text{clamp}_{[0,1]}\left(\frac{\text{segmentCount} / \text{minutes}}{\text{maxSegmentsPerMin}}\right) $$
|
||||
|
||||
**满扣分条件:** `segmentsPerMin ≥ 6 次/分钟`
|
||||
|
||||
#### 5.2.4 权重解读
|
||||
|
||||
- **持续噪音 (40%)**:持续底噪仍会明显拉低分数
|
||||
- **超阈时长 (30%)**:只要大部分时间安静,偶尔的噪音仍可被容忍
|
||||
- **打断频次 (30%)**:强调"被频繁打断"对心流的破坏,提升对碎片化干扰的惩罚力度
|
||||
|
||||
#### 5.2.5 边界条件处理
|
||||
|
||||
- DBFS 范围限制:-100 到 0 dB
|
||||
- 惩罚系数范围限制:0 到 1
|
||||
- 评分范围限制:0 到 100 分
|
||||
|
||||
#### 5.2.6 有效时长处理
|
||||
|
||||
优先使用采样有效时长,不存在时回退到物理时长。
|
||||
|
||||
#### 5.2.7 评分示例
|
||||
|
||||
**场景 1:安静环境**
|
||||
- p50Dbfs = -60 dBFS, threshold = -50 dBFS
|
||||
- overRatioDbfs = 0.05 (5%)
|
||||
- segmentCount = 1, duration = 30s
|
||||
|
||||
```
|
||||
sustainedPenalty = clamp01((-60 - (-50)) / 6) = clamp01(-10/6) = 0
|
||||
timePenalty = clamp01(0.05 / 0.3) = 0.167
|
||||
segmentPenalty = clamp01((1/0.5) / 6) = clamp01(2/6) = 0.333
|
||||
|
||||
TotalPenalty = 0.4×0 + 0.3×0.167 + 0.3×0.333 = 0.15
|
||||
Score = 100 × (1 - 0.15) = 85 分
|
||||
```
|
||||
|
||||
**场景 2:嘈杂环境**
|
||||
- p50Dbfs = -45 dBFS, threshold = -50 dBFS
|
||||
- overRatioDbfs = 0.40 (40%)
|
||||
- segmentCount = 8, duration = 30s
|
||||
|
||||
```
|
||||
sustainedPenalty = clamp01((-45 - (-50)) / 6) = clamp01(5/6) = 0.833
|
||||
timePenalty = clamp01(0.40 / 0.3) = 1.0
|
||||
segmentPenalty = clamp01((8/0.5) / 6) = clamp01(16/6) = 1.0
|
||||
|
||||
TotalPenalty = 0.4×0.833 + 0.3×1.0 + 0.3×1.0 = 0.933
|
||||
Score = 100 × (1 - 0.933) = 6.7 分
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 数据存储层
|
||||
|
||||
### 6.1 切片服务
|
||||
|
||||
#### 6.1.1 localStorage 存储策略
|
||||
|
||||
存储键:`noise-slices`
|
||||
|
||||
**隐私说明:**
|
||||
- 存储内容:时间戳、噪音统计(不包含音频数据)
|
||||
- 风险:可能泄露位置/日程信息
|
||||
- 建议:在 UI 中提供"清除历史"功能
|
||||
|
||||
#### 6.1.2 时间窗口清理
|
||||
|
||||
**默认保留时长:** 14 天
|
||||
**可配置范围:** 1 ~ 365 天
|
||||
|
||||
使用新切片的结束时间作为基准计算 cutoff,确保新切片不会被清理。
|
||||
|
||||
#### 6.1.3 容量限制
|
||||
|
||||
**容量上限:** 本地存储配额的 90%
|
||||
|
||||
#### 6.1.4 数据规范化与校验
|
||||
|
||||
**精度控制:**
|
||||
- dBFS:3 位小数
|
||||
- overRatioDbfs:4 位小数
|
||||
- 显示分贝:2 位小数
|
||||
- 评分:1 位小数
|
||||
|
||||
---
|
||||
|
||||
## 7. 历史报告生成
|
||||
|
||||
### 7.1 历史构建器
|
||||
|
||||
#### 7.1.1 与课表关联逻辑
|
||||
|
||||
**关联规则:**
|
||||
1. 按日期分组切片
|
||||
2. 对每个日期的每个课时,查找重叠的切片
|
||||
3. 计算该课时的平均评分
|
||||
|
||||
#### 7.1.2 时段平均评分计算(加权平均)
|
||||
|
||||
**公式:**
|
||||
$$ \text{avgScore} = \frac{\sum_{i} \text{score}_i \times \text{effectiveMs}_i}{\sum_{i} \text{effectiveMs}_i} $$
|
||||
|
||||
其中:
|
||||
$$ \text{effectiveMs}_i = \text{sampledDurationMs}_i \times \frac{\text{overlapMs}_i}{\text{sliceMs}_i} $$
|
||||
|
||||
#### 7.1.3 覆盖率计算
|
||||
|
||||
**公式:**
|
||||
$$ \text{coverageRatio} = \frac{\text{totalMs}}{\text{periodMs}} $$
|
||||
|
||||
**含义:** 课时内有效采样时长占课时总时长的比例
|
||||
|
||||
#### 7.1.4 日期时间处理
|
||||
|
||||
**时区说明:**
|
||||
- 使用本地时区
|
||||
- 内部存储使用 UTC 时间戳
|
||||
- 对外展示使用本地时间
|
||||
|
||||
**日期格式:** `YYYY-MM-DD`
|
||||
**时间格式:** `HH:MM`
|
||||
|
||||
#### 7.1.5 跨天课时处理
|
||||
|
||||
如果结束时间 ≤ 开始时间,则课时跨越到次日。
|
||||
|
||||
#### 7.1.6 报告中的图表
|
||||
|
||||
在噪音统计报告中,您可以直观地看到这些数据:
|
||||
|
||||
- **评分走势图**:展示了 `Score` 随时间的变化,帮助您回顾专注状态
|
||||
- **噪音等级分布**:将每一帧归类为安静/正常/吵闹/极吵,直观展示时间占比
|
||||
- **扣分归因**:直接显示上述三个维度的扣分比例,告诉您为什么分低(是因为一直吵,还是因为总被打断)
|
||||
- **打断次数密度**:展示每分钟被干扰的次数
|
||||
|
||||
---
|
||||
|
||||
## 8. 流服务整合
|
||||
|
||||
### 8.1 噪音流服务
|
||||
|
||||
#### 8.1.1 订阅/发布模式
|
||||
|
||||
**模式:** 观察者模式
|
||||
- 多个组件可同时订阅
|
||||
- 最后一个订阅者取消时自动停止采集
|
||||
|
||||
#### 8.1.2 生命周期管理
|
||||
|
||||
流服务支持启动、停止和重启操作,自动管理采集资源的生命周期。
|
||||
|
||||
#### 8.1.3 预热帧处理
|
||||
|
||||
**目的:** 丢弃麦克风启动后的不稳定数据(约 500ms)
|
||||
|
||||
#### 8.1.4 设置热更新响应
|
||||
|
||||
**需要重启的参数:**
|
||||
- frameMs
|
||||
- sliceSec
|
||||
- scoreThresholdDbfs
|
||||
- segmentMergeGapMs
|
||||
- maxSegmentsPerMin
|
||||
|
||||
**无需重启的参数:**
|
||||
- maxLevelDb
|
||||
- showRealtimeDb
|
||||
- alertSoundEnabled
|
||||
- avgWindowSec
|
||||
- baselineDb
|
||||
|
||||
#### 8.1.5 时间加权平均
|
||||
|
||||
**公式:**
|
||||
$$ \text{avg} = \frac{\sum_{i} v_i \times (t_{i+1} - t_i)}{\sum_{i} (t_{i+1} - t_i)} $$
|
||||
|
||||
---
|
||||
|
||||
## 9. 配置参数体系
|
||||
|
||||
### 9.1 常量定义
|
||||
|
||||
#### 9.1.1 分析参数
|
||||
|
||||
```typescript
|
||||
NOISE_ANALYSIS_SLICE_SEC = 30; // 切片时长 30 秒
|
||||
NOISE_ANALYSIS_FRAME_MS = 50; // 帧间隔 50ms
|
||||
NOISE_SCORE_THRESHOLD_DBFS = -50; // 评分阈值 -50dBFS
|
||||
NOISE_SCORE_SEGMENT_MERGE_GAP_MS = 500; // 事件段合并间隔 500ms
|
||||
NOISE_SCORE_MAX_SEGMENTS_PER_MIN = 6; // 每分钟最大事件段数 6
|
||||
NOISE_REALTIME_CHART_SLICE_COUNT = 1; // 实时图表切片数 1
|
||||
```
|
||||
|
||||
#### 9.1.2 报告参数
|
||||
|
||||
```typescript
|
||||
DEFAULT_NOISE_REPORT_RETENTION_DAYS = 14; // 默认保留 14 天
|
||||
MIN_NOISE_REPORT_RETENTION_DAYS = 1; // 最小保留 1 天
|
||||
MAX_NOISE_REPORT_RETENTION_DAYS_FALLBACK = 365; // 最大保留 365 天
|
||||
```
|
||||
|
||||
### 9.2 设置管理
|
||||
|
||||
#### 9.2.1 固定参数
|
||||
|
||||
为保证评分口径稳定,避免用户通过调整参数"刷分",以下参数固定为程序内常量:
|
||||
- sliceSec
|
||||
- frameMs
|
||||
- scoreThresholdDbfs
|
||||
- segmentMergeGapMs
|
||||
- maxSegmentsPerMin
|
||||
|
||||
#### 9.2.2 可配置参数
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| maxLevelDb | number | 55 | 最大允许噪音级别(显示分贝) |
|
||||
| baselineDb | number | 40 | 手动基准显示分贝 |
|
||||
| showRealtimeDb | boolean | true | 是否显示实时分贝 |
|
||||
| avgWindowSec | number | 1 | 噪音平均时间窗(秒) |
|
||||
| alertSoundEnabled | boolean | false | 超阈值提示音开关 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 类型定义
|
||||
|
||||
### 10.1 核心类型
|
||||
|
||||
#### 10.1.1 噪音帧采样
|
||||
|
||||
```typescript
|
||||
interface NoiseFrameSample {
|
||||
t: number; // 时间戳
|
||||
rms: number; // 均方根值
|
||||
dbfs: number; // 分贝值 (dBFS)
|
||||
peak?: number; // 峰值
|
||||
}
|
||||
```
|
||||
|
||||
#### 10.1.2 噪音切片原始统计
|
||||
|
||||
```typescript
|
||||
interface NoiseSliceRawStats {
|
||||
avgDbfs: number; // 平均分贝
|
||||
maxDbfs: number; // 最大分贝
|
||||
p50Dbfs: number; // 中位数分贝
|
||||
p95Dbfs: number; // 95分位数分贝
|
||||
overRatioDbfs: number; // 超阈值比例
|
||||
segmentCount: number; // 事件段数量
|
||||
sampledDurationMs?: number; // 采样时长
|
||||
gapCount?: number; // 缺口数量
|
||||
maxGapMs?: number; // 最大缺口时长
|
||||
}
|
||||
```
|
||||
|
||||
#### 10.1.3 噪音切片显示统计
|
||||
|
||||
```typescript
|
||||
interface NoiseSliceDisplayStats {
|
||||
avgDb: number; // 平均显示分贝
|
||||
p95Db: number; // 95分位数显示分贝
|
||||
}
|
||||
```
|
||||
|
||||
#### 10.1.4 噪音评分明细
|
||||
|
||||
```typescript
|
||||
interface NoiseScoreBreakdown {
|
||||
sustainedPenalty: number; // 持续噪音惩罚
|
||||
timePenalty: number; // 时间惩罚
|
||||
segmentPenalty: number; // 事件段惩罚
|
||||
thresholdsUsed: {
|
||||
scoreThresholdDbfs: number; // 使用的评分阈值
|
||||
segmentMergeGapMs: number; // 使用的合并间隔
|
||||
maxSegmentsPerMin: number; // 使用的最大事件段数
|
||||
};
|
||||
sustainedLevelDbfs: number; // 持续电平
|
||||
overRatioDbfs: number; // 超阈值比例
|
||||
segmentCount: number; // 事件段数量
|
||||
minutes: number; // 时长(分钟)
|
||||
durationMs?: number; // 物理时长
|
||||
sampledDurationMs?: number; // 采样时长
|
||||
coverageRatio?: number; // 覆盖率
|
||||
}
|
||||
```
|
||||
|
||||
#### 10.1.5 噪音切片摘要
|
||||
|
||||
```typescript
|
||||
interface NoiseSliceSummary {
|
||||
start: number; // 开始时间戳
|
||||
end: number; // 结束时间戳
|
||||
frames: number; // 帧数
|
||||
raw: NoiseSliceRawStats; // 原始统计
|
||||
display: NoiseSliceDisplayStats; // 显示统计
|
||||
score: number; // 评分
|
||||
scoreDetail: NoiseScoreBreakdown; // 评分明细
|
||||
}
|
||||
```
|
||||
|
||||
#### 10.1.6 实时数据点
|
||||
|
||||
```typescript
|
||||
interface NoiseRealtimePoint {
|
||||
t: number; // 时间戳
|
||||
dbfs: number; // 分贝值 (dBFS)
|
||||
displayDb: number; // 显示分贝
|
||||
}
|
||||
```
|
||||
|
||||
#### 10.1.7 噪音流快照
|
||||
|
||||
```typescript
|
||||
interface NoiseStreamSnapshot {
|
||||
status: NoiseStreamStatus; // 流状态
|
||||
realtimeDisplayDb: number; // 实时显示分贝
|
||||
realtimeDbfs: number; // 实时分贝 (dBFS)
|
||||
maxLevelDb: number; // 最大允许级别
|
||||
showRealtimeDb: boolean; // 是否显示实时分贝
|
||||
alertSoundEnabled: boolean; // 提示音开关
|
||||
ringBuffer: NoiseRealtimePoint[]; // 环形缓冲区快照
|
||||
latestSlice: NoiseSliceSummary | null; // 最新切片
|
||||
}
|
||||
```
|
||||
|
||||
#### 10.1.8 噪音流状态
|
||||
|
||||
```typescript
|
||||
type NoiseStreamStatus =
|
||||
| "initializing" // 初始化中
|
||||
| "quiet" // 安静
|
||||
| "noisy" // 嘈杂
|
||||
| "permission-denied" // 权限拒绝
|
||||
| "error"; // 错误
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 附录
|
||||
|
||||
### A. 术语表
|
||||
|
||||
| 术语 | 英文 | 说明 |
|
||||
|------|------|------|
|
||||
| 均方根 | RMS (Root Mean Square) | 衡量音频信号强度的标准方法 |
|
||||
| 分贝满刻度 | dBFS (Decibels relative to Full Scale) | 数字音频的标准分贝单位,范围 -100 到 0 dB |
|
||||
| 显示分贝 | Display dB | 用于用户界面展示的分贝值,范围 20 到 100 dB |
|
||||
| 切片 | Slice | 固定时间窗口(默认 30 秒)内的噪音数据聚合 |
|
||||
| 帧 | Frame | 单次音频采样(默认 50ms) |
|
||||
| 事件段 | Segment | 独立的噪音事件,通过合并窗口(500ms)合并 |
|
||||
|
||||
### B. 参数固定策略
|
||||
|
||||
为保证统计口径稳定,当前版本将"分析与评分"的高级参数固定为程序内常量:
|
||||
|
||||
| 参数 | 值 | 说明 |
|
||||
|------|-----|------|
|
||||
| frameMs | 50ms | 约 20fps |
|
||||
| sliceSec | 30s | 切片时长 |
|
||||
| scoreThresholdDbfs | -50 dBFS | 评分阈值 |
|
||||
| segmentMergeGapMs | 500ms | 事件段合并间隔 |
|
||||
| maxSegmentsPerMin | 6 | 每分钟最大事件段数 |
|
||||
|
||||
### C. 技术栈
|
||||
|
||||
- **音频处理**:Web Audio API
|
||||
- **数据存储**:localStorage
|
||||
- **前端框架**:React 18
|
||||
- **构建工具**:Vite 5
|
||||
- **类型系统**:TypeScript 5.4
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { app as electronApp } from 'electron'
|
||||
import { join } from 'path'
|
||||
import Database from 'better-sqlite3'
|
||||
|
||||
export type SqliteDb = {
|
||||
pragma: (sql: string) => unknown
|
||||
exec: (sql: string) => unknown
|
||||
close: () => void
|
||||
}
|
||||
|
||||
export type SqliteHandle = {
|
||||
db: SqliteDb
|
||||
filePath: string
|
||||
close: () => void
|
||||
}
|
||||
|
||||
export function createSqlite(): SqliteHandle {
|
||||
const filePath = join(electronApp.getPath('userData'), 'lanmountain.sqlite3')
|
||||
const db = new Database(filePath) as unknown as SqliteDb
|
||||
|
||||
db.pragma('journal_mode = WAL')
|
||||
db.pragma('foreign_keys = ON')
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS kv (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updatedAt INTEGER NOT NULL
|
||||
);
|
||||
`)
|
||||
|
||||
const close = (): void => {
|
||||
try {
|
||||
db.close()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
electronApp.once('before-quit', close)
|
||||
|
||||
return { db, filePath, close }
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export { listWindowsStartMenuApps } from './list'
|
||||
export { launchStartMenuEntry } from './launch'
|
||||
export type { WindowsStartMenuAppEntry, WindowsStartMenuEntryType } from './types'
|
||||
@@ -1,33 +0,0 @@
|
||||
import { shell } from 'electron'
|
||||
import { execFile } from 'child_process'
|
||||
import { resolve } from 'path'
|
||||
import { promisify } from 'util'
|
||||
import { getStartMenuRoots } from './paths'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
function isUnderRoot(filePath: string, root: string): boolean {
|
||||
const normalizedFile = resolve(filePath).toLowerCase()
|
||||
const normalizedRoot = resolve(root).toLowerCase()
|
||||
return normalizedFile === normalizedRoot || normalizedFile.startsWith(normalizedRoot + '\\')
|
||||
}
|
||||
|
||||
export async function launchStartMenuEntry(filePath: string): Promise<void> {
|
||||
if (process.platform !== 'win32') return
|
||||
|
||||
if (filePath.startsWith('shell:AppsFolder\\')) {
|
||||
await execFileAsync('explorer.exe', [filePath], { windowsHide: true })
|
||||
return
|
||||
}
|
||||
|
||||
const roots = getStartMenuRoots()
|
||||
const allowed = roots.some((root) => isUnderRoot(filePath, root))
|
||||
if (!allowed) {
|
||||
throw new Error('PathNotAllowed')
|
||||
}
|
||||
|
||||
const result = await shell.openPath(filePath)
|
||||
if (result) {
|
||||
throw new Error(result)
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { join } from 'path'
|
||||
import { promisify } from 'util'
|
||||
import { getStartMenuRoots } from './paths'
|
||||
import type { WindowsStartMenuAppEntry } from './types'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
function getWindowsPowerShellExe(): string {
|
||||
const systemRoot = process.env['SystemRoot'] ?? process.env['WINDIR'] ?? 'C:\\Windows'
|
||||
return join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
|
||||
}
|
||||
|
||||
function buildPowerShellScript(roots: string[]): string {
|
||||
const rootsJson = JSON.stringify(roots).replace(/'/g, "''")
|
||||
|
||||
return [
|
||||
'[Console]::OutputEncoding = [System.Text.Encoding]::UTF8',
|
||||
'$ErrorActionPreference = "Stop"',
|
||||
'$out = $null',
|
||||
'try {',
|
||||
`$roots = '${rootsJson}' | ConvertFrom-Json`,
|
||||
'$wsh = New-Object -ComObject WScript.Shell',
|
||||
'$entries = @()',
|
||||
'foreach ($root in $roots) {',
|
||||
' if (-not (Test-Path -LiteralPath $root)) { continue }',
|
||||
' Get-ChildItem -LiteralPath $root -Recurse -File -ErrorAction SilentlyContinue | ForEach-Object {',
|
||||
' $ext = $_.Extension.ToLowerInvariant()',
|
||||
' if ($ext -ne ".lnk" -and $ext -ne ".url" -and $ext -ne ".appref-ms") { return }',
|
||||
' $source = "unknown"',
|
||||
' if ($root -like "*\\\\AppData\\\\Roaming*") { $source = "user" }',
|
||||
' if ($root -like "*\\\\ProgramData*") { $source = "common" }',
|
||||
' $rel = $_.FullName.Substring($root.Length).TrimStart("\\\\")',
|
||||
' if ($ext -eq ".lnk") {',
|
||||
' $sc = $wsh.CreateShortcut($_.FullName)',
|
||||
' $entries += [pscustomobject]@{',
|
||||
' id = $_.FullName',
|
||||
' name = $_.BaseName',
|
||||
' type = "lnk"',
|
||||
' filePath = $_.FullName',
|
||||
' relativePath = $rel',
|
||||
' targetPath = $sc.TargetPath',
|
||||
' arguments = $sc.Arguments',
|
||||
' workingDirectory = $sc.WorkingDirectory',
|
||||
' iconLocation = $sc.IconLocation',
|
||||
' description = $sc.Description',
|
||||
' source = $source',
|
||||
' }',
|
||||
' return',
|
||||
' }',
|
||||
' $t = "appref-ms"',
|
||||
' if ($ext -eq ".url") { $t = "url" }',
|
||||
' $entries += [pscustomobject]@{',
|
||||
' id = $_.FullName',
|
||||
' name = $_.BaseName',
|
||||
' type = $t',
|
||||
' filePath = $_.FullName',
|
||||
' relativePath = $rel',
|
||||
' source = $source',
|
||||
' }',
|
||||
' }',
|
||||
'}',
|
||||
'$usedStartApps = $false',
|
||||
'try {',
|
||||
' $startApps = Get-StartApps | Select-Object Name, AppID',
|
||||
' if ($startApps -ne $null) {',
|
||||
' $usedStartApps = $true',
|
||||
' foreach ($a in $startApps) {',
|
||||
' if ([string]::IsNullOrWhiteSpace($a.AppID)) { continue }',
|
||||
' $entries += [pscustomobject]@{',
|
||||
' id = "appsFolder:" + $a.AppID',
|
||||
' name = $a.Name',
|
||||
' type = "uwp"',
|
||||
' filePath = "shell:AppsFolder\\\\" + $a.AppID',
|
||||
' relativePath = "AppsFolder\\\\" + $a.Name',
|
||||
' appUserModelId = $a.AppID',
|
||||
' source = "appsfolder"',
|
||||
' }',
|
||||
' }',
|
||||
' }',
|
||||
'} catch { }',
|
||||
'if (-not $usedStartApps) {',
|
||||
' $shell = New-Object -ComObject Shell.Application',
|
||||
' $appsFolder = $shell.NameSpace("shell:AppsFolder")',
|
||||
' if ($appsFolder -ne $null) {',
|
||||
' $appsFolder.Items() | ForEach-Object {',
|
||||
' $aumid = $_.Path',
|
||||
' if ([string]::IsNullOrWhiteSpace($aumid)) { return }',
|
||||
' $name = $_.Name',
|
||||
' $entries += [pscustomobject]@{',
|
||||
' id = "appsFolder:" + $aumid',
|
||||
' name = $name',
|
||||
' type = "uwp"',
|
||||
' filePath = "shell:AppsFolder\\\\" + $aumid',
|
||||
' relativePath = "AppsFolder\\\\" + $name',
|
||||
' appUserModelId = $aumid',
|
||||
' source = "appsfolder"',
|
||||
' }',
|
||||
' }',
|
||||
' }',
|
||||
'}',
|
||||
'$out = [pscustomobject]@{ ok = $true; entries = $entries }',
|
||||
'} catch {',
|
||||
' $out = [pscustomobject]@{ ok = $false; error = ($_ | Out-String); entries = @() }',
|
||||
'}',
|
||||
'$out | ConvertTo-Json -Depth 6'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export async function listWindowsStartMenuApps(): Promise<WindowsStartMenuAppEntry[]> {
|
||||
if (process.platform !== 'win32') return []
|
||||
|
||||
const roots = getStartMenuRoots()
|
||||
|
||||
const script = buildPowerShellScript(roots)
|
||||
const powershellExe = getWindowsPowerShellExe()
|
||||
const { stdout } = await execFileAsync(
|
||||
powershellExe,
|
||||
['-NoProfile', '-NonInteractive', '-Sta', '-ExecutionPolicy', 'Bypass', '-Command', script],
|
||||
{ windowsHide: true, maxBuffer: 50 * 1024 * 1024, timeout: 60_000 }
|
||||
)
|
||||
|
||||
const trimmed = stdout.trim()
|
||||
if (!trimmed) return []
|
||||
|
||||
const parsed: unknown = JSON.parse(trimmed)
|
||||
|
||||
if (typeof parsed === 'object' && parsed !== null && 'ok' in parsed) {
|
||||
const ok = (parsed as { ok?: unknown }).ok
|
||||
if (ok === false) {
|
||||
const error = (parsed as { error?: unknown }).error
|
||||
throw new Error(typeof error === 'string' ? error : 'PowerShellFailed')
|
||||
}
|
||||
}
|
||||
|
||||
const rawEntries: unknown =
|
||||
typeof parsed === 'object' && parsed !== null && 'entries' in parsed
|
||||
? (parsed as { entries?: unknown }).entries
|
||||
: parsed
|
||||
|
||||
const list: unknown[] = Array.isArray(rawEntries) ? rawEntries : rawEntries ? [rawEntries] : []
|
||||
|
||||
const isEntry = (value: unknown): value is WindowsStartMenuAppEntry => {
|
||||
if (typeof value !== 'object' || value === null) return false
|
||||
const v = value as Record<string, unknown>
|
||||
return (
|
||||
typeof v.id === 'string' &&
|
||||
typeof v.name === 'string' &&
|
||||
typeof v.type === 'string' &&
|
||||
typeof v.filePath === 'string' &&
|
||||
typeof v.relativePath === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
const seen = new Set<string>()
|
||||
const result: WindowsStartMenuAppEntry[] = []
|
||||
|
||||
for (const item of list) {
|
||||
if (!isEntry(item)) continue
|
||||
if (seen.has(item.id)) continue
|
||||
seen.add(item.id)
|
||||
result.push(item)
|
||||
}
|
||||
|
||||
result.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'))
|
||||
return result
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { join } from 'path'
|
||||
|
||||
export interface StartMenuPaths {
|
||||
userProgramsPath: string | null
|
||||
commonProgramsPath: string | null
|
||||
}
|
||||
|
||||
export function getStartMenuPaths(): StartMenuPaths {
|
||||
const appData = process.env['APPDATA']
|
||||
const programData = process.env['ProgramData'] ?? process.env['PROGRAMDATA']
|
||||
|
||||
return {
|
||||
userProgramsPath: appData
|
||||
? join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs')
|
||||
: null,
|
||||
commonProgramsPath: programData
|
||||
? join(programData, 'Microsoft', 'Windows', 'Start Menu', 'Programs')
|
||||
: null
|
||||
}
|
||||
}
|
||||
|
||||
export function getStartMenuRoots(): string[] {
|
||||
const { userProgramsPath, commonProgramsPath } = getStartMenuPaths()
|
||||
return [userProgramsPath, commonProgramsPath].filter((p): p is string => Boolean(p))
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
export type WindowsStartMenuEntryType = 'lnk' | 'url' | 'appref-ms' | 'uwp' | 'other'
|
||||
|
||||
export interface WindowsStartMenuAppEntry {
|
||||
id: string
|
||||
name: string
|
||||
type: WindowsStartMenuEntryType
|
||||
filePath: string
|
||||
relativePath: string
|
||||
appUserModelId?: string | null
|
||||
targetPath?: string | null
|
||||
arguments?: string | null
|
||||
workingDirectory?: string | null
|
||||
iconLocation?: string | null
|
||||
description?: string | null
|
||||
source: 'user' | 'common' | 'appsfolder' | 'unknown'
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
import { app as electronApp, BrowserWindow, shell } from 'electron'
|
||||
import { Elysia } from 'elysia'
|
||||
import { execFile } from 'child_process'
|
||||
import { promises as fs } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { promisify } from 'util'
|
||||
import { launchStartMenuEntry, listWindowsStartMenuApps } from '../app_list'
|
||||
import { createSqlite } from '../SQLitte'
|
||||
|
||||
export interface EiysiaDependencies {
|
||||
getMainWindow: () => BrowserWindow | null
|
||||
getHttpPort: () => number | null
|
||||
}
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
export function createEiysiaApp(deps: EiysiaDependencies): {
|
||||
handle: (request: Request) => Response | Promise<Response>
|
||||
} {
|
||||
type CachedApp = {
|
||||
id: string
|
||||
name: string
|
||||
filePath: string
|
||||
iconDataUrl: string
|
||||
}
|
||||
|
||||
const sqliteHandle = createSqlite()
|
||||
|
||||
const iconCache = new Map<string, string>()
|
||||
const appsCacheFilePath = join(electronApp.getPath('userData'), 'apps-cache.json')
|
||||
let cachedApps: CachedApp[] = []
|
||||
let cacheLoadPromise: Promise<void> | null = null
|
||||
let refreshPromise: Promise<void> | null = null
|
||||
|
||||
const ensureAppsCacheLoaded = async (): Promise<void> => {
|
||||
if (cacheLoadPromise) return cacheLoadPromise
|
||||
cacheLoadPromise = (async () => {
|
||||
try {
|
||||
const raw = await fs.readFile(appsCacheFilePath, 'utf-8')
|
||||
const parsed = JSON.parse(raw) as {
|
||||
apps?: Array<{ id?: unknown; name?: unknown; filePath?: unknown; iconDataUrl?: unknown }>
|
||||
}
|
||||
|
||||
const apps = Array.isArray(parsed.apps) ? parsed.apps : []
|
||||
const parsedApps = apps.map((a) => {
|
||||
const id = typeof a.id === 'string' ? a.id : ''
|
||||
const name = typeof a.name === 'string' ? a.name : ''
|
||||
const filePath = typeof a.filePath === 'string' ? a.filePath : ''
|
||||
const iconDataUrl = typeof a.iconDataUrl === 'string' ? a.iconDataUrl : ''
|
||||
if (!id || !name || !filePath || !iconDataUrl) return null
|
||||
return { id, name, filePath, iconDataUrl }
|
||||
})
|
||||
|
||||
cachedApps = parsedApps.filter((a): a is CachedApp => Boolean(a))
|
||||
} catch {
|
||||
cachedApps = []
|
||||
}
|
||||
})()
|
||||
return cacheLoadPromise
|
||||
}
|
||||
|
||||
const persistAppsCache = async (apps: CachedApp[]): Promise<void> => {
|
||||
const payload = JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
updatedAt: Date.now(),
|
||||
apps
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
await fs.writeFile(appsCacheFilePath, payload, 'utf-8')
|
||||
}
|
||||
|
||||
const refreshAppsCache = async (): Promise<void> => {
|
||||
if (refreshPromise) return refreshPromise
|
||||
refreshPromise = (async () => {
|
||||
const cachedById = new Map(cachedApps.map((a) => [a.id, a]))
|
||||
const entries = await listWindowsStartMenuApps()
|
||||
|
||||
let index = 0
|
||||
const limit = Math.max(4, Math.min(16, entries.length))
|
||||
const nextApps: CachedApp[] = new Array(entries.length)
|
||||
|
||||
const workers = Array.from({ length: limit }, async () => {
|
||||
while (true) {
|
||||
const i = index
|
||||
index += 1
|
||||
if (i >= entries.length) return
|
||||
|
||||
const e = entries[i]
|
||||
const cached = cachedById.get(e.id)
|
||||
if (
|
||||
cached &&
|
||||
cached.name === e.name &&
|
||||
cached.filePath === e.filePath &&
|
||||
cached.iconDataUrl
|
||||
) {
|
||||
nextApps[i] = cached
|
||||
continue
|
||||
}
|
||||
|
||||
const iconDataUrl = await getIconDataUrl(e)
|
||||
nextApps[i] = { id: e.id, name: e.name, filePath: e.filePath, iconDataUrl }
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.all(workers)
|
||||
cachedApps = nextApps.filter(Boolean)
|
||||
await persistAppsCache(cachedApps)
|
||||
})().finally(() => {
|
||||
refreshPromise = null
|
||||
})
|
||||
|
||||
return refreshPromise
|
||||
}
|
||||
|
||||
const placeholderIconDataUrl = (name: string, id: string): string => {
|
||||
const letter = (name.trim().charAt(0) || '?').toUpperCase()
|
||||
|
||||
let hash = 0
|
||||
for (let i = 0; i < id.length; i += 1) {
|
||||
hash = (hash * 31 + id.charCodeAt(i)) | 0
|
||||
}
|
||||
const hue = Math.abs(hash) % 360
|
||||
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"><circle cx="32" cy="32" r="32" fill="hsl(${hue} 70% 45%)"/><text x="32" y="40" text-anchor="middle" font-family="Segoe UI, Arial" font-size="28" font-weight="700" fill="white">${letter.replace(
|
||||
/[<>&]/g,
|
||||
''
|
||||
)}</text></svg>`
|
||||
|
||||
return `data:image/svg+xml;base64,${Buffer.from(svg, 'utf-8').toString('base64')}`
|
||||
}
|
||||
|
||||
const getIconDataUrl = async (entry: {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
filePath: string
|
||||
targetPath?: string | null
|
||||
}): Promise<string> => {
|
||||
const cached = iconCache.get(entry.id)
|
||||
if (cached) return cached
|
||||
|
||||
if (entry.type === 'uwp' || entry.filePath.startsWith('shell:')) {
|
||||
const dataUrl = placeholderIconDataUrl(entry.name, entry.id)
|
||||
iconCache.set(entry.id, dataUrl)
|
||||
return dataUrl
|
||||
}
|
||||
|
||||
if (!electronApp.isReady()) {
|
||||
const dataUrl = placeholderIconDataUrl(entry.name, entry.id)
|
||||
iconCache.set(entry.id, dataUrl)
|
||||
return dataUrl
|
||||
}
|
||||
|
||||
try {
|
||||
const tryPath =
|
||||
typeof entry.targetPath === 'string' && entry.targetPath.trim()
|
||||
? entry.targetPath
|
||||
: entry.filePath
|
||||
const rawIcon = await electronApp
|
||||
.getFileIcon(tryPath, { size: 'large' })
|
||||
.catch(() => electronApp.getFileIcon(tryPath, { size: 'normal' }))
|
||||
|
||||
let icon = rawIcon
|
||||
if (!icon.isEmpty()) {
|
||||
const { width, height } = icon.getSize()
|
||||
const target = 64
|
||||
if (width > 0 && height > 0 && (width < target || height < target)) {
|
||||
icon = icon.resize({ width: target, height: target, quality: 'best' })
|
||||
}
|
||||
}
|
||||
|
||||
const dataUrl = icon.isEmpty()
|
||||
? placeholderIconDataUrl(entry.name, entry.id)
|
||||
: icon.toDataURL()
|
||||
iconCache.set(entry.id, dataUrl)
|
||||
return dataUrl
|
||||
} catch {
|
||||
const dataUrl = placeholderIconDataUrl(entry.name, entry.id)
|
||||
iconCache.set(entry.id, dataUrl)
|
||||
return dataUrl
|
||||
}
|
||||
}
|
||||
|
||||
return new Elysia()
|
||||
.get('/db/sqlite/health', () => ({
|
||||
ok: true,
|
||||
path: sqliteHandle.filePath
|
||||
}))
|
||||
.get('/health', () => ({
|
||||
ok: true,
|
||||
time: Date.now()
|
||||
}))
|
||||
.onStart(() => {
|
||||
void ensureAppsCacheLoaded().then(() => refreshAppsCache())
|
||||
})
|
||||
.get('/apps/list', async () => {
|
||||
try {
|
||||
await ensureAppsCacheLoaded()
|
||||
if (cachedApps.length === 0) {
|
||||
await refreshAppsCache()
|
||||
} else {
|
||||
void refreshAppsCache()
|
||||
}
|
||||
|
||||
return { apps: cachedApps, error: null as string | null }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'UnknownError'
|
||||
console.error('[apps/list] failed:', message)
|
||||
return { apps: [], error: message }
|
||||
}
|
||||
})
|
||||
.post('/apps/launch', async ({ body }) => {
|
||||
const payload = body as { filePath?: unknown }
|
||||
if (typeof payload.filePath !== 'string') {
|
||||
return new Response('BadRequest', { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
await launchStartMenuEntry(payload.filePath)
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'UnknownError'
|
||||
if (message === 'PathNotAllowed') return new Response('Forbidden', { status: 403 })
|
||||
return new Response('LaunchFailed', { status: 500 })
|
||||
}
|
||||
})
|
||||
.post('/open/external', async ({ body }) => {
|
||||
const payload = body as { url?: unknown }
|
||||
if (typeof payload.url !== 'string') {
|
||||
return new Response('BadRequest', { status: 400 })
|
||||
}
|
||||
|
||||
const url = payload.url.trim()
|
||||
if (!url) return new Response('BadRequest', { status: 400 })
|
||||
|
||||
const lower = url.toLowerCase()
|
||||
if (
|
||||
lower.startsWith('javascript:') ||
|
||||
lower.startsWith('data:') ||
|
||||
lower.startsWith('file:')
|
||||
) {
|
||||
return new Response('Forbidden', { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
if (process.platform === 'win32' && lower.startsWith('shell:')) {
|
||||
await execFileAsync('explorer.exe', [url], { windowsHide: true })
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
await shell.openExternal(url)
|
||||
return { ok: true }
|
||||
} catch {
|
||||
return new Response('OpenFailed', { status: 500 })
|
||||
}
|
||||
})
|
||||
.get('/backend/port', () => ({
|
||||
port: deps.getHttpPort()
|
||||
}))
|
||||
.post('/app/minimize', () => {
|
||||
deps.getMainWindow()?.minimize()
|
||||
return { ok: true }
|
||||
})
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export { createEiysiaApp } from './app'
|
||||
export { registerEiysiaIpc } from './ipc'
|
||||
export { startEiysiaHttpServer } from './server'
|
||||
@@ -1,59 +0,0 @@
|
||||
import { ipcMain } from 'electron'
|
||||
|
||||
export interface EiysiaIpcRequest {
|
||||
method: string
|
||||
path: string
|
||||
headers?: Record<string, string>
|
||||
body?: unknown
|
||||
}
|
||||
|
||||
export interface EiysiaIpcResponse {
|
||||
status: number
|
||||
headers: Record<string, string>
|
||||
bodyText: string
|
||||
}
|
||||
|
||||
export function registerEiysiaIpc(app: {
|
||||
handle: (request: Request) => Response | Promise<Response>
|
||||
}): void {
|
||||
ipcMain.handle(
|
||||
'eiysia:request',
|
||||
async (_event, payload: EiysiaIpcRequest): Promise<EiysiaIpcResponse> => {
|
||||
const method = payload.method?.toUpperCase?.() ?? 'GET'
|
||||
const path = payload.path?.startsWith('/') ? payload.path : `/${payload.path ?? ''}`
|
||||
|
||||
const headers = new Headers(payload.headers ?? {})
|
||||
let body: BodyInit | undefined
|
||||
|
||||
if (payload.body !== undefined) {
|
||||
if (
|
||||
typeof payload.body === 'string' ||
|
||||
payload.body instanceof ArrayBuffer ||
|
||||
ArrayBuffer.isView(payload.body)
|
||||
) {
|
||||
body = payload.body as BodyInit
|
||||
} else {
|
||||
body = JSON.stringify(payload.body)
|
||||
if (!headers.has('content-type')) {
|
||||
headers.set('content-type', 'application/json')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const request = new Request(`http://eiysia.local${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body
|
||||
})
|
||||
|
||||
const response = await app.handle(request)
|
||||
const bodyText = await response.text()
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
bodyText
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Elysia } from 'elysia'
|
||||
import { node } from '@elysiajs/node'
|
||||
|
||||
export interface EiysiaServerHandle {
|
||||
app: unknown
|
||||
port: number | null
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
export function startEiysiaHttpServer(app: unknown): EiysiaServerHandle {
|
||||
const serverApp = new Elysia({ adapter: node() })
|
||||
.use(app as never)
|
||||
.listen({ hostname: '127.0.0.1', port: 0 })
|
||||
|
||||
let stopped = false
|
||||
const stop = (): void => {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
|
||||
try {
|
||||
if (!serverApp.server) return
|
||||
|
||||
const maybe = serverApp as unknown as {
|
||||
stop?: () => Promise<void> | void
|
||||
close?: () => void
|
||||
}
|
||||
if (typeof maybe.stop === 'function') maybe.stop()
|
||||
else if (typeof maybe.close === 'function') maybe.close()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
app: serverApp,
|
||||
port: serverApp.server?.port ?? null,
|
||||
stop
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { app, shell, BrowserWindow } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||
import icon from '../../resources/icon.png?asset'
|
||||
import { createEiysiaApp, registerEiysiaIpc, startEiysiaHttpServer } from '../eiysia'
|
||||
|
||||
app.commandLine.appendSwitch('touch-events', 'enabled')
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let eiysiaServerStop: (() => void) | null = null
|
||||
let eiysiaHttpPort: number | null = null
|
||||
|
||||
function createWindow(): void {
|
||||
// Create the browser window.
|
||||
const window = new BrowserWindow({
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
fullscreen: true,
|
||||
...(process.platform === 'linux' ? { icon } : {}),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow = window
|
||||
|
||||
window.on('ready-to-show', () => {
|
||||
window.show()
|
||||
})
|
||||
|
||||
window.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
// HMR for renderer base on electron-vite cli.
|
||||
// Load the remote URL for development or the local html file for production.
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
window.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||
} else {
|
||||
window.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
}
|
||||
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
app.whenReady().then(() => {
|
||||
// Set app user model id for windows
|
||||
electronApp.setAppUserModelId('com.electron')
|
||||
|
||||
// Default open or close DevTools by F12 in development
|
||||
// and ignore CommandOrControl + R in production.
|
||||
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
||||
app.on('browser-window-created', (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window)
|
||||
})
|
||||
|
||||
const eiysia = createEiysiaApp({
|
||||
getMainWindow: () => mainWindow,
|
||||
getHttpPort: () => eiysiaHttpPort
|
||||
})
|
||||
|
||||
registerEiysiaIpc(eiysia)
|
||||
|
||||
const server = startEiysiaHttpServer(eiysia)
|
||||
eiysiaHttpPort = server.port
|
||||
eiysiaServerStop = server.stop
|
||||
|
||||
createWindow()
|
||||
|
||||
app.on('activate', function () {
|
||||
// On macOS it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
})
|
||||
|
||||
// Quit when all windows are closed, except on macOS. There, it's common
|
||||
// for applications and their menu bar to stay active until the user quits
|
||||
// explicitly with Cmd + Q.
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
app.on('before-quit', () => {
|
||||
eiysiaServerStop?.()
|
||||
eiysiaServerStop = null
|
||||
eiysiaHttpPort = null
|
||||
})
|
||||
|
||||
// In this file you can include the rest of your app's specific main process
|
||||
// code. You can also put them in separate files and require them here.
|
||||
23
src/preload/index.d.ts
vendored
@@ -1,23 +0,0 @@
|
||||
import { ElectronAPI } from '@electron-toolkit/preload'
|
||||
|
||||
export interface AppApi {
|
||||
request: (payload: {
|
||||
method: string
|
||||
path: string
|
||||
headers?: Record<string, string>
|
||||
body?: unknown
|
||||
}) => Promise<{ status: number; headers: Record<string, string>; bodyText: string }>
|
||||
call: <T = unknown>(payload: {
|
||||
method: string
|
||||
path: string
|
||||
headers?: Record<string, string>
|
||||
body?: unknown
|
||||
}) => Promise<T>
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electron: ElectronAPI
|
||||
api: AppApi
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
|
||||
// Custom APIs for renderer
|
||||
const api = {
|
||||
request: (payload: {
|
||||
method: string
|
||||
path: string
|
||||
headers?: Record<string, string>
|
||||
body?: unknown
|
||||
}): Promise<{ status: number; headers: Record<string, string>; bodyText: string }> =>
|
||||
ipcRenderer.invoke('eiysia:request', payload),
|
||||
call: async <T = unknown>(payload: {
|
||||
method: string
|
||||
path: string
|
||||
headers?: Record<string, string>
|
||||
body?: unknown
|
||||
}): Promise<T> => {
|
||||
const response: { status: number; headers: Record<string, string>; bodyText: string } =
|
||||
await ipcRenderer.invoke('eiysia:request', payload)
|
||||
|
||||
const contentType = response.headers['content-type'] ?? response.headers['Content-Type']
|
||||
if (contentType?.includes('application/json')) {
|
||||
return JSON.parse(response.bodyText) as T
|
||||
}
|
||||
|
||||
return response.bodyText as T
|
||||
}
|
||||
}
|
||||
|
||||
// Use `contextBridge` APIs to expose Electron APIs to
|
||||
// renderer only if context isolation is enabled, otherwise
|
||||
// just add to the DOM global.
|
||||
if (process.contextIsolated) {
|
||||
try {
|
||||
contextBridge.exposeInMainWorld('electron', electronAPI)
|
||||
contextBridge.exposeInMainWorld('api', api)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
} else {
|
||||
// @ts-ignore (define in dts)
|
||||
window.electron = electronAPI
|
||||
// @ts-ignore (define in dts)
|
||||
window.api = api
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Electron</title>
|
||||
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
|
||||
/>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,67 +0,0 @@
|
||||
:root {
|
||||
--ev-c-white: #ffffff;
|
||||
--ev-c-white-soft: #f8f8f8;
|
||||
--ev-c-white-mute: #f2f2f2;
|
||||
|
||||
--ev-c-black: #1b1b1f;
|
||||
--ev-c-black-soft: #222222;
|
||||
--ev-c-black-mute: #282828;
|
||||
|
||||
--ev-c-gray-1: #515c67;
|
||||
--ev-c-gray-2: #414853;
|
||||
--ev-c-gray-3: #32363f;
|
||||
|
||||
--ev-c-text-1: rgba(255, 255, 245, 0.86);
|
||||
--ev-c-text-2: rgba(235, 235, 245, 0.6);
|
||||
--ev-c-text-3: rgba(235, 235, 245, 0.38);
|
||||
|
||||
--ev-button-alt-border: transparent;
|
||||
--ev-button-alt-text: var(--ev-c-text-1);
|
||||
--ev-button-alt-bg: var(--ev-c-gray-3);
|
||||
--ev-button-alt-hover-border: transparent;
|
||||
--ev-button-alt-hover-text: var(--ev-c-text-1);
|
||||
--ev-button-alt-hover-bg: var(--ev-c-gray-2);
|
||||
}
|
||||
|
||||
:root {
|
||||
--color-background: var(--ev-c-black);
|
||||
--color-background-soft: var(--ev-c-black-soft);
|
||||
--color-background-mute: var(--ev-c-black-mute);
|
||||
|
||||
--color-text: var(--ev-c-text-1);
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
color: var(--color-text);
|
||||
background: var(--color-background);
|
||||
line-height: 1.6;
|
||||
font-family:
|
||||
Inter,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
Oxygen,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
'Fira Sans',
|
||||
'Droid Sans',
|
||||
'Helvetica Neue',
|
||||
sans-serif;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
<svg viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="64" cy="64" r="64" fill="#2F3242"/>
|
||||
<ellipse cx="63.9835" cy="23.2036" rx="4.48794" ry="4.495" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
|
||||
<path d="M51.3954 39.5028C52.3733 39.6812 53.3108 39.033 53.4892 38.055C53.6676 37.0771 53.0194 36.1396 52.0414 35.9612L51.3954 39.5028ZM28.6153 43.5751L30.1748 44.4741L30.1748 44.4741L28.6153 43.5751ZM28.9393 60.9358C29.4332 61.7985 30.5329 62.0976 31.3957 61.6037C32.2585 61.1098 32.5575 60.0101 32.0636 59.1473L28.9393 60.9358ZM37.6935 66.7457C37.025 66.01 35.8866 65.9554 35.1508 66.6239C34.415 67.2924 34.3605 68.4308 35.029 69.1666L37.6935 66.7457ZM53.7489 81.7014L52.8478 83.2597L53.7489 81.7014ZM96.9206 89.515C97.7416 88.9544 97.9526 87.8344 97.3919 87.0135C96.8313 86.1925 95.7113 85.9815 94.8904 86.5422L96.9206 89.515ZM52.0414 35.9612C46.4712 34.9451 41.2848 34.8966 36.9738 35.9376C32.6548 36.9806 29.0841 39.1576 27.0559 42.6762L30.1748 44.4741C31.5693 42.0549 34.1448 40.3243 37.8188 39.4371C41.5009 38.5479 46.1547 38.5468 51.3954 39.5028L52.0414 35.9612ZM27.0559 42.6762C24.043 47.9029 25.2781 54.5399 28.9393 60.9358L32.0636 59.1473C28.6579 53.1977 28.1088 48.0581 30.1748 44.4741L27.0559 42.6762ZM35.029 69.1666C39.6385 74.24 45.7158 79.1355 52.8478 83.2597L54.6499 80.1432C47.8081 76.1868 42.0298 71.5185 37.6935 66.7457L35.029 69.1666ZM52.8478 83.2597C61.344 88.1726 70.0465 91.2445 77.7351 92.3608C85.359 93.4677 92.2744 92.6881 96.9206 89.515L94.8904 86.5422C91.3255 88.9767 85.4902 89.849 78.2524 88.7982C71.0793 87.7567 62.809 84.8612 54.6499 80.1432L52.8478 83.2597ZM105.359 84.9077C105.359 81.4337 102.546 78.6127 99.071 78.6127V82.2127C100.553 82.2127 101.759 83.4166 101.759 84.9077H105.359ZM99.071 78.6127C95.5956 78.6127 92.7831 81.4337 92.7831 84.9077H96.3831C96.3831 83.4166 97.5892 82.2127 99.071 82.2127V78.6127ZM92.7831 84.9077C92.7831 88.3817 95.5956 91.2027 99.071 91.2027V87.6027C97.5892 87.6027 96.3831 86.3988 96.3831 84.9077H92.7831ZM99.071 91.2027C102.546 91.2027 105.359 88.3817 105.359 84.9077H101.759C101.759 86.3988 100.553 87.6027 99.071 87.6027V91.2027Z" fill="#A2ECFB"/>
|
||||
<path d="M91.4873 65.382C90.8456 66.1412 90.9409 67.2769 91.7002 67.9186C92.4594 68.5603 93.5951 68.465 94.2368 67.7058L91.4873 65.382ZM99.3169 43.6354L97.7574 44.5344L99.3169 43.6354ZM84.507 35.2412C83.513 35.2282 82.6967 36.0236 82.6838 37.0176C82.6708 38.0116 83.4661 38.8279 84.4602 38.8409L84.507 35.2412ZM74.9407 39.8801C75.9127 39.6716 76.5315 38.7145 76.323 37.7425C76.1144 36.7706 75.1573 36.1517 74.1854 36.3603L74.9407 39.8801ZM53.7836 46.3728L54.6847 47.931L53.7836 46.3728ZM25.5491 80.9047C25.6932 81.8883 26.6074 82.5688 27.5911 82.4247C28.5747 82.2806 29.2552 81.3664 29.1111 80.3828L25.5491 80.9047ZM94.2368 67.7058C97.8838 63.3907 100.505 58.927 101.752 54.678C103.001 50.4213 102.9 46.2472 100.876 42.7365L97.7574 44.5344C99.1494 46.9491 99.3603 50.0419 98.2974 53.6644C97.2323 57.2945 94.9184 61.3223 91.4873 65.382L94.2368 67.7058ZM100.876 42.7365C97.9119 37.5938 91.7082 35.335 84.507 35.2412L84.4602 38.8409C91.1328 38.9278 95.7262 41.0106 97.7574 44.5344L100.876 42.7365ZM74.1854 36.3603C67.4362 37.8086 60.0878 40.648 52.8826 44.8146L54.6847 47.931C61.5972 43.9338 68.5948 41.2419 74.9407 39.8801L74.1854 36.3603ZM52.8826 44.8146C44.1366 49.872 36.9669 56.0954 32.1491 62.3927C27.3774 68.63 24.7148 75.2115 25.5491 80.9047L29.1111 80.3828C28.4839 76.1026 30.4747 70.5062 35.0084 64.5802C39.496 58.7143 46.2839 52.7889 54.6847 47.931L52.8826 44.8146Z" fill="#A2ECFB"/>
|
||||
<path d="M49.0825 87.2295C48.7478 86.2934 47.7176 85.8059 46.7816 86.1406C45.8455 86.4753 45.358 87.5055 45.6927 88.4416L49.0825 87.2295ZM78.5635 96.4256C79.075 95.5732 78.7988 94.4675 77.9464 93.9559C77.0941 93.4443 75.9884 93.7205 75.4768 94.5729L78.5635 96.4256ZM79.5703 85.1795C79.2738 86.1284 79.8027 87.1379 80.7516 87.4344C81.7004 87.7308 82.71 87.2019 83.0064 86.2531L79.5703 85.1795ZM84.3832 64.0673H82.5832H84.3832ZM69.156 22.5301C68.2477 22.1261 67.1838 22.535 66.7799 23.4433C66.3759 24.3517 66.7848 25.4155 67.6931 25.8194L69.156 22.5301ZM45.6927 88.4416C47.5994 93.7741 50.1496 98.2905 53.2032 101.505C56.2623 104.724 59.9279 106.731 63.9835 106.731V103.131C61.1984 103.131 58.4165 101.765 55.8131 99.0249C53.2042 96.279 50.8768 92.2477 49.0825 87.2295L45.6927 88.4416ZM63.9835 106.731C69.8694 106.731 74.8921 102.542 78.5635 96.4256L75.4768 94.5729C72.0781 100.235 68.0122 103.131 63.9835 103.131V106.731ZM83.0064 86.2531C85.0269 79.7864 86.1832 72.1831 86.1832 64.0673H82.5832C82.5832 71.8536 81.4723 79.0919 79.5703 85.1795L83.0064 86.2531ZM86.1832 64.0673C86.1832 54.1144 84.4439 44.922 81.4961 37.6502C78.5748 30.4436 74.3436 24.8371 69.156 22.5301L67.6931 25.8194C71.6364 27.5731 75.3846 32.1564 78.1598 39.0026C80.9086 45.7836 82.5832 54.507 82.5832 64.0673H86.1832Z" fill="#A2ECFB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M103.559 84.9077C103.559 82.4252 101.55 80.4127 99.071 80.4127C96.5924 80.4127 94.5831 82.4252 94.5831 84.9077C94.5831 87.3902 96.5924 89.4027 99.071 89.4027C101.55 89.4027 103.559 87.3902 103.559 84.9077V84.9077Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M28.8143 89.4027C31.2929 89.4027 33.3023 87.3902 33.3023 84.9077C33.3023 82.4252 31.2929 80.4127 28.8143 80.4127C26.3357 80.4127 24.3264 82.4252 24.3264 84.9077C24.3264 87.3902 26.3357 89.4027 28.8143 89.4027V89.4027V89.4027Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M64.8501 68.0857C62.6341 68.5652 60.451 67.1547 59.9713 64.9353C59.4934 62.7159 60.9007 60.5293 63.1167 60.0489C65.3326 59.5693 67.5157 60.9798 67.9954 63.1992C68.4742 65.4186 67.066 67.6052 64.8501 68.0857Z" fill="#A2ECFB"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.7 KiB |
@@ -1,25 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1422 800" opacity="0.3">
|
||||
<defs>
|
||||
<linearGradient x1="50%" y1="0%" x2="50%" y2="100%" id="oooscillate-grad">
|
||||
<stop stop-color="hsl(206, 75%, 49%)" stop-opacity="1" offset="0%"></stop>
|
||||
<stop stop-color="hsl(331, 90%, 56%)" stop-opacity="1" offset="100%"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g stroke-width="1" stroke="url(#oooscillate-grad)" fill="none" stroke-linecap="round">
|
||||
<path d="M 0 448 Q 355.5 -100 711 400 Q 1066.5 900 1422 448" opacity="0.05"></path>
|
||||
<path d="M 0 420 Q 355.5 -100 711 400 Q 1066.5 900 1422 420" opacity="0.11"></path>
|
||||
<path d="M 0 392 Q 355.5 -100 711 400 Q 1066.5 900 1422 392" opacity="0.18"></path>
|
||||
<path d="M 0 364 Q 355.5 -100 711 400 Q 1066.5 900 1422 364" opacity="0.24"></path>
|
||||
<path d="M 0 336 Q 355.5 -100 711 400 Q 1066.5 900 1422 336" opacity="0.30"></path>
|
||||
<path d="M 0 308 Q 355.5 -100 711 400 Q 1066.5 900 1422 308" opacity="0.37"></path>
|
||||
<path d="M 0 280 Q 355.5 -100 711 400 Q 1066.5 900 1422 280" opacity="0.43"></path>
|
||||
<path d="M 0 252 Q 355.5 -100 711 400 Q 1066.5 900 1422 252" opacity="0.49"></path>
|
||||
<path d="M 0 224 Q 355.5 -100 711 400 Q 1066.5 900 1422 224" opacity="0.56"></path>
|
||||
<path d="M 0 196 Q 355.5 -100 711 400 Q 1066.5 900 1422 196" opacity="0.62"></path>
|
||||
<path d="M 0 168 Q 355.5 -100 711 400 Q 1066.5 900 1422 168" opacity="0.68"></path>
|
||||
<path d="M 0 140 Q 355.5 -100 711 400 Q 1066.5 900 1422 140" opacity="0.75"></path>
|
||||
<path d="M 0 112 Q 355.5 -100 711 400 Q 1066.5 900 1422 112" opacity="0.81"></path>
|
||||
<path d="M 0 84 Q 355.5 -100 711 400 Q 1066.5 900 1422 84" opacity="0.87"></path>
|
||||
<path d="M 0 56 Q 355.5 -100 711 400 Q 1066.5 900 1422 56" opacity="0.94"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |
7
src/renderer/src/env.d.ts
vendored
@@ -1,7 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<object, object, unknown>
|
||||
export default component
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import './assets/main.css'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#root')
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }]
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
|
||||
"include": [
|
||||
"electron.vite.config.*",
|
||||
"src/main/**/*",
|
||||
"src/preload/**/*",
|
||||
"src/eiysia/**/*",
|
||||
"src/app_list/**/*",
|
||||
"src/SQLitte/**/*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"types": ["electron-vite/node"]
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
|
||||
"include": [
|
||||
"src/renderer/src/env.d.ts",
|
||||
"src/renderer/src/**/*",
|
||||
"src/renderer/src/**/*.vue",
|
||||
"src/preload/*.d.ts"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@renderer/*": [
|
||||
"src/renderer/src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||