Compare commits

...

8 Commits

Author SHA1 Message Date
a8c26ea6de
merge: branch 'main' into dev 2024-12-23 02:09:09 +08:00
09b9cb220e
feature: encoding (compress) screenshots 2024-12-23 02:08:18 +08:00
aa4376c9ab
Merge pull request #6 from alikia2x/dev
update: README & LICENSE
2024-12-10 00:38:25 +08:00
04cf5a8ab8
fix: small mistake in migration script 2024-12-09 00:34:12 +08:00
1f9b8ea124
update: function to add frames into encoding queue
add: scripts to migrate to V3 schema
TODO: fix processEncodingTasks() in `encoding.ts`
TODO: write docs for V3 schema
2024-12-09 00:23:05 +08:00
37e0fbfc89
add: functions to migrate database to V2 schema
add: doc for database schema
2024-12-08 17:31:17 +08:00
95f982f245 improve: compatibility for windows 2024-12-08 00:43:56 +08:00
7dac731479
feature: background recording 2024-12-07 19:01:32 +08:00
29 changed files with 3023 additions and 721 deletions

160
.tokeignore Normal file
View File

@ -0,0 +1,160 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
bin
*.yaml
*.json
*.md

View File

@ -0,0 +1,16 @@
# Build Instructions
## Binaries & Libraries
### Simple
Simple is a SQLite3 FTS extension for indexing Chinese, OpenRewind use it to improve searching experience for Chinese users.
Before building, you need to download latest release of Simple at their [GitHub Release](https://github.com/wangfenjin/simple/releases),
decompress all files in the zip into `[PROJECT_ROOT]/bin/[PLATFORM]/libsimple/`, in which:
- [PROJECT_ROOT] is the root directory of OpenRewind you pulled.
- [PLATFORM] refers to the specific platform you are targeting:
- win32: Windows
- darwin: macOS
- linux: Linux

202
docs/database-changelog.md Normal file
View File

@ -0,0 +1,202 @@
# Database Schema Documentation
This document outlines the changes made across different versions of
database structure used in the OpenRewind, including tables and fields.
## Version 2 Schema Changes
Cooresponding version: Since 0.4.0
### New Table: `config`
Stores configuration data, including the database version.
| Column Name | Data Type | Constraints/Default | Description |
|-------------|-----------|---------------------|-----------------------------------------------------------------------------|
| `key` | TEXT | PRIMARY KEY | Unique key for configuration settings. |
| `value` | TEXT | | Value associated with the key. |
#### Insert Default Version
```sql
INSERT INTO config (key, value) VALUES ('version', '2');
```
### New Table: `encoding_task`
Stores encoding tasks that are queued for processing.
| Column Name | Data Type | Constraints/Default | Description |
|-------------|-----------|----------------------------|--------------------------------------|
| `id` | INTEGER | PRIMARY KEY, AUTOINCREMENT | Unique ID for the task. |
| `createAt` | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Timestamp when the task was created. |
| `status` | INTEGER | DEFAULT 0 | Indicates the status of the task. |
### Task status
- `0`: Pending
- `1`: In Progress
- `2`: Completed
- Once the task was set to this status, it will be imminently deleted by a trigger mentioned below.
### New Trigger: `delete_encoding_task`
Triggered after updating the `status` of an encoding task to `2` (Completed).
```sql
CREATE TRIGGER delete_encoding_task
AFTER UPDATE OF status
ON encoding_task
BEGIN
DELETE FROM encoding_task_data
WHERE encodingTaskID = OLD.id AND NEW.status = 2;
DELETE FROM encoding_task
WHERE id = OLD.id AND NEW.status = 2;
END;
```
### New Table: `encoding_task_data`
Stores the frames that need to be encoded for the encoding task
| Column Name | Data Type | Constraints/Default | Description |
|------------------|-----------|-------------------------------------|------------------------------------------------------|
| `frame` | INTEGER | PRIMARY KEY, FOREIGN KEY (frame.id) | ID for the frame associated with the encoding task. |
| `encodingTaskID` | TIMESTAMP | FOREIGN KEY (encoding_task.id) | ID for the encoding task associated with this frame. |
### Update `frame` Table
#### Simplify `imgFilename`
The `imgFilename` column was updated to store only the filename without the full path.
```typescript
const rows = db.prepare('SELECT id, imgFilename FROM frame').all() as OldFrame[];
rows.forEach(row => {
const filename = row.imgFilename.match(/[^\\/]+$/)?.[0];
if (filename) {
db.prepare('UPDATE frame SET imgFilename = ? WHERE id = ?').run(filename, row.id);
}
});
```
#### Add `encodeStatus` Column
A new column `encodeStatus` was added to replace the deprecated `encoded` column.
```sql
ALTER TABLE frame ADD encodeStatus INT;
UPDATE frame SET encodeStatus = CASE WHEN encoded THEN 2 ELSE 0 END;
```
### Summary of Changes
- **New Table:** `config` to store configuration data.
- **Update `frame` Table:**
- Simplified `imgFilename` to store only the filename.
- Added `encodeStatus` column to replace the deprecated `encoded` column.
- **Deprecated `encoded` column.**
- The `encoded` column is no longer used and is retained due to SQLite's inability to drop columns.
Creating a new table without this column and copying data to the new table could be time-consuming.
## Version 1 Schema
Cooresponding version: 0.3.x
### Table: `frame`
Stores information about individual frames.
| Column Name | Data Type | Constraints/Default | Description |
|-------------------|-----------|---------------------------------|-------------------------------------------------------------------------|
| `id` | INTEGER | PRIMARY KEY, AUTOINCREMENT | Unique identifier for each frame. |
| `createAt` | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Timestamp when the frame was created. |
| `imgFilename` | TEXT | | Filename of the image associated with the frame. |
| `segmentID` | INTEGER | NULL, FOREIGN KEY (segments.id) | ID of the segment to which the frame belongs. |
| `videoPath` | TEXT | NULL | Path to the video file if the frame is part of a video. |
| `videoFrameIndex` | INTEGER | NULL | Index of the frame within the video. |
| `collectionID` | INTEGER | NULL | ID of the collection to which the frame belongs. |
| `encoded` | BOOLEAN | DEFAULT 0 | Indicates whether the frame has been encoded (0 for false, 1 for true). |
### Table: `recognition_data`
Stores recognition data associated with frames.
| Column Name | Data Type | Constraints/Default | Desc[database-structure.md](database-structure.md)ription |
|-------------|-----------|----------------------------|-----------------------------------------------------------|
| `id` | INTEGER | PRIMARY KEY, AUTOINCREMENT | Unique identifier for each recognition data entry. |
| `frameID` | INTEGER | FOREIGN KEY (frame.id) | ID of the frame to which the recognition data belongs. |
| `data` | TEXT | | Raw recognition data. |
| `text` | TEXT | | Recognized text. |
### Table: `segments`
A segment is a period of time when a user uses an application.
While capturing the screen, OpenRewind retrieves the currently active window.
When it finds that the currently active window has changed to another application, a new segment will start.
| Column Name | Data Type | Constraints/Default | Description |
|---------------|-----------|----------------------------|------------------------------------------------------|
| `id` | INTEGER | PRIMARY KEY, AUTOINCREMENT | Unique identifier for each segment. |
| `startAt` | TIMESTAMP | | Timestamp when the segment starts. |
| `endAt` | TIMESTAMP | | Timestamp when the segment ends. |
| `title` | TEXT | | Title of the segment. |
| `appName` | TEXT | | Name of the application associated with the segment. |
| `appPath` | TEXT | | Path to the application. |
| `text` | TEXT | | Text content of the segment. |
| `type` | TEXT | | Type of the segment. |
| `appBundleID` | TEXT | NULL | Bundle ID of the application. |
| `url` | TEXT | NULL | URL associated with the segment. |
### Virtual Table: `text_search`
Used for full-text search on recognition data.
| Column Name | Data Type | Constraints/Default | Description |
|-------------|-----------|---------------------|--------------------------------------------------------|
| `id` | INTEGER | UNINDEXED | ID of the recognition data entry. |
| `frameID` | INTEGER | UNINDEXED | ID of the frame to which the recognition data belongs. |
| `data` | TEXT | | Raw recognition data. |
| `text` | TEXT | | Recognized text. |
### Triggers
#### `recognition_data_after_insert`
Triggered after inserting a new row into `recognition_data`.
```sql
CREATE TRIGGER IF NOT EXISTS recognition_data_after_insert AFTER INSERT ON recognition_data
BEGIN
INSERT INTO text_search (id, frameID, data, text)
VALUES (NEW.id, NEW.frameID, NEW.data, NEW.text);
END;
```
#### `recognition_data_after_update`
Triggered after updating a row in `recognition_data`.
```sql
CREATE TRIGGER IF NOT EXISTS recognition_data_after_update AFTER UPDATE ON recognition_data
BEGIN
UPDATE text_search
SET frameID = NEW.frameID, data = NEW.data, text = NEW.text
WHERE id = NEW.id;
END;
```
#### `recognition_data_after_delete`
Triggered after deleting a row from `recognition_data`.
```sql
CREATE TRIGGER IF NOT EXISTS recognition_data_after_delete AFTER DELETE ON recognition_data
BEGIN
DELETE FROM text_search WHERE id = OLD.id;
END;
```

169
docs/database-structure.md Normal file
View File

@ -0,0 +1,169 @@
# Database Schema Documentation (Version 2)
This document outlines the current structure of the database schema used in the application.
It includes tables, fields, and their descriptions.
## Table: `config`
Stores configuration data.
| Column Name | Data Type | Constraints/Default | Description |
|-------------|-----------|---------------------|-----------------------------------------------------------------------------|
| `key` | TEXT | PRIMARY KEY | Unique key for configuration settings. |
| `value` | TEXT | | Value associated with the key. |
### Key: version
The current database schema version, represented as a integer.
Since the `config` table does not exist in V1, the version must be at least 2.
## Table: `frame`
Stores information about individual frames.
| Column Name | Data Type | Constraints/Default | Description |
|-------------------|-----------|---------------------------------|-----------------------------------------------------------|
| `id` | INTEGER | PRIMARY KEY, AUTOINCREMENT | Unique identifier for each frame. |
| `createAt` | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Timestamp when the frame was created. |
| `imgFilename` | TEXT | | Filename of the image associated with the frame. |
| `segmentID` | INTEGER | NULL, FOREIGN KEY (segments.id) | ID of the segment to which the frame belongs. |
| `videoPath` | TEXT | NULL | Relative path to the video file if the frame was encoded. |
| `videoFrameIndex` | INTEGER | NULL | Index of the frame within the encoded video. |
| `collectionID` | INTEGER | NULL | ID of the collection to which the frame belongs. |
| `encodeStatus` | INTEGER | 0 | Indicates the encoding status of the frame. |
### Status Description
- `0`: The frame is not encoded.
- `1`: The frame is scheduled for encoding. It will appear in the `encoding_task` table.
- `2`: The frame is already encoded.
## Table: `recognition_data`
Stores recognition data associated with frames.
| Column Name | Data Type | Constraints/Default | Description |
|-------------|-----------|------------------------------|-----------------------------------------------------------------------------|
| `id` | INTEGER | PRIMARY KEY, AUTOINCREMENT | Unique identifier for each recognition data entry. |
| `frameID` | INTEGER | FOREIGN KEY (frame.id) | ID of the frame to which the recognition data belongs. |
| `data` | TEXT | | Raw recognition data. |
| `text` | TEXT | | Recognized text. |
## Table: `segments`
A segment is a period of time when a user uses a particular application.
While capturing the screen, OpenRewind detects the currently active window.
When it finds that the currently active window has changed to another application, a new segment will start.
| Column Name | Data Type | Constraints/Default | Description |
|---------------|-----------|----------------------------|------------------------------------------------------|
| `id` | INTEGER | PRIMARY KEY, AUTOINCREMENT | Unique identifier for each segment. |
| `startAt` | TIMESTAMP | | Timestamp when the segment starts. |
| `endAt` | TIMESTAMP | | Timestamp when the segment ends. |
| `title` | TEXT | | Title of the segment. |
| `appName` | TEXT | | Name of the application associated with the segment. |
| `appPath` | TEXT | | Path to the application. |
| `text` | TEXT | | Text content of the segment. |
| `type` | TEXT | | Type of the segment. |
| `appBundleID` | TEXT | NULL | Bundle ID of the application. |
| `url` | TEXT | NULL | URL associated with the segment. |
## Table: `encoding_task`
Stores encoding tasks that are queued for processing.
| Column Name | Data Type | Constraints/Default | Description |
|-------------|-----------|----------------------------|--------------------------------------|
| `id` | INTEGER | PRIMARY KEY, AUTOINCREMENT | Unique ID for the task. |
| `createAt` | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Timestamp when the task was created. |
| `status` | INTEGER | DEFAULT 0 | Indicates the status of the task. |
### Task status Description
- `0`: Pending
- `1`: In Progress
- `2`: Completed
- Once the task was set to this status, it will be imminently deleted by a trigger mentioned below.
## Table: `encoding_task_data`
Stores the frames that need to be encoded for the encoding task
| Column Name | Data Type | Constraints/Default | Description |
|------------------|-----------|-------------------------------------|------------------------------------------------------|
| `frame` | INTEGER | PRIMARY KEY, FOREIGN KEY (frame.id) | ID for the frame associated with the encoding task. |
| `encodingTaskID` | TIMESTAMP | FOREIGN KEY (encoding_task.id) | ID for the encoding task associated with this frame. |
## Virtual Table: `text_search`
Used for full-text search on recognition data.
| Column Name | Data Type | Constraints/Default | Description |
|-------------|-----------|---------------------|--------------------------------------------------------|
| `id` | INTEGER | UNINDEXED | ID of the recognition data entry. |
| `frameID` | INTEGER | UNINDEXED | ID of the frame to which the recognition data belongs. |
| `data` | TEXT | | Raw recognition data. |
| `text` | TEXT | | Recognized text. |
## Triggers
### `recognition_data_after_insert`
Triggered after inserting a new row into `recognition_data`.
Inserts a new row into `text_search` with the same data.
```sql
CREATE TRIGGER IF NOT EXISTS recognition_data_after_insert AFTER INSERT ON recognition_data
BEGIN
INSERT INTO text_search (id, frameID, data, text)
VALUES (NEW.id, NEW.frameID, NEW.data, NEW.text);
END;
```
### `recognition_data_after_update`
Triggered after updating a row in `recognition_data`.
Updates the associated `text_search` row.
```sql
CREATE TRIGGER IF NOT EXISTS recognition_data_after_update AFTER UPDATE ON recognition_data
BEGIN
UPDATE text_search
SET frameID = NEW.frameID, data = NEW.data, text = NEW.text
WHERE id = NEW.id;
END;
```
### `recognition_data_after_delete`
Triggered after deleting a row from `recognition_data`.
Deletes the associated `text_search` row.
```sql
CREATE TRIGGER IF NOT EXISTS recognition_data_after_delete AFTER DELETE ON recognition_data
BEGIN
DELETE FROM text_search WHERE id = OLD.id;
END;
```
### `delete_encoding_task`
Triggered after updating the `status` of an encoding task to `2` (Completed).
Deletes the associated `encoding_task_data` and `encoding_task` rows.
```sql
CREATE TRIGGER delete_encoding_task
AFTER UPDATE OF status
ON encoding_task
BEGIN
DELETE FROM encoding_task_data
WHERE encodingTaskID = OLD.id AND NEW.status = 2;
DELETE FROM encoding_task
WHERE id = OLD.id AND NEW.status = 2;
END;
```

View File

@ -12,14 +12,17 @@
"output": "dist/release" "output": "dist/release"
}, },
"files": [ "files": [
"dist/dev/**/*", "dist/electron/**/*",
"dist/dev/assets/*", "dist/electron/assets/*",
"dist/dev/i18n/*", "dist/electron/i18n/*",
"dist/renderer/**/*", "dist/renderer/**/*",
"dist/renderer/assets/*" "dist/renderer/assets/*"
], ],
"win": { "win": {
"target": "nsis" "target": "nsis",
"files": [
"bin/win32"
]
}, },
"linux": { "linux": {
"target": "AppImage" "target": "AppImage"

View File

@ -7,13 +7,13 @@ import fs from "fs";
const tsProject = ts.createProject('tsconfig.json'); const tsProject = ts.createProject('tsconfig.json');
gulp.task('clean', function () { gulp.task('clean', function () {
return gulp.src('dist/dev', {read: false, allowEmpty: true}) return gulp.src('dist/electron', {read: false, allowEmpty: true})
.pipe(clean()); .pipe(clean());
}); });
gulp.task('scripts', () => { gulp.task('scripts', () => {
if (!fs.existsSync("dist/dev")) { if (!fs.existsSync("dist/electron")) {
fs.mkdirSync("dist/dev", { recursive: true }); fs.mkdirSync("dist/electron", { recursive: true });
} }
const tsResult = tsProject.src() const tsResult = tsProject.src()
.pipe(tsProject()); .pipe(tsProject());
@ -21,25 +21,25 @@ gulp.task('scripts', () => {
const jsFiles = gulp.src(['src/electron/**/*.js', 'src/electron/**/*.cjs']); const jsFiles = gulp.src(['src/electron/**/*.js', 'src/electron/**/*.cjs']);
return tsResult.js return tsResult.js
.pipe(gulp.dest('dist/dev')) .pipe(gulp.dest('dist/electron'))
.on('end', () => { .on('end', () => {
jsFiles.pipe(gulp.dest('dist/dev')); jsFiles.pipe(gulp.dest('dist/electron'));
}); });
}); });
gulp.task('assets', () => { gulp.task('assets', () => {
return gulp.src('src/electron/assets/**/*', { encoding: false }) return gulp.src('src/electron/assets/**/*', { encoding: false })
.pipe(gulp.dest('dist/dev/assets')); .pipe(gulp.dest('dist/electron/assets'));
}); });
gulp.task('binary', () => { gulp.task('binary', () => {
return gulp.src('bin/**/*', { encoding: false }) return gulp.src('bin/**/*', { encoding: false })
.pipe(gulp.dest('dist/dev/bin')); .pipe(gulp.dest('dist/electron/bin'));
}); });
gulp.task("locales", () => { gulp.task("locales", () => {
return gulp.src('i18n/**/*') return gulp.src('i18n/**/*')
.pipe(gulp.dest('dist/dev/i18n')); .pipe(gulp.dest('dist/electron/i18n'));
}) })
gulp.task('build', gulp.series('clean', 'scripts', 'assets', 'binary', 'locales')); gulp.task('build', gulp.series('clean', 'scripts', 'assets', 'binary', 'locales'));

View File

@ -1,24 +1,25 @@
{ {
"name": "openrewind", "name": "openrewind",
"version": "0.2.1", "version": "0.5.1",
"type": "module", "type": "module",
"description": "Your second brain, superpowered.", "description": "Your second brain, superpowered.",
"main": "dist/dev/index.js", "main": "dist/electron/index.js",
"scripts": { "scripts": {
"dev": "cross-env NODE_ENV=dev bun run dev:all", "dev": "cross-env NODE_ENV=dev bun run dev:all",
"dev:all": "concurrently -n=react,electron -c='#ff3e00',blue \"bun run dev:react\" \"bun run dev:electron\"", "dev:all": "concurrently -n=react,electron -c='#ff3e00',blue \"bun run dev:react\" \"bun run dev:electron\"",
"dev:react": "vite dev", "dev:react": "vite dev",
"dev:electron": "bunx gulp build && electron dist/dev/index.js", "dev:electron": "bunx gulp build && electron dist/electron/index.js",
"build:react": "vite build", "build:react": "vite build",
"build:app": "bunx gulp build", "build:app": "bunx gulp build",
"build:electron": "electron-builder", "build:electron": "electron-builder"
"start": "cross-env NODE_ENV=production bun run start:all"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@electron/remote": "^2.1.2",
"@unly/universal-language-detector": "^2.0.3", "@unly/universal-language-detector": "^2.0.3",
"better-sqlite3": "^11.6.0",
"electron-context-menu": "^4.0.4", "electron-context-menu": "^4.0.4",
"electron-reloader": "^1.2.3", "electron-reloader": "^1.2.3",
"electron-screencapture": "^1.1.0", "electron-screencapture": "^1.1.0",
@ -26,33 +27,41 @@
"electron-store": "^10.0.0", "electron-store": "^10.0.0",
"electron-window-state": "^5.0.3", "electron-window-state": "^5.0.3",
"execa": "^9.5.1", "execa": "^9.5.1",
"ffmpeg-static": "^5.2.0",
"fluent-ffmpeg": "^2.1.3",
"i18next": "^24.0.2", "i18next": "^24.0.2",
"i18next-browser-languagedetector": "^8.0.0", "i18next-browser-languagedetector": "^8.0.0",
"i18next-electron-fs-backend": "^3.0.2", "i18next-electron-fs-backend": "^3.0.2",
"i18next-fs-backend": "^2.6.0", "i18next-fs-backend": "^2.6.0",
"i18next-icu": "^2.3.0", "i18next-icu": "^2.3.0",
"image-size": "^1.1.1",
"memory-cache": "^0.2.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-i18next": "^15.1.2", "react-i18next": "^15.1.2",
"react-router": "^7.0.1", "react-router": "^7.0.1",
"react-router-dom": "^7.0.1", "react-router-dom": "^7.0.1",
"screenshot-desktop": "^1.15.0",
"sqlite3": "^5.1.7",
"sqlstring": "^2.3.3",
"vite-tsconfig-paths": "^5.1.3" "vite-tsconfig-paths": "^5.1.3"
}, },
"devDependencies": { "devDependencies": {
"@electron/rebuild": "^3.7.1",
"@eslint/js": "^9.13.0", "@eslint/js": "^9.13.0",
"@iconify-icon/react": "^2.1.0", "@iconify-icon/react": "^2.1.0",
"@types/fluent-ffmpeg": "^2.1.27", "@types/better-sqlite3": "^7.6.12",
"@types/gulp": "^4.0.17", "@types/gulp": "^4.0.17",
"@types/memory-cache": "^0.2.6",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@types/screenshot-desktop": "^1.12.3",
"@types/sqlstring": "^2.3.2",
"@vitejs/plugin-react": "^4.3.3", "@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"del": "^8.0.0", "del": "^8.0.0",
"electron": "^33.2.0", "electron": "^33.2.0",
"electron-build": "^0.0.3",
"electron-builder": "^25.1.8", "electron-builder": "^25.1.8",
"eslint": "^9.13.0", "eslint": "^9.13.0",
"eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-hooks": "^5.0.0",

View File

@ -1,41 +1,11 @@
import { useState } from "react";
import "./index.css"; import "./index.css";
export default function RewindPage() { export default function RewindPage() {
const [currentScreenShotBase64, setScreenShotData] = useState<string | null>(null);
window.api.receive("fromMain", (message: string) => {
setScreenShotData(message);
});
return ( return (
<> <>
<div className="w-screen h-screen relative dark:text-white"> <div className="w-screen h-screen relative dark:text-white">
{currentScreenShotBase64 && (
<img
className="absolute top-0 left-0 z-10 w-full h-full"
src={"data:image/png;base64," + currentScreenShotBase64}
alt=""
/>
)}
{currentScreenShotBase64 ? (
<div
className="rounded-xl bottom-12 left-6 fixed z-30 w-auto h-auto px-4 py-3
bg-white bg-opacity-50 backdrop-blur-lg text-gray-800"
>
Here's a screenshot captured just now.
<br />
The relavant features has not been implemented, and you han hit Esc to quit
this window.
<br />
Meow! (=^ω^=)丿
</div>
) : (
<div
className="rounded-xl bottom-12 left-6 fixed z-30 w-auto h-auto px-4 py-3
bg-white bg-opacity-50 backdrop-blur-lg text-gray-800"
>
Now capturing a screenshot for your screen...( ̀_́)
</div>
)}
</div> </div>
</> </>
); );

View File

@ -8,10 +8,59 @@ import Title from "components/settings/Title.tsx";
import OpenSourceNote from "components/settings/OpenSourceNote.tsx"; import OpenSourceNote from "components/settings/OpenSourceNote.tsx";
import EnvironmentDetails from "components/settings/EnvironmentDetails.tsx"; import EnvironmentDetails from "components/settings/EnvironmentDetails.tsx";
function showFrame() {
return navigator.userAgent.includes("Mac");
}
interface SettingsGroupRefs { interface SettingsGroupRefs {
[key: string]: HTMLDivElement; [key: string]: HTMLDivElement;
} }
function TitleBar() {
const { t } = useTranslation();
if (showFrame()) {
return (
<div className="w-full flex items-center justify-center h-9" id="title-bar">
{t("settings.title")}
</div>
);
} else {
return (
<div className="w-full h-9">
<div
className="w-[calc(100%-44px)] h-9 absolute left-[22px] flex justify-center"
id="title-bar"
>
<span className="self-center">{t("settings.title")}</span>
</div>
<div
className="z-50 absolute right-2.5 top-2.5 bg-red-500 hover:bg-rose-400 h-3 w-3 rounded-full"
onClick={() => {
console.log(window.settingsWindow)
window.settingsWindow.close();
}}
>
<svg
className="hover:opacity-100 opacity-0"
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
d="m8.464 15.535l7.072-7.07m-7.072 0l7.072 7.07"
/>
</svg>
</div>
</div>
);
}
}
export default function SettingsPage() { export default function SettingsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const [groupRefs, setGroupRefs] = useState<SettingsGroupRefs>({}); const [groupRefs, setGroupRefs] = useState<SettingsGroupRefs>({});
@ -29,18 +78,20 @@ export default function SettingsPage() {
const key = groupName as keyof typeof groupRefs; const key = groupName as keyof typeof groupRefs;
console.log(groupRefs[key]); console.log(groupRefs[key]);
if (!groupRefs[key]) return; if (!groupRefs[key]) return;
containerRef.current!.scrollTop = groupRefs[key].getBoundingClientRect().top - containerRef.current!.scrollTop =
groupRefs[key].getBoundingClientRect().top -
titleBarRef.current!.getBoundingClientRect().height; titleBarRef.current!.getBoundingClientRect().height;
} }
return ( return (
<> <>
<title>{t("settings.title")}</title> <title>{t("settings.title")}</title>
<div className="w-full h-auto bg-white dark:bg-gray-800 !bg-opacity-80 flex flex-col <div
dark:text-white font-semibold fixed z-10 backdrop-blur-lg text-[.9rem]" ref={titleBarRef}> className="w-full h-auto bg-white dark:bg-gray-800 !bg-opacity-80 flex flex-col
<div className="w-full flex items-center justify-center h-9" id="title-bar"> dark:text-white font-semibold fixed z-10 backdrop-blur-lg text-[.9rem]"
{t("settings.title")} ref={titleBarRef}
</div> >
<TitleBar />
<div className="h-[4.5rem] pt-0 pb-2"> <div className="h-[4.5rem] pt-0 pb-2">
<div className="w-full h-full px-20 flex items-center justify-center gap-2"> <div className="w-full h-full px-20 flex items-center justify-center gap-2">
<MenuItem <MenuItem
@ -56,8 +107,12 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
</div> </div>
<div className="h-full bg-slate-100 dark:bg-gray-900 w-full scroll-smooth <div
relative dark:text-white pt-28 pb-12 px-10 overflow-auto" ref={containerRef} id="settings-scroll-container"> className="h-full bg-slate-100 dark:bg-gray-900 w-full scroll-smooth
relative dark:text-white pt-28 pb-12 px-10 overflow-auto"
ref={containerRef}
id="settings-scroll-container"
>
<SettingsGroup groupName="screen" addGroupRef={addGroupRef}> <SettingsGroup groupName="screen" addGroupRef={addGroupRef}>
<Title i18nKey="settings.screen-recording" /> <Title i18nKey="settings.screen-recording" />
<div className="flex"> <div className="flex">

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 KiB

View File

@ -0,0 +1,145 @@
import { Database } from "better-sqlite3";
import { exec, spawnSync } from "child_process";
import fs from "fs";
import path, { join } from "path";
import type { EncodingTask, Frame } from "./schema";
import sizeOf from "image-size";
import { getEncodingTempDir, getRecordingsDir, getScreenshotsDir } from "../utils/backend.js";
import cache from "memory-cache";
const ENCODING_INTERVAL = 10000; // 10 sec
const CHECK_TASK_INTERVAL = 5000; // 5 sec
const MIN_FRAMES_TO_ENCODE = 60; // At least 10 mins (0.5fps)
const CONCURRENCY = 1; // Number of concurrent encoding tasks
// Detect and insert encoding tasks
export function checkFramesForEncoding(db: Database) {
const stmt = db.prepare(`
SELECT id, imgFilename, createdAt
FROM frame
WHERE encodeStatus = 0
ORDER BY createdAt ASC;
`);
const frames = stmt.all() as Frame[];
const buffer: Frame[] = [];
if (frames.length < MIN_FRAMES_TO_ENCODE) return;
for (let i = 1; i < frames.length; i++) {
const frame = frames[i];
const lastFrame = frames[i - 1];
const currentFrameSize = sizeOf(join(getScreenshotsDir(), frame.imgFilename));
const lastFrameSize = sizeOf(join(getScreenshotsDir(), lastFrame.imgFilename));
const twoFramesHaveSameSize =
currentFrameSize.width === lastFrameSize.width
&& currentFrameSize.height === lastFrameSize.height;
const bufferIsBigEnough = buffer.length >= MIN_FRAMES_TO_ENCODE;
const chunkConditionSatisfied = !twoFramesHaveSameSize || bufferIsBigEnough;
buffer.push(lastFrame);
if (chunkConditionSatisfied) {
// Create new encoding task
const taskStmt = db.prepare(`
INSERT INTO encoding_task (status) VALUES (0);
`);
const taskId = taskStmt.run().lastInsertRowid;
// Insert frames into encoding_task_data
const insertStmt = db.prepare(`
INSERT INTO encoding_task_data (encodingTaskID, frame) VALUES (?, ?);
`);
for (const frame of buffer) {
insertStmt.run(taskId, frame.id);
db.prepare(`
UPDATE frame SET encodeStatus = 1 WHERE id = ?;
`).run(frame.id);
}
console.log(`Created encoding task ${taskId} with ${buffer.length} frames`);
buffer.length = 0;
}
}
}
export async function deleteEncodedScreenshots(db: Database) {
const stmt = db.prepare(`
SELECT * FROM frame WHERE encodeStatus = 2 AND imgFilename IS NOT NULL;
`);
const frames = stmt.all() as Frame[];
for (const frame of frames) {
fs.unlinkSync(path.join(getScreenshotsDir(), frame.imgFilename));
const updateStmt = db.prepare(`
UPDATE frame SET imgFilename = NULL WHERE id = ?;
`);
updateStmt.run(frame.id);
}
}
// Check and process encoding task
export function processEncodingTasks(db: Database) {
const tasksPerforming = cache.get("tasksPerforming") as string[] || [];
if (tasksPerforming.length >= CONCURRENCY) return;
const stmt = db.prepare(`
SELECT id, status
FROM encoding_task
WHERE status = 0
LIMIT ?
`);
const tasks = stmt.all(CONCURRENCY - tasksPerforming.length) as EncodingTask[];
for (const task of tasks) {
const taskId = task.id;
// Create transaction
db.prepare(`BEGIN TRANSACTION;`).run();
// Update task status as processing (1)
const updateStmt = db.prepare(`
UPDATE encoding_task SET status = 1 WHERE id = ?
`);
updateStmt.run(taskId);
const framesStmt = db.prepare(`
SELECT frame.imgFilename, frame.id
FROM encoding_task_data
JOIN frame ON encoding_task_data.frame = frame.id
WHERE encoding_task_data.encodingTaskID = ?
ORDER BY frame.createdAt ASC
`);
const frames = framesStmt.all(taskId) as Frame[];
const metaFilePath = path.join(getEncodingTempDir(), `${taskId}_meta.txt`);
const metaContent = frames.map(frame => `file '${path.join(getScreenshotsDir(), frame.imgFilename)}'\nduration 0.03333`).join("\n");
fs.writeFileSync(metaFilePath, metaContent);
cache.put("tasksPerforming", [...tasksPerforming, taskId.toString()]);
const videoPath = path.join(getRecordingsDir(), `${taskId}.mp4`);
const ffmpegCommand = `ffmpeg -f concat -safe 0 -i "${metaFilePath}" -c:v libx264 -r 30 "${videoPath}"`;
console.log("FFMPEG", ffmpegCommand);
exec(ffmpegCommand, (error, stdout, stderr) => {
if (error) {
console.error(`FFmpeg error: ${error.message}`);
// Roll back transaction
db.prepare(`ROLLBACK;`).run();
} else {
console.log(`Video ${videoPath} created successfully`);
// Update task status to complete (2)
const completeStmt = db.prepare(`
UPDATE encoding_task SET status = 2 WHERE id = ?
`);
completeStmt.run(taskId);
for (let frameIndex = 0; frameIndex < frames.length; frameIndex++) {
const frame = frames[frameIndex];
const updateFrameStmt = db.prepare(`
UPDATE frame SET videoPath = ?, videoFrameIndex = ?, encodeStatus = 2 WHERE id = ?
`);
updateFrameStmt.run(`${taskId}.mp4`, frameIndex, frame.id);
}
db.prepare(`COMMIT;`).run();
}
cache.put("tasksPerforming", tasksPerforming.filter(id => id !== taskId.toString()));
fs.unlinkSync(metaFilePath);
});
}
}

View File

@ -0,0 +1,158 @@
import * as path from "path";
import { Database } from "better-sqlite3";
import DB from "better-sqlite3";
import { __dirname } from "../dirname.js";
import { getDatabaseDir } from "../utils/backend.js";
import { migrate } from "./migrate/index.js";
function getLibSimpleExtensionPath() {
switch (process.platform) {
case "win32":
return path.join(__dirname, "bin", process.platform, "libsimple", "simple.dll");
case "darwin":
return path.join(__dirname, "bin", process.platform, "libsimple", "libsimple.dylib");
case "linux":
return path.join(__dirname, "bin", process.platform, "libsimple", "libsimple.so");
default:
throw new Error("Unsupported platform");
}
}
function databaseInitialized(db: Database) {
return db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='frame';`).get()
!== undefined;
}
function init(db: Database) {
db.exec(`
CREATE TABLE IF NOT EXISTS frame (
id INTEGER PRIMARY KEY AUTOINCREMENT,
createdAt REAL,
imgFilename TEXT,
segmentID INTEGER NULL,
videoPath TEXT NULL,
videoFrameIndex INTEGER NULL,
collectionID INTEGER NULL,
encodeStatus INTEGER DEFAULT 0,
FOREIGN KEY (segmentID) REFERENCES segments (id)
);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS recognition_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
frameID INTEGER,
data TEXT,
text TEXT,
FOREIGN KEY (frameID) REFERENCES frame (id)
);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS segments(
id INTEGER PRIMARY KEY AUTOINCREMENT,
startedAt REAL,
endedAt REAL,
title TEXT,
appName TEXT,
appPath TEXT,
text TEXT,
type TEXT,
appBundleID TEXT NULL,
url TEXT NULL
);
`);
db.exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS text_search USING fts5(
id UNINDEXED,
frameID UNINDEXED,
data,
text
);
`);
db.exec(`
CREATE TRIGGER IF NOT EXISTS recognition_data_after_insert AFTER INSERT ON recognition_data
BEGIN
INSERT INTO text_search (id, frameID, data, text)
VALUES (NEW.id, NEW.frameID, NEW.data, NEW.text);
END;
`);
db.exec(`
CREATE TRIGGER IF NOT EXISTS recognition_data_after_update AFTER UPDATE ON recognition_data
BEGIN
UPDATE text_search
SET frameID = NEW.frameID, data = NEW.data, text = NEW.text
WHERE id = NEW.id;
END;
`);
db.exec(`
CREATE TRIGGER IF NOT EXISTS recognition_data_after_delete AFTER DELETE ON recognition_data
BEGIN
DELETE FROM text_search WHERE id = OLD.id;
END;
`);
db.exec(`
CREATE TABLE config (
key TEXT PRIMARY KEY,
value TEXT
);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS encoding_task (
id INTEGER PRIMARY KEY AUTOINCREMENT,
createdAt REAL,
status INT DEFAULT 0
);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS encoding_task_data (
encodingTaskID INTEGER,
frame ID INTEGER PRIMARY KEY,
FOREIGN KEY (encodingTaskID) REFERENCES encoding_task(id),
FOREIGN KEY (frame) REFERENCES frame(id)
);
`);
db.exec(`
CREATE TRIGGER IF NOT EXISTS delete_encoding_task
AFTER UPDATE OF status
ON encoding_task
BEGIN
DELETE FROM encoding_task_data
WHERE encodingTaskID = OLD.id AND NEW.status = 2;
DELETE FROM encoding_task
WHERE id = OLD.id AND NEW.status = 2;
END;
`);
db.exec(`
INSERT INTO config (key, value) VALUES ('version', '3');
`);
}
export async function initDatabase() {
const dbPath = getDatabaseDir();
const db = new DB(dbPath, { verbose: console.log });
const libSimpleExtensionPath = getLibSimpleExtensionPath();
db.loadExtension(libSimpleExtensionPath);
if (!databaseInitialized(db)) {
init(db);
}
else {
migrate(db);
}
db.exec("PRAGMA journal_mode=WAL;");
return db;
}

View File

@ -0,0 +1,37 @@
import { Database } from "better-sqlite3";
import { migrateToV2 } from "./migrateToV2.js";
import { migrateToV3 } from "./migrateToV3.js";
const CURRENT_VERSION = 3;
function migrateTo(version: number, db: Database) {
switch (version) {
case 2:
migrateToV3(db);
break;
}
}
export function migrate(db: Database) {
const configTableExists =
db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='config';`).get()
!== undefined;
if (!configTableExists) {
migrateToV2(db);
}
let databaseVersion = parseInt(
(
db.prepare(`SELECT value FROM config WHERE key = 'version';`).get() as
{ value: any }
).value
);
while (databaseVersion < CURRENT_VERSION) {
migrateTo(databaseVersion, db);
databaseVersion = parseInt(
(
db.prepare(`SELECT value FROM config WHERE key = 'version';`).get() as
{ value: any }
).value
);
}
}

View File

@ -0,0 +1,81 @@
import { Database } from "better-sqlite3";
interface OldFrame {
id: number;
createAt: string;
imgFilename: string;
segmentID: number | null;
videoPath: string | null;
videoFrameIndex: number | null;
collectionID: number | null;
encoded: number;
}
function initSchemaInV2(db: Database) {
db.exec(`
CREATE TABLE config (
key TEXT PRIMARY KEY,
value TEXT
);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS encoding_task (
id INTEGER PRIMARY KEY AUTOINCREMENT,
createAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
status INT DEFAULT 0
);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS encoding_task_data (
encodingTaskID INTEGER,
frame ID INTEGER PRIMARY KEY,
FOREIGN KEY (encodingTaskID) REFERENCES encoding_task(id),
FOREIGN KEY (frame) REFERENCES frame(id)
);
`);
db.exec(`
CREATE TRIGGER IF NOT EXISTS delete_encoding_task
AFTER UPDATE OF status
ON encoding_task
BEGIN
DELETE FROM encoding_task_data
WHERE encodingTaskID = OLD.id AND NEW.status = 2;
DELETE FROM encoding_task
WHERE id = OLD.id AND NEW.status = 2;
END;
`);
db.exec(`
INSERT INTO config (key, value) VALUES ('version', '2');
`);
}
/*
* This function assumes that the database does not contain the "config" table,
* and thus needs to be migrated to Version 2.
* */
export function migrateToV2(db: Database) {
initSchemaInV2(db);
// Oh we saved tens of millions of bytes for user!
// Before: /Users/username/Library/Application Support/OpenRewind/Record Data/temp/screenshots/1733568609960.jpg
// After: 1733568609960.jpg
const rows = db.prepare("SELECT id, imgFilename FROM frame").all() as OldFrame[];
rows.forEach(row => {
const filename = row.imgFilename.match(/[^\\/]+$/)?.[0];
if (filename) {
db.prepare("UPDATE frame SET imgFilename = ? WHERE id = ?")
.run(filename, row.id);
}
});
db.exec(`
ALTER TABLE frame ADD encodeStatus INT DEFAULT 0;
UPDATE frame SET encodeStatus = CASE WHEN encoded THEN 2 ELSE 0 END;
`);
}

View File

@ -0,0 +1,191 @@
import { Database } from "better-sqlite3";
function convertTimestampToUnix(timestamp: string): number {
const date = new Date(timestamp);
const now = new Date();
const offsetInMinutes = now.getTimezoneOffset();
const offsetInSeconds = offsetInMinutes * 60;
return date.getTime() / 1000 - offsetInSeconds;
}
function transformEncodingTask(db: Database) {
const createTableSql = `
CREATE TABLE IF NOT EXISTS encoding_task_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
status INT DEFAULT 0
);
INSERT INTO encoding_task_new (id, createdAt, status)
SELECT id, createdAt, status FROM encoding_task;
DROP TABLE encoding_task;
ALTER TABLE encoding_task_new RENAME TO encoding_task;
ALTER TABLE encoding_task ADD COLUMN createdAt_new REAL;
`;
db.exec(createTableSql);
const rows = db.prepare(`SELECT id, createdAt FROM encoding_task`).all() as { [x: string]: unknown; id: unknown; }[];
const updateStmt = db.prepare(`UPDATE encoding_task SET createdAt_new = ? WHERE id = ?`);
rows.forEach((row) => {
const unixTimestamp = convertTimestampToUnix(row.createdAt as string);
updateStmt.run(unixTimestamp, row.id);
});
db.exec(`
ALTER TABLE encoding_task DROP COLUMN createdAt;
ALTER TABLE encoding_task RENAME COLUMN createdAt_new TO createdAt;
`);
}
function transformFrame(db: Database) {
const createTableSql = `
CREATE TABLE frame_new(
id INTEGER PRIMARY KEY AUTOINCREMENT,
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
imgFilename TEXT,
segmentID INTEGER NULL,
videoPath TEXT NULL,
videoFrameIndex INTEGER NULL,
collectionID INTEGER NULL,
encodeStatus INT DEFAULT 0,
FOREIGN KEY (segmentID) REFERENCES segments (id)
);
INSERT INTO frame_new (id, createdAt, imgFilename, segmentID, videoPath, videoFrameIndex, collectionID, encodeStatus)
SELECT id, createdAt, imgFilename, segmentID, videoPath, videoFrameIndex, collectionID, encodeStatus FROM frame;
DROP TABLE frame;
ALTER TABLE frame_new RENAME TO frame;
ALTER TABLE frame ADD COLUMN createdAt_new REAL;
`
db.exec(createTableSql);
const rows = db.prepare(`SELECT id, createdAt FROM frame`).all() as { [x: string]: unknown; id: unknown; }[];
const updateStmt = db.prepare(`UPDATE frame SET createdAt_new = ? WHERE id = ?`);
rows.forEach((row) => {
const unixTimestamp = convertTimestampToUnix(row.createdAt as string);
updateStmt.run(unixTimestamp, row.id);
});
db.exec(`
ALTER TABLE frame DROP COLUMN createdAt;
ALTER TABLE frame RENAME COLUMN createdAt_new TO createdAt;
`);
}
function transformSegments(db: Database) {
db.exec(`
CREATE TABLE IF NOT EXISTS segments_new(
id INTEGER PRIMARY KEY AUTOINCREMENT,
startedAt REAL,
endedAt REAL,
title TEXT,
appName TEXT,
appPath TEXT,
text TEXT,
type TEXT,
appBundleID TEXT NULL,
url TEXT NULL
);
INSERT INTO segments_new (id, startedAt, endedAt, title, appName, appPath, text, type, appBundleID, url)
SELECT id, startedAt, endedAt, title, appName, appPath, text, type, appBundleID, url FROM segments;
DROP TABLE segments;
ALTER TABLE segments_new RENAME TO segments;
ALTER TABLE segments ADD COLUMN startedAt_new REAL;
ALTER TABLE segments ADD COLUMN endedAt_new REAL;
`);
const rows = db.prepare(`SELECT id, startedAt, endedAt FROM segments`).all() as { [x: string]: unknown; id: unknown; }[];
const updateStart = db.prepare(`UPDATE segments SET startedAt_new = ? WHERE id = ?`);
const updateEnd = db.prepare(`UPDATE segments SET endedAt_new = ? WHERE id = ?`);
rows.forEach((row) => {
updateStart.run(convertTimestampToUnix(row.startedAt as string), row.id);
updateEnd.run(convertTimestampToUnix(row.endedAt as string), row.id);
});
db.exec(`
ALTER TABLE segments DROP COLUMN startedAt;
ALTER TABLE segments DROP COLUMN endedAt;
ALTER TABLE segments RENAME COLUMN startedAt_new TO startedAt;
ALTER TABLE segments RENAME COLUMN endedAt_new TO endedAt;
`);
}
function renameColumn(tableName: string, oldColumnName: string, newColumnName: string, db: Database) {
if (db.prepare(`SELECT 1 FROM pragma_table_info(?) WHERE name=?`).get([tableName, oldColumnName])) {
db.exec(`ALTER TABLE ${tableName} RENAME COLUMN ${oldColumnName} TO ${newColumnName};`);
}
}
export function migrateToV3(db: Database) {
db.prepare(`ALTER TABLE segements RENAME TO segments`).run();
db.exec(`
PRAGMA foreign_keys = OFF;
CREATE TABLE frame_new(
id INTEGER PRIMARY KEY AUTOINCREMENT,
createAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
imgFilename TEXT,
segmentID INTEGER NULL,
videoPath TEXT NULL,
videoFrameIndex INTEGER NULL,
collectionID INTEGER NULL,
encodeStatus INT DEFAULT 0,
FOREIGN KEY (segmentID) REFERENCES segments (id)
);
INSERT INTO frame_new (id, createAt, imgFilename, segmentID, videoPath, videoFrameIndex, collectionID, encodeStatus)
SELECT id, createAt, imgFilename, segmentID, videoPath, videoFrameIndex, collectionID, encodeStatus FROM frame;
DROP TABLE frame;
ALTER TABLE frame_new RENAME TO frame;
CREATE TABLE encoding_task_data_new (
encodingTaskID INTEGER,
frame ID INTEGER PRIMARY KEY,
FOREIGN KEY (encodingTaskID) REFERENCES encoding_task(id),
FOREIGN KEY (frame) REFERENCES frame(id)
);
INSERT INTO encoding_task_data SELECT * FROM encoding_task_data_new;
DROP TRIGGER delete_encoding_task;
DROP TABLE encoding_task_data;
ALTER TABLE encoding_task_data_new RENAME TO encoding_task_data;
CREATE TRIGGER IF NOT EXISTS delete_encoding_task
AFTER UPDATE OF status
ON encoding_task
BEGIN
DELETE FROM encoding_task_data
WHERE encodingTaskID = OLD.id AND NEW.status = 2;
DELETE FROM encoding_task
WHERE id = OLD.id AND NEW.status = 2;
END;
CREATE TABLE recognition_data_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
frameID INTEGER,
data TEXT,
text TEXT,
FOREIGN KEY (frameID) REFERENCES frame (id)
);
INSERT INTO recognition_data SELECT * FROM recognition_data_new;
DROP TABLE recognition_data;
ALTER TABLE recognition_data_new RENAME TO recognition_data;
PRAGMA foreign_keys = ON;
`);
renameColumn('encoding_task', 'createAt', 'createdAt', db);
renameColumn('frame', 'createAt', 'createdAt', db);
renameColumn('segments', 'startAt', 'startedAt', db);
renameColumn('segments', 'endAt', 'endedAt', db);
if (db.prepare(`SELECT 1 FROM pragma_table_info('frame') WHERE name='encoded'`).get()) {
db.prepare(`ALTER TABLE frame DROP COLUMN encoded`).run();
}
transformSegments(db);
transformFrame(db);
transformEncodingTask(db);
db.exec(`
UPDATE config SET value = '3' WHERE key = 'version';
`);
}

22
src/electron/backend/schema.d.ts vendored Normal file
View File

@ -0,0 +1,22 @@
export interface Frame {
id: number;
createdAt: number;
imgFilename: string;
segmentID: number | null;
videoPath: string | null;
videoFrameIndex: number | null;
collectionID: number | null;
encodeStatus: number;
}
export interface EncodingTask {
id: number;
createdAt: number;
status: number;
}
export interface EncodingTaskData {
encodingTaskID: number;
frame: number;
}

View File

@ -0,0 +1,24 @@
import screenshot from "screenshot-desktop";
import { getScreenshotsDir } from "../utils/backend.js";
import { join } from "path";
import { Database }from "better-sqlite3";
import SqlString from "sqlstring";
export function startScreenshotLoop(db: Database) {
return setInterval(() => {
const timestamp = new Date().getTime();
const screenshotDir = getScreenshotsDir();
const filename = `${timestamp}.png`;
const screenshotPath = join(screenshotDir, filename);
screenshot({filename: screenshotPath, format: "png"}).then((absolutePath) => {
const SQL = SqlString.format(
"INSERT INTO frame (imgFilename, createdAt) VALUES (?, ?)",
[filename, new Date().getTime() / 1000]
);
db.exec(SQL);
}).catch((err) => {
console.error(err);
});
}, 2000);
}

View File

@ -1,7 +1,8 @@
import { app, BrowserWindow, screen } from "electron"; import { app, BrowserWindow, screen } from "electron";
import { join } from "path"; import { join } from "path";
import { __dirname } from "./utils.js"; import { __dirname } from "./dirname.js";
import windowStateManager from "electron-window-state"; import windowStateManager from "electron-window-state";
import { hideDock, showDock } from "./utils/electron.js";
function loadURL(window: BrowserWindow, path = "", vitePort: string) { function loadURL(window: BrowserWindow, path = "", vitePort: string) {
const dev = !app.isPackaged; const dev = !app.isPackaged;
@ -24,6 +25,21 @@ export function createSettingsWindow(vitePort: string, closeCallBack: Function)
defaultWidth: 650, defaultWidth: 650,
defaultHeight: 550 defaultHeight: 550
}); });
const enableFrame = process.platform === "darwin";
let icon
switch (process.platform) {
case "darwin":
icon = undefined;
break;
case "win32":
icon = join(__dirname, "assets/icon.ico");
break;
case "linux":
icon = join(__dirname, "assets/icon.png");
break;
default:
icon = undefined;
}
const window = new BrowserWindow({ const window = new BrowserWindow({
width: 650, width: 650,
height: 550, height: 550,
@ -34,11 +50,13 @@ export function createSettingsWindow(vitePort: string, closeCallBack: Function)
}, },
titleBarStyle: "hiddenInset", titleBarStyle: "hiddenInset",
resizable: false, resizable: false,
show: false,
frame: enableFrame,
icon: icon,
}); });
windowState.manage(window); windowState.manage(window);
window.once("ready-to-show", () => { window.on("show", () => {
window.show(); showDock();
window.focus();
}); });
window.on("close", (e) => { window.on("close", (e) => {
window.hide(); window.hide();
@ -46,7 +64,8 @@ export function createSettingsWindow(vitePort: string, closeCallBack: Function)
e.preventDefault(); e.preventDefault();
}); });
window.once("close", () => { window.once("close", () => {
window.hide() window.hide();
hideDock();
}); });
loadURL(window, "settings", vitePort); loadURL(window, "settings", vitePort);
return window; return window;
@ -77,17 +96,11 @@ export function createMainWindow(vitePort: string, closeCallBack: Function) {
}, },
roundedCorners: false, roundedCorners: false,
transparent: true, transparent: true,
show: false
}); });
windowState.manage(window); windowState.manage(window);
window.once("ready-to-show", () => {
window.show();
window.setAlwaysOnTop(true, "screen-saver");
window.setBounds({ x: 0, y: 0, width, height });
window.focus();
});
window.on("close", () => { window.on("close", () => {
windowState.saveState(window); windowState.saveState(window);
}); });

4
src/electron/dirname.ts Normal file
View File

@ -0,0 +1,4 @@
import path from "path";
import { fileURLToPath } from "url";
export const __dirname = path.dirname(fileURLToPath(import.meta.url));

View File

@ -2,7 +2,7 @@ import { join } from "path";
import i18n from "i18next"; import i18n from "i18next";
import fs from "fs"; import fs from "fs";
import { app } from "electron"; import { app } from "electron";
import { __dirname } from "./utils.js"; import { __dirname } from "./dirname.js";
/** /**

View File

@ -1,10 +1,14 @@
import { app, BrowserWindow, globalShortcut, Menu, nativeImage, Tray } from "electron"; import { app, BrowserWindow, globalShortcut, ipcMain, Menu, nativeImage, screen, Tray } from "electron";
import contextMenu from "electron-context-menu"; import contextMenu from "electron-context-menu";
import { join } from "path"; import { join } from "path";
import initI18n from "./i18n.js"; import initI18n from "./i18n.js";
import { createMainWindow, createSettingsWindow } from "./createWindow.js"; import { createMainWindow, createSettingsWindow } from "./createWindow.js";
import { __dirname, captureScreen, getFirstCaptureScreenDeviceId } from "./utils.js"; import { initDatabase } from "./backend/init.js";
import * as fs from "fs"; import { Database } from "better-sqlite3";
import { startScreenshotLoop } from "./backend/screenshot.js";
import { __dirname } from "./dirname.js";
import { hideDock } from "./utils/electron.js";
import { checkFramesForEncoding, deleteEncodedScreenshots, processEncodingTasks } from "./backend/encoding.js";
const i18n = initI18n(); const i18n = initI18n();
@ -12,19 +16,12 @@ const t = i18n.t.bind(i18n);
const port = process.env.PORT || "5173"; const port = process.env.PORT || "5173";
const dev = !app.isPackaged; const dev = !app.isPackaged;
let tray = null; let tray: null | Tray = null;
let dbConnection: null | Database = null;
let screenshotInterval: null | NodeJS.Timeout = null;
async function c() { let mainWindow: BrowserWindow | null;
const screenshotpath = join(__dirname, "screenshot.png"); let settingsWindow: BrowserWindow | null;
const ffmpegPath = join(__dirname, dev ? "bin/macos/ffmpeg" : "../../bin/macos/ffmpeg");
const deviceID = await getFirstCaptureScreenDeviceId(ffmpegPath);
if (deviceID) {
await captureScreen(ffmpegPath, deviceID, screenshotpath);
const screenshotData = fs.readFileSync(screenshotpath, "base64");
return screenshotData;
}
return null;
}
function createTray() { function createTray() {
const pathRoot: string = dev ? "./src/electron/assets/" : join(__dirname, "./assets/"); const pathRoot: string = dev ? "./src/electron/assets/" : join(__dirname, "./assets/");
@ -36,26 +33,18 @@ function createTray() {
const contextMenu = Menu.buildFromTemplate([ const contextMenu = Menu.buildFromTemplate([
{ {
label: t("tray.showMainWindow"), label: t("tray.showMainWindow"),
click: async () => { click: () => {
if (!mainWindow) mainWindow = createMainWindow(port, () => (mainWindow = null)); const display = screen.getPrimaryDisplay();
mainWindow!.webContents.send("fromMain", null); const { width, height } = display.bounds;
mainWindow!.setIgnoreMouseEvents(true);
mainWindow!.show(); mainWindow!.show();
c() mainWindow!.setAlwaysOnTop(true, "screen-saver");
.then((data) => { mainWindow!.setBounds({ x: 0, y: 0, width, height });
mainWindow!.webContents.send("fromMain", data); mainWindow!.focus();
mainWindow!.setIgnoreMouseEvents(false);
})
.catch((err) => {
console.error(err);
});
} }
}, },
{ {
label: t("tray.showSettingsWindow"), label: t("tray.showSettingsWindow"),
click: () => { click: () => {
if (!settingsWindow)
settingsWindow = createSettingsWindow(port, () => (settingsWindow = null));
settingsWindow!.show(); settingsWindow!.show();
} }
}, },
@ -72,9 +61,6 @@ function createTray() {
tray.setToolTip("OpenRewind"); tray.setToolTip("OpenRewind");
} }
let mainWindow: BrowserWindow | null;
let settingsWindow: BrowserWindow | null;
contextMenu({ contextMenu({
showLookUpSelection: true, showLookUpSelection: true,
showSearchWithGoogle: true, showSearchWithGoogle: true,
@ -82,17 +68,35 @@ contextMenu({
}); });
app.once("ready", () => { app.once("ready", () => {
app.dock.hide(); hideDock();
}); });
app.on("activate", () => {}); app.on("activate", () => {});
app.on("ready", () => { app.on("ready", () => {
createTray(); createTray();
initDatabase().then((db) => {
screenshotInterval = startScreenshotLoop(db);
setInterval(checkFramesForEncoding, 5000, db);
setInterval(processEncodingTasks, 10000, db);
setInterval(deleteEncodedScreenshots, 5000, db)
dbConnection = db;
});
mainWindow = createMainWindow(port, () => (mainWindow = null));
settingsWindow = createSettingsWindow(port, () => (settingsWindow = null));
globalShortcut.register("Escape", () => { globalShortcut.register("Escape", () => {
if (!mainWindow) return; if (!mainWindow || !mainWindow.isVisible()) return;
mainWindow.hide(); mainWindow.hide();
}); });
}); });
app.on("will-quit", ()=> {
dbConnection?.close();
});
// app.on("window-all-closed", () => { // app.on("window-all-closed", () => {
// if (process.platform !== "darwin") app.quit(); // if (process.platform !== "darwin") app.quit();
// }); // });
ipcMain.on('close-settings', () => {
settingsWindow?.hide();
});

View File

@ -1,8 +1,8 @@
const { contextBridge } = require('electron') const { contextBridge, ipcRenderer } = require("electron");
const os = require('os'); const os = require("os");
const osName = require('./os-name.cjs'); const osName = require("./os-name.cjs");
contextBridge.exposeInMainWorld('versions', { contextBridge.exposeInMainWorld("versions", {
node: () => process.versions.node, node: () => process.versions.node,
chrome: () => process.versions.chrome, chrome: () => process.versions.chrome,
electron: () => process.versions.electron, electron: () => process.versions.electron,
@ -10,4 +10,10 @@ contextBridge.exposeInMainWorld('versions', {
return `${os.platform()} ${os.release()}`; return `${os.platform()} ${os.release()}`;
}, },
osDisplay: osName osDisplay: osName
}) });
contextBridge.exposeInMainWorld("settingsWindow", {
close: () => {
ipcRenderer.send("close-settings", {});
}
});

View File

@ -1,35 +0,0 @@
import path from "path";
import { fileURLToPath } from "url";
import { exec } from "child_process";
export const __dirname = path.dirname(fileURLToPath(import.meta.url));
export function getFirstCaptureScreenDeviceId(ffmpegPath: string): Promise<string | null> {
return new Promise((resolve, reject) => {
exec(`${ffmpegPath} -f avfoundation -list_devices true -i ""`, (error, stdout, stderr) => {
// stderr contains the output we need to parse
const output = stderr;
const captureScreenRegex = /\[(\d+)]\s+Capture screen \d+/g;
const match = captureScreenRegex.exec(output);
if (match) {
resolve(match[1]);
} else {
resolve(null);
}
});
});
}
export function captureScreen(ffmpegPath: string ,deviceId: string, outputPath: string): Promise<void> {
return new Promise((resolve, reject) => {
exec(`${ffmpegPath} -f avfoundation -pixel_format uyvy422 -i ${deviceId} -y -frames:v 1 ${outputPath}`,
(error, _stdout, _stderr) => {
if (error) {
reject(error);
} else {
resolve();
}
});
});
}

View File

@ -0,0 +1,78 @@
import path from "path";
import os from "os";
import fs from "fs";
import { __dirname } from "../dirname.js";
export function getUserDataDir() {
switch (process.platform) {
case "win32":
return path.join(process.env.APPDATA!, "OpenRewind", "Record Data");
case "darwin":
return path.join(os.homedir(), "Library", "Application Support", "OpenRewind", "Record Data");
case "linux":
return path.join(os.homedir(), ".config", "OpenRewind", "Record Data");
default:
throw new Error("Unsupported platform");
}
}
export function createDataDir() {
const dataDir = getUserDataDir();
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
return dataDir;
}
export function createTempDir() {
const tempDir = path.join(getUserDataDir(), "temp");
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
return tempDir;
}
export function getDatabaseDir() {
const dataDir = createDataDir();
return path.join(dataDir, "main.db");
}
export function getScreenshotsDir() {
const tempDir = createTempDir();
const screenshotsDir = path.join(tempDir, "screenshots");
if (!fs.existsSync(screenshotsDir)) {
fs.mkdirSync(screenshotsDir, { recursive: true });
}
return screenshotsDir;
}
export function getRecordingsDir() {
const dataDir = createDataDir();
const recordingsDir = path.join(dataDir, "recordings");
if (!fs.existsSync(recordingsDir)) {
fs.mkdirSync(recordingsDir, { recursive: true });
}
return path.join(dataDir, "recordings");
}
export function getEncodingTempDir() {
const tempDir = createTempDir();
const encodingTempDir = path.join(tempDir, "encoding");
if (!fs.existsSync(encodingTempDir)) {
fs.mkdirSync(encodingTempDir, { recursive: true });
}
return encodingTempDir;
}
export function getFFmpegPath() {
switch (process.platform) {
case "win32":
return path.join(__dirname, "bin", process.platform, "ffmpeg.exe");
case "darwin":
return path.join(__dirname, "bin", process.platform, "ffmpeg");
case "linux":
return path.join(__dirname, "bin", process.platform, "ffmpeg");
default:
throw new Error("Unsupported platform");
}
}

View File

@ -0,0 +1,15 @@
import { app } from "electron";
export function hideDock(){
if (process.platform === 'darwin') {
// Hide the dock icon on macOS
app.dock.hide();
}
}
export function showDock(){
if (process.platform === 'darwin') {
// Show the dock icon on macOS
app.dock.show();
}
}

3
src/global.d.ts vendored
View File

@ -12,5 +12,8 @@ interface Window {
api: { api: {
send: (channel: any, data: any) => void, send: (channel: any, data: any) => void,
receive: (channel: any, func: any) => void receive: (channel: any, func: any) => void
},
settingsWindow: {
close: () => void,
} }
} }

View File

@ -3,7 +3,7 @@
"target": "ESNext", "target": "ESNext",
"module": "ESNext", "module": "ESNext",
"moduleResolution": "node", "moduleResolution": "node",
"outDir": "./dist/dev", "outDir": "./dist/electron",
"rootDir": "./src/electron", "rootDir": "./src/electron",
"strict": true, "strict": true,
"esModuleInterop": true, "esModuleInterop": true,