* handle client error ref #1255

* use bugsnag & remove unhandled modal

* fix upload storage

* fix assessment play origin

* upgrade deps

* try to fix  JavaScript heap out of memory when package

* update github action

* fix workflow

* try to fix CI in linux
This commit is contained in:
an-lee
2025-01-15 11:39:51 +08:00
committed by GitHub
parent 931bfeb5a3
commit 33a0b9ec18
23 changed files with 1223 additions and 592 deletions

View File

@@ -43,7 +43,18 @@ jobs:
brew install sdl2
- name: Package App
run: yarn enjoy:package
shell: bash
env:
NODE_OPTIONS: "--max-old-space-size=8192"
run: |
set -e
yarn enjoy:package
- name: Configure Chrome sandbox for Linux
if: contains(matrix.os, 'ubuntu')
run: |
sudo chown root:root enjoy/out/Enjoy-linux-x64/chrome-sandbox
sudo chmod 4755 enjoy/out/Enjoy-linux-x64/chrome-sandbox
- name: Run main tests with xvfb-run on Ubuntu
# continue-on-error: true

View File

@@ -9,7 +9,7 @@
"markdown-it-sub": "^2.0.0",
"markdown-it-sup": "^2.0.0",
"mermaid": "^11.4.1",
"sass": "^1.83.1",
"sass": "^1.83.4",
"vitepress": "^1.5.0",
"vitepress-plugin-mermaid": "^2.0.17",
"vue": "^3.5.13"

View File

@@ -10,16 +10,16 @@
"postinstall": "nuxt prepare"
},
"dependencies": {
"@nuxtjs/seo": "^2.0.2",
"@nuxtjs/seo": "^2.0.3",
"nuxt": "^3.15.1",
"nuxt-og-image": "^4.0.2",
"nuxt-og-image": "^4.0.3",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"sass": "^1.83.1",
"postcss": "^8.5.1",
"sass": "^1.83.4",
"tailwindcss": "^3.4.17"
}
}

View File

@@ -11,16 +11,17 @@
"predev": "yarn run download",
"dev": "rimraf .vite && yarn run download && WEB_API_URL=http://localhost:3000 WS_URL=ws://localhost:3000 SETTINGS_PATH=${PWD}/enjoy/tmp LIBRARY_PATH=${PWD}/enjoy/tmp electron-forge start",
"start": "rimraf .vite && yarn run download && electron-forge start",
"package": "rimraf .vite && yarn run download && electron-forge package",
"make": "rimraf .vite && yarn run download && electron-forge make",
"publish": "rimraf .vite && yarn run download && electron-forge publish",
"package": "NODE_OPTIONS='--max-old-space-size=8192' rimraf .vite && yarn run download && electron-forge package",
"make": "NODE_OPTIONS='--max-old-space-size=8192' rimraf .vite && yarn run download && electron-forge make",
"publish": "NODE_OPTIONS='--max-old-space-size=8192' rimraf .vite && yarn run download && electron-forge publish",
"lint": "eslint --ext .ts,.tsx .",
"test": "yarn run package && yarn run playwright test",
"test:main": "yarn run playwright test e2e/main.spec.ts",
"test:renderer": "yarn run playwright test e2e/renderer.spec.ts",
"create-migration": "zx ./src/main/db/create-migration.mjs",
"download-dictionaries": "zx ./scripts/download-dictionaries.mjs",
"download": "yarn run download-dictionaries"
"download": "yarn run download-dictionaries",
"sourcemap": "bugsnag-source-maps upload-node --directory .vite/build --app-version 0.7.6"
},
"keywords": [],
"author": {
@@ -29,6 +30,7 @@
},
"license": "MIT",
"devDependencies": {
"@bugsnag/source-maps": "^2.3.3",
"@divisey/js-mdict": "^5.0.0",
"@electron-forge/cli": "^7.6.0",
"@electron-forge/maker-deb": "^7.6.0",
@@ -44,7 +46,7 @@
"@electron/fuses": "^1.8.0",
"@hookform/resolvers": "^3.10.0",
"@langchain/community": "^0.3.24",
"@langchain/core": "^0.3.29",
"@langchain/core": "^0.3.30",
"@langchain/ollama": "^0.1.4",
"@mozilla/readability": "^0.5.0",
"@playwright/test": "^1.49.1",
@@ -85,7 +87,7 @@
"@types/mark.js": "^8.11.12",
"@types/mime-types": "^2.1.4",
"@types/mustache": "^4.2.5",
"@types/node": "^22.10.5",
"@types/node": "^22.10.6",
"@types/prop-types": "^15.7.14",
"@types/rails__actioncable": "^6.1.11",
"@types/react": "^18.3.18",
@@ -95,8 +97,8 @@
"@types/unzipper": "^0.10.10",
"@types/validator": "^13.12.2",
"@types/wavesurfer.js": "^6.0.12",
"@typescript-eslint/eslint-plugin": "^8.19.1",
"@typescript-eslint/parser": "^8.19.1",
"@typescript-eslint/eslint-plugin": "^8.20.0",
"@typescript-eslint/parser": "^8.20.0",
"@uidotdev/usehooks": "^2.4.1",
"@vidstack/react": "^1.12.12",
"@vitejs/plugin-react": "^4.3.4",
@@ -119,14 +121,13 @@
"decamelize": "^6.0.0",
"decamelize-keys": "^2.0.1",
"dependencies-tree": "^2.0.0",
"electron": "^33.3.1",
"electron": "^34.0.0",
"electron-context-menu": "^4.0.4",
"electron-devtools-installer": "^3.2.1",
"electron-devtools-installer": "^4.0.0",
"electron-forge-plugin-dependencies": "^1.0.0",
"electron-log": "^5.2.4",
"electron-playwright-helpers": "^1.7.1",
"electron-squirrel-startup": "^1.0.1",
"electron-unhandled": "^5.0.0",
"eslint": "^9.18.0",
"eslint-import-resolver-typescript": "^3.7.0",
"eslint-plugin-import": "^2.31.0",
@@ -141,23 +142,22 @@
"langchain": "^0.3.11",
"lodash": "^4.17.21",
"lru-cache": "^11.0.2",
"lucide-react": "^0.471.0",
"lucide-react": "^0.471.1",
"mark.js": "^8.11.1",
"media-captions": "^0.0.18",
"microsoft-cognitiveservices-speech-sdk": "^1.42.0",
"mime-types": "^2.1.35",
"mustache": "^4.2.0",
"new-github-issue-url": "^1.0.0",
"next-themes": "^0.4.4",
"octokit": "^4.1.0",
"openai": "^4.78.1",
"pitchfinder": "^2.3.2",
"postcss": "^8.4.49",
"postcss": "^8.5.1",
"progress": "^2.0.3",
"prop-types": "^15.8.1",
"proxy-agent": "^6.5.0",
"react": "^18.3.1",
"react-activity-calendar": "^2.7.6",
"react-activity-calendar": "^2.7.7",
"react-audio-visualize": "^1.2.0",
"react-audio-voice-recorder": "^2.2.0",
"react-dom": "^18.3.1",
@@ -176,7 +176,7 @@
"sonner": "^1.7.1",
"tailwind-merge": "^2.6.0",
"tailwind-scrollbar": "^3.1.0",
"tailwind-scrollbar-hide": "^1.3.1",
"tailwind-scrollbar-hide": "^2.0.0",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"ts-node": "^10.9.2",
@@ -185,13 +185,15 @@
"typescript": "^5.7.3",
"vite": "^6.0.7",
"vite-plugin-static-copy": "^2.2.0",
"wavesurfer.js": "^7.8.15",
"wavesurfer.js": "^7.8.16",
"zod": "^3.24.1",
"zod-to-json-schema": "^3.24.1",
"zx": "^8.3.0"
},
"dependencies": {
"@andrkrn/ffprobe-static": "^5.2.0",
"@bugsnag/electron": "^8.1.2",
"@bugsnag/plugin-react": "^8.1.1",
"echogarden": "^1.8.7",
"electron-settings": "^4.0.4",
"ffmpeg-static": "^5.2.0",

View File

@@ -118,3 +118,15 @@ export const AGENT_FIXTURE_ANDREW = {
ttsVoice: "en-US-AndrewNeural",
},
};
export const BUGSNAG_API_KEY = "828ee1de10c079a250be7fd05177662f";
export const MIME_TYPES: Record<string, string> = {
".mp3": "audio/mpeg",
".wav": "audio/wav",
".ogg": "audio/ogg",
".m4a": "audio/mp4",
".aac": "audio/aac",
".flac": "audio/flac",
".wma": "audio/x-ms-wma",
};

View File

@@ -1,4 +1,4 @@
import { app, BrowserWindow, protocol, net, shell } from "electron";
import { app, BrowserWindow, protocol, net } from "electron";
import path from "path";
import fs from "fs-extra";
import settings from "@main/settings";
@@ -6,15 +6,17 @@ import log from "@main/logger";
import mainWindow from "@main/window";
import ElectronSquirrelStartup from "electron-squirrel-startup";
import contextMenu from "electron-context-menu";
import Bugsnag from "@bugsnag/electron";
import { t } from "i18next";
import unhandled from "electron-unhandled";
import newGithubIssueUrl from "new-github-issue-url";
import { BUGSNAG_API_KEY } from "./constants";
const logger = log.scope("main");
app.commandLine.appendSwitch("enable-features", "SharedArrayBuffer");
if (!app.isPackaged) {
if (app.isPackaged) {
Bugsnag.start({ apiKey: BUGSNAG_API_KEY });
} else {
app.disableHardwareAcceleration();
app.commandLine.appendSwitch("disable-software-rasterizer");
}
@@ -121,43 +123,6 @@ app.on("ready", async () => {
});
mainWindow.init();
unhandled({
showDialog: true,
logger: logger.error,
reportButton: (error) => {
const url = newGithubIssueUrl({
user: "ZuodaoTech",
repo: "everyone-can-use-english",
title: "Unhandled Error",
body: `
## Node.js error stack
**Message:**
\`\`\`
${error.message}
\`\`\`
**Stack:**
\`\`\`
${error.stack}
\`\`\`
**Environment:**
\`\`\`
${app.name} ${app.getVersion()}
Electron ${process.versions.electron}
${process.platform} ${process.arch}
Locale: ${app.getLocale()}
\`\`\``,
});
shell.openExternal(url);
},
});
});
// Quit when all windows are closed, except on macOS. There, it's common

View File

@@ -21,7 +21,7 @@ import {
Video,
} from "@main/db/models";
import settings from "@main/settings";
import { AudioFormats, VideoFormats } from "@/constants";
import { AudioFormats, MIME_TYPES, VideoFormats } from "@/constants";
import { hashFile } from "@main/utils";
import path from "path";
import fs from "fs-extra";
@@ -159,6 +159,14 @@ export class Audio extends Model<Audio> {
return this.getDataValue("md5") + this.extname;
}
get mimeType(): string {
if (this.metadata?.mimeType) {
return this.metadata.mimeType;
}
return MIME_TYPES[this.extname.toLowerCase()] || "audio/mpeg";
}
get extname(): string {
return (
this.getDataValue("metadata")?.extname ||
@@ -204,7 +212,7 @@ export class Audio extends Model<Audio> {
if (this.isUploaded && !force) return;
return storage
.put(this.md5, this.filePath)
.put(this.md5, this.filePath, this.mimeType)
.then((result) => {
logger.debug("upload result:", result.data);
if (result.data.success) {

View File

@@ -146,7 +146,7 @@ export class Document extends Model<Document> {
if (this.isUploaded && !force) return;
return storage
.put(this.md5, this.filePath)
.put(this.md5, this.filePath, this.metadata.mimeType)
.then((result) => {
logger.debug("upload result:", result.data);
if (result.data.success) {

View File

@@ -97,7 +97,9 @@ export class Note extends Model<Note> {
@AfterCreate
static syncAndUploadAfterCreate(note: Note) {
note.sync();
note.sync().catch((err) => {
logger.error("sync note error", note.id, err);
});
}
@AfterCreate
@@ -113,7 +115,7 @@ export class Note extends Model<Note> {
@AfterUpdate
static syncAfterUpdate(note: Note) {
note.sync().catch((err) => {
logger.error("sync error", err);
logger.error("sync note error", note.id, err);
});
}

View File

@@ -34,6 +34,7 @@ import { t } from "i18next";
import { Attributes, Op, Transaction } from "sequelize";
import { v5 as uuidv5 } from "uuid";
import FfmpegWrapper from "@main/ffmpeg";
import { MIME_TYPES } from "@/constants";
const logger = log.scope("db/models/recording");
@@ -141,6 +142,15 @@ export class Recording extends Model<Recording> {
)}`;
}
@Column(DataType.VIRTUAL)
get mimeType(): string {
return MIME_TYPES[this.extname.toLowerCase()] || "audio/mpeg";
}
get extname(): string {
return path.extname(this.filePath);
}
get filePath(): string {
const file = path.join(
settings.userDataPath(),
@@ -169,7 +179,7 @@ export class Recording extends Model<Recording> {
}
return storage
.put(this.md5, this.filePath)
.put(this.md5, this.filePath, this.mimeType)
.then((result) => {
logger.debug("upload result:", result.data);
if (result.data.success) {

View File

@@ -25,6 +25,7 @@ import FfmpegWrapper from "@/main/ffmpeg";
import { hashFile } from "@/main/utils";
import fs from "fs-extra";
import { v5 as uuidv5 } from "uuid";
import { MIME_TYPES } from "@/constants";
const logger = log.scope("db/models/segment");
const OUTPUT_FORMAT = "mp3";
@@ -105,6 +106,14 @@ export class Segment extends Model<Segment> {
);
}
get extname(): string {
return path.extname(this.filePath);
}
get mimeType(): string {
return MIME_TYPES[this.extname.toLowerCase()] || "audio/mpeg";
}
async sync() {
if (this.isSynced) return;
@@ -123,7 +132,7 @@ export class Segment extends Model<Segment> {
if (this.isUploaded) return;
return storage
.put(this.md5, this.filePath)
.put(this.md5, this.filePath, this.mimeType)
.then((result) => {
logger.debug("upload result:", result.data);
if (result.data.success) {
@@ -207,7 +216,7 @@ export class Segment extends Model<Segment> {
unsyncedSegments.forEach((segment) => {
segment.sync().catch((err) => {
logger.error("sync error", err);
logger.error("sync segment error", segment.id, err);
});
});
@@ -230,8 +239,12 @@ export class Segment extends Model<Segment> {
@AfterCreate
static syncAndUploadAfterCreate(segment: Segment) {
segment.sync();
segment.upload();
segment.sync().catch((err) => {
logger.error("sync segment error", segment.id, err);
});
segment.upload().catch((err) => {
logger.error("upload segment error", segment.id, err);
});
}
@AfterUpdate
@@ -242,7 +255,7 @@ export class Segment extends Model<Segment> {
@AfterUpdate
static syncAfterUpdate(segment: Segment) {
segment.sync().catch((err) => {
logger.error("sync error", err);
logger.error("sync segment error", segment.id, err);
});
}

View File

@@ -106,7 +106,7 @@ export class Transcription extends Model<Transcription> {
@AfterUpdate
static syncAfterUpdate(transcription: Transcription) {
transcription.sync().catch((err) => {
logger.error("sync error", err);
logger.error("sync transcription error", transcription.id, err);
});
}

View File

@@ -21,7 +21,12 @@ import {
UserSetting,
} from "@main/db/models";
import settings from "@main/settings";
import { AudioFormats, VideoFormats, WEB_API_URL } from "@/constants";
import {
AudioFormats,
MIME_TYPES,
VideoFormats,
WEB_API_URL,
} from "@/constants";
import { hashFile } from "@main/utils";
import path from "path";
import fs from "fs-extra";
@@ -159,6 +164,10 @@ export class Video extends Model<Video> {
return this.getDataValue("md5") + this.extname;
}
get mimeType(): string {
return MIME_TYPES[this.extname.toLowerCase()] || "video/mp4";
}
get extname(): string {
return (
this.getDataValue("metadata").extname ||
@@ -212,7 +221,7 @@ export class Video extends Model<Video> {
const finalFile = path.join(settings.cachePath(), `${hash}.png`);
fs.renameSync(coverFile, finalFile);
storage.put(hash, finalFile).then((result) => {
storage.put(hash, finalFile, "image/png").then((result) => {
logger.debug("cover upload result:", result.data);
if (result.data.success) {
this.update({ coverUrl: storage.getUrl(hash) });
@@ -224,7 +233,7 @@ export class Video extends Model<Video> {
if (this.isUploaded && !force) return;
return storage
.put(this.md5, this.filePath)
.put(this.md5, this.filePath, this.mimeType)
.then((result) => {
logger.debug("upload result:", result.data);
if (result.data.success) {
@@ -273,9 +282,12 @@ export class Video extends Model<Video> {
@AfterCreate
static autoSync(video: Video) {
// auto sync should not block the main thread
video.sync().catch(() => {});
video.generateCover().catch(() => {});
video.sync().catch((err) => {
logger.error("sync video error", video.id, err);
});
video.generateCover().catch((err) => {
logger.error("generate cover error", video.id, err);
});
}
@AfterCreate
@@ -286,7 +298,9 @@ export class Video extends Model<Video> {
@AfterUpdate
static notifyForUpdate(video: Video) {
this.notify(video, "update");
video.sync().catch(() => {});
video.sync().catch((err) => {
logger.error("sync video error", video.id, err);
});
}
@AfterDestroy

View File

@@ -29,9 +29,11 @@ class Storage {
return this.api.get(`/${key}`);
}
put(key: string, filePath: string) {
put(key: string, filePath: string, contentType?: string) {
const data = fs.readFileSync(filePath);
return this.api.postForm(`/${key}`, data);
const form = new FormData();
form.append("file", new Blob([data], { type: contentType }), key);
return this.api.postForm(`/${key}`, form);
}
}

View File

@@ -12,8 +12,20 @@ import { RouterProvider } from "react-router-dom";
import { Toaster, toast } from "@renderer/components/ui";
import { Tooltip } from "react-tooltip";
import { LookupWidget, TranslateWidget } from "./components";
import Bugsnag from "@bugsnag/electron";
import BugsnagPluginReact from "@bugsnag/plugin-react";
import { BUGSNAG_API_KEY } from "@/constants";
function App() {
window.__ENJOY_APP__.app.isPackaged().then((isPackaged) => {
if (isPackaged) {
Bugsnag.start({
apiKey: BUGSNAG_API_KEY,
plugins: [new BugsnagPluginReact()],
});
}
});
window.__ENJOY_APP__.onNotification((_event, notification) => {
switch (notification.type) {
case "success":

View File

@@ -1,4 +1,11 @@
import { useEffect, useContext, useRef, useState, useMemo } from "react";
import {
useEffect,
useContext,
useRef,
useState,
useMemo,
useCallback,
} from "react";
import {
AppSettingsProviderContext,
HotKeysSettingsProviderContext,
@@ -261,22 +268,25 @@ export const MediaCurrentRecording = () => {
});
};
const playWord = (word: string, index: number) => {
const candidates = caption.timeline.filter(
(w: TimelineEntry) => w.text.toLowerCase() === word.toLowerCase()
);
const target = candidates[index];
if (!target) return;
const playWord = useCallback(
(word: string, index: number) => {
const candidates = caption.timeline.filter(
(w: TimelineEntry) => w.text.toLowerCase() === word.toLowerCase()
);
const target = candidates[index];
if (!target) return;
const wordIndex = caption.timeline.findIndex(
(w) => w.startTime === target.startTime
);
const wordIndex = caption.timeline.findIndex(
(w) => w.startTime === target.startTime
);
toggleRegion([wordIndex]);
setTimeout(() => {
wavesurfer?.playPause();
}, 250);
};
toggleRegion([wordIndex]);
setTimeout(() => {
wavesurfer?.playPause();
}, 250);
},
[caption?.timeline, toggleRegion, wavesurfer]
);
const calContainerSize = () => {
const size = ref?.current
@@ -709,9 +719,7 @@ export const MediaCurrentRecording = () => {
<RecordingDetail
recording={currentRecording}
onPlayOrigin={(word: string, index: number = 0) =>
playWord(word, index)
}
onPlayOrigin={playWord}
/>
</SheetContent>
</Sheet>

View File

@@ -1,5 +1,5 @@
import { t } from "i18next";
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import { PronunciationAssessmentWordResult } from "@renderer/components";
import { Switch, ScrollArea } from "@renderer/components/ui";
import { InfoIcon } from "lucide-react";
@@ -30,6 +30,11 @@ export const PronunciationAssessmentFulltextResult = (props: {
monotone: true,
});
const handlePlayOrigin = useCallback((word: string, index: number) => {
if (!onPlayOrigin) return;
onPlayOrigin(word, index);
}, []);
const calErrorStats = () => {
return {
mispronunciation: words.filter(
@@ -69,14 +74,14 @@ export const PronunciationAssessmentFulltextResult = (props: {
currentTime={currentTime}
src={src}
onPlayOrigin={() => {
if (!onPlayOrigin) return;
// if (!onPlayOrigin) return;
const word = words[index];
const candidates = words.filter((w) => w.word === word.word);
const wordIndex = candidates.findIndex(
(w) => w.offset === word.offset
);
onPlayOrigin(word.word, wordIndex);
handlePlayOrigin(word.word, wordIndex);
}}
/>
))}

View File

@@ -8,7 +8,7 @@ import {
ScrollBar,
} from "@renderer/components/ui";
import { Volume2Icon } from "lucide-react";
import { useEffect, useRef } from "react";
import { memo, useEffect, useRef } from "react";
export const PronunciationAssessmentWordResult = (props: {
src?: string;
@@ -219,29 +219,28 @@ const scoreColor = (score: number) => {
return "font-bold text-red-600";
};
export const PronunciationAssessmentPhonemeResult = (props: {
result: PronunciationAssessmentWordResultType;
}) => {
const { result } = props;
console.log(result);
export const PronunciationAssessmentPhonemeResult = memo(
(props: { result: PronunciationAssessmentWordResultType }) => {
const { result } = props;
return (
<ScrollArea className="w-full">
<div className="w-full flex items-center gap-2">
{result.phonemes.map((phoneme, index) => (
<div key={index} className="text-sm text-center">
<div className="font-bold font-code">{phoneme.phoneme}</div>
<div
className={`text-xs font-serif ${scoreColor(
phoneme.pronunciationAssessment.accuracyScore
)}`}
>
{phoneme.pronunciationAssessment.accuracyScore}
return (
<ScrollArea className="w-full">
<div className="w-full flex items-center gap-2">
{result.phonemes.map((phoneme, index) => (
<div key={index} className="text-sm text-center">
<div className="font-bold font-code">{phoneme.phoneme}</div>
<div
className={`text-xs font-serif ${scoreColor(
phoneme.pronunciationAssessment.accuracyScore
)}`}
>
{phoneme.pronunciationAssessment.accuracyScore}
</div>
</div>
</div>
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
);
};
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
);
}
);

View File

@@ -14,7 +14,7 @@ export const RecordingDetail = (props: {
recording: RecordingType;
pronunciationAssessment?: PronunciationAssessmentType;
onAssess?: (assessment: PronunciationAssessmentType) => void;
onPlayOrigin?: (word: string) => void;
onPlayOrigin?: (word: string, index: number) => void;
}) => {
const { recording, onAssess, onPlayOrigin } = props;
if (!recording) return;

View File

@@ -46,6 +46,7 @@ export default defineConfig((env) => {
}
const config: UserConfig = {
build: {
sourcemap: true,
lib: {
entry: forgeConfigSelf.entry!,
fileName: () => "[name].js",

View File

@@ -16,6 +16,7 @@ export default defineConfig((env) => {
mode,
base: "./",
build: {
sourcemap: true,
outDir: `.vite/renderer/${name}`,
target: "esnext",
},

View File

@@ -19,6 +19,7 @@
"enjoy:publish": "yarn workspace enjoy publish",
"enjoy:lint": "yarn workspace enjoy eslint --ext .ts,.tsx .",
"enjoy:create-migration": "yarn workspace enjoy create-migration",
"enjoy:sourcemap": "yarn workspace enjoy sourcemap",
"docs:dev": "yarn workspace 1000-hours dev",
"docs:build": "yarn workspace 1000-hours build",
"docs:preview": "yarn workspace 1000-hours preview",

1485
yarn.lock

File diff suppressed because it is too large Load Diff