mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-06 09:33:45 +00:00
Compare commits
6 Commits
v0.26.0-be
...
darksky/na
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ac9158f87 | ||
|
|
e7d0f31546 | ||
|
|
fe5d6c0c0f | ||
|
|
510933becf | ||
|
|
3633c75c6f | ||
|
|
7ef550a736 |
File diff suppressed because one or more lines are too long
@@ -12,4 +12,4 @@ npmPublishAccess: public
|
||||
|
||||
npmRegistryServer: "https://registry.npmjs.org"
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.9.1.cjs
|
||||
yarnPath: .yarn/releases/yarn-4.12.0.cjs
|
||||
|
||||
156
Cargo.lock
generated
156
Cargo.lock
generated
@@ -98,6 +98,9 @@ dependencies = [
|
||||
"napi-derive",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"ogg",
|
||||
"opus-codec",
|
||||
"rand 0.9.1",
|
||||
"rubato",
|
||||
"screencapturekit",
|
||||
"symphonia",
|
||||
@@ -566,6 +569,26 @@ dependencies = [
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.72.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools 0.13.0",
|
||||
"log",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"rustc-hash 2.1.1",
|
||||
"shlex",
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.5.3"
|
||||
@@ -669,7 +692,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"regex-automata 0.4.9",
|
||||
"regex-automata",
|
||||
"serde",
|
||||
]
|
||||
|
||||
@@ -925,6 +948,15 @@ version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6"
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.54"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.3"
|
||||
@@ -1104,7 +1136,7 @@ version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ce857aa0b77d77287acc1ac3e37a05a8c95a2af3647d23b15f263bdaeb7562b"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"bindgen 0.70.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1490,7 +1522,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1557,8 +1589,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2"
|
||||
dependencies = [
|
||||
"bit-set 0.5.3",
|
||||
"regex-automata 0.4.9",
|
||||
"regex-syntax 0.8.5",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2012,7 +2044,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.61.2",
|
||||
"windows-core 0.57.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2263,7 +2295,7 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2457,7 +2489,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-targets 0.52.6",
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2618,11 +2650,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558"
|
||||
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
||||
dependencies = [
|
||||
"regex-automata 0.1.10",
|
||||
"regex-automata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2861,12 +2893,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.46.0"
|
||||
version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"overload",
|
||||
"winapi",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3047,6 +3078,15 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ogg"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdab8dcd8d4052eaacaf8fb07a3ccd9a6e26efadb42878a413c68fc4af1dee2b"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
@@ -3065,6 +3105,17 @@ version = "11.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
||||
|
||||
[[package]]
|
||||
name = "opus-codec"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37755dfadaa9c70fd26a4c1ea13d9bd035993cd0a19eb5b76449301609228280"
|
||||
dependencies = [
|
||||
"bindgen 0.72.1",
|
||||
"cmake",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered-float"
|
||||
version = "5.0.0"
|
||||
@@ -3082,12 +3133,6 @@ version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
|
||||
|
||||
[[package]]
|
||||
name = "overload"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
@@ -3406,6 +3451,16 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "primal-check"
|
||||
version = "0.3.4"
|
||||
@@ -3471,7 +3526,7 @@ dependencies = [
|
||||
"rand 0.8.5",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_xorshift",
|
||||
"regex-syntax 0.8.5",
|
||||
"regex-syntax",
|
||||
"rusty-fork",
|
||||
"tempfile",
|
||||
"unarray",
|
||||
@@ -3663,17 +3718,8 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata 0.4.9",
|
||||
"regex-syntax 0.8.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132"
|
||||
dependencies = [
|
||||
"regex-syntax 0.6.29",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3684,15 +3730,9 @@ checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax 0.8.5",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.6.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1"
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.5"
|
||||
@@ -3837,7 +3877,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4686,7 +4726,7 @@ dependencies = [
|
||||
"getrandom 0.3.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4940,14 +4980,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.19"
|
||||
version = "0.3.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008"
|
||||
checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5"
|
||||
dependencies = [
|
||||
"matchers",
|
||||
"nu-ansi-term",
|
||||
"once_cell",
|
||||
"regex",
|
||||
"regex-automata",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
@@ -4974,7 +5014,7 @@ checksum = "6d7b8994f367f16e6fa14b5aebbcb350de5d7cbea82dc5b00ae997dd71680dd2"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"regex",
|
||||
"regex-syntax 0.8.5",
|
||||
"regex-syntax",
|
||||
"serde_json",
|
||||
"streaming-iterator",
|
||||
"tree-sitter-language",
|
||||
@@ -5560,37 +5600,15 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd7cf3379ca1aac9eea11fba24fd7e315d621f8dfe35c8d7d2be8b793726e07d"
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.54.0"
|
||||
|
||||
@@ -64,6 +64,7 @@ resolver = "3"
|
||||
notify = { version = "8", features = ["serde"] }
|
||||
objc2 = "0.6"
|
||||
objc2-foundation = "0.3"
|
||||
ogg = "0.9"
|
||||
once_cell = "1"
|
||||
ordered-float = "5"
|
||||
parking_lot = "0.12"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MarkdownTransformer } from '@blocksuite/affine/widgets/linked-doc';
|
||||
import {
|
||||
DefaultTheme,
|
||||
NoteDisplayMode,
|
||||
@@ -16,12 +17,15 @@ import type {
|
||||
SliceSnapshot,
|
||||
TransformerMiddleware,
|
||||
} from '@blocksuite/store';
|
||||
import { AssetsManager, MemoryBlobCRUD } from '@blocksuite/store';
|
||||
import { AssetsManager, MemoryBlobCRUD, Schema } from '@blocksuite/store';
|
||||
import { TestWorkspace } from '@blocksuite/store/test';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { AffineSchemas } from '../../schemas.js';
|
||||
import { createJob } from '../utils/create-job.js';
|
||||
import { getProvider } from '../utils/get-provider.js';
|
||||
import { nanoidReplacement } from '../utils/nanoid-replacement.js';
|
||||
import { testStoreExtensions } from '../utils/store.js';
|
||||
|
||||
const provider = getProvider();
|
||||
|
||||
@@ -90,6 +94,39 @@ describe('snapshot to markdown', () => {
|
||||
expect(target.file).toBe(markdown);
|
||||
});
|
||||
|
||||
test('imports frontmatter metadata into doc meta', async () => {
|
||||
const schema = new Schema().register(AffineSchemas);
|
||||
const collection = new TestWorkspace();
|
||||
collection.storeExtensions = testStoreExtensions;
|
||||
collection.meta.initialize();
|
||||
|
||||
const markdown = `---
|
||||
title: Web developer
|
||||
created: 2018-04-12T09:51:00
|
||||
updated: 2018-04-12T10:00:00
|
||||
tags: [a, b]
|
||||
favorite: true
|
||||
---
|
||||
Hello world
|
||||
`;
|
||||
|
||||
const docId = await MarkdownTransformer.importMarkdownToDoc({
|
||||
collection,
|
||||
schema,
|
||||
markdown,
|
||||
fileName: 'fallback-title',
|
||||
extensions: testStoreExtensions,
|
||||
});
|
||||
|
||||
expect(docId).toBeTruthy();
|
||||
const meta = collection.meta.getDocMeta(docId!);
|
||||
expect(meta?.title).toBe('Web developer');
|
||||
expect(meta?.createDate).toBe(Date.parse('2018-04-12T09:51:00'));
|
||||
expect(meta?.updatedDate).toBe(Date.parse('2018-04-12T10:00:00'));
|
||||
expect(meta?.favorite).toBe(true);
|
||||
expect(meta?.tags).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
test('paragraph', async () => {
|
||||
const blockSnapshot: BlockSnapshot = {
|
||||
type: 'block',
|
||||
@@ -2996,6 +3033,50 @@ describe('markdown to snapshot', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('html inline color span imports to nearest supported text color', async () => {
|
||||
const markdown = `<span style="color: #00afde;">Hello</span>`;
|
||||
const blockSnapshot: BlockSnapshot = {
|
||||
type: 'block',
|
||||
id: 'matchesReplaceMap[0]',
|
||||
flavour: 'affine:note',
|
||||
props: {
|
||||
xywh: '[0,0,800,95]',
|
||||
background: DefaultTheme.noteBackgrounColor,
|
||||
index: 'a0',
|
||||
hidden: false,
|
||||
displayMode: NoteDisplayMode.DocAndEdgeless,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: 'block',
|
||||
id: 'matchesReplaceMap[1]',
|
||||
flavour: 'affine:paragraph',
|
||||
props: {
|
||||
type: 'text',
|
||||
text: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: [
|
||||
{
|
||||
insert: 'Hello',
|
||||
attributes: {
|
||||
color: 'var(--affine-v2-text-highlight-fg-blue)',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mdAdapter = new MarkdownAdapter(createJob(), provider);
|
||||
const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({
|
||||
file: markdown,
|
||||
});
|
||||
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
|
||||
});
|
||||
|
||||
test('paragraph', async () => {
|
||||
const markdown = `aaa
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { parseStringToRgba } from '@blocksuite/affine-components/color-picker';
|
||||
import { cssVarV2, darkThemeV2, lightThemeV2 } from '@toeverything/theme/v2';
|
||||
|
||||
type Rgb = { r: number; g: number; b: number };
|
||||
|
||||
const COLOR_DISTANCE_THRESHOLD = 90;
|
||||
const supportedTextColorNames = [
|
||||
'red',
|
||||
'orange',
|
||||
'yellow',
|
||||
'green',
|
||||
'teal',
|
||||
'blue',
|
||||
'purple',
|
||||
'grey',
|
||||
] as const;
|
||||
|
||||
const supportedTextColors = supportedTextColorNames.map(name => ({
|
||||
name,
|
||||
cssVar: cssVarV2(`text/highlight/fg/${name}`),
|
||||
light: lightThemeV2[`text/highlight/fg/${name}`],
|
||||
dark: darkThemeV2[`text/highlight/fg/${name}`],
|
||||
}));
|
||||
|
||||
const hexToRgb = (value: string): Rgb | null => {
|
||||
const hex = value.replace('#', '');
|
||||
if (![3, 4, 6, 8].includes(hex.length)) {
|
||||
return null;
|
||||
}
|
||||
const normalized =
|
||||
hex.length === 3 || hex.length === 4
|
||||
? hex
|
||||
.slice(0, 3)
|
||||
.split('')
|
||||
.map(c => c + c)
|
||||
.join('')
|
||||
: hex.slice(0, 6);
|
||||
const intVal = Number.parseInt(normalized, 16);
|
||||
if (Number.isNaN(intVal)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
r: (intVal >> 16) & 255,
|
||||
g: (intVal >> 8) & 255,
|
||||
b: intVal & 255,
|
||||
};
|
||||
};
|
||||
|
||||
export const parseCssColor = (value: string): Rgb | null => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
if (trimmed.startsWith('#')) {
|
||||
return hexToRgb(trimmed);
|
||||
}
|
||||
if (/^rgba?\(/i.test(trimmed)) {
|
||||
const rgba = parseStringToRgba(trimmed);
|
||||
return {
|
||||
r: Math.round(rgba.r * 255),
|
||||
g: Math.round(rgba.g * 255),
|
||||
b: Math.round(rgba.b * 255),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const colorDistance = (a: Rgb, b: Rgb) => {
|
||||
const dr = a.r - b.r;
|
||||
const dg = a.g - b.g;
|
||||
const db = a.b - b.b;
|
||||
return Math.sqrt(dr * dr + dg * dg + db * db);
|
||||
};
|
||||
|
||||
export const resolveNearestSupportedColor = (color: string): string | null => {
|
||||
const target = parseCssColor(color);
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
let nearest:
|
||||
| {
|
||||
cssVar: string;
|
||||
distance: number;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
for (const supported of supportedTextColors) {
|
||||
const light = parseCssColor(supported.light);
|
||||
const dark = parseCssColor(supported.dark);
|
||||
for (const ref of [light, dark]) {
|
||||
if (!ref) continue;
|
||||
const distance = colorDistance(target, ref);
|
||||
if (!nearest || distance < nearest.distance) {
|
||||
nearest = { cssVar: supported.cssVar, distance };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nearest && nearest.distance <= COLOR_DISTANCE_THRESHOLD) {
|
||||
return nearest.cssVar;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const extractColorFromStyle = (
|
||||
style: string | undefined
|
||||
): string | null => {
|
||||
if (typeof style !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const declarations = style.split(';');
|
||||
for (const declaration of declarations) {
|
||||
const [rawKey, rawValue] = declaration.split(':');
|
||||
if (!rawKey || !rawValue) continue;
|
||||
if (rawKey.trim().toLowerCase() === 'color') {
|
||||
return rawValue.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -5,6 +5,11 @@ import {
|
||||
import { collapseWhiteSpace } from 'collapse-white-space';
|
||||
import type { Element } from 'hast';
|
||||
|
||||
import {
|
||||
extractColorFromStyle,
|
||||
resolveNearestSupportedColor,
|
||||
} from './color-utils.js';
|
||||
|
||||
/**
|
||||
* Handle empty text nodes created by HTML parser for styling purposes.
|
||||
* These nodes typically contain only whitespace/newlines, for example:
|
||||
@@ -173,6 +178,40 @@ export const htmlTextToDeltaMatcher = HtmlASTToDeltaExtension({
|
||||
},
|
||||
});
|
||||
|
||||
export const htmlColorStyleElementToDeltaMatcher = HtmlASTToDeltaExtension({
|
||||
name: 'color-style-element',
|
||||
match: ast =>
|
||||
isElement(ast) &&
|
||||
ast.tagName === 'span' &&
|
||||
typeof ast.properties?.style === 'string' &&
|
||||
/color\s*:/i.test(ast.properties.style),
|
||||
toDelta: (ast, context) => {
|
||||
if (!isElement(ast)) {
|
||||
return [];
|
||||
}
|
||||
const baseOptions = { ...context.options, trim: false };
|
||||
// In preformatted contexts (e.g. code blocks) we don't keep inline colors.
|
||||
if (baseOptions.pre) {
|
||||
return ast.children.flatMap(child => context.toDelta(child, baseOptions));
|
||||
}
|
||||
const colorValue = extractColorFromStyle(
|
||||
typeof ast.properties?.style === 'string' ? ast.properties.style : ''
|
||||
);
|
||||
const mappedColor = colorValue
|
||||
? resolveNearestSupportedColor(colorValue)
|
||||
: null;
|
||||
const deltas = ast.children.flatMap(child =>
|
||||
context.toDelta(child, baseOptions).map(delta => {
|
||||
if (mappedColor) {
|
||||
delta.attributes = { ...delta.attributes, color: mappedColor };
|
||||
}
|
||||
return delta;
|
||||
})
|
||||
);
|
||||
return deltas;
|
||||
},
|
||||
});
|
||||
|
||||
export const htmlTextLikeElementToDeltaMatcher = HtmlASTToDeltaExtension({
|
||||
name: 'text-like-element',
|
||||
match: ast => isTextLikeElement(ast),
|
||||
@@ -300,6 +339,7 @@ export const htmlBrElementToDeltaMatcher = HtmlASTToDeltaExtension({
|
||||
|
||||
export const HtmlInlineToDeltaAdapterExtensions = [
|
||||
htmlTextToDeltaMatcher,
|
||||
htmlColorStyleElementToDeltaMatcher,
|
||||
htmlTextLikeElementToDeltaMatcher,
|
||||
htmlStrongElementToDeltaMatcher,
|
||||
htmlItalicElementToDeltaMatcher,
|
||||
|
||||
@@ -79,11 +79,11 @@ export const markdownListToDeltaMatcher = MarkdownASTToDeltaExtension({
|
||||
export const markdownHtmlToDeltaMatcher = MarkdownASTToDeltaExtension({
|
||||
name: 'html',
|
||||
match: ast => ast.type === 'html',
|
||||
toDelta: ast => {
|
||||
toDelta: (ast, context) => {
|
||||
if (!('value' in ast)) {
|
||||
return [];
|
||||
}
|
||||
return [{ insert: ast.value }];
|
||||
return context?.htmlToDelta?.(ast.value) ?? [{ insert: ast.value }];
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,9 +3,17 @@ import {
|
||||
type ServiceIdentifier,
|
||||
} from '@blocksuite/global/di';
|
||||
import type { DeltaInsert, ExtensionType } from '@blocksuite/store';
|
||||
import type { Root } from 'hast';
|
||||
import type { PhrasingContent } from 'mdast';
|
||||
import rehypeParse from 'rehype-parse';
|
||||
import { unified } from 'unified';
|
||||
|
||||
import type { AffineTextAttributes } from '../../types/index.js';
|
||||
import { HtmlDeltaConverter } from '../html/delta-converter.js';
|
||||
import {
|
||||
rehypeInlineToBlock,
|
||||
rehypeWrapInlineElements,
|
||||
} from '../html/rehype-plugins/index.js';
|
||||
import {
|
||||
type ASTToDeltaMatcher,
|
||||
DeltaASTConverter,
|
||||
@@ -13,6 +21,88 @@ import {
|
||||
} from '../types/delta-converter.js';
|
||||
import type { MarkdownAST } from './type.js';
|
||||
|
||||
const INLINE_HTML_TAGS = new Set([
|
||||
'span',
|
||||
'strong',
|
||||
'b',
|
||||
'em',
|
||||
'i',
|
||||
'del',
|
||||
'u',
|
||||
'mark',
|
||||
'code',
|
||||
'ins',
|
||||
'bdi',
|
||||
'bdo',
|
||||
]);
|
||||
|
||||
const VOID_HTML_TAGS = new Set([
|
||||
'area',
|
||||
'base',
|
||||
'br',
|
||||
'col',
|
||||
'embed',
|
||||
'hr',
|
||||
'img',
|
||||
'input',
|
||||
'link',
|
||||
'meta',
|
||||
'param',
|
||||
'source',
|
||||
'track',
|
||||
'wbr',
|
||||
]);
|
||||
|
||||
const ALLOWED_INLINE_HTML_TAGS = new Set([
|
||||
...INLINE_HTML_TAGS,
|
||||
...VOID_HTML_TAGS,
|
||||
]);
|
||||
|
||||
const isHtmlNode = (
|
||||
node: MarkdownAST
|
||||
): node is MarkdownAST & { type: 'html'; value: string } =>
|
||||
node.type === 'html' && 'value' in node && typeof node.value === 'string';
|
||||
|
||||
const isTextNode = (
|
||||
node: MarkdownAST
|
||||
): node is MarkdownAST & { type: 'text'; value: string } =>
|
||||
node.type === 'text' && 'value' in node && typeof node.value === 'string';
|
||||
|
||||
type HtmlTagInfo =
|
||||
| { name: string; kind: 'open' | 'self' }
|
||||
| { name: string; kind: 'close' };
|
||||
|
||||
const getHtmlTagInfo = (value: string): HtmlTagInfo | null => {
|
||||
const closingMatch = value.match(/^<\/([A-Za-z][A-Za-z0-9-]*)\s*>$/);
|
||||
if (closingMatch) {
|
||||
return {
|
||||
name: closingMatch[1].toLowerCase(),
|
||||
kind: 'close',
|
||||
};
|
||||
}
|
||||
|
||||
const selfClosingMatch = value.match(
|
||||
/^<([A-Za-z][A-Za-z0-9-]*)(\s[^>]*)?\/>$/i
|
||||
);
|
||||
if (selfClosingMatch) {
|
||||
return {
|
||||
name: selfClosingMatch[1].toLowerCase(),
|
||||
kind: 'self',
|
||||
};
|
||||
}
|
||||
|
||||
const openingMatch = value.match(/^<([A-Za-z][A-Za-z0-9-]*)(\s[^>]*)?>$/);
|
||||
if (openingMatch) {
|
||||
const name = openingMatch[1].toLowerCase();
|
||||
return {
|
||||
name,
|
||||
kind: VOID_HTML_TAGS.has(name) ? 'self' : 'open',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export type InlineDeltaToMarkdownAdapterMatcher =
|
||||
InlineDeltaMatcher<PhrasingContent>;
|
||||
|
||||
@@ -63,11 +153,30 @@ export class MarkdownDeltaConverter extends DeltaASTConverter<
|
||||
constructor(
|
||||
readonly configs: Map<string, string>,
|
||||
readonly inlineDeltaMatchers: InlineDeltaToMarkdownAdapterMatcher[],
|
||||
readonly markdownASTToDeltaMatchers: MarkdownASTToDeltaMatcher[]
|
||||
readonly markdownASTToDeltaMatchers: MarkdownASTToDeltaMatcher[],
|
||||
readonly htmlDeltaConverter?: HtmlDeltaConverter
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
private _convertHtmlToDelta(
|
||||
html: string
|
||||
): DeltaInsert<AffineTextAttributes>[] {
|
||||
if (!this.htmlDeltaConverter) {
|
||||
return [{ insert: html }];
|
||||
}
|
||||
try {
|
||||
const processor = unified()
|
||||
.use(rehypeParse, { fragment: true })
|
||||
.use(rehypeInlineToBlock)
|
||||
.use(rehypeWrapInlineElements);
|
||||
const ast = processor.runSync(processor.parse(html)) as Root;
|
||||
return this.htmlDeltaConverter.astToDelta(ast, { trim: false });
|
||||
} catch {
|
||||
return [{ insert: html }];
|
||||
}
|
||||
}
|
||||
|
||||
applyTextFormatting(
|
||||
delta: DeltaInsert<AffineTextAttributes>
|
||||
): PhrasingContent {
|
||||
@@ -95,11 +204,110 @@ export class MarkdownDeltaConverter extends DeltaASTConverter<
|
||||
return mdast;
|
||||
}
|
||||
|
||||
private _mergeInlineHtml(
|
||||
children: MarkdownAST[],
|
||||
startIndex: number
|
||||
): {
|
||||
endIndex: number;
|
||||
deltas: DeltaInsert<AffineTextAttributes>[];
|
||||
} | null {
|
||||
const startNode = children[startIndex];
|
||||
if (!isHtmlNode(startNode)) {
|
||||
return null;
|
||||
}
|
||||
const startTag = getHtmlTagInfo(startNode.value);
|
||||
if (
|
||||
!startTag ||
|
||||
startTag.kind !== 'open' ||
|
||||
!INLINE_HTML_TAGS.has(startTag.name)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stack = [startTag.name];
|
||||
let html = startNode.value;
|
||||
let endIndex = startIndex;
|
||||
|
||||
for (let i = startIndex + 1; i < children.length; i++) {
|
||||
const node = children[i];
|
||||
if (isHtmlNode(node)) {
|
||||
const info = getHtmlTagInfo(node.value);
|
||||
if (!info) {
|
||||
html += node.value;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (info.kind === 'open') {
|
||||
if (!ALLOWED_INLINE_HTML_TAGS.has(info.name)) {
|
||||
return null;
|
||||
}
|
||||
stack.push(info.name);
|
||||
html += node.value;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (info.kind === 'self') {
|
||||
if (!ALLOWED_INLINE_HTML_TAGS.has(info.name)) {
|
||||
return null;
|
||||
}
|
||||
html += node.value;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ALLOWED_INLINE_HTML_TAGS.has(info.name)) {
|
||||
return null;
|
||||
}
|
||||
const last = stack[stack.length - 1];
|
||||
if (last !== info.name) {
|
||||
return null;
|
||||
}
|
||||
stack.pop();
|
||||
|
||||
html += node.value;
|
||||
endIndex = i;
|
||||
if (stack.length === 0) {
|
||||
return {
|
||||
endIndex,
|
||||
deltas: this._convertHtmlToDelta(html),
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isTextNode(node)) {
|
||||
html += node.value;
|
||||
continue;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private _astChildrenToDelta(
|
||||
children: MarkdownAST[]
|
||||
): DeltaInsert<AffineTextAttributes>[] {
|
||||
const deltas: DeltaInsert<AffineTextAttributes>[] = [];
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const merged = this._mergeInlineHtml(children, i);
|
||||
if (merged) {
|
||||
deltas.push(...merged.deltas);
|
||||
i = merged.endIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
deltas.push(...this.astToDelta(children[i]));
|
||||
}
|
||||
return deltas;
|
||||
}
|
||||
|
||||
astToDelta(ast: MarkdownAST): DeltaInsert<AffineTextAttributes>[] {
|
||||
const context = {
|
||||
configs: this.configs,
|
||||
options: Object.create(null),
|
||||
toDelta: (ast: MarkdownAST) => this.astToDelta(ast),
|
||||
htmlToDelta: (html: string) => this._convertHtmlToDelta(html),
|
||||
};
|
||||
for (const matcher of this.markdownASTToDeltaMatchers) {
|
||||
if (matcher.match(ast)) {
|
||||
@@ -107,7 +315,7 @@ export class MarkdownDeltaConverter extends DeltaASTConverter<
|
||||
}
|
||||
}
|
||||
return 'children' in ast
|
||||
? ast.children.flatMap(child => this.astToDelta(child))
|
||||
? this._astChildrenToDelta(ast.children as MarkdownAST[])
|
||||
: [];
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,11 @@ import remarkParse from 'remark-parse';
|
||||
import remarkStringify from 'remark-stringify';
|
||||
import { unified } from 'unified';
|
||||
|
||||
import {
|
||||
HtmlASTToDeltaMatcherIdentifier,
|
||||
HtmlDeltaConverter,
|
||||
InlineDeltaToHtmlAdapterMatcherIdentifier,
|
||||
} from '../html/delta-converter.js';
|
||||
import { type AdapterContext, AdapterFactoryIdentifier } from '../types';
|
||||
import {
|
||||
type BlockMarkdownAdapterMatcher,
|
||||
@@ -184,11 +189,24 @@ export class MarkdownAdapter extends BaseAdapter<Markdown> {
|
||||
const markdownInlineToDeltaMatchers = Array.from(
|
||||
provider.getAll(MarkdownASTToDeltaMatcherIdentifier).values()
|
||||
);
|
||||
const inlineDeltaToHtmlAdapterMatchers = Array.from(
|
||||
provider.getAll(InlineDeltaToHtmlAdapterMatcherIdentifier).values()
|
||||
);
|
||||
const htmlInlineToDeltaMatchers = Array.from(
|
||||
provider.getAll(HtmlASTToDeltaMatcherIdentifier).values()
|
||||
);
|
||||
const htmlDeltaConverter = new HtmlDeltaConverter(
|
||||
job.adapterConfigs,
|
||||
inlineDeltaToHtmlAdapterMatchers,
|
||||
htmlInlineToDeltaMatchers,
|
||||
provider
|
||||
);
|
||||
this.blockMatchers = blockMatchers;
|
||||
this.deltaConverter = new MarkdownDeltaConverter(
|
||||
job.adapterConfigs,
|
||||
inlineDeltaToMarkdownAdapterMatchers,
|
||||
markdownInlineToDeltaMatchers
|
||||
markdownInlineToDeltaMatchers,
|
||||
htmlDeltaConverter
|
||||
);
|
||||
this.preprocessorManager = new MarkdownPreprocessorManager(provider);
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ export type ASTToDeltaMatcher<AST> = {
|
||||
ast: AST,
|
||||
options?: DeltaASTConverterOptions
|
||||
) => DeltaInsert<AffineTextAttributes>[];
|
||||
htmlToDelta?: (html: string) => DeltaInsert<AffineTextAttributes>[];
|
||||
}
|
||||
) => DeltaInsert<AffineTextAttributes>[];
|
||||
};
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"fflate": "^0.8.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
"lit": "^3.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"mammoth": "^1.11.0",
|
||||
|
||||
@@ -15,10 +15,183 @@ import type {
|
||||
Store,
|
||||
Workspace,
|
||||
} from '@blocksuite/store';
|
||||
import type { DocMeta } from '@blocksuite/store';
|
||||
import { extMimeMap, Transformer } from '@blocksuite/store';
|
||||
|
||||
import type { AssetMap, ImportedFileEntry, PathBlobIdMap } from './type.js';
|
||||
import { createAssetsArchive, download, Unzip } from './utils.js';
|
||||
import { createAssetsArchive, download, parseMatter, Unzip } from './utils.js';
|
||||
|
||||
type ParsedFrontmatterMeta = Partial<
|
||||
Pick<DocMeta, 'title' | 'createDate' | 'updatedDate' | 'tags' | 'favorite'>
|
||||
>;
|
||||
|
||||
const FRONTMATTER_KEYS = {
|
||||
title: ['title', 'name'],
|
||||
created: [
|
||||
'created',
|
||||
'createdat',
|
||||
'created_at',
|
||||
'createddate',
|
||||
'created_date',
|
||||
'creationdate',
|
||||
'date',
|
||||
'time',
|
||||
],
|
||||
updated: [
|
||||
'updated',
|
||||
'updatedat',
|
||||
'updated_at',
|
||||
'updateddate',
|
||||
'updated_date',
|
||||
'modified',
|
||||
'modifiedat',
|
||||
'modified_at',
|
||||
'lastmodified',
|
||||
'last_modified',
|
||||
'lastedited',
|
||||
'last_edited',
|
||||
'lasteditedtime',
|
||||
'last_edited_time',
|
||||
],
|
||||
tags: ['tags', 'tag', 'categories', 'category', 'labels', 'keywords'],
|
||||
favorite: ['favorite', 'favourite', 'star', 'starred', 'pinned'],
|
||||
trash: ['trash', 'trashed', 'deleted', 'archived'],
|
||||
};
|
||||
|
||||
const truthyStrings = new Set(['true', 'yes', 'y', '1', 'on']);
|
||||
const falsyStrings = new Set(['false', 'no', 'n', '0', 'off']);
|
||||
|
||||
function parseBoolean(value: unknown): boolean | undefined {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value === 'number') {
|
||||
if (value === 1) return true;
|
||||
if (value === 0) return false;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (truthyStrings.has(normalized)) return true;
|
||||
if (falsyStrings.has(normalized)) return false;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseTimestamp(value: unknown): number | undefined {
|
||||
if (value && value instanceof Date) {
|
||||
return value.getTime();
|
||||
}
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value > 1e10 ? value : Math.round(value * 1000);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const num = Number(value);
|
||||
if (!Number.isNaN(num)) {
|
||||
return num > 1e10 ? num : Math.round(num * 1000);
|
||||
}
|
||||
const parsed = Date.parse(value);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseTags(value: unknown): string[] | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
const tags = value
|
||||
.map(v => (typeof v === 'string' ? v : String(v)))
|
||||
.map(v => v.trim())
|
||||
.filter(Boolean);
|
||||
return tags.length ? [...new Set(tags)] : undefined;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const tags = value
|
||||
.split(/[,;]+/)
|
||||
.map(v => v.trim())
|
||||
.filter(Boolean);
|
||||
return tags.length ? [...new Set(tags)] : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildMetaFromFrontmatter(
|
||||
data: Record<string, unknown>
|
||||
): ParsedFrontmatterMeta {
|
||||
const meta: ParsedFrontmatterMeta = {};
|
||||
for (const [rawKey, value] of Object.entries(data)) {
|
||||
const key = rawKey.trim().toLowerCase();
|
||||
if (FRONTMATTER_KEYS.title.includes(key) && typeof value === 'string') {
|
||||
const title = value.trim();
|
||||
if (title) meta.title = title;
|
||||
continue;
|
||||
}
|
||||
if (FRONTMATTER_KEYS.created.includes(key)) {
|
||||
const timestamp = parseTimestamp(value);
|
||||
if (timestamp !== undefined) {
|
||||
meta.createDate = timestamp;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (FRONTMATTER_KEYS.updated.includes(key)) {
|
||||
const timestamp = parseTimestamp(value);
|
||||
if (timestamp !== undefined) {
|
||||
meta.updatedDate = timestamp;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (FRONTMATTER_KEYS.tags.includes(key)) {
|
||||
const tags = parseTags(value);
|
||||
if (tags) meta.tags = tags;
|
||||
continue;
|
||||
}
|
||||
if (FRONTMATTER_KEYS.favorite.includes(key)) {
|
||||
const favorite = parseBoolean(value);
|
||||
if (favorite !== undefined) {
|
||||
meta.favorite = favorite;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return meta;
|
||||
}
|
||||
|
||||
function parseFrontmatter(markdown: string): {
|
||||
content: string;
|
||||
meta: ParsedFrontmatterMeta;
|
||||
} {
|
||||
try {
|
||||
const parsed = parseMatter(markdown);
|
||||
if (!parsed) {
|
||||
return { content: markdown, meta: {} };
|
||||
}
|
||||
const content = parsed.body ?? markdown;
|
||||
|
||||
if (Array.isArray(parsed.metadata)) {
|
||||
return { content: String(content), meta: {} };
|
||||
}
|
||||
|
||||
const meta = buildMetaFromFrontmatter({ ...parsed.metadata });
|
||||
return { content: String(content), meta };
|
||||
} catch {
|
||||
return { content: markdown, meta: {} };
|
||||
}
|
||||
}
|
||||
|
||||
function applyMetaPatch(
|
||||
collection: Workspace,
|
||||
docId: string,
|
||||
meta: ParsedFrontmatterMeta
|
||||
) {
|
||||
const metaPatch: Partial<DocMeta> = {};
|
||||
if (meta.title) metaPatch.title = meta.title;
|
||||
if (meta.createDate !== undefined) metaPatch.createDate = meta.createDate;
|
||||
if (meta.updatedDate !== undefined) metaPatch.updatedDate = meta.updatedDate;
|
||||
if (meta.tags) metaPatch.tags = meta.tags;
|
||||
if (meta.favorite !== undefined) metaPatch.favorite = meta.favorite;
|
||||
|
||||
if (Object.keys(metaPatch).length) {
|
||||
collection.meta.setDocMeta(docId, metaPatch);
|
||||
}
|
||||
}
|
||||
|
||||
function getProvider(extensions: ExtensionType[]) {
|
||||
const container = new Container();
|
||||
@@ -153,6 +326,8 @@ async function importMarkdownToDoc({
|
||||
fileName,
|
||||
extensions,
|
||||
}: ImportMarkdownToDocOptions) {
|
||||
const { content, meta } = parseFrontmatter(markdown);
|
||||
const preferredTitle = meta.title ?? fileName;
|
||||
const provider = getProvider(extensions);
|
||||
const job = new Transformer({
|
||||
schema,
|
||||
@@ -164,18 +339,19 @@ async function importMarkdownToDoc({
|
||||
},
|
||||
middlewares: [
|
||||
defaultImageProxyMiddleware,
|
||||
fileNameMiddleware(fileName),
|
||||
fileNameMiddleware(preferredTitle),
|
||||
docLinkBaseURLMiddleware(collection.id),
|
||||
],
|
||||
});
|
||||
const mdAdapter = new MarkdownAdapter(job, provider);
|
||||
const page = await mdAdapter.toDoc({
|
||||
file: markdown,
|
||||
file: content,
|
||||
assets: job.assetsManager,
|
||||
});
|
||||
if (!page) {
|
||||
return;
|
||||
}
|
||||
applyMetaPatch(collection, page.id, meta);
|
||||
return page.id;
|
||||
}
|
||||
|
||||
@@ -232,6 +408,9 @@ async function importMarkdownZip({
|
||||
markdownBlobs.map(async markdownFile => {
|
||||
const { filename, contentBlob, fullPath } = markdownFile;
|
||||
const fileNameWithoutExt = filename.replace(/\.[^/.]+$/, '');
|
||||
const markdown = await contentBlob.text();
|
||||
const { content, meta } = parseFrontmatter(markdown);
|
||||
const preferredTitle = meta.title ?? fileNameWithoutExt;
|
||||
const job = new Transformer({
|
||||
schema,
|
||||
blobCRUD: collection.blobSync,
|
||||
@@ -242,7 +421,7 @@ async function importMarkdownZip({
|
||||
},
|
||||
middlewares: [
|
||||
defaultImageProxyMiddleware,
|
||||
fileNameMiddleware(fileNameWithoutExt),
|
||||
fileNameMiddleware(preferredTitle),
|
||||
docLinkBaseURLMiddleware(collection.id),
|
||||
filePathMiddleware(fullPath),
|
||||
],
|
||||
@@ -262,12 +441,12 @@ async function importMarkdownZip({
|
||||
}
|
||||
|
||||
const mdAdapter = new MarkdownAdapter(job, provider);
|
||||
const markdown = await contentBlob.text();
|
||||
const doc = await mdAdapter.toDoc({
|
||||
file: markdown,
|
||||
file: content,
|
||||
assets: job.assetsManager,
|
||||
});
|
||||
if (doc) {
|
||||
applyMetaPatch(collection, doc.id, meta);
|
||||
docIds.push(doc.id);
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { extMimeMap, getAssetName } from '@blocksuite/store';
|
||||
import * as fflate from 'fflate';
|
||||
import { FAILSAFE_SCHEMA, load as loadYaml } from 'js-yaml';
|
||||
|
||||
export class Zip {
|
||||
private compressed = new Uint8Array();
|
||||
@@ -208,3 +209,14 @@ export function download(blob: Blob, name: string) {
|
||||
element.remove();
|
||||
URL.revokeObjectURL(fileURL);
|
||||
}
|
||||
|
||||
const metaMatcher = /(?<=---)(.*?)(?=---)/ms;
|
||||
const bodyMatcher = /---.*?---/s;
|
||||
export const parseMatter = (contents: string) => {
|
||||
const matterMatch = contents.match(metaMatcher);
|
||||
if (!matterMatch || !matterMatch[0]) return null;
|
||||
const metadata = loadYaml(matterMatch[0], { schema: FAILSAFE_SCHEMA });
|
||||
if (!metadata || typeof metadata !== 'object') return null;
|
||||
const body = contents.replace(bodyMatcher, '');
|
||||
return { matter: matterMatch[0], body, metadata };
|
||||
};
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
"vite": "^7.2.7",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"packageManager": "yarn@4.9.1",
|
||||
"packageManager": "yarn@4.12.0",
|
||||
"resolutions": {
|
||||
"array-buffer-byte-length": "npm:@nolyfill/array-buffer-byte-length@^1",
|
||||
"array-includes": "npm:@nolyfill/array-includes@^1",
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `expires_at` on the `multiple_users_sessions` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `seq` on the `snapshots` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `seq` on the `updates` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `accepted` on the `workspace_user_permissions` table. All the data in the column will be lost.
|
||||
- You are about to drop the `app_runtime_settings` table. If the table is not empty, all the data it contains will be lost.
|
||||
- You are about to drop the `user_invoices` table. If the table is not empty, all the data it contains will be lost.
|
||||
- You are about to drop the `user_subscriptions` table. If the table is not empty, all the data it contains will be lost.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "app_runtime_settings" DROP CONSTRAINT "app_runtime_settings_last_updated_by_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "multiple_users_sessions" DROP COLUMN "expires_at";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "snapshots" DROP COLUMN "seq";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "updates" DROP COLUMN "seq";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "workspace_user_permissions" DROP COLUMN "accepted";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "app_runtime_settings";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "user_invoices";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "user_subscriptions";
|
||||
|
||||
-- DropEnum
|
||||
DROP TYPE "RuntimeConfigType";
|
||||
@@ -93,7 +93,7 @@
|
||||
"html-validate": "^9.0.0",
|
||||
"htmlrewriter": "^0.0.12",
|
||||
"http-errors": "^2.0.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"ioredis": "^5.8.2",
|
||||
"is-mobile": "^5.0.0",
|
||||
"jose": "^6.1.3",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
|
||||
@@ -25,31 +25,29 @@ model User {
|
||||
registered Boolean @default(true)
|
||||
disabled Boolean @default(false)
|
||||
|
||||
features UserFeature[]
|
||||
userStripeCustomer UserStripeCustomer?
|
||||
workspaces WorkspaceUserRole[]
|
||||
features UserFeature[]
|
||||
userStripeCustomer UserStripeCustomer?
|
||||
workspaces WorkspaceUserRole[]
|
||||
// Invite others to join the workspace
|
||||
WorkspaceInvitations WorkspaceUserRole[] @relation("inviter")
|
||||
docPermissions WorkspaceDocUserRole[]
|
||||
connectedAccounts ConnectedAccount[]
|
||||
sessions UserSession[]
|
||||
aiSessions AiSession[]
|
||||
/// @deprecated
|
||||
deprecatedAppRuntimeSettings DeprecatedAppRuntimeSettings[]
|
||||
appConfigs AppConfig[]
|
||||
userSnapshots UserSnapshot[]
|
||||
createdSnapshot Snapshot[] @relation("createdSnapshot")
|
||||
updatedSnapshot Snapshot[] @relation("updatedSnapshot")
|
||||
createdUpdate Update[] @relation("createdUpdate")
|
||||
createdHistory SnapshotHistory[] @relation("createdHistory")
|
||||
createdAiJobs AiJobs[] @relation("createdAiJobs")
|
||||
WorkspaceInvitations WorkspaceUserRole[] @relation("inviter")
|
||||
docPermissions WorkspaceDocUserRole[]
|
||||
connectedAccounts ConnectedAccount[]
|
||||
sessions UserSession[]
|
||||
aiSessions AiSession[]
|
||||
appConfigs AppConfig[]
|
||||
userSnapshots UserSnapshot[]
|
||||
createdSnapshot Snapshot[] @relation("createdSnapshot")
|
||||
updatedSnapshot Snapshot[] @relation("updatedSnapshot")
|
||||
createdUpdate Update[] @relation("createdUpdate")
|
||||
createdHistory SnapshotHistory[] @relation("createdHistory")
|
||||
createdAiJobs AiJobs[] @relation("createdAiJobs")
|
||||
// receive notifications
|
||||
notifications Notification[] @relation("user_notifications")
|
||||
settings UserSettings?
|
||||
comments Comment[]
|
||||
replies Reply[]
|
||||
commentAttachments CommentAttachment[] @relation("createdCommentAttachments")
|
||||
AccessToken AccessToken[]
|
||||
notifications Notification[] @relation("user_notifications")
|
||||
settings UserSettings?
|
||||
comments Comment[]
|
||||
replies Reply[]
|
||||
commentAttachments CommentAttachment[] @relation("createdCommentAttachments")
|
||||
AccessToken AccessToken[]
|
||||
|
||||
@@index([email])
|
||||
@@map("users")
|
||||
@@ -79,9 +77,6 @@ model Session {
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
userSessions UserSession[]
|
||||
|
||||
// @deprecated use [UserSession.expiresAt]
|
||||
deprecated_expiresAt DateTime? @map("expires_at") @db.Timestamptz(3)
|
||||
|
||||
@@map("multiple_users_sessions")
|
||||
}
|
||||
|
||||
@@ -207,9 +202,6 @@ model WorkspaceUserRole {
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
inviter User? @relation(name: "inviter", fields: [inviterId], references: [id], onDelete: SetNull)
|
||||
|
||||
/// @deprecated Whether the permission invitation is accepted by the user, use status instead
|
||||
accepted Boolean @default(false)
|
||||
|
||||
@@unique([workspaceId, userId])
|
||||
// optimize for querying user's workspace permissions
|
||||
@@index([workspaceId, type, status])
|
||||
@@ -329,9 +321,6 @@ model Snapshot {
|
||||
createdByUser User? @relation(name: "createdSnapshot", fields: [createdBy], references: [id], onDelete: SetNull)
|
||||
updatedByUser User? @relation(name: "updatedSnapshot", fields: [updatedBy], references: [id], onDelete: SetNull)
|
||||
|
||||
// @deprecated use updatedAt only
|
||||
seq Int? @default(0) @db.Integer
|
||||
|
||||
// we need to clear all hanging updates and snapshots before enable the foreign key on workspaceId
|
||||
// workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -369,9 +358,6 @@ model Update {
|
||||
// will delete creator record if creator's account is deleted
|
||||
createdByUser User? @relation(name: "createdUpdate", fields: [createdBy], references: [id], onDelete: SetNull)
|
||||
|
||||
// @deprecated use createdAt only
|
||||
seq Int? @db.Integer
|
||||
|
||||
@@id([workspaceId, id, createdAt])
|
||||
@@map("updates")
|
||||
}
|
||||
@@ -645,32 +631,6 @@ model DataMigration {
|
||||
@@map("_data_migrations")
|
||||
}
|
||||
|
||||
enum RuntimeConfigType {
|
||||
String
|
||||
Number
|
||||
Boolean
|
||||
Object
|
||||
Array
|
||||
}
|
||||
|
||||
/// @deprecated use AppConfig instead
|
||||
model DeprecatedAppRuntimeSettings {
|
||||
id String @id @db.VarChar
|
||||
type RuntimeConfigType
|
||||
module String @db.VarChar
|
||||
key String @db.VarChar
|
||||
value Json @db.Json
|
||||
description String @db.Text
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
|
||||
lastUpdatedBy String? @map("last_updated_by") @db.VarChar
|
||||
|
||||
lastUpdatedByUser User? @relation(fields: [lastUpdatedBy], references: [id])
|
||||
|
||||
@@unique([module, key])
|
||||
@@map("app_runtime_settings")
|
||||
}
|
||||
|
||||
model AppConfig {
|
||||
id String @id @db.VarChar
|
||||
value Json @db.JsonB
|
||||
@@ -683,64 +643,6 @@ model AppConfig {
|
||||
@@map("app_configs")
|
||||
}
|
||||
|
||||
model DeprecatedUserSubscription {
|
||||
id Int @id @default(autoincrement()) @db.Integer
|
||||
userId String @map("user_id") @db.VarChar
|
||||
plan String @db.VarChar(20)
|
||||
// yearly/monthly/lifetime
|
||||
recurring String @db.VarChar(20)
|
||||
// onetime subscription or anything else
|
||||
variant String? @db.VarChar(20)
|
||||
// subscription.id, null for lifetime payment or one time payment subscription
|
||||
stripeSubscriptionId String? @unique @map("stripe_subscription_id")
|
||||
// subscription.status, active/past_due/canceled/unpaid...
|
||||
status String @db.VarChar(20)
|
||||
// subscription.current_period_start
|
||||
start DateTime @map("start") @db.Timestamptz(3)
|
||||
// subscription.current_period_end, null for lifetime payment
|
||||
end DateTime? @map("end") @db.Timestamptz(3)
|
||||
// subscription.billing_cycle_anchor
|
||||
nextBillAt DateTime? @map("next_bill_at") @db.Timestamptz(3)
|
||||
// subscription.canceled_at
|
||||
canceledAt DateTime? @map("canceled_at") @db.Timestamptz(3)
|
||||
// subscription.trial_start
|
||||
trialStart DateTime? @map("trial_start") @db.Timestamptz(3)
|
||||
// subscription.trial_end
|
||||
trialEnd DateTime? @map("trial_end") @db.Timestamptz(3)
|
||||
stripeScheduleId String? @map("stripe_schedule_id") @db.VarChar
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
@@unique([userId, plan])
|
||||
@@map("user_subscriptions")
|
||||
}
|
||||
|
||||
model DeprecatedUserInvoice {
|
||||
id Int @id @default(autoincrement()) @db.Integer
|
||||
userId String @map("user_id") @db.VarChar
|
||||
stripeInvoiceId String @unique @map("stripe_invoice_id")
|
||||
currency String @db.VarChar(3)
|
||||
// CNY 12.50 stored as 1250
|
||||
amount Int @db.Integer
|
||||
status String @db.VarChar(20)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
// billing reason
|
||||
reason String? @db.VarChar
|
||||
lastPaymentError String? @map("last_payment_error") @db.Text
|
||||
// stripe hosted invoice link
|
||||
link String? @db.Text
|
||||
|
||||
// @deprecated
|
||||
plan String? @db.VarChar(20)
|
||||
// @deprecated
|
||||
recurring String? @db.VarChar(20)
|
||||
|
||||
@@index([userId])
|
||||
@@map("user_invoices")
|
||||
}
|
||||
|
||||
model UserStripeCustomer {
|
||||
userId String @id @map("user_id") @db.VarChar
|
||||
stripeCustomerId String @unique @map("stripe_customer_id") @db.VarChar
|
||||
|
||||
@@ -41,7 +41,6 @@ const snapshot: Snapshot = {
|
||||
id: 'doc1',
|
||||
blob: Uint8Array.from([1, 0]),
|
||||
state: Uint8Array.from([0]),
|
||||
seq: 0,
|
||||
size: BigInt(2),
|
||||
updatedAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
|
||||
@@ -38,7 +38,6 @@ test('should create a new session', async t => {
|
||||
const session = await t.context.session.createSession();
|
||||
t.truthy(session.id);
|
||||
t.truthy(session.createdAt);
|
||||
t.is(session.deprecated_expiresAt, null);
|
||||
});
|
||||
|
||||
test('should get a exists session', async t => {
|
||||
|
||||
@@ -16,7 +16,6 @@ export class WorkspaceResolverMock {
|
||||
create: {
|
||||
type: WorkspaceRole.Owner,
|
||||
userId: user.id,
|
||||
accepted: true,
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client';
|
||||
|
||||
export class MigrateInviteStatus1732861452428 {
|
||||
// do the migration
|
||||
static async up(db: PrismaClient) {
|
||||
await db.workspaceUserRole.updateMany({
|
||||
where: {
|
||||
accepted: true,
|
||||
},
|
||||
data: {
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// revert the migration
|
||||
static async down(_db: PrismaClient) {}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { loop } from './utils/loop';
|
||||
|
||||
export class UniversalSubscription1733125339942 {
|
||||
// do the migration
|
||||
static async up(db: PrismaClient) {
|
||||
await loop(async (offset, take) => {
|
||||
const oldSubscriptions = await db.deprecatedUserSubscription.findMany({
|
||||
skip: offset,
|
||||
take,
|
||||
});
|
||||
|
||||
await db.subscription.createMany({
|
||||
data: oldSubscriptions.map(({ userId, ...subscription }) => ({
|
||||
targetId: userId,
|
||||
...subscription,
|
||||
})),
|
||||
});
|
||||
|
||||
return oldSubscriptions.length;
|
||||
}, 50);
|
||||
}
|
||||
|
||||
// revert the migration
|
||||
static async down(_db: PrismaClient) {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
export * from './1698398506533-guid';
|
||||
export * from './1703756315970-unamed-account';
|
||||
export * from './1721299086340-refresh-unnamed-user';
|
||||
export * from './1732861452428-migrate-invite-status';
|
||||
export * from './1733125339942-universal-subscription';
|
||||
export * from './1745211351719-create-indexer-tables';
|
||||
export * from './1751966744168-correct-session-update-time';
|
||||
|
||||
@@ -61,7 +61,6 @@ export class DocModel extends BaseModel {
|
||||
blob: record.blob,
|
||||
createdAt: new Date(record.timestamp),
|
||||
createdBy: record.editorId || null,
|
||||
seq: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,8 @@ interface CloudBlobStorageOptions {
|
||||
id: string;
|
||||
}
|
||||
|
||||
const SHOULD_MANUAL_REDIRECT = BUILD_CONFIG.isAndroid || BUILD_CONFIG.isIOS;
|
||||
const SHOULD_MANUAL_REDIRECT =
|
||||
BUILD_CONFIG.isAndroid || BUILD_CONFIG.isIOS || BUILD_CONFIG.isElectron;
|
||||
const UPLOAD_REQUEST_TIMEOUT = 0;
|
||||
|
||||
export class CloudBlobStorage extends BlobStorageBase {
|
||||
|
||||
Submodule packages/common/y-octo/yjs deleted from 7126035d1b
@@ -1,11 +1,6 @@
|
||||
import { Button } from '@affine/component';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { appIconMap } from '@affine/core/utils';
|
||||
import {
|
||||
createStreamEncoder,
|
||||
encodeRawBufferToOpus,
|
||||
type OpusStreamEncoder,
|
||||
} from '@affine/core/utils/opus-encoding';
|
||||
import { apis, events } from '@affine/electron-api';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import track from '@affine/track';
|
||||
@@ -105,61 +100,8 @@ export function Recording() {
|
||||
await apis?.recording?.stopRecording(status.id);
|
||||
}, [status]);
|
||||
|
||||
const handleProcessStoppedRecording = useAsyncCallback(
|
||||
async (currentStreamEncoder?: OpusStreamEncoder) => {
|
||||
let id: number | undefined;
|
||||
try {
|
||||
const result = await apis?.recording?.getCurrentRecording();
|
||||
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
id = result.id;
|
||||
|
||||
const { filepath, sampleRate, numberOfChannels } = result;
|
||||
if (!filepath || !sampleRate || !numberOfChannels) {
|
||||
return;
|
||||
}
|
||||
const [buffer] = await Promise.all([
|
||||
currentStreamEncoder
|
||||
? currentStreamEncoder.finish()
|
||||
: encodeRawBufferToOpus({
|
||||
filepath,
|
||||
sampleRate,
|
||||
numberOfChannels,
|
||||
}),
|
||||
new Promise<void>(resolve => {
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, 500); // wait at least 500ms for better user experience
|
||||
}),
|
||||
]);
|
||||
await apis?.recording.readyRecording(result.id, buffer);
|
||||
} catch (error) {
|
||||
console.error('Failed to stop recording', error);
|
||||
await apis?.popup?.dismissCurrentRecording();
|
||||
if (id) {
|
||||
await apis?.recording.removeRecording(id);
|
||||
}
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let removed = false;
|
||||
let currentStreamEncoder: OpusStreamEncoder | undefined;
|
||||
|
||||
apis?.recording
|
||||
.getCurrentRecording()
|
||||
.then(status => {
|
||||
if (status) {
|
||||
return handleRecordingStatusChanged(status);
|
||||
}
|
||||
return;
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
const handleRecordingStatusChanged = async (status: Status) => {
|
||||
if (removed) {
|
||||
@@ -171,27 +113,18 @@ export function Recording() {
|
||||
appName: status.appName || 'System Audio',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
status?.status === 'recording' &&
|
||||
status.sampleRate &&
|
||||
status.numberOfChannels &&
|
||||
(!currentStreamEncoder || currentStreamEncoder.id !== status.id)
|
||||
) {
|
||||
currentStreamEncoder?.close();
|
||||
currentStreamEncoder = createStreamEncoder(status.id, {
|
||||
sampleRate: status.sampleRate,
|
||||
numberOfChannels: status.numberOfChannels,
|
||||
});
|
||||
currentStreamEncoder.poll().catch(console.error);
|
||||
}
|
||||
|
||||
if (status?.status === 'stopped') {
|
||||
handleProcessStoppedRecording(currentStreamEncoder);
|
||||
currentStreamEncoder = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
apis?.recording
|
||||
.getCurrentRecording()
|
||||
.then(status => {
|
||||
if (status) {
|
||||
return handleRecordingStatusChanged(status);
|
||||
}
|
||||
return;
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
// allow processing stopped event in tray menu as well:
|
||||
const unsubscribe = events?.recording.onRecordingStatusChanged(status => {
|
||||
if (status) {
|
||||
@@ -202,9 +135,8 @@ export function Recording() {
|
||||
return () => {
|
||||
removed = true;
|
||||
unsubscribe?.();
|
||||
currentStreamEncoder?.close();
|
||||
};
|
||||
}, [handleProcessStoppedRecording]);
|
||||
}, []);
|
||||
|
||||
const handleStartRecording = useAsyncCallback(async () => {
|
||||
if (!status) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/* oxlint-disable no-var-requires */
|
||||
import { execSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
@@ -32,12 +31,7 @@ import { getMainWindow } from '../windows-manager';
|
||||
import { popupManager } from '../windows-manager/popup';
|
||||
import { isAppNameAllowed } from './allow-list';
|
||||
import { recordingStateMachine } from './state-machine';
|
||||
import type {
|
||||
AppGroupInfo,
|
||||
Recording,
|
||||
RecordingStatus,
|
||||
TappableAppInfo,
|
||||
} from './types';
|
||||
import type { AppGroupInfo, RecordingStatus, TappableAppInfo } from './types';
|
||||
|
||||
export const MeetingsSettingsState = {
|
||||
$: globalStateStorage.watch<MeetingSettingsSchema>(MeetingSettingsKey).pipe(
|
||||
@@ -56,7 +50,12 @@ export const MeetingsSettingsState = {
|
||||
},
|
||||
};
|
||||
|
||||
type Subscriber = {
|
||||
unsubscribe: () => void;
|
||||
};
|
||||
|
||||
const subscribers: Subscriber[] = [];
|
||||
let appStateSubscribers: Subscriber[] = [];
|
||||
|
||||
// recordings are saved in the app data directory
|
||||
// may need a way to clean up old recordings
|
||||
@@ -67,8 +66,22 @@ export const SAVED_RECORDINGS_DIR = path.join(
|
||||
|
||||
let shareableContent: ShareableContentType | null = null;
|
||||
|
||||
type NativeModule = typeof import('@affine/native');
|
||||
|
||||
function getNativeModule(): NativeModule {
|
||||
return require('@affine/native') as NativeModule;
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
shareableContent = null;
|
||||
appStateSubscribers.forEach(subscriber => {
|
||||
try {
|
||||
subscriber.unsubscribe();
|
||||
} catch {
|
||||
// ignore unsubscribe error
|
||||
}
|
||||
});
|
||||
appStateSubscribers = [];
|
||||
subscribers.forEach(subscriber => {
|
||||
try {
|
||||
subscriber.unsubscribe();
|
||||
@@ -76,6 +89,9 @@ function cleanup() {
|
||||
// ignore unsubscribe error
|
||||
}
|
||||
});
|
||||
subscribers.length = 0;
|
||||
applications$.next([]);
|
||||
appGroups$.next([]);
|
||||
}
|
||||
|
||||
beforeAppQuit(() => {
|
||||
@@ -87,18 +103,12 @@ export const appGroups$ = new BehaviorSubject<AppGroupInfo[]>([]);
|
||||
|
||||
export const updateApplicationsPing$ = new Subject<number>();
|
||||
|
||||
// recording id -> recording
|
||||
// recordings will be saved in memory before consumed and created as an audio block to user's doc
|
||||
const recordings = new Map<number, Recording>();
|
||||
|
||||
// there should be only one active recording at a time
|
||||
// We'll now use recordingStateMachine.status$ instead of our own BehaviorSubject
|
||||
// There should be only one active recording at a time; state is managed by the state machine
|
||||
export const recordingStatus$ = recordingStateMachine.status$;
|
||||
|
||||
function createAppGroup(processGroupId: number): AppGroupInfo | undefined {
|
||||
// MUST require dynamically to avoid loading @affine/native for unsupported platforms
|
||||
const SC: typeof ShareableContentType =
|
||||
require('@affine/native').ShareableContent;
|
||||
const SC: typeof ShareableContentType = getNativeModule().ShareableContent;
|
||||
const groupProcess = SC?.applicationWithProcessId(processGroupId);
|
||||
if (!groupProcess) {
|
||||
return;
|
||||
@@ -176,7 +186,9 @@ function setupNewRunningAppGroup() {
|
||||
const debounceStartRecording = debounce((appGroup: AppGroupInfo) => {
|
||||
// check if the app is running again
|
||||
if (appGroup.isRunning) {
|
||||
startRecording(appGroup);
|
||||
startRecording(appGroup).catch(err => {
|
||||
logger.error('failed to start recording', err);
|
||||
});
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
@@ -242,91 +254,20 @@ function setupNewRunningAppGroup() {
|
||||
);
|
||||
}
|
||||
|
||||
function getSanitizedAppId(bundleIdentifier?: string) {
|
||||
if (!bundleIdentifier) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
return isWindows()
|
||||
? createHash('sha256')
|
||||
.update(bundleIdentifier)
|
||||
.digest('hex')
|
||||
.substring(0, 8)
|
||||
: bundleIdentifier;
|
||||
}
|
||||
|
||||
export function createRecording(status: RecordingStatus) {
|
||||
let recording = recordings.get(status.id);
|
||||
if (recording) {
|
||||
return recording;
|
||||
}
|
||||
|
||||
const appId = getSanitizedAppId(status.appGroup?.bundleIdentifier);
|
||||
|
||||
const bufferedFilePath = path.join(
|
||||
SAVED_RECORDINGS_DIR,
|
||||
`${appId}-${status.id}-${status.startTime}.raw`
|
||||
);
|
||||
|
||||
fs.ensureDirSync(SAVED_RECORDINGS_DIR);
|
||||
const file = fs.createWriteStream(bufferedFilePath);
|
||||
|
||||
function tapAudioSamples(err: Error | null, samples: Float32Array) {
|
||||
const recordingStatus = recordingStatus$.getValue();
|
||||
if (
|
||||
!recordingStatus ||
|
||||
recordingStatus.id !== status.id ||
|
||||
recordingStatus.status === 'paused'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (err) {
|
||||
logger.error('failed to get audio samples', err);
|
||||
} else {
|
||||
// Writing raw Float32Array samples directly to file
|
||||
// For stereo audio, samples are interleaved [L,R,L,R,...]
|
||||
file.write(Buffer.from(samples.buffer));
|
||||
}
|
||||
}
|
||||
|
||||
// MUST require dynamically to avoid loading @affine/native for unsupported platforms
|
||||
const SC: typeof ShareableContentType =
|
||||
require('@affine/native').ShareableContent;
|
||||
|
||||
const stream = status.app
|
||||
? SC.tapAudio(status.app.processId, tapAudioSamples)
|
||||
: SC.tapGlobalAudio(null, tapAudioSamples);
|
||||
|
||||
recording = {
|
||||
id: status.id,
|
||||
startTime: status.startTime,
|
||||
app: status.app,
|
||||
appGroup: status.appGroup,
|
||||
file,
|
||||
session: stream,
|
||||
};
|
||||
|
||||
recordings.set(status.id, recording);
|
||||
|
||||
return recording;
|
||||
}
|
||||
|
||||
export async function getRecording(id: number) {
|
||||
const recording = recordings.get(id);
|
||||
if (!recording) {
|
||||
const recording = recordingStateMachine.status;
|
||||
if (!recording || recording.id !== id) {
|
||||
logger.error(`Recording ${id} not found`);
|
||||
return;
|
||||
}
|
||||
const rawFilePath = String(recording.file.path);
|
||||
return {
|
||||
id,
|
||||
appGroup: recording.appGroup,
|
||||
app: recording.app,
|
||||
startTime: recording.startTime,
|
||||
filepath: rawFilePath,
|
||||
sampleRate: recording.session.sampleRate,
|
||||
numberOfChannels: recording.session.channels,
|
||||
filepath: recording.filepath,
|
||||
sampleRate: recording.sampleRate,
|
||||
numberOfChannels: recording.numberOfChannels,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -350,18 +291,7 @@ function setupRecordingListeners() {
|
||||
});
|
||||
}
|
||||
|
||||
if (status?.status === 'recording') {
|
||||
let recording = recordings.get(status.id);
|
||||
// create a recording if not exists
|
||||
if (!recording) {
|
||||
recording = createRecording(status);
|
||||
}
|
||||
} else if (status?.status === 'stopped') {
|
||||
const recording = recordings.get(status.id);
|
||||
if (recording) {
|
||||
recording.session.stop();
|
||||
}
|
||||
} else if (
|
||||
if (
|
||||
status?.status === 'create-block-success' ||
|
||||
status?.status === 'create-block-failed'
|
||||
) {
|
||||
@@ -400,9 +330,7 @@ function getAllApps(): TappableAppInfo[] {
|
||||
}
|
||||
|
||||
// MUST require dynamically to avoid loading @affine/native for unsupported platforms
|
||||
const { ShareableContent } = require('@affine/native') as {
|
||||
ShareableContent: typeof ShareableContentType;
|
||||
};
|
||||
const { ShareableContent } = getNativeModule();
|
||||
|
||||
const apps = ShareableContent.applications().map(app => {
|
||||
try {
|
||||
@@ -433,12 +361,8 @@ function getAllApps(): TappableAppInfo[] {
|
||||
return filteredApps;
|
||||
}
|
||||
|
||||
type Subscriber = {
|
||||
unsubscribe: () => void;
|
||||
};
|
||||
|
||||
function setupMediaListeners() {
|
||||
const ShareableContent = require('@affine/native').ShareableContent;
|
||||
const ShareableContent = getNativeModule().ShareableContent;
|
||||
applications$.next(getAllApps());
|
||||
subscribers.push(
|
||||
interval(3000).subscribe(() => {
|
||||
@@ -454,8 +378,6 @@ function setupMediaListeners() {
|
||||
})
|
||||
);
|
||||
|
||||
let appStateSubscribers: Subscriber[] = [];
|
||||
|
||||
subscribers.push(
|
||||
applications$.subscribe(apps => {
|
||||
appStateSubscribers.forEach(subscriber => {
|
||||
@@ -484,15 +406,6 @@ function setupMediaListeners() {
|
||||
});
|
||||
|
||||
appStateSubscribers = _appStateSubscribers;
|
||||
return () => {
|
||||
_appStateSubscribers.forEach(subscriber => {
|
||||
try {
|
||||
subscriber.unsubscribe();
|
||||
} catch {
|
||||
// ignore unsubscribe error
|
||||
}
|
||||
});
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -502,7 +415,7 @@ function askForScreenRecordingPermission() {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const ShareableContent = require('@affine/native').ShareableContent;
|
||||
const ShareableContent = getNativeModule().ShareableContent;
|
||||
// this will trigger the permission prompt
|
||||
new ShareableContent();
|
||||
return true;
|
||||
@@ -519,7 +432,7 @@ export function setupRecordingFeature() {
|
||||
}
|
||||
|
||||
try {
|
||||
const ShareableContent = require('@affine/native').ShareableContent;
|
||||
const ShareableContent = getNativeModule().ShareableContent;
|
||||
if (!shareableContent) {
|
||||
shareableContent = new ShareableContent();
|
||||
setupMediaListeners();
|
||||
@@ -558,24 +471,48 @@ export function newRecording(
|
||||
});
|
||||
}
|
||||
|
||||
export function startRecording(
|
||||
export async function startRecording(
|
||||
appGroup?: AppGroupInfo | number
|
||||
): RecordingStatus | null {
|
||||
const state = recordingStateMachine.dispatch(
|
||||
{
|
||||
type: 'START_RECORDING',
|
||||
appGroup: normalizeAppGroupInfo(appGroup),
|
||||
},
|
||||
false
|
||||
);
|
||||
): Promise<RecordingStatus | null> {
|
||||
const state = recordingStateMachine.dispatch({
|
||||
type: 'START_RECORDING',
|
||||
appGroup: normalizeAppGroupInfo(appGroup),
|
||||
});
|
||||
|
||||
if (state?.status === 'recording') {
|
||||
createRecording(state);
|
||||
if (!state || state.status !== 'recording') {
|
||||
return state;
|
||||
}
|
||||
|
||||
recordingStateMachine.status$.next(state);
|
||||
try {
|
||||
fs.ensureDirSync(SAVED_RECORDINGS_DIR);
|
||||
|
||||
return state;
|
||||
const meta = getNativeModule().startRecording({
|
||||
appProcessId: state.app?.processId,
|
||||
outputDir: SAVED_RECORDINGS_DIR,
|
||||
format: 'opus',
|
||||
id: String(state.id),
|
||||
});
|
||||
|
||||
const filepath = assertRecordingFilepath(meta.filepath);
|
||||
const nextState = recordingStateMachine.dispatch({
|
||||
type: 'ATTACH_NATIVE_RECORDING',
|
||||
id: state.id,
|
||||
nativeId: meta.id,
|
||||
startTime: meta.startedAt ?? state.startTime,
|
||||
filepath,
|
||||
sampleRate: meta.sampleRate,
|
||||
numberOfChannels: meta.channels,
|
||||
});
|
||||
|
||||
return nextState;
|
||||
} catch (error) {
|
||||
logger.error('failed to start recording', error);
|
||||
return recordingStateMachine.dispatch({
|
||||
type: 'CREATE_BLOCK_FAILED',
|
||||
id: state.id,
|
||||
error: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function pauseRecording(id: number) {
|
||||
@@ -587,61 +524,49 @@ export function resumeRecording(id: number) {
|
||||
}
|
||||
|
||||
export async function stopRecording(id: number) {
|
||||
const recording = recordings.get(id);
|
||||
if (!recording) {
|
||||
const recording = recordingStateMachine.status;
|
||||
if (!recording || recording.id !== id) {
|
||||
logger.error(`stopRecording: Recording ${id} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!recording.file.path) {
|
||||
logger.error(`Recording ${id} has no file path`);
|
||||
if (!recording.nativeId) {
|
||||
logger.error(`stopRecording: Recording ${id} missing native id`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { file, session: stream } = recording;
|
||||
|
||||
// First stop the audio stream to prevent more data coming in
|
||||
try {
|
||||
stream.stop();
|
||||
} catch (err) {
|
||||
logger.error('Failed to stop audio stream', err);
|
||||
}
|
||||
|
||||
// End the file with a timeout
|
||||
file.end();
|
||||
recordingStateMachine.dispatch({
|
||||
type: 'STOP_RECORDING',
|
||||
id,
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve, reject) => {
|
||||
file.on('finish', () => {
|
||||
// check if the file is empty
|
||||
const stats = fs.statSync(file.path);
|
||||
if (stats.size === 0) {
|
||||
reject(new Error('Recording is empty'));
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
|
||||
file.on('error', err => {
|
||||
reject(err);
|
||||
});
|
||||
}),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('File writing timeout')), 10000)
|
||||
),
|
||||
]);
|
||||
|
||||
const recordingStatus = recordingStateMachine.dispatch({
|
||||
type: 'STOP_RECORDING',
|
||||
const artifact = getNativeModule().stopRecording(recording.nativeId);
|
||||
const filepath = assertRecordingFilepath(artifact.filepath);
|
||||
const readyStatus = recordingStateMachine.dispatch({
|
||||
type: 'SAVE_RECORDING',
|
||||
id,
|
||||
filepath,
|
||||
sampleRate: artifact.sampleRate,
|
||||
numberOfChannels: artifact.channels,
|
||||
});
|
||||
|
||||
if (!recordingStatus) {
|
||||
logger.error('No recording status to stop');
|
||||
if (!readyStatus) {
|
||||
logger.error('No recording status to save');
|
||||
return;
|
||||
}
|
||||
return serializeRecordingStatus(recordingStatus);
|
||||
|
||||
getMainWindow()
|
||||
.then(mainWindow => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show();
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
logger.error('failed to bring up the window', err);
|
||||
});
|
||||
|
||||
return serializeRecordingStatus(readyStatus);
|
||||
} catch (error: unknown) {
|
||||
logger.error('Failed to stop recording', error);
|
||||
const recordingStatus = recordingStateMachine.dispatch({
|
||||
@@ -654,38 +579,9 @@ export async function stopRecording(id: number) {
|
||||
return;
|
||||
}
|
||||
return serializeRecordingStatus(recordingStatus);
|
||||
} finally {
|
||||
// Clean up the file stream if it's still open
|
||||
if (!file.closed) {
|
||||
file.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRawAudioBuffers(
|
||||
id: number,
|
||||
cursor?: number
|
||||
): Promise<{
|
||||
buffer: Buffer;
|
||||
nextCursor: number;
|
||||
}> {
|
||||
const recording = recordings.get(id);
|
||||
if (!recording) {
|
||||
throw new Error(`getRawAudioBuffers: Recording ${id} not found`);
|
||||
}
|
||||
const start = cursor ?? 0;
|
||||
const file = await fsp.open(recording.file.path, 'r');
|
||||
const stats = await file.stat();
|
||||
const buffer = Buffer.alloc(stats.size - start);
|
||||
const result = await file.read(buffer, 0, buffer.length, start);
|
||||
await file.close();
|
||||
|
||||
return {
|
||||
buffer,
|
||||
nextCursor: start + result.bytesRead,
|
||||
};
|
||||
}
|
||||
|
||||
function assertRecordingFilepath(filepath: string) {
|
||||
const normalizedPath = path.normalize(filepath);
|
||||
const normalizedBase = path.normalize(SAVED_RECORDINGS_DIR + path.sep);
|
||||
@@ -702,55 +598,6 @@ export async function readRecordingFile(filepath: string) {
|
||||
return fsp.readFile(normalizedPath);
|
||||
}
|
||||
|
||||
export async function readyRecording(id: number, buffer: Buffer) {
|
||||
logger.info('readyRecording', id);
|
||||
|
||||
const recordingStatus = recordingStatus$.value;
|
||||
const recording = recordings.get(id);
|
||||
if (!recordingStatus || recordingStatus.id !== id || !recording) {
|
||||
logger.error(`readyRecording: Recording ${id} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const rawFilePath = String(recording.file.path);
|
||||
|
||||
const filepath = rawFilePath.replace('.raw', '.opus');
|
||||
|
||||
if (!filepath) {
|
||||
logger.error(`readyRecording: Recording ${id} has no filepath`);
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.writeFile(filepath, buffer);
|
||||
|
||||
// can safely remove the raw file now
|
||||
logger.info('remove raw file', rawFilePath);
|
||||
if (rawFilePath) {
|
||||
try {
|
||||
await fs.unlink(rawFilePath);
|
||||
} catch (err) {
|
||||
logger.error('failed to remove raw file', err);
|
||||
}
|
||||
}
|
||||
// Update the status through the state machine
|
||||
recordingStateMachine.dispatch({
|
||||
type: 'SAVE_RECORDING',
|
||||
id,
|
||||
filepath,
|
||||
});
|
||||
|
||||
// bring up the window
|
||||
getMainWindow()
|
||||
.then(mainWindow => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show();
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
logger.error('failed to bring up the window', err);
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleBlockCreationSuccess(id: number) {
|
||||
recordingStateMachine.dispatch({
|
||||
type: 'CREATE_BLOCK_SUCCESS',
|
||||
@@ -767,7 +614,6 @@ export async function handleBlockCreationFailed(id: number, error?: Error) {
|
||||
}
|
||||
|
||||
export function removeRecording(id: number) {
|
||||
recordings.delete(id);
|
||||
recordingStateMachine.dispatch({ type: 'REMOVE_RECORDING', id });
|
||||
}
|
||||
|
||||
@@ -787,7 +633,6 @@ export interface SerializedRecordingStatus {
|
||||
export function serializeRecordingStatus(
|
||||
status: RecordingStatus
|
||||
): SerializedRecordingStatus | null {
|
||||
const recording = recordings.get(status.id);
|
||||
return {
|
||||
id: status.id,
|
||||
status: status.status,
|
||||
@@ -795,10 +640,9 @@ export function serializeRecordingStatus(
|
||||
appGroupId: status.appGroup?.processGroupId,
|
||||
icon: status.appGroup?.icon,
|
||||
startTime: status.startTime,
|
||||
filepath:
|
||||
status.filepath ?? (recording ? String(recording.file.path) : undefined),
|
||||
sampleRate: recording?.session.sampleRate,
|
||||
numberOfChannels: recording?.session.channels,
|
||||
filepath: status.filepath,
|
||||
sampleRate: status.sampleRate,
|
||||
numberOfChannels: status.numberOfChannels,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,13 +14,11 @@ import {
|
||||
checkMeetingPermissions,
|
||||
checkRecordingAvailable,
|
||||
disableRecordingFeature,
|
||||
getRawAudioBuffers,
|
||||
getRecording,
|
||||
handleBlockCreationFailed,
|
||||
handleBlockCreationSuccess,
|
||||
pauseRecording,
|
||||
readRecordingFile,
|
||||
readyRecording,
|
||||
recordingStatus$,
|
||||
removeRecording,
|
||||
SAVED_RECORDINGS_DIR,
|
||||
@@ -51,16 +49,9 @@ export const recordingHandlers = {
|
||||
stopRecording: async (_, id: number) => {
|
||||
return stopRecording(id);
|
||||
},
|
||||
getRawAudioBuffers: async (_, id: number, cursor?: number) => {
|
||||
return getRawAudioBuffers(id, cursor);
|
||||
},
|
||||
readRecordingFile: async (_, filepath: string) => {
|
||||
return readRecordingFile(filepath);
|
||||
},
|
||||
// save the encoded recording buffer to the file system
|
||||
readyRecording: async (_, id: number, buffer: Uint8Array) => {
|
||||
return readyRecording(id, Buffer.from(buffer));
|
||||
},
|
||||
handleBlockCreationSuccess: async (_, id: number) => {
|
||||
return handleBlockCreationSuccess(id);
|
||||
},
|
||||
|
||||
@@ -13,6 +13,15 @@ export type RecordingEvent =
|
||||
type: 'START_RECORDING';
|
||||
appGroup?: AppGroupInfo;
|
||||
}
|
||||
| {
|
||||
type: 'ATTACH_NATIVE_RECORDING';
|
||||
id: number;
|
||||
nativeId: string;
|
||||
startTime: number;
|
||||
filepath: string;
|
||||
sampleRate: number;
|
||||
numberOfChannels: number;
|
||||
}
|
||||
| { type: 'PAUSE_RECORDING'; id: number }
|
||||
| { type: 'RESUME_RECORDING'; id: number }
|
||||
| {
|
||||
@@ -23,6 +32,8 @@ export type RecordingEvent =
|
||||
type: 'SAVE_RECORDING';
|
||||
id: number;
|
||||
filepath: string;
|
||||
sampleRate?: number;
|
||||
numberOfChannels?: number;
|
||||
}
|
||||
| {
|
||||
type: 'CREATE_BLOCK_FAILED';
|
||||
@@ -74,6 +85,9 @@ export class RecordingStateMachine {
|
||||
case 'START_RECORDING':
|
||||
newStatus = this.handleStartRecording(event.appGroup);
|
||||
break;
|
||||
case 'ATTACH_NATIVE_RECORDING':
|
||||
newStatus = this.handleAttachNativeRecording(event);
|
||||
break;
|
||||
case 'PAUSE_RECORDING':
|
||||
newStatus = this.handlePauseRecording();
|
||||
break;
|
||||
@@ -84,7 +98,12 @@ export class RecordingStateMachine {
|
||||
newStatus = this.handleStopRecording(event.id);
|
||||
break;
|
||||
case 'SAVE_RECORDING':
|
||||
newStatus = this.handleSaveRecording(event.id, event.filepath);
|
||||
newStatus = this.handleSaveRecording(
|
||||
event.id,
|
||||
event.filepath,
|
||||
event.sampleRate,
|
||||
event.numberOfChannels
|
||||
);
|
||||
break;
|
||||
case 'CREATE_BLOCK_SUCCESS':
|
||||
newStatus = this.handleCreateBlockSuccess(event.id);
|
||||
@@ -159,6 +178,35 @@ export class RecordingStateMachine {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach native recording metadata to the current recording
|
||||
*/
|
||||
private handleAttachNativeRecording(
|
||||
event: Extract<RecordingEvent, { type: 'ATTACH_NATIVE_RECORDING' }>
|
||||
): RecordingStatus | null {
|
||||
const currentStatus = this.recordingStatus$.value;
|
||||
if (!currentStatus || currentStatus.id !== event.id) {
|
||||
logger.error(`Recording ${event.id} not found for native attachment`);
|
||||
return currentStatus;
|
||||
}
|
||||
|
||||
if (currentStatus.status !== 'recording') {
|
||||
logger.error(
|
||||
`Cannot attach native metadata when recording is in ${currentStatus.status} state`
|
||||
);
|
||||
return currentStatus;
|
||||
}
|
||||
|
||||
return {
|
||||
...currentStatus,
|
||||
nativeId: event.nativeId,
|
||||
startTime: event.startTime,
|
||||
filepath: event.filepath,
|
||||
sampleRate: event.sampleRate,
|
||||
numberOfChannels: event.numberOfChannels,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the PAUSE_RECORDING event
|
||||
*/
|
||||
@@ -233,7 +281,9 @@ export class RecordingStateMachine {
|
||||
*/
|
||||
private handleSaveRecording(
|
||||
id: number,
|
||||
filepath: string
|
||||
filepath: string,
|
||||
sampleRate?: number,
|
||||
numberOfChannels?: number
|
||||
): RecordingStatus | null {
|
||||
const currentStatus = this.recordingStatus$.value;
|
||||
|
||||
@@ -246,6 +296,8 @@ export class RecordingStateMachine {
|
||||
...currentStatus,
|
||||
status: 'ready',
|
||||
filepath,
|
||||
sampleRate,
|
||||
numberOfChannels,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import type { WriteStream } from 'node:fs';
|
||||
|
||||
import type { ApplicationInfo, AudioCaptureSession } from '@affine/native';
|
||||
import type { ApplicationInfo } from '@affine/native';
|
||||
|
||||
export interface TappableAppInfo {
|
||||
info: ApplicationInfo;
|
||||
@@ -20,18 +18,6 @@ export interface AppGroupInfo {
|
||||
isRunning: boolean;
|
||||
}
|
||||
|
||||
export interface Recording {
|
||||
id: number;
|
||||
// the app may not be available if the user choose to record system audio
|
||||
app?: TappableAppInfo;
|
||||
appGroup?: AppGroupInfo;
|
||||
// the buffered file that is being recorded streamed to
|
||||
file: WriteStream;
|
||||
session: AudioCaptureSession;
|
||||
startTime: number;
|
||||
filepath?: string; // the filepath of the recording (only available when status is ready)
|
||||
}
|
||||
|
||||
export interface RecordingStatus {
|
||||
id: number; // corresponds to the recording id
|
||||
// the status of the recording in a linear state machine
|
||||
@@ -54,4 +40,7 @@ export interface RecordingStatus {
|
||||
appGroup?: AppGroupInfo;
|
||||
startTime: number; // 0 means not started yet
|
||||
filepath?: string; // encoded file path
|
||||
nativeId?: string;
|
||||
sampleRate?: number;
|
||||
numberOfChannels?: number;
|
||||
}
|
||||
|
||||
@@ -423,98 +423,3 @@ export async function encodeAudioBlobToOpusSlices(
|
||||
await audioContext.close();
|
||||
}
|
||||
}
|
||||
|
||||
export const createStreamEncoder = (
|
||||
recordingId: number,
|
||||
codecs: {
|
||||
sampleRate: number;
|
||||
numberOfChannels: number;
|
||||
targetBitrate?: number;
|
||||
}
|
||||
) => {
|
||||
const { encoder, encodedChunks } = createOpusEncoder({
|
||||
sampleRate: codecs.sampleRate,
|
||||
numberOfChannels: codecs.numberOfChannels,
|
||||
bitrate: codecs.targetBitrate,
|
||||
});
|
||||
|
||||
const toAudioData = (buffer: Uint8Array) => {
|
||||
// Each sample in f32 format is 4 bytes
|
||||
const BYTES_PER_SAMPLE = 4;
|
||||
return new AudioData({
|
||||
format: 'f32',
|
||||
sampleRate: codecs.sampleRate,
|
||||
numberOfChannels: codecs.numberOfChannels,
|
||||
numberOfFrames:
|
||||
buffer.length / BYTES_PER_SAMPLE / codecs.numberOfChannels,
|
||||
timestamp: 0,
|
||||
data: buffer,
|
||||
});
|
||||
};
|
||||
|
||||
let cursor = 0;
|
||||
let isClosed = false;
|
||||
|
||||
const next = async () => {
|
||||
if (!apis) {
|
||||
throw new Error('Electron API is not available');
|
||||
}
|
||||
if (isClosed) {
|
||||
return;
|
||||
}
|
||||
const { buffer, nextCursor } = await apis.recording.getRawAudioBuffers(
|
||||
recordingId,
|
||||
cursor
|
||||
);
|
||||
if (isClosed || cursor === nextCursor) {
|
||||
return;
|
||||
}
|
||||
cursor = nextCursor;
|
||||
logger.debug('Encoding next chunk', cursor, nextCursor);
|
||||
encoder.encode(toAudioData(buffer));
|
||||
};
|
||||
|
||||
const poll = async () => {
|
||||
if (isClosed) {
|
||||
return;
|
||||
}
|
||||
logger.debug('Polling next chunk');
|
||||
await next();
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
await poll();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
if (isClosed) {
|
||||
return;
|
||||
}
|
||||
isClosed = true;
|
||||
return encoder.close();
|
||||
};
|
||||
|
||||
return {
|
||||
id: recordingId,
|
||||
next,
|
||||
poll,
|
||||
flush: () => {
|
||||
return encoder.flush();
|
||||
},
|
||||
close,
|
||||
finish: async () => {
|
||||
logger.debug('Finishing encoding');
|
||||
await next();
|
||||
close();
|
||||
const buffer = muxToMp4(encodedChunks, {
|
||||
sampleRate: codecs.sampleRate,
|
||||
numberOfChannels: codecs.numberOfChannels,
|
||||
bitrate: codecs.targetBitrate,
|
||||
});
|
||||
return buffer;
|
||||
},
|
||||
[Symbol.dispose]: () => {
|
||||
close();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export type OpusStreamEncoder = ReturnType<typeof createStreamEncoder>;
|
||||
|
||||
31
packages/frontend/native/index.d.ts
vendored
31
packages/frontend/native/index.d.ts
vendored
@@ -40,6 +40,37 @@ export declare function decodeAudio(buf: Uint8Array, destSampleRate?: number | u
|
||||
|
||||
/** Decode audio file into a Float32Array */
|
||||
export declare function decodeAudioSync(buf: Uint8Array, destSampleRate?: number | undefined | null, filename?: string | undefined | null): Float32Array
|
||||
|
||||
export interface RecordingArtifact {
|
||||
id: string
|
||||
filepath: string
|
||||
sampleRate: number
|
||||
channels: number
|
||||
durationMs: number
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface RecordingSessionMeta {
|
||||
id: string
|
||||
filepath: string
|
||||
sampleRate: number
|
||||
channels: number
|
||||
startedAt: number
|
||||
}
|
||||
|
||||
export interface RecordingStartOptions {
|
||||
appProcessId?: number
|
||||
excludeProcessIds?: Array<number>
|
||||
outputDir: string
|
||||
format?: string
|
||||
sampleRate?: number
|
||||
channels?: number
|
||||
id?: string
|
||||
}
|
||||
|
||||
export declare function startRecording(opts: RecordingStartOptions): RecordingSessionMeta
|
||||
|
||||
export declare function stopRecording(id: string): RecordingArtifact
|
||||
export declare function mintChallengeResponse(resource: string, bits?: number | undefined | null): Promise<string>
|
||||
|
||||
export declare function verifyChallengeResponse(response: string, bits: number, resource: string): Promise<boolean>
|
||||
|
||||
@@ -579,6 +579,8 @@ module.exports.AudioCaptureSession = nativeBinding.AudioCaptureSession
|
||||
module.exports.ShareableContent = nativeBinding.ShareableContent
|
||||
module.exports.decodeAudio = nativeBinding.decodeAudio
|
||||
module.exports.decodeAudioSync = nativeBinding.decodeAudioSync
|
||||
module.exports.startRecording = nativeBinding.startRecording
|
||||
module.exports.stopRecording = nativeBinding.stopRecording
|
||||
module.exports.mintChallengeResponse = nativeBinding.mintChallengeResponse
|
||||
module.exports.verifyChallengeResponse = nativeBinding.verifyChallengeResponse
|
||||
module.exports.DocStorage = nativeBinding.DocStorage
|
||||
|
||||
@@ -11,11 +11,15 @@ harness = false
|
||||
name = "mix_audio_samples"
|
||||
|
||||
[dependencies]
|
||||
napi = { workspace = true, features = ["napi4"] }
|
||||
napi-derive = { workspace = true, features = ["type-def"] }
|
||||
rubato = { workspace = true }
|
||||
symphonia = { workspace = true, features = ["all", "opt-simd"] }
|
||||
thiserror = { workspace = true }
|
||||
crossbeam-channel = { workspace = true }
|
||||
napi = { workspace = true, features = ["napi4"] }
|
||||
napi-derive = { workspace = true, features = ["type-def"] }
|
||||
ogg = { workspace = true }
|
||||
opus-codec = "0.1.2"
|
||||
rand = { workspace = true }
|
||||
rubato = { workspace = true }
|
||||
symphonia = { workspace = true, features = ["all", "opt-simd"] }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
block2 = { workspace = true }
|
||||
@@ -29,10 +33,9 @@ screencapturekit = { workspace = true }
|
||||
uuid = { workspace = true, features = ["v4"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
cpal = { workspace = true }
|
||||
crossbeam-channel = { workspace = true }
|
||||
windows = { workspace = true }
|
||||
windows-core = { workspace = true }
|
||||
cpal = { workspace = true }
|
||||
windows = { workspace = true }
|
||||
windows-core = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion2 = { workspace = true }
|
||||
|
||||
29
packages/frontend/native/media_capture/src/audio_callback.rs
Normal file
29
packages/frontend/native/media_capture/src/audio_callback.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use crossbeam_channel::Sender;
|
||||
use napi::{
|
||||
bindgen_prelude::Float32Array,
|
||||
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Internal callback abstraction so audio taps can target JS or native pipelines.
|
||||
#[derive(Clone)]
|
||||
pub enum AudioCallback {
|
||||
Js(Arc<ThreadsafeFunction<Float32Array, ()>>),
|
||||
Channel(Sender<Vec<f32>>),
|
||||
}
|
||||
|
||||
impl AudioCallback {
|
||||
pub fn call(&self, samples: Vec<f32>) {
|
||||
match self {
|
||||
Self::Js(func) => {
|
||||
// Non-blocking call into JS; errors are ignored to avoid blocking the
|
||||
// audio thread.
|
||||
let _ = func.call(Ok(samples.into()), ThreadsafeFunctionCallMode::NonBlocking);
|
||||
}
|
||||
Self::Channel(sender) => {
|
||||
// Drop the chunk if the channel is full to avoid blocking capture.
|
||||
let _ = sender.try_send(samples);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,4 +8,6 @@ pub mod windows;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub use windows::*;
|
||||
|
||||
pub mod audio_callback;
|
||||
pub mod audio_decoder;
|
||||
pub mod recording;
|
||||
|
||||
@@ -36,6 +36,7 @@ use screencapturekit::shareable_content::SCShareableContent;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
audio_callback::AudioCallback,
|
||||
error::CoreAudioError,
|
||||
pid::{audio_process_list, get_process_property},
|
||||
tap_audio::{AggregateDeviceManager, AudioCaptureSession},
|
||||
@@ -677,10 +678,9 @@ impl ShareableContent {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn tap_audio(
|
||||
pub(crate) fn tap_audio_with_callback(
|
||||
process_id: u32,
|
||||
audio_stream_callback: ThreadsafeFunction<napi::bindgen_prelude::Float32Array, ()>,
|
||||
audio_stream_callback: AudioCallback,
|
||||
) -> Result<AudioCaptureSession> {
|
||||
let app = ShareableContent::applications()?
|
||||
.into_iter()
|
||||
@@ -694,13 +694,10 @@ impl ShareableContent {
|
||||
));
|
||||
}
|
||||
|
||||
// Convert ThreadsafeFunction to Arc<ThreadsafeFunction>
|
||||
let callback_arc = Arc::new(audio_stream_callback);
|
||||
|
||||
// Use AggregateDeviceManager instead of AggregateDevice directly
|
||||
// This provides automatic default device change detection
|
||||
let mut device_manager = AggregateDeviceManager::new(&app)?;
|
||||
device_manager.start_capture(callback_arc)?;
|
||||
device_manager.start_capture(audio_stream_callback)?;
|
||||
let boxed_manager = Box::new(device_manager);
|
||||
Ok(AudioCaptureSession::new(boxed_manager))
|
||||
} else {
|
||||
@@ -712,9 +709,19 @@ impl ShareableContent {
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn tap_global_audio(
|
||||
excluded_processes: Option<Vec<&ApplicationInfo>>,
|
||||
pub fn tap_audio(
|
||||
process_id: u32,
|
||||
audio_stream_callback: ThreadsafeFunction<napi::bindgen_prelude::Float32Array, ()>,
|
||||
) -> Result<AudioCaptureSession> {
|
||||
ShareableContent::tap_audio_with_callback(
|
||||
process_id,
|
||||
AudioCallback::Js(Arc::new(audio_stream_callback)),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn tap_global_audio_with_callback(
|
||||
excluded_processes: Option<Vec<&ApplicationInfo>>,
|
||||
audio_stream_callback: AudioCallback,
|
||||
) -> Result<AudioCaptureSession> {
|
||||
let excluded_object_ids = excluded_processes
|
||||
.unwrap_or_default()
|
||||
@@ -722,13 +729,21 @@ impl ShareableContent {
|
||||
.map(|app| app.object_id)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// Convert ThreadsafeFunction to Arc<ThreadsafeFunction>
|
||||
let callback_arc = Arc::new(audio_stream_callback);
|
||||
|
||||
// Use the new AggregateDeviceManager for automatic device adaptation
|
||||
let mut device_manager = AggregateDeviceManager::new_global(&excluded_object_ids)?;
|
||||
device_manager.start_capture(callback_arc)?;
|
||||
device_manager.start_capture(audio_stream_callback)?;
|
||||
let boxed_manager = Box::new(device_manager);
|
||||
Ok(AudioCaptureSession::new(boxed_manager))
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn tap_global_audio(
|
||||
excluded_processes: Option<Vec<&ApplicationInfo>>,
|
||||
audio_stream_callback: ThreadsafeFunction<napi::bindgen_prelude::Float32Array, ()>,
|
||||
) -> Result<AudioCaptureSession> {
|
||||
ShareableContent::tap_global_audio_with_callback(
|
||||
excluded_processes,
|
||||
AudioCallback::Js(Arc::new(audio_stream_callback)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,15 +23,13 @@ use coreaudio::sys::{
|
||||
AudioObjectGetPropertyDataSize, AudioObjectID, AudioObjectPropertyAddress,
|
||||
AudioObjectRemovePropertyListenerBlock, AudioTimeStamp, OSStatus,
|
||||
};
|
||||
use napi::{
|
||||
bindgen_prelude::{Float32Array, Result, Status},
|
||||
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
|
||||
};
|
||||
use napi::bindgen_prelude::Result;
|
||||
use napi_derive::napi;
|
||||
use objc2::runtime::AnyObject;
|
||||
|
||||
use crate::{
|
||||
audio_buffer::InputAndOutputAudioBufferList,
|
||||
audio_callback::AudioCallback,
|
||||
ca_tap_description::CATapDescription,
|
||||
cf_types::CFDictionaryBuilder,
|
||||
device::get_device_uid,
|
||||
@@ -241,7 +239,7 @@ impl AggregateDevice {
|
||||
/// Implementation for the AggregateDevice to start processing audio
|
||||
pub fn start(
|
||||
&mut self,
|
||||
audio_stream_callback: Arc<ThreadsafeFunction<Float32Array, (), Float32Array, Status, true>>,
|
||||
audio_stream_callback: AudioCallback,
|
||||
// Add original_audio_stats to ensure consistent target rate
|
||||
original_audio_stats: AudioStats,
|
||||
) -> Result<AudioTapStream> {
|
||||
@@ -300,11 +298,8 @@ impl AggregateDevice {
|
||||
return kAudioHardwareBadStreamError as i32;
|
||||
};
|
||||
|
||||
// Send the processed audio data to JavaScript
|
||||
audio_stream_callback.call(
|
||||
Ok(mixed_samples.into()),
|
||||
ThreadsafeFunctionCallMode::NonBlocking,
|
||||
);
|
||||
// Send the processed audio data to the configured sink
|
||||
audio_stream_callback.call(mixed_samples);
|
||||
|
||||
kAudioHardwareNoError as i32
|
||||
},
|
||||
@@ -576,7 +571,7 @@ pub struct AggregateDeviceManager {
|
||||
app_id: Option<AudioObjectID>,
|
||||
excluded_processes: Vec<AudioObjectID>,
|
||||
active_stream: Option<Arc<std::sync::Mutex<Option<AudioTapStream>>>>,
|
||||
audio_callback: Option<Arc<ThreadsafeFunction<Float32Array, (), Float32Array, Status, true>>>,
|
||||
audio_callback: Option<AudioCallback>,
|
||||
original_audio_stats: Option<AudioStats>,
|
||||
}
|
||||
|
||||
@@ -614,10 +609,7 @@ impl AggregateDeviceManager {
|
||||
}
|
||||
|
||||
/// This sets up the initial stream and listeners.
|
||||
pub fn start_capture(
|
||||
&mut self,
|
||||
audio_stream_callback: Arc<ThreadsafeFunction<Float32Array, (), Float32Array, Status, true>>,
|
||||
) -> Result<()> {
|
||||
pub fn start_capture(&mut self, audio_stream_callback: AudioCallback) -> Result<()> {
|
||||
// Store the callback for potential device switch later
|
||||
self.audio_callback = Some(audio_stream_callback.clone());
|
||||
|
||||
|
||||
581
packages/frontend/native/media_capture/src/recording.rs
Normal file
581
packages/frontend/native/media_capture/src/recording.rs
Normal file
@@ -0,0 +1,581 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
io::{BufWriter, Write},
|
||||
path::PathBuf,
|
||||
sync::{LazyLock, Mutex},
|
||||
thread::{self, JoinHandle},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use crossbeam_channel::{bounded, Receiver, Sender};
|
||||
use napi::{bindgen_prelude::Result, Error, Status};
|
||||
use napi_derive::napi;
|
||||
use ogg::writing::{PacketWriteEndInfo, PacketWriter};
|
||||
use opus_codec::{Application, Channels, Encoder, FrameSize, SampleRate as OpusSampleRate};
|
||||
use rubato::Resampler;
|
||||
|
||||
use crate::audio_callback::AudioCallback;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::macos::screen_capture_kit::{ApplicationInfo, ShareableContent};
|
||||
#[cfg(target_os = "windows")]
|
||||
use crate::windows::screen_capture_kit::ShareableContent;
|
||||
|
||||
const ENCODE_SAMPLE_RATE: OpusSampleRate = OpusSampleRate::Hz48000;
|
||||
const MAX_PACKET_SIZE: usize = 4096;
|
||||
const RESAMPLER_INPUT_CHUNK: usize = 1024;
|
||||
|
||||
type RecordingResult<T> = std::result::Result<T, RecordingError>;
|
||||
|
||||
#[napi(object)]
|
||||
pub struct RecordingStartOptions {
|
||||
pub app_process_id: Option<u32>,
|
||||
pub exclude_process_ids: Option<Vec<u32>>,
|
||||
pub output_dir: String,
|
||||
pub format: Option<String>,
|
||||
pub sample_rate: Option<u32>,
|
||||
pub channels: Option<u32>,
|
||||
pub id: Option<String>,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct RecordingSessionMeta {
|
||||
pub id: String,
|
||||
pub filepath: String,
|
||||
pub sample_rate: u32,
|
||||
pub channels: u32,
|
||||
pub started_at: i64,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct RecordingArtifact {
|
||||
pub id: String,
|
||||
pub filepath: String,
|
||||
pub sample_rate: u32,
|
||||
pub channels: u32,
|
||||
pub duration_ms: i64,
|
||||
pub size: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
enum RecordingError {
|
||||
#[error("unsupported platform")]
|
||||
UnsupportedPlatform,
|
||||
#[error("invalid output directory")]
|
||||
InvalidOutputDir,
|
||||
#[error("invalid format {0}")]
|
||||
InvalidFormat(String),
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("encoding error: {0}")]
|
||||
Encoding(String),
|
||||
#[error("recording not found")]
|
||||
NotFound,
|
||||
#[error("empty recording")]
|
||||
Empty,
|
||||
#[error("start failure: {0}")]
|
||||
Start(String),
|
||||
#[error("join failure")]
|
||||
Join,
|
||||
}
|
||||
|
||||
impl RecordingError {
|
||||
fn code(&self) -> &'static str {
|
||||
match self {
|
||||
RecordingError::UnsupportedPlatform => "unsupported-platform",
|
||||
RecordingError::InvalidOutputDir => "invalid-output-dir",
|
||||
RecordingError::InvalidFormat(_) => "invalid-format",
|
||||
RecordingError::Io(_) => "io-error",
|
||||
RecordingError::Encoding(_) => "encoding-error",
|
||||
RecordingError::NotFound => "not-found",
|
||||
RecordingError::Empty => "empty-recording",
|
||||
RecordingError::Start(_) => "start-failure",
|
||||
RecordingError::Join => "join-failure",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RecordingError> for Error {
|
||||
fn from(err: RecordingError) -> Self {
|
||||
Error::new(Status::GenericFailure, format!("{}: {}", err.code(), err))
|
||||
}
|
||||
}
|
||||
|
||||
struct InterleavedResampler {
|
||||
resampler: rubato::FastFixedIn<f32>,
|
||||
channels: usize,
|
||||
fifo: Vec<Vec<f32>>,
|
||||
warmed: bool,
|
||||
}
|
||||
|
||||
impl InterleavedResampler {
|
||||
fn new(from_sr: u32, to_sr: u32, channels: usize) -> RecordingResult<Self> {
|
||||
let ratio = to_sr as f64 / from_sr as f64;
|
||||
let resampler = rubato::FastFixedIn::<f32>::new(
|
||||
ratio,
|
||||
1.0,
|
||||
rubato::PolynomialDegree::Linear,
|
||||
RESAMPLER_INPUT_CHUNK,
|
||||
channels,
|
||||
)
|
||||
.map_err(|e| RecordingError::Encoding(format!("resampler init failed: {e}")))?;
|
||||
|
||||
Ok(Self {
|
||||
resampler,
|
||||
channels,
|
||||
fifo: vec![Vec::<f32>::new(); channels],
|
||||
warmed: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn feed(&mut self, interleaved: &[f32]) -> Vec<f32> {
|
||||
for frame in interleaved.chunks(self.channels) {
|
||||
for (idx, sample) in frame.iter().enumerate() {
|
||||
if let Some(channel_fifo) = self.fifo.get_mut(idx) {
|
||||
channel_fifo.push(*sample);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
|
||||
while self.fifo.first().map(|q| q.len()).unwrap_or(0) >= RESAMPLER_INPUT_CHUNK {
|
||||
let mut chunk: Vec<Vec<f32>> = Vec::with_capacity(self.channels);
|
||||
for channel in &mut self.fifo {
|
||||
let take: Vec<f32> = channel.drain(..RESAMPLER_INPUT_CHUNK).collect();
|
||||
chunk.push(take);
|
||||
}
|
||||
|
||||
if let Ok(blocks) = self.resampler.process(&chunk, None) {
|
||||
if blocks.is_empty() || blocks.len() != self.channels {
|
||||
continue;
|
||||
}
|
||||
if !self.warmed {
|
||||
self.warmed = true;
|
||||
continue;
|
||||
}
|
||||
let out_len = blocks[0].len();
|
||||
for i in 0..out_len {
|
||||
for ch in 0..self.channels {
|
||||
out.push(blocks[ch][i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
struct OggOpusWriter {
|
||||
writer: PacketWriter<'static, BufWriter<fs::File>>,
|
||||
encoder: Encoder,
|
||||
frame_samples: usize,
|
||||
pending: Vec<f32>,
|
||||
granule_position: u64,
|
||||
samples_written: u64,
|
||||
channels: Channels,
|
||||
sample_rate: OpusSampleRate,
|
||||
resampler: Option<InterleavedResampler>,
|
||||
filepath: PathBuf,
|
||||
stream_serial: u32,
|
||||
}
|
||||
|
||||
impl OggOpusWriter {
|
||||
fn new(filepath: PathBuf, source_sample_rate: u32, channels: u32) -> RecordingResult<Self> {
|
||||
let channels = if channels > 1 {
|
||||
Channels::Stereo
|
||||
} else {
|
||||
Channels::Mono
|
||||
};
|
||||
|
||||
let sample_rate = ENCODE_SAMPLE_RATE;
|
||||
let resampler = if source_sample_rate != sample_rate.as_i32() as u32 {
|
||||
Some(InterleavedResampler::new(
|
||||
source_sample_rate,
|
||||
sample_rate.as_i32() as u32,
|
||||
channels.as_usize(),
|
||||
)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(parent) = filepath.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let file = fs::File::create(&filepath)?;
|
||||
let mut writer = PacketWriter::new(BufWriter::new(file));
|
||||
|
||||
let stream_serial: u32 = rand::random();
|
||||
write_opus_headers(&mut writer, stream_serial, channels, sample_rate)?;
|
||||
|
||||
let frame_samples = FrameSize::Ms20.samples(sample_rate);
|
||||
let encoder = Encoder::new(sample_rate, channels, Application::Audio)
|
||||
.map_err(|e| RecordingError::Encoding(e.to_string()))?;
|
||||
|
||||
Ok(Self {
|
||||
writer,
|
||||
encoder,
|
||||
frame_samples,
|
||||
pending: Vec::new(),
|
||||
granule_position: 0,
|
||||
samples_written: 0,
|
||||
channels,
|
||||
sample_rate,
|
||||
resampler,
|
||||
filepath,
|
||||
stream_serial,
|
||||
})
|
||||
}
|
||||
|
||||
fn push_samples(&mut self, samples: &[f32]) -> RecordingResult<()> {
|
||||
let mut processed = if let Some(resampler) = &mut self.resampler {
|
||||
resampler.feed(samples)
|
||||
} else {
|
||||
samples.to_vec()
|
||||
};
|
||||
|
||||
if processed.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.pending.append(&mut processed);
|
||||
let frame_len = self.frame_samples * self.channels.as_usize();
|
||||
|
||||
while self.pending.len() >= frame_len {
|
||||
let frame: Vec<f32> = self.pending.drain(..frame_len).collect();
|
||||
self.encode_frame(frame, self.frame_samples, PacketWriteEndInfo::NormalPacket)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn encode_frame(
|
||||
&mut self,
|
||||
frame: Vec<f32>,
|
||||
samples_in_frame: usize,
|
||||
end: PacketWriteEndInfo,
|
||||
) -> RecordingResult<()> {
|
||||
let mut out = vec![0u8; MAX_PACKET_SIZE];
|
||||
let encoded = self
|
||||
.encoder
|
||||
.encode_float(&frame, &mut out)
|
||||
.map_err(|e| RecordingError::Encoding(e.to_string()))?;
|
||||
|
||||
self.granule_position += samples_in_frame as u64;
|
||||
self.samples_written += samples_in_frame as u64;
|
||||
|
||||
let packet = out[..encoded].to_vec();
|
||||
|
||||
self
|
||||
.writer
|
||||
.write_packet(packet, self.stream_serial, end, self.granule_position)
|
||||
.map_err(|e| RecordingError::Encoding(format!("failed to write packet: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish(mut self) -> RecordingResult<RecordingArtifact> {
|
||||
let frame_len = self.frame_samples * self.channels.as_usize();
|
||||
if !self.pending.is_empty() {
|
||||
let mut frame = self.pending.clone();
|
||||
let samples_in_frame = frame.len() / self.channels.as_usize();
|
||||
frame.resize(frame_len, 0.0);
|
||||
self.encode_frame(frame, samples_in_frame, PacketWriteEndInfo::NormalPacket)?;
|
||||
self.pending.clear();
|
||||
}
|
||||
|
||||
// Mark end of stream with an empty packet if nothing was written, otherwise
|
||||
// flag the last packet as end of stream.
|
||||
if self.samples_written == 0 {
|
||||
fs::remove_file(&self.filepath).ok();
|
||||
return Err(RecordingError::Empty);
|
||||
}
|
||||
|
||||
// Flush a final end-of-stream marker.
|
||||
self
|
||||
.writer
|
||||
.write_packet(
|
||||
Vec::<u8>::new(),
|
||||
self.stream_serial,
|
||||
PacketWriteEndInfo::EndStream,
|
||||
self.granule_position,
|
||||
)
|
||||
.map_err(|e| RecordingError::Encoding(format!("failed to finish stream: {e}")))?;
|
||||
|
||||
let _ = self.writer.inner_mut().flush();
|
||||
|
||||
let size = fs::metadata(&self.filepath)?.len() as i64;
|
||||
let duration_ms = (self.samples_written * 1000) as i64 / self.sample_rate.as_i32() as i64;
|
||||
|
||||
Ok(RecordingArtifact {
|
||||
id: String::new(),
|
||||
filepath: self.filepath.to_string_lossy().to_string(),
|
||||
sample_rate: self.sample_rate.as_i32() as u32,
|
||||
channels: self.channels.as_usize() as u32,
|
||||
duration_ms,
|
||||
size,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn write_opus_headers(
|
||||
writer: &mut PacketWriter<'static, BufWriter<fs::File>>,
|
||||
stream_serial: u32,
|
||||
channels: Channels,
|
||||
sample_rate: OpusSampleRate,
|
||||
) -> RecordingResult<()> {
|
||||
let mut opus_head = Vec::with_capacity(19);
|
||||
opus_head.extend_from_slice(b"OpusHead");
|
||||
opus_head.push(1); // version
|
||||
opus_head.push(channels.as_usize() as u8);
|
||||
opus_head.extend_from_slice(&0u16.to_le_bytes()); // pre-skip
|
||||
opus_head.extend_from_slice(&(sample_rate.as_i32() as u32).to_le_bytes());
|
||||
opus_head.extend_from_slice(&0i16.to_le_bytes()); // output gain
|
||||
opus_head.push(0); // channel mapping
|
||||
|
||||
writer
|
||||
.write_packet(opus_head, stream_serial, PacketWriteEndInfo::EndPage, 0)
|
||||
.map_err(|e| RecordingError::Encoding(format!("failed to write OpusHead: {e}")))?;
|
||||
|
||||
let vendor = b"AFFiNE Native";
|
||||
let mut opus_tags = Vec::new();
|
||||
opus_tags.extend_from_slice(b"OpusTags");
|
||||
opus_tags.extend_from_slice(&(vendor.len() as u32).to_le_bytes());
|
||||
opus_tags.extend_from_slice(vendor);
|
||||
opus_tags.extend_from_slice(&0u32.to_le_bytes()); // user comment list length
|
||||
|
||||
writer
|
||||
.write_packet(opus_tags, stream_serial, PacketWriteEndInfo::EndPage, 0)
|
||||
.map_err(|e| RecordingError::Encoding(format!("failed to write OpusTags: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
enum PlatformCapture {
|
||||
#[cfg(target_os = "macos")]
|
||||
Mac(crate::macos::tap_audio::AudioCaptureSession),
|
||||
#[cfg(target_os = "windows")]
|
||||
Windows(crate::windows::audio_capture::AudioCaptureSession),
|
||||
}
|
||||
|
||||
unsafe impl Send for PlatformCapture {}
|
||||
|
||||
impl PlatformCapture {
|
||||
fn stop(&mut self) -> Result<()> {
|
||||
match self {
|
||||
#[cfg(target_os = "macos")]
|
||||
PlatformCapture::Mac(session) => session.stop(),
|
||||
#[cfg(target_os = "windows")]
|
||||
PlatformCapture::Windows(session) => session.stop(),
|
||||
#[allow(unreachable_patterns)]
|
||||
_ => Err(RecordingError::UnsupportedPlatform.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ActiveRecording {
|
||||
sender: Option<Sender<Vec<f32>>>,
|
||||
capture: PlatformCapture,
|
||||
worker: Option<JoinHandle<std::result::Result<RecordingArtifact, RecordingError>>>,
|
||||
}
|
||||
|
||||
static ACTIVE_RECORDINGS: LazyLock<Mutex<HashMap<String, ActiveRecording>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
fn now_millis() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn sanitize_id(id: Option<String>) -> String {
|
||||
let raw = id.unwrap_or_else(|| format!("{}", now_millis()));
|
||||
let filtered: String = raw
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
|
||||
.collect();
|
||||
if filtered.is_empty() {
|
||||
format!("{}", now_millis())
|
||||
} else {
|
||||
filtered
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_output_dir(path: &str) -> Result<PathBuf> {
|
||||
let dir = PathBuf::from(path);
|
||||
if !dir.is_absolute() {
|
||||
return Err(RecordingError::InvalidOutputDir.into());
|
||||
}
|
||||
fs::create_dir_all(&dir)?;
|
||||
let normalized = dir
|
||||
.canonicalize()
|
||||
.map_err(|_| RecordingError::InvalidOutputDir)?;
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn build_excluded_refs(ids: &[u32]) -> Result<Vec<ApplicationInfo>> {
|
||||
if ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let apps = ShareableContent::applications()?;
|
||||
let mut excluded = Vec::new();
|
||||
for app in apps {
|
||||
if ids.contains(&(app.process_id as u32)) {
|
||||
excluded.push(app);
|
||||
}
|
||||
}
|
||||
Ok(excluded)
|
||||
}
|
||||
|
||||
fn start_capture(
|
||||
opts: &RecordingStartOptions,
|
||||
tx: Sender<Vec<f32>>,
|
||||
) -> Result<(PlatformCapture, u32, u32)> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let callback = AudioCallback::Channel(tx);
|
||||
let session = if let Some(app_id) = opts.app_process_id {
|
||||
ShareableContent::tap_audio_with_callback(app_id, callback)?
|
||||
} else {
|
||||
let excluded_apps = build_excluded_refs(
|
||||
opts
|
||||
.exclude_process_ids
|
||||
.as_ref()
|
||||
.map(|v| v.as_slice())
|
||||
.unwrap_or(&[]),
|
||||
)?;
|
||||
let excluded_refs: Vec<&ApplicationInfo> = excluded_apps.iter().collect();
|
||||
ShareableContent::tap_global_audio_with_callback(Some(excluded_refs), callback)?
|
||||
};
|
||||
let sample_rate = session.get_sample_rate()?.round().clamp(1.0, f64::MAX) as u32;
|
||||
let channels = session.get_channels()?;
|
||||
return Ok((PlatformCapture::Mac(session), sample_rate, channels));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let callback = AudioCallback::Channel(tx);
|
||||
let session = ShareableContent::tap_audio_with_callback(
|
||||
opts.app_process_id.unwrap_or(0),
|
||||
callback,
|
||||
opts.sample_rate,
|
||||
)?;
|
||||
let sample_rate = session.get_sample_rate().round() as u32;
|
||||
let channels = session.get_channels();
|
||||
return Ok((PlatformCapture::Windows(session), sample_rate, channels));
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
let _ = opts;
|
||||
let _ = tx;
|
||||
Err(RecordingError::UnsupportedPlatform.into())
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_worker(
|
||||
id: String,
|
||||
filepath: PathBuf,
|
||||
rx: Receiver<Vec<f32>>,
|
||||
source_sample_rate: u32,
|
||||
channels: u32,
|
||||
) -> JoinHandle<std::result::Result<RecordingArtifact, RecordingError>> {
|
||||
thread::spawn(move || {
|
||||
let mut writer = OggOpusWriter::new(filepath.clone(), source_sample_rate, channels)?;
|
||||
for chunk in rx {
|
||||
writer.push_samples(&chunk)?;
|
||||
}
|
||||
let mut artifact = writer.finish()?;
|
||||
artifact.id = id;
|
||||
Ok(artifact)
|
||||
})
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn start_recording(opts: RecordingStartOptions) -> Result<RecordingSessionMeta> {
|
||||
if let Some(fmt) = opts.format.as_deref() {
|
||||
if fmt.to_ascii_lowercase() != "opus" {
|
||||
return Err(RecordingError::InvalidFormat(fmt.to_string()).into());
|
||||
}
|
||||
}
|
||||
|
||||
let output_dir = validate_output_dir(&opts.output_dir)?;
|
||||
let id = sanitize_id(opts.id.clone());
|
||||
let filepath = output_dir.join(format!("{id}.opus"));
|
||||
if filepath.exists() {
|
||||
fs::remove_file(&filepath)?;
|
||||
}
|
||||
|
||||
let (tx, rx) = bounded::<Vec<f32>>(32);
|
||||
let (capture, capture_rate, capture_channels) =
|
||||
start_capture(&opts, tx.clone()).map_err(|e| RecordingError::Start(e.to_string()))?;
|
||||
|
||||
let encoding_channels = match opts.channels {
|
||||
Some(1) => 1,
|
||||
Some(2) => 2,
|
||||
_ => capture_channels,
|
||||
};
|
||||
|
||||
let worker = spawn_worker(
|
||||
id.clone(),
|
||||
filepath.clone(),
|
||||
rx,
|
||||
capture_rate,
|
||||
encoding_channels,
|
||||
);
|
||||
|
||||
let meta = RecordingSessionMeta {
|
||||
id: id.clone(),
|
||||
filepath: filepath.to_string_lossy().to_string(),
|
||||
sample_rate: ENCODE_SAMPLE_RATE.as_i32() as u32,
|
||||
channels: encoding_channels,
|
||||
started_at: now_millis(),
|
||||
};
|
||||
|
||||
let mut recordings = ACTIVE_RECORDINGS
|
||||
.lock()
|
||||
.map_err(|_| RecordingError::Start("lock poisoned".into()))?;
|
||||
|
||||
if recordings.contains_key(&id) {
|
||||
return Err(RecordingError::Start("duplicate recording id".into()).into());
|
||||
}
|
||||
|
||||
recordings.insert(
|
||||
id,
|
||||
ActiveRecording {
|
||||
sender: Some(tx),
|
||||
capture,
|
||||
worker: Some(worker),
|
||||
},
|
||||
);
|
||||
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn stop_recording(id: String) -> Result<RecordingArtifact> {
|
||||
let mut recordings = ACTIVE_RECORDINGS
|
||||
.lock()
|
||||
.map_err(|_| RecordingError::Start("lock poisoned".into()))?;
|
||||
|
||||
let mut entry = recordings.remove(&id).ok_or(RecordingError::NotFound)?;
|
||||
|
||||
entry
|
||||
.capture
|
||||
.stop()
|
||||
.map_err(|e| RecordingError::Start(e.to_string()))?;
|
||||
|
||||
drop(entry.sender.take());
|
||||
|
||||
let handle = entry.worker.take().ok_or(RecordingError::Join)?;
|
||||
let artifact = handle
|
||||
.join()
|
||||
.map_err(|_| RecordingError::Join)?
|
||||
.map_err(|e| e)?;
|
||||
|
||||
Ok(artifact)
|
||||
}
|
||||
@@ -8,16 +8,13 @@ use std::{
|
||||
thread::JoinHandle,
|
||||
};
|
||||
|
||||
use crate::audio_callback::AudioCallback;
|
||||
use cpal::{
|
||||
traits::{DeviceTrait, HostTrait, StreamTrait},
|
||||
SampleRate,
|
||||
};
|
||||
use crossbeam_channel::unbounded;
|
||||
use napi::{
|
||||
bindgen_prelude::{Float32Array, Result},
|
||||
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
|
||||
Error, Status,
|
||||
};
|
||||
use napi::{bindgen_prelude::Result, Error, Status};
|
||||
use napi_derive::napi;
|
||||
use rubato::{FastFixedIn, PolynomialDegree, Resampler};
|
||||
|
||||
@@ -221,7 +218,8 @@ impl Drop for AudioCaptureSession {
|
||||
}
|
||||
|
||||
pub fn start_recording(
|
||||
audio_buffer_callback: ThreadsafeFunction<Float32Array, ()>,
|
||||
audio_buffer_callback: AudioCallback,
|
||||
target_sample_rate: Option<SampleRate>,
|
||||
) -> Result<AudioCaptureSession> {
|
||||
let available_hosts = cpal::available_hosts();
|
||||
let host_id = available_hosts
|
||||
@@ -247,7 +245,7 @@ pub fn start_recording(
|
||||
|
||||
let mic_sample_rate = mic_config.sample_rate();
|
||||
let lb_sample_rate = lb_config.sample_rate();
|
||||
let target_rate = SampleRate(mic_sample_rate.min(lb_sample_rate).0);
|
||||
let target_rate = target_sample_rate.unwrap_or(SampleRate(mic_sample_rate.min(lb_sample_rate).0));
|
||||
|
||||
let mic_channels = mic_config.channels();
|
||||
let lb_channels = lb_config.channels();
|
||||
@@ -347,10 +345,7 @@ pub fn start_recording(
|
||||
let lb_chunk: Vec<f32> = post_lb.drain(..TARGET_FRAME_SIZE).collect();
|
||||
let mixed = mix(&mic_chunk, &lb_chunk);
|
||||
if !mixed.is_empty() {
|
||||
let _ = audio_buffer_callback.call(
|
||||
Ok(mixed.clone().into()),
|
||||
ThreadsafeFunctionCallMode::NonBlocking,
|
||||
);
|
||||
audio_buffer_callback.call(mixed);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ use std::{
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use cpal::SampleRate;
|
||||
use napi::{
|
||||
bindgen_prelude::{Buffer, Error, Result, Status},
|
||||
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
|
||||
@@ -27,6 +28,7 @@ use windows::Win32::System::{
|
||||
};
|
||||
|
||||
// Import the function from microphone_listener
|
||||
use crate::audio_callback::AudioCallback;
|
||||
use crate::windows::microphone_listener::is_process_actively_using_microphone;
|
||||
|
||||
// Type alias to match macOS API
|
||||
@@ -230,6 +232,15 @@ impl ShareableContent {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn tap_audio_with_callback(
|
||||
_process_id: u32,
|
||||
audio_stream_callback: AudioCallback,
|
||||
target_sample_rate: Option<u32>,
|
||||
) -> Result<AudioCaptureSession> {
|
||||
let target = target_sample_rate.map(SampleRate);
|
||||
crate::windows::audio_capture::start_recording(audio_stream_callback, target)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn tap_audio(
|
||||
_process_id: u32, // Currently unused - Windows captures global audio
|
||||
@@ -237,7 +248,22 @@ impl ShareableContent {
|
||||
) -> Result<AudioCaptureSession> {
|
||||
// On Windows with CPAL, we capture global audio (mic + loopback)
|
||||
// since per-application audio tapping isn't supported the same way as macOS
|
||||
crate::windows::audio_capture::start_recording(audio_stream_callback)
|
||||
ShareableContent::tap_audio_with_callback(
|
||||
_process_id,
|
||||
AudioCallback::Js(Arc::new(audio_stream_callback)),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn tap_global_audio_with_callback(
|
||||
_excluded_processes: Option<Vec<&ApplicationInfo>>,
|
||||
audio_stream_callback: AudioCallback,
|
||||
target_sample_rate: Option<u32>,
|
||||
) -> Result<AudioCaptureSession> {
|
||||
let target = target_sample_rate.map(SampleRate);
|
||||
// Delegate to audio_capture::start_recording which handles mixing mic +
|
||||
// loopback
|
||||
crate::windows::audio_capture::start_recording(audio_stream_callback, target)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
@@ -245,9 +271,11 @@ impl ShareableContent {
|
||||
_excluded_processes: Option<Vec<&ApplicationInfo>>,
|
||||
audio_stream_callback: ThreadsafeFunction<napi::bindgen_prelude::Float32Array, ()>,
|
||||
) -> Result<AudioCaptureSession> {
|
||||
// Delegate to audio_capture::start_recording which handles mixing mic +
|
||||
// loopback
|
||||
crate::windows::audio_capture::start_recording(audio_stream_callback)
|
||||
ShareableContent::tap_global_audio_with_callback(
|
||||
_excluded_processes,
|
||||
AudioCallback::Js(Arc::new(audio_stream_callback)),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
|
||||
@@ -92,7 +92,6 @@ export async function addUserToWorkspace(
|
||||
data: {
|
||||
workspaceId: workspace.id,
|
||||
userId,
|
||||
accepted: true,
|
||||
status: 'Accepted',
|
||||
type: permission,
|
||||
},
|
||||
|
||||
35
yarn.lock
35
yarn.lock
@@ -1006,7 +1006,7 @@ __metadata:
|
||||
html-validate: "npm:^9.0.0"
|
||||
htmlrewriter: "npm:^0.0.12"
|
||||
http-errors: "npm:^2.0.0"
|
||||
ioredis: "npm:^5.4.1"
|
||||
ioredis: "npm:^5.8.2"
|
||||
is-mobile: "npm:^5.0.0"
|
||||
jose: "npm:^6.1.3"
|
||||
jsonwebtoken: "npm:^9.0.3"
|
||||
@@ -4118,6 +4118,7 @@ __metadata:
|
||||
"@toeverything/theme": "npm:^1.1.16"
|
||||
"@types/lodash-es": "npm:^4.17.12"
|
||||
fflate: "npm:^0.8.2"
|
||||
js-yaml: "npm:^4.1.1"
|
||||
lit: "npm:^3.2.0"
|
||||
lodash-es: "npm:^4.17.21"
|
||||
mammoth: "npm:^1.11.0"
|
||||
@@ -8462,10 +8463,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@ioredis/commands@npm:^1.1.1":
|
||||
version: 1.2.0
|
||||
resolution: "@ioredis/commands@npm:1.2.0"
|
||||
checksum: 10/a8253c9539b7e5463d4a98e6aa5b1b863fb4a4978191ba9dc42ec2c0fb5179d8d1fe4a29096d5954f91ba9600d1bdc6c1d18b044eab36f645f267fd37d7c0906
|
||||
"@ioredis/commands@npm:1.4.0":
|
||||
version: 1.4.0
|
||||
resolution: "@ioredis/commands@npm:1.4.0"
|
||||
checksum: 10/ea299f22057139261ebd7dbc7fc85615e90c254e667620a276d3cf771b9d9190ffbdf25af6586c3f52598a2c830d64bee17f32c3ce734db403e93f73548d54d4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -27955,11 +27956,11 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ioredis@npm:^5.4.1":
|
||||
version: 5.6.1
|
||||
resolution: "ioredis@npm:5.6.1"
|
||||
"ioredis@npm:^5.4.1, ioredis@npm:^5.8.2":
|
||||
version: 5.8.2
|
||||
resolution: "ioredis@npm:5.8.2"
|
||||
dependencies:
|
||||
"@ioredis/commands": "npm:^1.1.1"
|
||||
"@ioredis/commands": "npm:1.4.0"
|
||||
cluster-key-slot: "npm:^1.1.0"
|
||||
debug: "npm:^4.3.4"
|
||||
denque: "npm:^2.1.0"
|
||||
@@ -27968,7 +27969,7 @@ __metadata:
|
||||
redis-errors: "npm:^1.2.0"
|
||||
redis-parser: "npm:^3.0.0"
|
||||
standard-as-callback: "npm:^2.1.0"
|
||||
checksum: 10/632186e21f2d8b94a12918b5feef0f5df70315e303988989adb0d190377759ffa601660be7536489c0768803a0fb6eb94f861b33c1fcfc673d518fdd31c541e2
|
||||
checksum: 10/550cbe7bba409b5bc38fa290fd2de7f569548603892904680db0f5e74f7163e52e067428be766d9e4d1a7f1220f93abe8e1687804aab951a96a6dafc289947eb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -28727,7 +28728,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"js-yaml@npm:^4.0.0, js-yaml@npm:^4.1.0":
|
||||
"js-yaml@npm:^4.0.0, js-yaml@npm:^4.1.0, js-yaml@npm:^4.1.1":
|
||||
version: 4.1.1
|
||||
resolution: "js-yaml@npm:4.1.1"
|
||||
dependencies:
|
||||
@@ -33851,11 +33852,11 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"qs@npm:^6.11.0, qs@npm:^6.11.2, qs@npm:^6.14.0, qs@npm:^6.7.0":
|
||||
version: 6.14.0
|
||||
resolution: "qs@npm:6.14.0"
|
||||
version: 6.14.1
|
||||
resolution: "qs@npm:6.14.1"
|
||||
dependencies:
|
||||
side-channel: "npm:^1.1.0"
|
||||
checksum: 10/a60e49bbd51c935a8a4759e7505677b122e23bf392d6535b8fc31c1e447acba2c901235ecb192764013cd2781723dc1f61978b5fdd93cc31d7043d31cdc01974
|
||||
checksum: 10/34b5ab00a910df432d55180ef39c1d1375e550f098b5ec153b41787f1a6a6d7e5f9495593c3b112b77dbc6709d0ae18e55b82847a4c2bbbb0de1e8ccbb1794c5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -37442,9 +37443,9 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"tmp@npm:^0.2.0":
|
||||
version: 0.2.3
|
||||
resolution: "tmp@npm:0.2.3"
|
||||
checksum: 10/7b13696787f159c9754793a83aa79a24f1522d47b87462ddb57c18ee93ff26c74cbb2b8d9138f571d2e0e765c728fb2739863a672b280528512c6d83d511c6fa
|
||||
version: 0.2.5
|
||||
resolution: "tmp@npm:0.2.5"
|
||||
checksum: 10/dd4b78b32385eab4899d3ae296007b34482b035b6d73e1201c4a9aede40860e90997a1452c65a2d21aee73d53e93cd167d741c3db4015d90e63b6d568a93d7ec
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user