fix(desktop): save a shared file as a note

Finder was handing the share extension a file-id URL, which was stored as a fake WeChat zip and failed with the chat import dialog. Copy the real file, keep its name, and turn anything that is not a WeChat chat into a note.
This commit is contained in:
tianma-if committed 2026-09-27 09:54:04 +08:00
1 parent e615489d91
commit e49d09a017
12 files changed
+777 -68

No files matched your search

@@ -1,7 +1,9 @@
import AppKit
import UniformTypeIdentifiers
/// Receives WeChat's merged-forward ZIP and hands the file to the EdgeEver app.
/// Copies a file from the macOS share sheet into Downloads and opens EdgeEver.
/// WeChat merged-forward ZIPs stay on this path; the app turns those into chat notes
/// and turns every other file into a note attachment.
@objc(ShareViewController)
final class ShareViewController: NSViewController {
private let incomingDirectoryName = "EdgeEver Incoming"
@@ -54,24 +56,109 @@ final class ShareViewController: NSViewController {
}
for item in items {
for provider in item.attachments ?? [] {
guard let type = preferredType(for: provider) else { continue }
return try await writeProvider(provider, type: type)
if let copied = try await copyProvider(provider) {
return copied
}
}
}
throw CocoaError(.fileReadNoSuchFile)
}
private func preferredType(for provider: NSItemProvider) -> String? {
let preferred = [
UTType.zip.identifier,
UTType.fileURL.identifier,
UTType.data.identifier,
UTType.item.identifier,
]
return preferred.first { provider.hasItemConformingToTypeIdentifier($0) }
/// Finder often hands over `public.file-url` as `file:///.file/id=…`. Asking for that
/// type writes the URL text, not the file. Copy the security-scoped file when it is
/// readable, and otherwise ask for the file's own content type.
private func copyProvider(_ provider: NSItemProvider) async throws -> URL? {
let fileURL = await loadFileURL(provider)
if let fileURL, let copied = try await copySecurityScopedFile(fileURL, suggested: provider.suggestedName) {
return copied
}
guard let type = contentType(for: provider) else { return nil }
// The provider's temporary file exists only inside this callback.
let filename = preferredFilename(suggested: provider.suggestedName, fileURL: fileURL, copiedSource: fileURL ?? URL(fileURLWithPath: "共享的文件"))
return try await copyFileRepresentation(provider, type: type, filename: filename)
}
private func writeProvider(_ provider: NSItemProvider, type: String) async throws -> URL {
private func contentType(for provider: NSItemProvider) -> String? {
let skipped: Set<String> = [
UTType.fileURL.identifier,
UTType.url.identifier,
UTType.plainText.identifier,
UTType.text.identifier,
UTType.utf8PlainText.identifier,
UTType.item.identifier,
]
let registered = provider.registeredTypeIdentifiers.filter { !skipped.contains($0) }
if let concrete = registered.first(where: { $0 != UTType.data.identifier && !$0.hasPrefix("dyn.") }) {
return concrete
}
if registered.contains(UTType.data.identifier) || provider.hasItemConformingToTypeIdentifier(UTType.data.identifier) {
return UTType.data.identifier
}
return registered.first
}
private func loadFileURL(_ provider: NSItemProvider) async -> URL? {
guard provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) else { return nil }
return await withCheckedContinuation { continuation in
var resumed = false
provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, _ in
guard !resumed else { return }
resumed = true
if let url = item as? URL {
continuation.resume(returning: url)
return
}
if let data = item as? Data, let url = URL(dataRepresentation: data, relativeTo: nil), url.isFileURL {
continuation.resume(returning: url)
return
}
if let text = item as? String, let url = URL(string: text), url.isFileURL {
continuation.resume(returning: url)
return
}
continuation.resume(returning: nil)
}
}
}
private func copyFileRepresentation(_ provider: NSItemProvider, type: String, filename: String) async throws -> URL? {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<URL?, Error>) in
var resumed = false
provider.loadFileRepresentation(forTypeIdentifier: type) { source, error in
guard !resumed else { return }
resumed = true
guard let source else {
continuation.resume(throwing: error ?? CocoaError(.fileReadUnknown))
return
}
if self.isFileReferencePlaceholder(source) {
continuation.resume(returning: nil)
return
}
do {
let resolvedName = self.filenameWithExtension(from: source, preferred: filename)
continuation.resume(returning: try self.moveIntoIncoming(source, filename: resolvedName))
} catch {
continuation.resume(throwing: error)
}
}
}
}
private func copySecurityScopedFile(_ url: URL, suggested: String?) async throws -> URL? {
let accessing = url.startAccessingSecurityScopedResource()
defer { if accessing { url.stopAccessingSecurityScopedResource() } }
let resolved = url.resolvingSymlinksInPath()
var isDirectory = ObjCBool(false)
guard FileManager.default.fileExists(atPath: resolved.path, isDirectory: &isDirectory), !isDirectory.boolValue else {
return nil
}
guard FileManager.default.isReadableFile(atPath: resolved.path) else { return nil }
let filename = preferredFilename(suggested: suggested, fileURL: url, copiedSource: resolved)
return try moveIntoIncoming(resolved, filename: filename)
}
private func moveIntoIncoming(_ source: URL, filename: String) throws -> URL {
let downloads = try FileManager.default.url(
for: .downloadsDirectory,
in: .userDomainMask,
@@ -82,38 +169,69 @@ final class ShareViewController: NSViewController {
.appendingPathComponent(incomingDirectoryName, isDirectory: true)
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: batch, withIntermediateDirectories: true)
let filename = sanitizedFilename(provider.suggestedName)
let destination = batch.appendingPathComponent(filename, isDirectory: false)
let partial = batch.appendingPathComponent("\(filename).partial", isDirectory: false)
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
provider.loadFileRepresentation(forTypeIdentifier: type) { source, error in
guard let source else {
continuation.resume(throwing: error ?? CocoaError(.fileReadUnknown))
return
}
do {
try FileManager.default.copyItem(at: source, to: partial)
try FileManager.default.moveItem(at: partial, to: destination)
continuation.resume()
} catch {
continuation.resume(throwing: error)
}
}
}
try FileManager.default.copyItem(at: source, to: partial)
try FileManager.default.moveItem(at: partial, to: destination)
return destination
}
private func isFileReferencePlaceholder(_ url: URL) -> Bool {
guard let data = try? Data(contentsOf: url), data.count <= 256, let text = String(data: data, encoding: .utf8) else {
return false
}
return text.hasPrefix("file:///.file/id=") && !text.contains("\n")
}
private func preferredFilename(suggested: String?, fileURL: URL?, copiedSource: URL) -> String {
if let suggested {
let trimmed = suggested.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { return sanitizedFilename(trimmed) }
}
if let fileURL {
let values = try? fileURL.resourceValues(forKeys: [.localizedNameKey, .nameKey])
if let name = values?.localizedName ?? values?.name {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty && !trimmed.hasPrefix("id=") {
return sanitizedFilename(trimmed)
}
}
}
let copiedName = copiedSource.lastPathComponent
if !copiedName.isEmpty && !copiedName.hasPrefix("id=") {
return sanitizedFilename(copiedName)
}
return sanitizedFilename(nil)
}
private func filenameWithExtension(from source: URL, preferred: String) -> String {
let sourceName = source.lastPathComponent
let preferredExt = (preferred as NSString).pathExtension
let sourceExt = (sourceName as NSString).pathExtension
if preferredExt.isEmpty && !sourceExt.isEmpty && !sourceName.hasPrefix("id=") {
return sanitizedFilename(sourceName)
}
return preferred
}
private func sanitizedFilename(_ suggested: String?) -> String {
let base = ((suggested ?? "聊天记录.zip") as NSString).lastPathComponent
let trimmed = base.replacingOccurrences(of: "/", with: "").trimmingCharacters(in: .whitespacesAndNewlines)
let name = trimmed.isEmpty ? "聊天记录.zip" : trimmed
return (name as NSString).pathExtension.lowercased() == "zip" ? name : "\(name).zip"
let fallback = Locale.preferredLanguages.first?.hasPrefix("zh") == true ? "共享的文件" : "Shared file"
let base = ((suggested ?? fallback) as NSString).lastPathComponent
let trimmed = base
.replacingOccurrences(of: "/", with: "")
.replacingOccurrences(of: ":", with: "")
.replacingOccurrences(of: "\0", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty || trimmed == "." || trimmed == ".." || trimmed.hasPrefix("id=") {
return fallback
}
return trimmed
}
private func shareURL(for file: URL) -> URL? {
var components = URLComponents()
components.scheme = "edgeever"
components.host = "wechat-import"
components.host = "share-import"
components.queryItems = [URLQueryItem(name: "path", value: file.path)]
return components.url
}
+1 -1
View File
@@ -712,7 +712,7 @@ const handleProtocolUrl = (target) => {
if (typeof target !== "string" || !target.startsWith("edgeever://")) return;
try {
const url = new URL(target);
if (url.hostname === "wechat-import") {
if (url.hostname === "wechat-import" || url.hostname === "share-import") {
void wechatShare().importFromProtocolUrl(target);
return;
}
+157 -28
View File
@@ -1,10 +1,57 @@
import { randomUUID } from "node:crypto";
import { mkdir, readFile, realpath, readdir, rmdir, rm, writeFile } from "node:fs/promises";
import { mkdir, readFile, realpath, readdir, rmdir, rm, stat, writeFile } from "node:fs/promises";
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { WeChatArchiveError, wechatChatNoteFromArchive } from "./wechat-chat-archive.mjs";
export const WECHAT_INCOMING_DIRECTORY_NAME = "EdgeEver Incoming";
export const SHARE_IMPORT_FILE_ID = "file";
export const MAX_SHARE_IMPORT_BYTES = 512 * 1024 * 1024;
const SAFE_ID = /^[A-Za-z0-9_-]{1,80}$/;
const MIME_TYPES = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
gif: "image/gif",
webp: "image/webp",
heic: "image/heic",
mp4: "video/mp4",
mov: "video/quicktime",
m4v: "video/mp4",
webm: "video/webm",
mp3: "audio/mpeg",
m4a: "audio/mp4",
wav: "audio/wav",
pdf: "application/pdf",
md: "text/markdown",
markdown: "text/markdown",
txt: "text/plain",
csv: "text/csv",
json: "application/json",
zip: "application/zip",
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
xls: "application/vnd.ms-excel",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
};
class ShareImportError extends Error {
constructor(code) {
super(code);
this.name = "ShareImportError";
this.code = code;
}
}
const extensionOf = (filename) => filename.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] ?? "";
export const mimeTypeForFilename = (filename) => MIME_TYPES[extensionOf(filename)] ?? "application/octet-stream";
// Finder's share sheet sometimes delivers `file:///.file/id=…` instead of the file.
export const isFileReferencePlaceholder = (bytes) => {
if (!bytes || bytes.length < 16 || bytes.length > 256) return false;
const text = Buffer.from(bytes).toString("utf8");
return text.startsWith("file:///.file/id=") && !text.includes("\n") && !text.includes("\0");
};
export const isPathInsideDirectory = (filePath, directory) => {
if (!filePath || !directory) return false;
@@ -21,7 +68,7 @@ export const wechatImportFilePath = (value) => {
} catch {
return null;
}
if (url.protocol !== "edgeever:" || url.hostname !== "wechat-import") return null;
if (url.protocol !== "edgeever:" || (url.hostname !== "wechat-import" && url.hostname !== "share-import")) return null;
const filePath = url.searchParams.get("path");
if (!filePath || !isAbsolute(filePath) || filePath.includes("\0")) return null;
return filePath;
@@ -29,11 +76,8 @@ export const wechatImportFilePath = (value) => {
const incomingDirectory = (downloadsPath) => join(downloadsPath, WECHAT_INCOMING_DIRECTORY_NAME);
const assertIncomingZip = async (filePath, downloadsPath) => {
const assertIncomingFile = async (filePath, downloadsPath) => {
const root = incomingDirectory(downloadsPath);
if (!filePath.toLowerCase().endsWith(".zip")) {
throw new WeChatArchiveError("rejected-path");
}
let realRoot;
let realFile;
try {
@@ -43,12 +87,37 @@ const assertIncomingZip = async (filePath, downloadsPath) => {
}
// The sandboxed share extension may name ~/Downloads through its container
// symlink. Validate the resolved file against the resolved incoming directory.
if (!isPathInsideDirectory(realFile, realRoot) || !realFile.toLowerCase().endsWith(".zip")) {
if (!isPathInsideDirectory(realFile, realRoot)) {
throw new WeChatArchiveError("rejected-path");
}
const info = await stat(realFile).catch(() => null);
if (!info?.isFile()) throw new WeChatArchiveError("rejected-path");
return realFile;
};
const assertIncomingZip = async (filePath, downloadsPath) => {
if (!filePath.toLowerCase().endsWith(".zip")) {
throw new WeChatArchiveError("rejected-path");
}
const realFile = await assertIncomingFile(filePath, downloadsPath);
if (!realFile.toLowerCase().endsWith(".zip")) {
throw new WeChatArchiveError("rejected-path");
}
return realFile;
};
const wechatNoteFromBytes = (bytes, filename) => {
if (bytes.length < 4 || bytes[0] !== 0x50 || bytes[1] !== 0x4b) return null;
try {
return wechatChatNoteFromArchive(bytes, filename);
} catch (error) {
if (error instanceof WeChatArchiveError && (error.code === "invalid" || error.code === "unrecognized")) {
return null;
}
throw error;
}
};
const removeImportedZip = async (zipPath, downloadsPath) => {
if (!zipPath) return;
const root = await realpath(incomingDirectory(downloadsPath)).catch(() => null);
@@ -67,6 +136,7 @@ export const createWeChatShareController = ({
sendToRenderer,
onActivity = () => {},
writeDiagnostic = () => {},
maxBytes = MAX_SHARE_IMPORT_BYTES,
}) => {
const sessions = new Map();
const preparedPaths = new Set();
@@ -81,7 +151,8 @@ export const createWeChatShareController = ({
if (!success) {
session.failed = true;
failedPaths.add(session.zipPath);
void writeDiagnostic("wechat-import.save-failed");
const failedEvent = session.payload?.kind === "file" ? "share-import.save-failed" : "wechat-import.save-failed";
void writeDiagnostic(failedEvent);
return;
}
sessions.delete(importId);
@@ -89,8 +160,9 @@ export const createWeChatShareController = ({
completedPaths.add(session.zipPath);
failedPaths.delete(session.zipPath);
await removeImportedZip(session.zipPath, downloadsPath());
await rm(session.directory, { recursive: true, force: true }).catch(() => {});
void writeDiagnostic("wechat-import.saved", { media: session.media.size });
if (session.directory) await rm(session.directory, { recursive: true, force: true }).catch(() => {});
const savedEvent = session.payload?.kind === "file" ? "share-import.saved" : "wechat-import.saved";
void writeDiagnostic(savedEvent, { media: session.media.size });
};
const retry = (importId) => {
@@ -115,20 +187,60 @@ export const createWeChatShareController = ({
return { filename: media.filename, mimeType: media.mimeType, bytes };
};
const prepareFileShare = (sourcePath, byteSize) => {
const filename = basename(sourcePath);
const importId = randomUUID();
const mimeType = mimeTypeForFilename(filename);
const payload = {
ok: true,
kind: "file",
importId,
filename,
mimeType,
byteSize,
};
sessions.set(importId, {
directory: null,
media: new Map([[SHARE_IMPORT_FILE_ID, { filename, mimeType, byteSize, path: sourcePath }]]),
zipPath: sourcePath,
payload,
failed: false,
});
preparedPaths.add(sourcePath);
sendToRenderer(payload);
void writeDiagnostic("share-import.prepared", { bytes: byteSize });
};
const importFromProtocolUrl = async (value) => {
const requestedPath = wechatImportFilePath(value);
if (!requestedPath) return;
let shareFile = false;
try {
shareFile = new URL(value).hostname === "share-import";
} catch {
return;
}
const downloads = downloadsPath();
let zipPath = null;
let sourcePath = null;
let preparedDirectory = null;
try {
zipPath = await assertIncomingZip(requestedPath, downloads);
if (inFlightPaths.has(zipPath) || preparedPaths.has(zipPath)
|| completedPaths.has(zipPath) || failedPaths.has(zipPath)) return;
inFlightPaths.add(zipPath);
sourcePath = shareFile
? await assertIncomingFile(requestedPath, downloads)
: await assertIncomingZip(requestedPath, downloads);
if (inFlightPaths.has(sourcePath) || preparedPaths.has(sourcePath)
|| completedPaths.has(sourcePath) || failedPaths.has(sourcePath)) return;
inFlightPaths.add(sourcePath);
onActivity();
const archive = await readFile(zipPath);
const note = wechatChatNoteFromArchive(archive, basename(zipPath));
const info = await stat(sourcePath);
if (!info.isFile() || info.size <= 0) throw new ShareImportError("empty");
if (info.size > maxBytes) throw new ShareImportError("too-large");
const bytes = await readFile(sourcePath);
if (isFileReferencePlaceholder(bytes)) throw new ShareImportError("unreadable");
const note = wechatNoteFromBytes(bytes, basename(sourcePath));
if (!note) {
prepareFileShare(sourcePath, bytes.length);
return;
}
const importId = randomUUID();
const directory = join(tempPath(), "edgeever-wechat-import", importId);
preparedDirectory = directory;
@@ -158,22 +270,39 @@ export const createWeChatShareController = ({
markdown: note.markdown,
media: listed,
};
sessions.set(importId, { directory, media, zipPath, payload, failed: false });
preparedPaths.add(zipPath);
sessions.set(importId, { directory, media, zipPath: sourcePath, payload, failed: false });
preparedPaths.add(sourcePath);
preparedDirectory = null;
sendToRenderer(payload);
void writeDiagnostic("wechat-import.prepared", { media: listed.length, bytes: archive.length });
void writeDiagnostic("wechat-import.prepared", { media: listed.length, bytes: bytes.length });
} catch (error) {
if (preparedDirectory) await rm(preparedDirectory, { recursive: true, force: true }).catch(() => {});
const reason = error instanceof WeChatArchiveError ? error.code : "failed";
if (zipPath) failedPaths.add(zipPath);
// A stale or malformed protocol URL is not a failed save attempt.
if (reason !== "rejected-path") {
sendToRenderer({ ok: false, reason: reason === "unrecognized" ? "unrecognized" : "failed" });
const reason = error instanceof WeChatArchiveError || error instanceof ShareImportError
? error.code
: "failed";
if (reason === "unreadable" && sourcePath) {
await removeImportedZip(sourcePath, downloads);
} else if (sourcePath && reason !== "rejected-path") {
failedPaths.add(sourcePath);
}
// A stale or malformed protocol URL is not a failed save attempt.
if (reason === "rejected-path") {
void writeDiagnostic("wechat-import.rejected", { reason });
return;
}
if (reason === "unreadable" || reason === "too-large" || reason === "empty") {
sendToRenderer({
ok: false,
kind: "file",
reason: reason === "too-large" ? "too-large" : "unreadable",
});
void writeDiagnostic("share-import.rejected", { reason });
return;
}
sendToRenderer({ ok: false, reason: reason === "unrecognized" ? "unrecognized" : "failed" });
void writeDiagnostic("wechat-import.rejected", { reason });
} finally {
if (zipPath) inFlightPaths.delete(zipPath);
if (sourcePath) inFlightPaths.delete(sourcePath);
}
};
@@ -185,8 +314,8 @@ export const createWeChatShareController = ({
const directory = join(root, batch.name);
const files = await readdir(directory, { withFileTypes: true }).catch(() => []);
for (const file of files) {
if (!file.isFile() || !file.name.toLowerCase().endsWith(".zip")) continue;
const url = new URL("edgeever://wechat-import");
if (!file.isFile() || file.name.startsWith(".") || file.name.toLowerCase().endsWith(".partial")) continue;
const url = new URL(file.name.toLowerCase().endsWith(".zip") ? "edgeever://wechat-import" : "edgeever://share-import");
url.searchParams.set("path", join(directory, file.name));
await importFromProtocolUrl(url.href);
}
@@ -4,8 +4,10 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "bun:test";
import {
SHARE_IMPORT_FILE_ID,
WECHAT_INCOMING_DIRECTORY_NAME,
createWeChatShareController,
isFileReferencePlaceholder,
isPathInsideDirectory,
wechatImportFilePath,
} from "./wechat-share-import.mjs";
@@ -74,6 +76,9 @@ describe("WeChat share handoff", () => {
expect(wechatImportFilePath("edgeever://wechat-import?path=/Users/me/Downloads/EdgeEver%20Incoming/a/chat.zip")).toBe(
"/Users/me/Downloads/EdgeEver Incoming/a/chat.zip",
);
expect(wechatImportFilePath("edgeever://share-import?path=/Users/me/Downloads/EdgeEver%20Incoming/a/sheet.xlsx")).toBe(
"/Users/me/Downloads/EdgeEver Incoming/a/sheet.xlsx",
);
expect(wechatImportFilePath("edgeever://memo/memo_1")).toBeNull();
expect(wechatImportFilePath("https://example.com/chat.zip")).toBeNull();
expect(wechatImportFilePath("edgeever://wechat-import?path=chat.zip")).toBeNull();
@@ -221,7 +226,12 @@ describe("WeChat share handoff", () => {
sendToRenderer: (payload) => sent.push(payload),
});
await controller.importPending();
expect(sent).toEqual([{ ok: false, reason: "failed" }]);
expect(sent).toHaveLength(1);
expect(sent[0].ok).toBe(true);
expect(sent[0].kind).toBe("file");
expect(sent[0].filename).toBe("broken.zip");
const media = await controller.readMedia(sent[0].importId, "file");
expect(Buffer.from(media.bytes).toString()).toBe("not a zip");
});
test("picks up a completed share without a protocol URL and ignores a duplicate URL", async () => {
@@ -319,4 +329,114 @@ describe("WeChat share handoff", () => {
await controller.finish(second.importId, true);
await expect(stat(batch)).rejects.toThrow();
});
test("saves a shared spreadsheet as a file instead of a WeChat chat", async () => {
const root = await mkdtemp(join(tmpdir(), "edgeever-wechat-share-"));
roots.push(root);
const downloads = join(root, "Downloads");
const batch = join(downloads, WECHAT_INCOMING_DIRECTORY_NAME, "batch");
await mkdir(batch, { recursive: true });
const filePath = join(batch, "云晰科技爆单键盘项目功能梳理表.xlsx");
await writeFile(filePath, Buffer.from("spreadsheet"));
const sent = [];
const controller = createWeChatShareController({
downloadsPath: () => downloads,
tempPath: () => join(root, "temp"),
sendToRenderer: (payload) => sent.push(payload),
});
await controller.importFromProtocolUrl(`edgeever://share-import?path=${encodeURIComponent(filePath)}`);
expect(sent).toHaveLength(1);
expect(sent[0]).toMatchObject({
ok: true,
kind: "file",
filename: "云晰科技爆单键盘项目功能梳理表.xlsx",
mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
byteSize: Buffer.byteLength("spreadsheet"),
});
const media = await controller.readMedia(sent[0].importId, SHARE_IMPORT_FILE_ID);
expect(Buffer.from(media.bytes).toString()).toBe("spreadsheet");
await controller.finish(sent[0].importId, true);
await expect(stat(filePath)).rejects.toThrow();
});
test("still unpacks a WeChat chat when it arrives through the file share URL", async () => {
const root = await mkdtemp(join(tmpdir(), "edgeever-wechat-share-"));
roots.push(root);
const downloads = join(root, "Downloads");
const batch = join(downloads, WECHAT_INCOMING_DIRECTORY_NAME, "batch");
await mkdir(batch, { recursive: true });
const zipPath = join(batch, "聊天记录.zip");
await writeFile(zipPath, zipOf("聊天记录.txt", "·鱼\n2026年9月22日 22:15\n你好\n", "附件/readme.txt", "hello"));
const sent = [];
const controller = createWeChatShareController({
downloadsPath: () => downloads,
tempPath: () => join(root, "temp"),
sendToRenderer: (payload) => sent.push(payload),
});
await controller.importFromProtocolUrl(`edgeever://share-import?path=${encodeURIComponent(zipPath)}`);
expect(sent).toHaveLength(1);
expect(sent[0].ok).toBe(true);
expect(sent[0].kind).toBeUndefined();
expect(sent[0].markdown).toContain("你好");
});
test("reports a Finder file-id placeholder instead of treating it as a chat", async () => {
const root = await mkdtemp(join(tmpdir(), "edgeever-wechat-share-"));
roots.push(root);
const downloads = join(root, "Downloads");
const batch = join(downloads, WECHAT_INCOMING_DIRECTORY_NAME, "batch");
await mkdir(batch, { recursive: true });
const zipPath = join(batch, "聊天记录.zip");
const placeholder = Buffer.from("file:///.file/id=6571367.1215912");
expect(isFileReferencePlaceholder(placeholder)).toBe(true);
await writeFile(zipPath, placeholder);
const sent = [];
const diagnostics = [];
const controller = createWeChatShareController({
downloadsPath: () => downloads,
tempPath: () => join(root, "temp"),
sendToRenderer: (payload) => sent.push(payload),
writeDiagnostic: (event, details) => diagnostics.push({ event, details }),
});
await controller.importFromProtocolUrl(`edgeever://wechat-import?path=${encodeURIComponent(zipPath)}`);
expect(sent).toEqual([{ ok: false, kind: "file", reason: "unreadable" }]);
await expect(stat(zipPath)).rejects.toThrow();
expect(diagnostics).toContainEqual({ event: "share-import.rejected", details: { reason: "unreadable" } });
});
test("rejects a shared file larger than the configured limit", async () => {
const root = await mkdtemp(join(tmpdir(), "edgeever-wechat-share-"));
roots.push(root);
const downloads = join(root, "Downloads");
const batch = join(downloads, WECHAT_INCOMING_DIRECTORY_NAME, "batch");
await mkdir(batch, { recursive: true });
const filePath = join(batch, "large.pdf");
await writeFile(filePath, Buffer.from("12345"));
const sent = [];
const controller = createWeChatShareController({
downloadsPath: () => downloads,
tempPath: () => join(root, "temp"),
sendToRenderer: (payload) => sent.push(payload),
maxBytes: 4,
});
await controller.importFromProtocolUrl(`edgeever://share-import?path=${encodeURIComponent(filePath)}`);
expect(sent).toEqual([{ ok: false, kind: "file", reason: "too-large" }]);
expect((await stat(filePath)).isFile()).toBe(true);
});
test("does not read a shared file outside the incoming folder", async () => {
const root = await mkdtemp(join(tmpdir(), "edgeever-wechat-share-"));
roots.push(root);
const outside = join(root, "secret.xlsx");
await writeFile(outside, Buffer.from("secret"));
const sent = [];
const controller = createWeChatShareController({
downloadsPath: () => join(root, "Downloads"),
tempPath: () => join(root, "temp"),
sendToRenderer: (payload) => sent.push(payload),
});
await controller.importFromProtocolUrl(`edgeever://share-import?path=${encodeURIComponent(outside)}`);
expect(sent).toHaveLength(0);
expect((await readFile(outside)).toString()).toBe("secret");
});
});
+120 -4
View File
@@ -133,6 +133,7 @@ import { EditorPaneErrorBoundary, EditorRecoveryPane } from "./EditorPaneErrorBo
import { isMarkdownFile, readMarkdownFile } from "@/lib/markdown-file-import";
import { compressImageForUpload } from "@/lib/image-compression";
import { createScreenshotMemo, screenshotFileFromImportPayload, screenshotImportDedupeKey, screenshotImportGate } from "@/lib/screenshot-import";
import { createSharedFileMemo, sharedFileTitle } from "@/lib/shared-file-import";
import { createWeChatChatMemo } from "@/lib/wechat-chat-import";
import { isDesktopResourceRuntime, stageDesktopResource, toDesktopResourceUrl } from "@/lib/desktop-resources";
import { findMatchingMemoResource } from "@/lib/staged-resource-repair";
@@ -265,6 +266,7 @@ export const WorkspaceApp = ({
const [appNoticeDialog, setAppNoticeDialog] = useState<AppNoticeDialogState | null>(null);
const [wechatImportFailureId, setWeChatImportFailureId] = useState<string | null>(null);
const [wechatImportsInProgress, setWeChatImportsInProgress] = useState(0);
const [sharedFileImportsInProgress, setSharedFileImportsInProgress] = useState(0);
const [pendingCatalogTrustPluginIds, setPendingCatalogTrustPluginIds] = useState<string[]>([]);
const [isExportingSelectedMemos, setIsExportingSelectedMemos] = useState(false);
const [selectedMarkdownExportProgress, setSelectedMarkdownExportProgress] = useState<MarkdownExportProgress>({
@@ -1780,21 +1782,135 @@ export const WorkspaceApp = ({
const pendingWeChatImportsRef = useRef<Array<{
ok: boolean;
kind?: "file";
reason?: string;
importId?: string;
title?: string;
filename?: string;
mimeType?: string;
byteSize?: number;
markdown?: string;
media?: Array<{ id: string; filename: string; mimeType: string; byteSize: number }>;
}>>([]);
const handleImportWeChatChat = useCallback(async (payload: {
const handleImportSharedFile = useCallback(async (payload: {
ok: boolean;
reason?: string;
importId?: string;
filename?: string;
mimeType?: string;
}) => {
const bridge = window.edgeeverDesktop;
if (!payload.ok || !payload.importId || !payload.filename) {
setAppNoticeDialog({
title: t("memoList.importSharedFileFailedTitle"),
description: payload.reason === "too-large"
? t("memoList.importSharedFileTooLarge")
: payload.reason === "unreadable"
? t("memoList.importSharedFileUnreadable")
: t("memoList.importSharedFileFailed"),
});
return;
}
const notebookId = selectedNotebookId && notebooks.some((notebook) => notebook.id === selectedNotebookId) && memoView !== "trash"
? selectedNotebookId
: defaultMemoNotebookId;
if (!notebookId) {
pendingWeChatImportsRef.current.push({ ...payload, kind: "file" });
return;
}
const importId = payload.importId;
const filename = payload.filename;
setTemplatesOpen(false);
setMobileBottomNavActive("home");
creatingMemoSelectionRef.current = true;
setSharedFileImportsInProgress((count) => count + 1);
let savedMemo: MemoDetail | null = null;
let resumeDesktopSync: (() => void) | null = null;
try {
if (isDesktopResourceRuntime()) {
resumeDesktopSync = await (await import("@/lib/desktop-sync")).pauseDesktopSyncForImport();
}
const memo = await createSharedFileMemo({
notebookId,
filename,
file: await (async () => {
const shared = await bridge?.readWeChatImportMedia?.(importId, "file");
if (!shared?.bytes?.byteLength) throw new Error("Missing shared file");
const bytes = shared.bytes;
const copy = new ArrayBuffer(bytes.byteLength);
new Uint8Array(copy).set(bytes);
return new File([copy], shared.filename || filename, { type: shared.mimeType || payload.mimeType || "application/octet-stream" });
})(),
createMemo: (input) => repository.createMemo(input),
prepareFile: async (file) => (imageCompressionEnabled && file.type.startsWith("image/")
? (await compressImageForUpload(file)).file
: file),
uploadResource: async (memoId, uploadFile) => {
try {
const { resource } = await repository.uploadMemoResource(memoId, uploadFile);
return { url: toDesktopResourceUrl(resource.url), filename: resource.filename };
} catch (error) {
if (!isDesktopResourceRuntime()) throw error;
const listed = await repository.listResources().catch(() => ({ resources: [] as Array<{ memoId?: string; url: string; filename?: string | null; kind?: string | null }> }));
const existing = findMatchingMemoResource(
listed.resources.filter((resource) => resource.memoId === memoId),
uploadFile.name,
uploadFile.type.startsWith("image/") ? "image" : "attachment",
);
if (existing) return { url: toDesktopResourceUrl(existing.url), filename: existing.filename };
const staged = await stageDesktopResource(memoId, uploadFile);
if (!staged) throw error;
return { url: `edgeever-staged://${staged.id}`, filename: uploadFile.name };
}
},
updateMemo: (created, content) => repository.updateMemo(created, {
expectedRevision: created.revision,
expectedContentHash: created.contentHash,
editSessionId: `share:${created.id}`,
title: created.title ?? sharedFileTitle(filename),
contentJson: content.contentJson,
contentMarkdown: content.contentMarkdown,
tags: created.tags,
}),
deleteMemo: (memoId) => isDesktopResourceRuntime()
? import("@/lib/desktop-repository").then(({ cancelPendingDesktopImportMemo }) => cancelPendingDesktopImportMemo(memoId))
: repository.deleteMemo(memoId, true),
});
savedMemo = memo;
await bridge?.finishWeChatImport?.(importId, true).catch(() => undefined);
await putLocalMemo(localDataScope, memo).catch(() => undefined);
revealCreatedMemo(memo);
} catch {
if (!savedMemo) {
await bridge?.finishWeChatImport?.(importId, false).catch(() => undefined);
setAppNoticeDialog({
title: t("memoList.importSharedFileFailedTitle"),
description: t("memoList.importSharedFileFailed"),
});
}
creatingMemoSelectionRef.current = false;
} finally {
resumeDesktopSync?.();
setSharedFileImportsInProgress((count) => Math.max(0, count - 1));
}
}, [defaultMemoNotebookId, imageCompressionEnabled, localDataScope, memoView, notebooks, repository, selectedNotebookId, t]);
const handleImportWeChatChat = useCallback(async (payload: {
ok: boolean;
kind?: "file";
reason?: string;
importId?: string;
title?: string;
filename?: string;
mimeType?: string;
markdown?: string;
media?: Array<{ id: string; filename: string; mimeType: string; byteSize: number }>;
}) => {
if (payload.kind === "file") {
await handleImportSharedFile(payload);
return;
}
const bridge = window.edgeeverDesktop;
if (!payload.ok || !payload.importId || !payload.markdown) {
setAppNoticeDialog({
@@ -1886,7 +2002,7 @@ export const WorkspaceApp = ({
resumeDesktopSync?.();
setWeChatImportsInProgress((count) => Math.max(0, count - 1));
}
}, [defaultMemoNotebookId, imageCompressionEnabled, localDataScope, memoView, notebooks, repository, selectedNotebookId, t]);
}, [defaultMemoNotebookId, handleImportSharedFile, imageCompressionEnabled, localDataScope, memoView, notebooks, repository, selectedNotebookId, t]);
const handleImportWeChatChatRef = useRef(handleImportWeChatChat);
handleImportWeChatChatRef.current = handleImportWeChatChat;
@@ -3035,10 +3151,10 @@ export const WorkspaceApp = ({
return (
<WorkspaceMotionProvider>
<div className="edgeever-workspace-shell flex h-[100dvh] overflow-hidden text-slate-950">
{wechatImportsInProgress > 0 && (
{(wechatImportsInProgress > 0 || sharedFileImportsInProgress > 0) && (
<div className="pointer-events-none fixed inset-x-0 top-3 z-50 flex justify-center" role="status" aria-live="polite">
<div className="rounded-full border border-slate-200 bg-card px-4 py-2 text-sm font-medium text-slate-700 shadow-lg">
{t("memoList.importWeChatInProgress")}
{wechatImportsInProgress > 0 ? t("memoList.importWeChatInProgress") : t("memoList.importSharedFileInProgress")}
</div>
</div>
)}
@@ -0,0 +1,114 @@
import { describe, expect, test } from "bun:test";
import { FILE_ATTACHMENT_NODE_TYPE, PDF_ATTACHMENT_NODE_TYPE } from "@edgeever/shared";
import { createSharedFileMemo, sharedFileTitle } from "./shared-file-import.ts";
const memo = {
id: "memo_1",
revision: 1,
contentHash: "hash",
title: "",
tags: [],
contentMarkdown: "",
};
describe("shared file note", () => {
test("uses the filename without its extension as the title", () => {
expect(sharedFileTitle("云晰科技爆单键盘项目功能梳理表.xlsx")).toBe("云晰科技爆单键盘项目功能梳理表");
expect(sharedFileTitle("notes.md")).toBe("notes");
expect(sharedFileTitle("README")).toBe("README");
});
test("stores a spreadsheet as an attachment", async () => {
let createdTitle = "";
let updated = null;
const result = await createSharedFileMemo({
notebookId: "nb_inbox",
filename: "云晰科技爆单键盘项目功能梳理表.xlsx",
file: new File(["sheet"], "云晰科技爆单键盘项目功能梳理表.xlsx", {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
}),
createMemo: async (input) => {
createdTitle = input.title;
return { memo: { ...memo, title: input.title } };
},
uploadResource: async () => ({ url: "edgeever-staged://sheet" }),
updateMemo: async (created, content) => {
updated = content;
return { memo: { ...created, contentMarkdown: content.contentMarkdown } };
},
});
expect(createdTitle).toBe("云晰科技爆单键盘项目功能梳理表");
expect(result.contentMarkdown).toContain("edgeever-staged://sheet");
expect(JSON.stringify(updated.contentJson)).toContain(FILE_ATTACHMENT_NODE_TYPE);
});
test("stores a picture inline", async () => {
let updated = null;
await createSharedFileMemo({
notebookId: "nb_inbox",
filename: "photo.png",
file: new File(["png"], "photo.png", { type: "image/png" }),
createMemo: async (input) => ({ memo: { ...memo, title: input.title } }),
uploadResource: async () => ({ url: "edgeever-staged://photo.png", filename: "photo.png" }),
updateMemo: async (created, content) => {
updated = content;
return { memo: { ...created, contentMarkdown: content.contentMarkdown } };
},
});
expect(updated.contentMarkdown).toContain("![photo.png](edgeever-staged://photo.png)");
expect(updated.contentJson.content.some((node) => node.type === "image")).toBe(true);
});
test("stores a pdf as a pdf attachment", async () => {
let updated = null;
await createSharedFileMemo({
notebookId: "nb_inbox",
filename: "brief.pdf",
file: new File(["pdf"], "brief.pdf", { type: "application/pdf" }),
createMemo: async () => ({ memo }),
uploadResource: async () => ({ url: "edgeever-staged://brief.pdf", filename: "brief.pdf" }),
updateMemo: async (_created, content) => {
updated = content;
return { memo: { ...memo, contentMarkdown: content.contentMarkdown } };
},
});
expect(JSON.stringify(updated.contentJson)).toContain(PDF_ATTACHMENT_NODE_TYPE);
});
test("puts markdown text in the note body", async () => {
let created = null;
const uploads = [];
const result = await createSharedFileMemo({
notebookId: "nb_inbox",
filename: "今天.md",
file: new File(["# 今天\n\n正文"], "今天.md", { type: "text/markdown" }),
createMemo: async (input) => {
created = input;
return { memo: { ...memo, title: input.title, contentMarkdown: input.contentMarkdown } };
},
uploadResource: async () => {
uploads.push(true);
return { url: "edgeever-staged://unused" };
},
updateMemo: async () => { throw new Error("should not update"); },
});
expect(created.title).toBe("今天");
expect(created.contentMarkdown).toBe("# 今天\n\n正文");
expect(uploads).toHaveLength(0);
expect(result.contentMarkdown).toBe("# 今天\n\n正文");
});
test("removes the empty note when the attachment cannot be saved", async () => {
const deleted = [];
await expect(createSharedFileMemo({
notebookId: "nb_inbox",
filename: "sheet.xlsx",
file: new File(["x"], "sheet.xlsx"),
createMemo: async () => ({ memo }),
uploadResource: async () => { throw new Error("offline"); },
updateMemo: async () => { throw new Error("should not update"); },
deleteMemo: async (id) => { deleted.push(id); },
})).rejects.toThrow("offline");
expect(deleted).toEqual(["memo_1"]);
});
});
+93
View File
@@ -0,0 +1,93 @@
import { docToMarkdown, markdownToDoc, type TiptapDoc } from "@edgeever/shared";
import { screenshotNoteContent } from "./screenshot-import";
const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "webp"]);
const MARKDOWN_EXTENSIONS = new Set(["md", "markdown"]);
export const MAX_SHARED_MARKDOWN_BYTES = 8 * 1024 * 1024;
const extensionOf = (filename: string) => filename.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] ?? "";
export const sharedFileTitle = (filename: string) => {
const trimmed = filename.trim();
const dot = trimmed.lastIndexOf(".");
if (dot <= 0) return trimmed || filename;
const stem = trimmed.slice(0, dot).trim();
return stem || trimmed;
};
const escapeLabel = (value: string) => value.replace(/[\\[\]]/g, "\\$&");
export const sharedFileAttachmentMarkdown = (filename: string, url: string) =>
`[附件:${escapeLabel(filename || "file")}](${url})`;
const isImageFile = (filename: string, mimeType: string) =>
mimeType.toLowerCase().startsWith("image/") || IMAGE_EXTENSIONS.has(extensionOf(filename));
const attachmentNoteContent = (markdown: string) => {
const parsed = markdownToDoc(markdown);
const nodes = parsed.content ?? [];
const contentJson: TiptapDoc = {
type: "doc",
content: nodes.at(-1)?.type === "paragraph" ? nodes : [...nodes, { type: "paragraph" }],
};
return { contentJson, contentMarkdown: docToMarkdown(contentJson) };
};
type SharedMemo = {
id: string;
revision: number;
contentHash: string;
title: string | null;
tags: string[];
contentMarkdown: string;
};
export const createSharedFileMemo = async <TMemo extends SharedMemo>(input: {
notebookId: string;
filename: string;
file: File;
createMemo: (payload: {
notebookId: string;
title: string;
contentMarkdown: string;
tags: string[];
}) => Promise<{ memo: TMemo }>;
prepareFile?: (file: File) => Promise<File>;
uploadResource: (memoId: string, file: File) => Promise<{ url: string; filename?: string | null }>;
updateMemo: (
memo: TMemo,
content: { contentJson: TiptapDoc; contentMarkdown: string },
) => Promise<{ memo: TMemo }>;
deleteMemo?: (memoId: string) => Promise<unknown>;
}) => {
const title = sharedFileTitle(input.filename);
const extension = extensionOf(input.filename);
if (MARKDOWN_EXTENSIONS.has(extension) && input.file.size <= MAX_SHARED_MARKDOWN_BYTES) {
const text = await input.file.text();
return (await input.createMemo({
notebookId: input.notebookId,
title,
contentMarkdown: text,
tags: [],
})).memo;
}
const created = await input.createMemo({
notebookId: input.notebookId,
title,
contentMarkdown: "",
tags: [],
});
try {
const prepared = input.prepareFile ? await input.prepareFile(input.file) : input.file;
const resource = await input.uploadResource(created.memo.id, prepared);
const filename = resource.filename || prepared.name || input.filename;
const content = isImageFile(filename, prepared.type || input.file.type)
? screenshotNoteContent(filename, resource.url)
: attachmentNoteContent(sharedFileAttachmentMarkdown(filename, resource.url));
return (await input.updateMemo(created.memo, content)).memo;
} catch (error) {
await input.deleteMemo?.(created.memo.id).catch(() => undefined);
throw error;
}
};
+4
View File
@@ -93,9 +93,13 @@ interface EdgeEverDesktopBridge {
retryWeChatImport?(importId: string): Promise<boolean>;
onImportWeChatChat?(callback: (payload: {
ok: boolean;
kind?: "file";
reason?: string;
importId?: string;
title?: string;
filename?: string;
mimeType?: string;
byteSize?: number;
markdown?: string;
media?: Array<{ id: string; filename: string; mimeType: string; byteSize: number }>;
}) => void): () => void;
+1 -1
View File
@@ -63,7 +63,7 @@
"release": "bun scripts/release.mjs",
"publish:stores": "bun scripts/store-delivery.mjs",
"test:desktop:sidecar": "bun scripts/test-desktop-sidecar.mjs",
"test:desktop": "bun test apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/app-protocol.test.mjs apps/desktop/src/main/child-process-start.test.mjs apps/desktop/src/main/clipboard-write.test.mjs apps/desktop/src/main/desktop-diagnostics.test.mjs apps/desktop/src/main/desktop-menu.test.mjs apps/desktop/src/main/desktop-update-flow.test.mjs apps/desktop/src/main/diagnostic-log.test.mjs apps/desktop/src/main/file-association.test.mjs apps/desktop/src/main/wechat-chat-archive.test.mjs apps/desktop/src/main/wechat-share-import.test.mjs apps/desktop/src/main/share-extension-registration.test.mjs apps/desktop/src/main/file-permissions.test.mjs apps/desktop/src/main/installation-location.test.mjs apps/desktop/src/main/local-data-reset.test.mjs apps/desktop/src/main/ai-direct.test.mjs apps/desktop/src/main/plugin-public-network.test.mjs apps/desktop/src/main/renderer-recovery.test.mjs apps/desktop/src/main/renderer-hibernate.test.mjs apps/desktop/src/main/renderer-startup-guard.test.mjs apps/desktop/src/main/renderer-storage-migration.test.mjs apps/desktop/src/main/resource-request.test.mjs apps/desktop/src/main/resource-url.test.mjs apps/desktop/src/main/scheduled-task-scheduler.test.mjs apps/desktop/src/main/screenshot-capture.test.mjs apps/desktop/src/main/staged-resource.test.mjs apps/desktop/src/main/staged-resource-alias.test.mjs apps/desktop/src/main/tray-icon.test.mjs apps/desktop/src/main/user-data-directory.test.mjs apps/desktop/src/main/window-open-policy.test.mjs apps/desktop/src/main/window-visibility.test.mjs apps/desktop/src/main/windows-update-trust.test.mjs apps/web/src/lib/api.test.mjs apps/web/src/lib/app-page-path.test.mjs apps/web/src/lib/clipboard.test.mjs apps/web/src/lib/desktop-renderer-ready.test.mjs apps/web/src/lib/desktop-workspace-restore.test.mjs apps/web/src/lib/desktop-repository.test.mjs apps/web/src/lib/desktop-resources.test.mjs apps/web/src/lib/desktop-sync.test.mjs apps/web/src/lib/local-mirror.test.mjs apps/web/src/lib/repository.test.mjs apps/web/src/lib/runtime.test.mjs apps/web/src/lib/screenshot-import.test.mjs apps/web/src/lib/wechat-chat-import.test.mjs apps/web/src/lib/sync-queue.test.mjs && bun run test:desktop:sidecar",
"test:desktop": "bun test apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/app-protocol.test.mjs apps/desktop/src/main/child-process-start.test.mjs apps/desktop/src/main/clipboard-write.test.mjs apps/desktop/src/main/desktop-diagnostics.test.mjs apps/desktop/src/main/desktop-menu.test.mjs apps/desktop/src/main/desktop-update-flow.test.mjs apps/desktop/src/main/diagnostic-log.test.mjs apps/desktop/src/main/file-association.test.mjs apps/desktop/src/main/wechat-chat-archive.test.mjs apps/desktop/src/main/wechat-share-import.test.mjs apps/desktop/src/main/share-extension-registration.test.mjs apps/desktop/src/main/file-permissions.test.mjs apps/desktop/src/main/installation-location.test.mjs apps/desktop/src/main/local-data-reset.test.mjs apps/desktop/src/main/ai-direct.test.mjs apps/desktop/src/main/plugin-public-network.test.mjs apps/desktop/src/main/renderer-recovery.test.mjs apps/desktop/src/main/renderer-hibernate.test.mjs apps/desktop/src/main/renderer-startup-guard.test.mjs apps/desktop/src/main/renderer-storage-migration.test.mjs apps/desktop/src/main/resource-request.test.mjs apps/desktop/src/main/resource-url.test.mjs apps/desktop/src/main/scheduled-task-scheduler.test.mjs apps/desktop/src/main/screenshot-capture.test.mjs apps/desktop/src/main/staged-resource.test.mjs apps/desktop/src/main/staged-resource-alias.test.mjs apps/desktop/src/main/tray-icon.test.mjs apps/desktop/src/main/user-data-directory.test.mjs apps/desktop/src/main/window-open-policy.test.mjs apps/desktop/src/main/window-visibility.test.mjs apps/desktop/src/main/windows-update-trust.test.mjs apps/web/src/lib/api.test.mjs apps/web/src/lib/app-page-path.test.mjs apps/web/src/lib/clipboard.test.mjs apps/web/src/lib/desktop-renderer-ready.test.mjs apps/web/src/lib/desktop-workspace-restore.test.mjs apps/web/src/lib/desktop-repository.test.mjs apps/web/src/lib/desktop-resources.test.mjs apps/web/src/lib/desktop-sync.test.mjs apps/web/src/lib/local-mirror.test.mjs apps/web/src/lib/repository.test.mjs apps/web/src/lib/runtime.test.mjs apps/web/src/lib/screenshot-import.test.mjs apps/web/src/lib/wechat-chat-import.test.mjs apps/web/src/lib/shared-file-import.test.mjs apps/web/src/lib/sync-queue.test.mjs && bun run test:desktop:sidecar",
"verify:desktop-renderer": "bun scripts/verify-desktop-renderer.mjs",
"verify:renderer-origin-migration": "bun run --cwd apps/desktop verify-origin-migration",
"verify:desktop-cross-version-startup": "bun scripts/verify-desktop-cross-version-startup.mjs",
+5
View File
@@ -1641,6 +1641,11 @@ export const enUS = {
importWeChatInProgress: "Importing the WeChat chat…",
importWeChatRetryHint: "The chat export is still in Downloads. Try importing it again.",
importWeChatRetry: "Retry import",
importSharedFileFailedTitle: "Could not save the file",
importSharedFileFailed: "Couldn't save this file as a note. Try again.",
importSharedFileUnreadable: "The selected file could not be read. Share it again.",
importSharedFileTooLarge: "This file is too large to save as a note.",
importSharedFileInProgress: "Saving the file as a note…",
loadingMore: "Loading more notes",
openMemo: "Open note",
unpin: "Unpin",
+5
View File
@@ -1641,6 +1641,11 @@ export const ja = {
importWeChatInProgress: "WeChat のチャットを取り込んでいます…",
importWeChatRetryHint: "チャットの書き出しファイルはダウンロードフォルダに残っています。もう一度お試しください。",
importWeChatRetry: "取り込みを再試行",
importSharedFileFailedTitle: "ファイルを保存できませんでした",
importSharedFileFailed: "このファイルをノートとして保存できませんでした。再試行してください。",
importSharedFileUnreadable: "選択したファイルを読み取れませんでした。もう一度共有してください。",
importSharedFileTooLarge: "このファイルは大きすぎるため、ノートとして保存できません。",
importSharedFileInProgress: "ファイルをノートとして保存しています…",
loadingMore: "さらにノートを読み込み中",
openMemo: "ノートを開く",
unpin: "ピン留めを外す",
+5
View File
@@ -1641,6 +1641,11 @@ export const zhCN = {
importWeChatInProgress: "正在导入微信聊天记录…",
importWeChatRetryHint: "聊天记录仍保存在下载目录。请重试导入。",
importWeChatRetry: "重试导入",
importSharedFileFailedTitle: "无法保存文件",
importSharedFileFailed: "无法把这个文件保存为笔记。请重试。",
importSharedFileUnreadable: "没能读取所选文件。请再共享一次。",
importSharedFileTooLarge: "这个文件太大,无法保存为笔记。",
importSharedFileInProgress: "正在把文件保存为笔记…",
loadingMore: "正在加载更多笔记",
openMemo: "打开笔记",
unpin: "取消置顶",