mixdog 1.3.4 → 1.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSES/oh-my-pi-MIT.txt +23 -0
- package/NOTICE.md +15 -0
- package/README.md +11 -0
- package/package.json +2 -1
- package/scripts/embed-google-oauth-client.mjs +49 -0
- package/scripts/lib/test-spawn-ledger.mjs +46 -1
- package/scripts/test.mjs +5 -0
- package/src/defaults/shipped-history.json +8 -0
- package/src/defaults/skills/browser-use/SKILL.md +21 -9
- package/src/defaults/skills/setup/SKILL.md +7 -2
- package/src/defaults/skills/setup/references/actions.md +82 -27
- package/src/defaults/skills/setup/references/surfaces.md +67 -34
- package/src/defaults/skills/setup-guide/SKILL.md +3 -3
- package/src/lib/keychain-cjs.cjs +359 -29
- package/src/rules/agent-mode/AGENT_MODE.md +10 -2
- package/src/rules/agent-mode/MIXY.md +12 -0
- package/src/runtime/agent/orchestrator/agent-runtime/heartbeat-completion.mjs +193 -0
- package/src/runtime/agent/orchestrator/config.mjs +18 -0
- package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +2 -2
- package/src/runtime/agent/orchestrator/mcp/client.mjs +6 -2
- package/src/runtime/agent/orchestrator/mcp/oauth-callback.mjs +31 -2
- package/src/runtime/agent/orchestrator/mcp/oauth-client.mjs +16 -3
- package/src/runtime/agent/orchestrator/mcp/oauth-login.mjs +42 -7
- package/src/runtime/agent/orchestrator/mcp/oauth-provider.mjs +21 -2
- package/src/runtime/agent/orchestrator/mcp/oauth-shared.mjs +23 -0
- package/src/runtime/agent/orchestrator/mcp/oauth.mjs +11 -1
- package/src/runtime/agent/orchestrator/providers/account-pool.mjs +68 -23
- package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +22 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-api-request.mjs +18 -5
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +15 -3
- package/src/runtime/agent/orchestrator/providers/antigravity-oauth-catalog.mjs +52 -8
- package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +10 -1
- package/src/runtime/agent/orchestrator/providers/antigravity-request.mjs +22 -6
- package/src/runtime/agent/orchestrator/providers/antigravity-response.mjs +15 -4
- package/src/runtime/agent/orchestrator/providers/antigravity-stream.mjs +17 -2
- package/src/runtime/agent/orchestrator/providers/antigravity-transport.mjs +19 -2
- package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/cursor-wire-normalization.mjs +10 -4
- package/src/runtime/agent/orchestrator/providers/cursor-wire-protobuf.mjs +10 -217
- package/src/runtime/agent/orchestrator/providers/cursor.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/devin-auth.mjs +409 -0
- package/src/runtime/agent/orchestrator/providers/devin-wire-families.mjs +187 -0
- package/src/runtime/agent/orchestrator/providers/devin-wire-identity.mjs +342 -0
- package/src/runtime/agent/orchestrator/providers/devin-wire-models.mjs +541 -0
- package/src/runtime/agent/orchestrator/providers/devin-wire-protobuf.mjs +305 -0
- package/src/runtime/agent/orchestrator/providers/devin-wire-request.mjs +558 -0
- package/src/runtime/agent/orchestrator/providers/devin-wire-schema.mjs +880 -0
- package/src/runtime/agent/orchestrator/providers/devin-wire-session.mjs +190 -0
- package/src/runtime/agent/orchestrator/providers/devin-wire-tool-args.mjs +351 -0
- package/src/runtime/agent/orchestrator/providers/devin-wire-transport.mjs +560 -0
- package/src/runtime/agent/orchestrator/providers/devin.mjs +491 -0
- package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/gemini-output-limit.mjs +46 -0
- package/src/runtime/agent/orchestrator/providers/gemini-reasoning-relay.mjs +30 -0
- package/src/runtime/agent/orchestrator/providers/gemini-request-body.mjs +7 -11
- package/src/runtime/agent/orchestrator/providers/gemini-retry-policy.mjs +61 -22
- package/src/runtime/agent/orchestrator/providers/gemini-sdk-request.mjs +1 -0
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +71 -4
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +25 -2
- package/src/runtime/agent/orchestrator/providers/grok-client-version.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +96 -5
- package/src/runtime/agent/orchestrator/providers/lib/tail-reminder-split.mjs +23 -5
- package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +17 -0
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +39 -3
- package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/stream-state.mjs +3 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/openai-error-contract.mjs +6 -1
- package/src/runtime/agent/orchestrator/providers/opencode-go-gemini.mjs +1 -0
- package/src/runtime/agent/orchestrator/providers/provider-availability.mjs +19 -0
- package/src/runtime/agent/orchestrator/providers/registry.mjs +17 -3
- package/src/runtime/agent/orchestrator/providers/wire-protobuf-codec.mjs +368 -0
- package/src/runtime/agent/orchestrator/runtime-core/builtin-features.mjs +2 -0
- package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +1 -1
- package/src/runtime/agent/orchestrator/runtime-core/reaction-tool-defs.mjs +133 -0
- package/src/runtime/agent/orchestrator/runtime-core/tool-catalog-data.mjs +3 -0
- package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +23 -5
- package/src/runtime/agent/orchestrator/runtime-core/tool-profile.mjs +35 -10
- package/src/runtime/agent/orchestrator/runtime-core/workflow/agents.mjs +57 -10
- package/src/runtime/agent/orchestrator/runtime-core/workflow/context.mjs +9 -6
- package/src/runtime/agent/orchestrator/runtime-core/workflow-ids.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +18 -0
- package/src/runtime/agent/orchestrator/session/agent-room.d.mts +0 -3
- package/src/runtime/agent/orchestrator/session/agent-room.mjs +10 -4
- package/src/runtime/agent/orchestrator/session/compact/runner.mjs +15 -3
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +5 -0
- package/src/runtime/agent/orchestrator/session/human-gate/gate.mjs +36 -4
- package/src/runtime/agent/orchestrator/session/human-gate/outbound-policy.mjs +6 -0
- package/src/runtime/agent/orchestrator/session/human-gate/store.mjs +15 -3
- package/src/runtime/agent/orchestrator/session/loop/assistant-commit.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/loop/loop-state.mjs +8 -1
- package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/segments.mjs +4 -1
- package/src/runtime/agent/orchestrator/session/loop/no-tool-turn.mjs +50 -2
- package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec/before-hook.mjs +11 -2
- package/src/runtime/agent/orchestrator/session/loop/tool-phase.mjs +11 -2
- package/src/runtime/agent/orchestrator/session/loop/turn-limits.mjs +60 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-commit.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-loop-options.mjs +14 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-settle.mjs +2 -1
- package/src/runtime/agent/orchestrator/session/manager/session-tool-surface.mjs +13 -0
- package/src/runtime/agent/orchestrator/session/message-ids.mjs +5 -3
- package/src/runtime/agent/orchestrator/session/message-reactions.mjs +89 -0
- package/src/runtime/agent/orchestrator/session/message-reactions.test-support.mjs +75 -0
- package/src/runtime/agent/orchestrator/session/routine-result.d.mts +0 -2
- package/src/runtime/agent/orchestrator/session/routine-result.mjs +11 -2
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +20 -2
- package/src/runtime/agent/orchestrator/session/store-agent-worker-header-cache.mjs +85 -0
- package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +125 -12
- package/src/runtime/agent/orchestrator/session/tail-reminder-rules.mjs +11 -5
- package/src/runtime/agent/orchestrator/session/tool-batch/state.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +5 -0
- package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +7 -1
- package/src/runtime/channels/lib/schedule-errors.mjs +51 -0
- package/src/runtime/channels/lib/scheduler.mjs +109 -14
- package/src/runtime/channels/lib/schedules-db.mjs +49 -0
- package/src/runtime/channels/lib/tool-dispatch.mjs +13 -0
- package/src/runtime/channels/lib/worker-main.mjs +9 -0
- package/src/runtime/office/com/com-adapter.mjs +17 -1
- package/src/runtime/office/com/office-com-host.ps1 +51 -1
- package/src/runtime/office/pdf/cfb-fixture.mjs +328 -0
- package/src/runtime/office/pdf/cfb-protection.mjs +311 -0
- package/src/runtime/office/pdf/document-preview.mjs +326 -8
- package/src/runtime/office/pdf/ooxml-crypto.mjs +469 -0
- package/src/runtime/office/pdf/ooxml-key-worker.mjs +14 -0
- package/src/runtime/office/portable/portable-soffice.mjs +45 -5
- package/src/runtime/shared/bot-hops.mjs +62 -2
- package/src/runtime/shared/config.mjs +49 -22
- package/src/runtime/shared/connector-catalog.d.mts +4 -2
- package/src/runtime/shared/connector-catalog.mjs +16 -8
- package/src/runtime/shared/developer-options.mjs +32 -1
- package/src/runtime/shared/google-oauth-client.generated.json +4 -0
- package/src/runtime/shared/google-oauth-client.mjs +34 -9
- package/src/runtime/shared/llm/anthropic-betas.mjs +7 -0
- package/src/runtime/shared/llm/anthropic-thinking-contract.mjs +12 -0
- package/src/runtime/shared/llm/provider-catalog-registry.mjs +1 -0
- package/src/runtime/shared/llm/well-formed.mjs +66 -0
- package/src/runtime/shared/provider-accounts.mjs +224 -58
- package/src/runtime/shared/provider-api-key.mjs +19 -8
- package/src/runtime/shared/routine-trigger.mjs +42 -0
- package/src/runtime/shared/tool-execution-contract.mjs +2 -0
- package/src/runtime/shared/transcript-metadata.mjs +38 -0
- package/src/runtime/shared/user-data-guard.mjs +30 -2
- package/src/runtime/shared/worktrees/activity-gate.mjs +12 -5
- package/src/session-runtime/agent-home-delivery.mjs +57 -13
- package/src/session-runtime/agent-room-delivery.mjs +28 -1
- package/src/session-runtime/agent-task-sessions.mjs +292 -79
- package/src/session-runtime/boot/begin.mjs +3 -2
- package/src/session-runtime/boot/tools.mjs +2 -0
- package/src/session-runtime/bot-chain.mjs +32 -0
- package/src/session-runtime/bot-status.mjs +7 -4
- package/src/session-runtime/channel-config-api.mjs +63 -17
- package/src/session-runtime/internal-tool-executor/reaction-tool.mjs +45 -0
- package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +72 -5
- package/src/session-runtime/internal-tool-executor.mjs +4 -0
- package/src/session-runtime/mcp-glue/server-catalog.mjs +7 -1
- package/src/session-runtime/mixy-heartbeat.mjs +807 -0
- package/src/session-runtime/model-route/route-tuning.mjs +10 -6
- package/src/session-runtime/owned-automation-run.mjs +33 -12
- package/src/session-runtime/provider-auth/login.mjs +7 -0
- package/src/session-runtime/provider-models/row-hydration.mjs +13 -2
- package/src/session-runtime/routine-home-turn.mjs +33 -6
- package/src/session-runtime/schedule-session-run.mjs +7 -3
- package/src/session-runtime/services/agent-tool/spawn-flow/spawn-plan.mjs +8 -12
- package/src/session-runtime/services/channel-admin.mjs +66 -18
- package/src/session-runtime/services/channel-worker.mjs +1 -1
- package/src/session-runtime/services/provider-admin.mjs +128 -25
- package/src/session-runtime/services/schedule-access.mjs +14 -0
- package/src/session-runtime/services/usage-dashboard-model.mjs +1 -0
- package/src/session-runtime/session-create/session-options.mjs +7 -4
- package/src/session-runtime/settings-system-api.mjs +16 -1
- package/src/session-runtime/setup-tool/executor.mjs +49 -14
- package/src/session-runtime/setup-tool/extended-actions.mjs +29 -3
- package/src/session-runtime/setup-tool/settings-contract.mjs +65 -4
- package/src/session-runtime/setup-tool/tool-defs.mjs +30 -3
- package/src/session-runtime/task-session-route.mjs +99 -0
- package/src/session-runtime/tool-policy/policy-stage.mjs +5 -2
- package/src/session-runtime/tool-policy/surface-helpers.mjs +2 -3
- package/src/session-runtime/tool-surface.mjs +4 -0
- package/src/session-runtime/workflow-agents-api/agent-editor.mjs +98 -21
- package/src/session-runtime/workflow-agents-api/workflow-packs.mjs +6 -1
- package/src/session-runtime/workspace/transitions.mjs +10 -2
- package/src/standalone/daemon-session-control.mjs +88 -20
- package/src/standalone/daemon.mjs +8 -0
- package/src/standalone/session-protocol.mjs +5 -0
- package/src/standalone/session-service/agent-tree/agent-cancel.mjs +6 -2
- package/src/standalone/session-service/session-calls/session-turns.mjs +6 -0
- package/src/standalone/session-service.mjs +13 -0
- package/src/standalone/session-tool-output.mjs +8 -5
- package/src/tui/app/model-options.mjs +2 -0
- package/src/tui/dist/index.mjs +24 -2
- package/src/tui/session/notice-surface.mjs +17 -1
- package/src/tui/session/session-api/integrations.mjs +1 -0
- package/src/tui/session/session-api/lifecycle.mjs +8 -0
- package/src/tui/session/session-api/settings.mjs +14 -2
- package/src/tui/session/turn-stream.mjs +17 -1
- package/src/tui/session/turn.mjs +22 -2
|
@@ -12,11 +12,22 @@
|
|
|
12
12
|
// removal instead of a filename convention that has to stay in sync with
|
|
13
13
|
// whatever the renderer happens to write.
|
|
14
14
|
import { createHash } from 'node:crypto';
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
15
|
+
import { rmSync } from 'node:fs';
|
|
16
|
+
import { copyFile, mkdir, mkdtemp, readdir, readFile, rename, rm, stat, utimes, writeFile } from 'node:fs/promises';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
17
19
|
|
|
20
|
+
import {
|
|
21
|
+
isEncryptedBinaryPresentation,
|
|
22
|
+
isEncryptedOoxml,
|
|
23
|
+
isEncryptedWordDocument,
|
|
24
|
+
readEncryptedOoxml,
|
|
25
|
+
readOoxmlEncryptionInfo,
|
|
26
|
+
} from './cfb-protection.mjs';
|
|
27
|
+
import { decryptOoxmlPackage, ooxmlEncryption, ooxmlPackageIntact, ooxmlPasswordKey } from './ooxml-crypto.mjs';
|
|
18
28
|
import { renderPdfPages } from './pdf-render.mjs';
|
|
19
|
-
import {
|
|
29
|
+
import { callMicrosoftOffice, detectMicrosoftOffice } from '../com/com-adapter.mjs';
|
|
30
|
+
import { libreOfficeAvailable, renderPortableOoxml } from '../portable/portable-soffice.mjs';
|
|
20
31
|
|
|
21
32
|
// What LibreOffice converts faithfully enough to READ. PDF is absent on
|
|
22
33
|
// purpose: it is already viewable and never needs the conversion pass.
|
|
@@ -50,6 +61,251 @@ const MAX_CACHED_PAGE_REVISIONS = 48;
|
|
|
50
61
|
const MAX_MEMORY_PAGES = 32;
|
|
51
62
|
const MAX_MEMORY_PAGE_BYTES = 48 * 1024 * 1024;
|
|
52
63
|
|
|
64
|
+
// The Office application family that opens each previewable extension.
|
|
65
|
+
const OFFICE_FAMILY = {
|
|
66
|
+
docx: 'docx',
|
|
67
|
+
doc: 'docx',
|
|
68
|
+
docm: 'docx',
|
|
69
|
+
dotx: 'docx',
|
|
70
|
+
rtf: 'docx',
|
|
71
|
+
odt: 'docx',
|
|
72
|
+
xlsx: 'xlsx',
|
|
73
|
+
xls: 'xlsx',
|
|
74
|
+
xlsm: 'xlsx',
|
|
75
|
+
ods: 'xlsx',
|
|
76
|
+
pptx: 'pptx',
|
|
77
|
+
ppt: 'pptx',
|
|
78
|
+
pptm: 'pptx',
|
|
79
|
+
odp: 'pptx',
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** Marks the "no converter installed" failure; the desktop shows it in the UI language. */
|
|
83
|
+
export const OFFICE_PREVIEW_UNAVAILABLE = 'OFFICE_PREVIEW_UNAVAILABLE';
|
|
84
|
+
|
|
85
|
+
/** Marks a password-protected document that a password can open: the desktop asks for it. */
|
|
86
|
+
export const OFFICE_PREVIEW_PASSWORD_PROTECTED = 'OFFICE_PREVIEW_PASSWORD_PROTECTED';
|
|
87
|
+
|
|
88
|
+
/** The password given for a protected document does not open it. */
|
|
89
|
+
export const OFFICE_PREVIEW_WRONG_PASSWORD = 'OFFICE_PREVIEW_WRONG_PASSWORD';
|
|
90
|
+
|
|
91
|
+
/** A protected document no password route can preview (an encrypted .ppt, an unknown encryption). */
|
|
92
|
+
export const OFFICE_PREVIEW_PASSWORD_UNSUPPORTED = 'OFFICE_PREVIEW_PASSWORD_UNSUPPORTED';
|
|
93
|
+
|
|
94
|
+
/** A protected binary Word document only Microsoft Word can open with its password, and Word is not installed. */
|
|
95
|
+
export const OFFICE_PREVIEW_PASSWORD_NEEDS_OFFICE = 'OFFICE_PREVIEW_PASSWORD_NEEDS_OFFICE';
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A protected document whose encrypted package fails its integrity check ([MS-OFFCRYPTO] 2.3.4.14), or whose
|
|
99
|
+
* integrity data cannot be checked: it is never rendered, whatever the password.
|
|
100
|
+
*/
|
|
101
|
+
export const OFFICE_PREVIEW_PASSWORD_CORRUPT = 'OFFICE_PREVIEW_PASSWORD_CORRUPT';
|
|
102
|
+
|
|
103
|
+
const OFFICE_PREVIEW_RENDER_TIMEOUT_MS = 300_000;
|
|
104
|
+
|
|
105
|
+
const defaultEngines = {
|
|
106
|
+
libreOfficeAvailable,
|
|
107
|
+
renderLibreOffice: renderPortableOoxml,
|
|
108
|
+
detectOffice: detectMicrosoftOffice,
|
|
109
|
+
callOffice: callMicrosoftOffice,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
function codedError(message, code) {
|
|
113
|
+
const error = new Error(`${message} (${code})`);
|
|
114
|
+
error.code = code;
|
|
115
|
+
return error;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const passwordProtectedError = () =>
|
|
119
|
+
codedError("This file is password-protected and can't be previewed.", OFFICE_PREVIEW_PASSWORD_PROTECTED);
|
|
120
|
+
const wrongPasswordError = () => codedError("That password didn't work.", OFFICE_PREVIEW_WRONG_PASSWORD);
|
|
121
|
+
const passwordUnsupportedError = () =>
|
|
122
|
+
codedError("This file is password-protected and can't be previewed.", OFFICE_PREVIEW_PASSWORD_UNSUPPORTED);
|
|
123
|
+
const passwordNeedsOfficeError = () =>
|
|
124
|
+
codedError('Previewing this password-protected file needs Microsoft Office.', OFFICE_PREVIEW_PASSWORD_NEEDS_OFFICE);
|
|
125
|
+
const passwordCorruptError = () =>
|
|
126
|
+
codedError("This password-protected file is damaged and can't be previewed.", OFFICE_PREVIEW_PASSWORD_CORRUPT);
|
|
127
|
+
|
|
128
|
+
// An engine's error text may echo what it was given; the password never leaves in it.
|
|
129
|
+
const withoutPassword = (text, password) => (password ? String(text).split(password).join('***') : String(text));
|
|
130
|
+
|
|
131
|
+
// Microsoft Office renders a private COPY of the document, read-only: the host
|
|
132
|
+
// opens it read-only (no recalculation, no link updates, macros off), exports the
|
|
133
|
+
// PDF, then closes the document without saving and quits the application
|
|
134
|
+
// instance it started. The copy keeps the source unlocked and unreachable. A
|
|
135
|
+
// password travels in the request on the host's stdin, never on its command line.
|
|
136
|
+
async function renderWithMicrosoftOffice(path, output, family, { signal, engines, password }) {
|
|
137
|
+
const { callOffice, removeDirectory: remove, removeBackoffMs: backoffMs } = engines;
|
|
138
|
+
const scratch = await createScratch('mixdog-preview-');
|
|
139
|
+
let result;
|
|
140
|
+
try {
|
|
141
|
+
const copy = join(scratch, basename(path));
|
|
142
|
+
await copyFile(path, copy);
|
|
143
|
+
const request = { action: 'render', format: family, mode: 'background', readOnly: true, path: copy, output };
|
|
144
|
+
if (password) request.password = password;
|
|
145
|
+
result = await callOffice(request, { signal, timeoutMs: OFFICE_PREVIEW_RENDER_TIMEOUT_MS });
|
|
146
|
+
signal?.throwIfAborted();
|
|
147
|
+
if (!result.ok) {
|
|
148
|
+
const error = String(result.error);
|
|
149
|
+
if (error.includes(OFFICE_PREVIEW_WRONG_PASSWORD)) throw wrongPasswordError();
|
|
150
|
+
if (error.includes(OFFICE_PREVIEW_PASSWORD_PROTECTED)) throw passwordProtectedError();
|
|
151
|
+
throw new Error(withoutPassword(result.error || 'Microsoft Office could not render this document.', password));
|
|
152
|
+
}
|
|
153
|
+
} finally {
|
|
154
|
+
await removeScratch(scratch, result?.hostExit, { remove, backoffMs });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Every scratch directory this module creates holds document content: a private copy for Microsoft Office, a
|
|
159
|
+
// decrypted package, an unlocked PDF. Each is tracked from its creation, while its render still runs, until its
|
|
160
|
+
// removal is confirmed; whatever is still tracked when the process exits (a removal that kept failing, a render
|
|
161
|
+
// still running) is removed then.
|
|
162
|
+
const scratchDirectories = new Set();
|
|
163
|
+
let scratchExitHook = false;
|
|
164
|
+
|
|
165
|
+
async function createScratch(prefix) {
|
|
166
|
+
const directory = await mkdtemp(join(tmpdir(), prefix));
|
|
167
|
+
scratchDirectories.add(directory);
|
|
168
|
+
if (!scratchExitHook) {
|
|
169
|
+
scratchExitHook = true;
|
|
170
|
+
process.once('exit', removeTrackedPreviewScratchSync);
|
|
171
|
+
}
|
|
172
|
+
return directory;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Scratch directories whose removal is not confirmed yet (for diagnostics and tests). */
|
|
176
|
+
export const trackedPreviewScratch = () => [...scratchDirectories];
|
|
177
|
+
|
|
178
|
+
/** Removes every tracked scratch directory at once; the process does this as it exits. */
|
|
179
|
+
export function removeTrackedPreviewScratchSync() {
|
|
180
|
+
for (const directory of scratchDirectories) {
|
|
181
|
+
try {
|
|
182
|
+
rmSync(directory, { recursive: true, force: true });
|
|
183
|
+
scratchDirectories.delete(directory);
|
|
184
|
+
} catch {}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Whether a path lies inside a tracked scratch directory (an unlocked PDF does).
|
|
189
|
+
function inScratch(path) {
|
|
190
|
+
const target = resolve(path);
|
|
191
|
+
for (const directory of scratchDirectories) {
|
|
192
|
+
const inside = relative(directory, target);
|
|
193
|
+
if (inside && inside !== '..' && !inside.startsWith(`..${sep}`) && !isAbsolute(inside)) return true;
|
|
194
|
+
}
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// A cancelled render can return while the host (and so Office) still holds the copy open. The first
|
|
199
|
+
// removal is immediate; if it fails, ownership moves to a background task that waits for the host to
|
|
200
|
+
// exit and retries a bounded number of times with growing delays. The caller never waits on it. The
|
|
201
|
+
// directory stays tracked until a removal succeeds, so one that never does is removed at exit.
|
|
202
|
+
const SCRATCH_REMOVE_ATTEMPTS = 6;
|
|
203
|
+
const SCRATCH_REMOVE_BACKOFF_MS = 250;
|
|
204
|
+
|
|
205
|
+
const removeTree = (target) => rm(target, { recursive: true, force: true });
|
|
206
|
+
|
|
207
|
+
async function removeScratch(scratch, hostExit, { remove = removeTree, backoffMs = SCRATCH_REMOVE_BACKOFF_MS } = {}) {
|
|
208
|
+
const removed = async () => {
|
|
209
|
+
try {
|
|
210
|
+
await remove(scratch);
|
|
211
|
+
} catch {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
scratchDirectories.delete(scratch);
|
|
215
|
+
return true;
|
|
216
|
+
};
|
|
217
|
+
if (await removed()) return;
|
|
218
|
+
void (async () => {
|
|
219
|
+
await Promise.resolve(hostExit).catch(() => {});
|
|
220
|
+
for (let attempt = 0; attempt < SCRATCH_REMOVE_ATTEMPTS; attempt++) {
|
|
221
|
+
await new Promise((done) => setTimeout(done, backoffMs * 2 ** attempt).unref?.());
|
|
222
|
+
if (await removed()) return;
|
|
223
|
+
}
|
|
224
|
+
})();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// What protects a document, read from its container before any renderer starts (a hidden PowerPoint or Word would
|
|
228
|
+
// wait on a password prompt nobody sees, LibreOffice would fail late): an encrypted OOXML package this module
|
|
229
|
+
// unlocks with the password ('package'), one whose integrity data is malformed ('corrupt'), an encrypted binary
|
|
230
|
+
// Word document Word opens with it ('word'), a protection no password route opens ('unsupported'), or null. An
|
|
231
|
+
// unreadable container is left to the renderer.
|
|
232
|
+
async function protection(path) {
|
|
233
|
+
if (/\.ppt$/i.test(path)) return (await isEncryptedBinaryPresentation(path)) ? 'unsupported' : null;
|
|
234
|
+
if (/\.doc$/i.test(path)) return (await isEncryptedWordDocument(path)) ? 'word' : null;
|
|
235
|
+
if (/\.(docx|docm|dotx|dotm|xlsx|xlsm|xltx|xltm|pptx|pptm|potx|potm|ppsx|ppsm)$/i.test(path)) {
|
|
236
|
+
if (!(await isEncryptedOoxml(path))) return null;
|
|
237
|
+
const descriptor = ooxmlEncryption(await readOoxmlEncryptionInfo(path));
|
|
238
|
+
if (!descriptor) return 'unsupported';
|
|
239
|
+
return descriptor.integrity?.malformed ? 'corrupt' : 'package';
|
|
240
|
+
}
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function officeInstalled(engines, family) {
|
|
245
|
+
const detected = await engines.detectOffice({ format: family });
|
|
246
|
+
return Boolean(detected?.applications?.some((application) => application.format === family && application.installed));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// LibreOffice when it is available, else Microsoft Office. A password only reaches Microsoft Office: a document
|
|
250
|
+
// its host finds protected (a legacy one the container check does not cover) is opened with it there.
|
|
251
|
+
async function renderWithEngine(path, output, format, { signal, engines, password }) {
|
|
252
|
+
if (await engines.libreOfficeAvailable()) {
|
|
253
|
+
await engines.renderLibreOffice(path, output, { signal });
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const family = OFFICE_FAMILY[format];
|
|
257
|
+
if (!(await officeInstalled(engines, family))) {
|
|
258
|
+
throw codedError(
|
|
259
|
+
'Previewing Office files needs LibreOffice or Microsoft Office, and neither was found. To install LibreOffice, open Extensions → Built-in → Office and choose Install, then try again.',
|
|
260
|
+
OFFICE_PREVIEW_UNAVAILABLE
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
await renderWithMicrosoftOffice(path, output, family, { signal, engines, password });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// An encrypted OOXML package is opened the way [MS-OFFCRYPTO] specifies (see ooxml-crypto.mjs): the password
|
|
267
|
+
// verifier decides a wrong password without starting anything (its hashing off this thread, ended by `signal`),
|
|
268
|
+
// the package's HMAC must then prove it intact, and only then does the key decrypt it into a private scratch copy
|
|
269
|
+
// that the usual engine renders and that is removed with it. No engine ever sees the password.
|
|
270
|
+
async function renderUnlockedPackage(path, output, format, { signal, engines, password }) {
|
|
271
|
+
const encrypted = await readEncryptedOoxml(path);
|
|
272
|
+
const descriptor = encrypted && ooxmlEncryption(encrypted.encryptionInfo);
|
|
273
|
+
if (!descriptor) throw passwordUnsupportedError();
|
|
274
|
+
if (descriptor.integrity?.malformed) throw passwordCorruptError();
|
|
275
|
+
const key = await ooxmlPasswordKey(descriptor, password, { signal });
|
|
276
|
+
if (!key) throw wrongPasswordError();
|
|
277
|
+
if (!ooxmlPackageIntact(descriptor, key, encrypted.encryptedPackage)) throw passwordCorruptError();
|
|
278
|
+
const plain = decryptOoxmlPackage(descriptor, key, encrypted.encryptedPackage);
|
|
279
|
+
signal?.throwIfAborted();
|
|
280
|
+
const scratch = await createScratch('mixdog-unlocked-');
|
|
281
|
+
try {
|
|
282
|
+
const copy = join(scratch, basename(path));
|
|
283
|
+
await writeFile(copy, plain, { mode: 0o600 });
|
|
284
|
+
await renderWithEngine(copy, output, format, { signal, engines });
|
|
285
|
+
} finally {
|
|
286
|
+
await removeScratch(scratch, undefined, { remove: engines.removeDirectory, backoffMs: engines.removeBackoffMs });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function renderPreviewPdf(path, output, format, { signal, engines, password }) {
|
|
291
|
+
const protectedBy = await protection(path);
|
|
292
|
+
if (protectedBy === 'unsupported') throw passwordUnsupportedError();
|
|
293
|
+
if (protectedBy === 'corrupt') throw passwordCorruptError();
|
|
294
|
+
if (protectedBy === 'package') {
|
|
295
|
+
if (!password) throw passwordProtectedError();
|
|
296
|
+
await renderUnlockedPackage(path, output, format, { signal, engines, password });
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (protectedBy === 'word') {
|
|
300
|
+
// LibreOffice's conversion takes no password, so only Word can open it.
|
|
301
|
+
if (!(await officeInstalled(engines, 'docx'))) throw passwordNeedsOfficeError();
|
|
302
|
+
if (!password) throw passwordProtectedError();
|
|
303
|
+
await renderWithMicrosoftOffice(path, output, 'docx', { signal, engines, password });
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
await renderWithEngine(path, output, format, { signal, engines, password });
|
|
307
|
+
}
|
|
308
|
+
|
|
53
309
|
/** The convertible format for this filename, or '' when there is none. */
|
|
54
310
|
export function documentPreviewFormat(path) {
|
|
55
311
|
const name =
|
|
@@ -92,19 +348,79 @@ async function pruneCache(root) {
|
|
|
92
348
|
// second one starts a second LibreOffice run writing the same output file.
|
|
93
349
|
const conversions = new Map();
|
|
94
350
|
|
|
351
|
+
// A document opened with its password is never written to the cache: its PDF lives in a private scratch directory
|
|
352
|
+
// for as long as this process runs (or until newer unlocks push it out), so the viewer's later page requests are
|
|
353
|
+
// served without the password, and nothing of it outlives the process. Keyed by source identity like the cache, so
|
|
354
|
+
// a changed document asks for its password again. An entry pushed out is no longer served at once; its directory
|
|
355
|
+
// stays tracked (and its pages off disk) until its removal is confirmed.
|
|
356
|
+
const MAX_UNLOCKED_DOCUMENTS = 8;
|
|
357
|
+
const unlockedPreviews = new Map();
|
|
358
|
+
|
|
359
|
+
function forgetUnlocked(key) {
|
|
360
|
+
const entry = unlockedPreviews.get(key);
|
|
361
|
+
if (!entry) return;
|
|
362
|
+
unlockedPreviews.delete(key);
|
|
363
|
+
void removeScratch(entry.directory, undefined, entry.removal);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function rememberUnlocked(key, entry) {
|
|
367
|
+
forgetUnlocked(key);
|
|
368
|
+
unlockedPreviews.set(key, entry);
|
|
369
|
+
for (const oldest of unlockedPreviews.keys()) {
|
|
370
|
+
if (unlockedPreviews.size <= MAX_UNLOCKED_DOCUMENTS) break;
|
|
371
|
+
forgetUnlocked(oldest);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function unlockedPreview(key) {
|
|
376
|
+
const entry = unlockedPreviews.get(key);
|
|
377
|
+
if (!entry) return null;
|
|
378
|
+
const ready = await stat(entry.output).catch(() => null);
|
|
379
|
+
if (!ready?.isFile()) {
|
|
380
|
+
forgetUnlocked(key);
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
// Most recently used last, so eviction drops the one nobody looked at.
|
|
384
|
+
unlockedPreviews.delete(key);
|
|
385
|
+
unlockedPreviews.set(key, entry);
|
|
386
|
+
return entry.output;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function renderUnlocked(path, key, identity, { signal, engines, password }) {
|
|
390
|
+
const removal = { remove: engines.removeDirectory, backoffMs: engines.removeBackoffMs };
|
|
391
|
+
const directory = await createScratch('mixdog-unlocked-preview-');
|
|
392
|
+
const output = join(directory, PREVIEW_PDF_NAME);
|
|
393
|
+
try {
|
|
394
|
+
await renderPreviewPdf(path, output, identity.format, { signal, engines, password });
|
|
395
|
+
} catch (error) {
|
|
396
|
+
await removeScratch(directory, undefined, removal);
|
|
397
|
+
throw error;
|
|
398
|
+
}
|
|
399
|
+
rememberUnlocked(key, { directory, output, removal });
|
|
400
|
+
return { path: output, ...identity, cached: false };
|
|
401
|
+
}
|
|
402
|
+
|
|
95
403
|
/**
|
|
96
|
-
* The cached PDF rendition of one document, converting it when needed.
|
|
404
|
+
* The cached PDF rendition of one document, converting it when needed. `password` opens a protected document for
|
|
405
|
+
* this one request; it is handed to the opening step and nowhere else.
|
|
97
406
|
* @returns {Promise<{ path: string; format: string; mtimeMs: number; size: number; cached: boolean }>}
|
|
98
407
|
*/
|
|
99
|
-
export async function documentPreviewPdf(
|
|
408
|
+
export async function documentPreviewPdf(
|
|
409
|
+
path,
|
|
410
|
+
{ cacheRoot, signal = null, engines = defaultEngines, password = '' } = {}
|
|
411
|
+
) {
|
|
100
412
|
const format = documentPreviewFormat(path);
|
|
101
413
|
if (!format) throw new Error('This file type has no built-in document preview.');
|
|
102
414
|
if (!cacheRoot) throw new Error('Document preview storage is unavailable.');
|
|
103
415
|
const info = await stat(path);
|
|
104
416
|
if (!info.isFile()) throw new Error('Not a file.');
|
|
105
|
-
const
|
|
417
|
+
const key = cacheKey(path, info);
|
|
418
|
+
const directory = join(cacheRoot, CACHE_DIR_NAME, key);
|
|
106
419
|
const output = join(directory, PREVIEW_PDF_NAME);
|
|
107
420
|
const identity = { format, mtimeMs: info.mtimeMs, size: info.size };
|
|
421
|
+
const unlocked = await unlockedPreview(key);
|
|
422
|
+
if (unlocked) return { path: unlocked, ...identity, cached: true };
|
|
423
|
+
if (password) return await renderUnlocked(path, key, identity, { signal, engines, password });
|
|
108
424
|
const ready = await stat(output).catch(() => null);
|
|
109
425
|
if (ready?.isFile() && ready.size > 0) {
|
|
110
426
|
// Touch on USE, so eviction drops what nobody opens rather than what was
|
|
@@ -118,7 +434,7 @@ export async function documentPreviewPdf(path, { cacheRoot, signal = null } = {}
|
|
|
118
434
|
const conversion = (async () => {
|
|
119
435
|
await mkdir(directory, { recursive: true });
|
|
120
436
|
try {
|
|
121
|
-
await
|
|
437
|
+
await renderPreviewPdf(path, output, format, { signal, engines });
|
|
122
438
|
} catch (error) {
|
|
123
439
|
await rm(directory, { recursive: true, force: true }).catch(() => {});
|
|
124
440
|
throw error;
|
|
@@ -138,8 +454,10 @@ export async function documentPreviewPdf(path, { cacheRoot, signal = null } = {}
|
|
|
138
454
|
*/
|
|
139
455
|
export async function documentPreviewPages(
|
|
140
456
|
pdfPath,
|
|
141
|
-
{ pages = [1], maxWidth = 1200, signal = null, cacheRoot = '', renderPages = renderPdfPages } = {}
|
|
457
|
+
{ pages = [1], maxWidth = 1200, signal = null, cacheRoot: storageRoot = '', renderPages = renderPdfPages } = {}
|
|
142
458
|
) {
|
|
459
|
+
// Pages of a document opened with its password stay in memory only, like its PDF.
|
|
460
|
+
const cacheRoot = inScratch(pdfPath) ? '' : storageRoot;
|
|
143
461
|
const wanted = Array.isArray(pages) && pages.length ? pages : [1];
|
|
144
462
|
const info = await stat(pdfPath).catch(() => null);
|
|
145
463
|
const revision = info?.isFile() ? pageRevision(pdfPath, info) : null;
|