dsh-tiddlywiki 0.26.6 → 0.26.7

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-tiddlywiki",
3
3
  "description": "TiddlyWiki 5 as the DSH persistent knowledge base: fifteen tiddlywiki_* agent tools (filtered search/recent/tags/batch/rewrite/trash/lint + git sync & conflict resolve), a full TiddlyWiki editor embedded in the GUI center column (same-origin proxy, works over Tailscale/LAN/domain), a right-side wiki tab, a per-session summary tab, a quick-note card with draft auto-save behind a single knowledge FAB, an optional clip bookmarklet that also saves selected images as attachments, and git-based sync with auto-commit. Mounts via the official dsh plugin system — no DSH source changes.",
4
- "version": "0.26.6",
4
+ "version": "0.26.7",
5
5
  "type": "module",
6
6
  "keywords": [
7
7
  "dsh",
@@ -86,7 +86,7 @@
86
86
  "smoke:client": "node scripts/smoke-client.mjs",
87
87
  "selftest": "node scripts/selftest.mjs",
88
88
  "verify:static": "node scripts/verify-send-to-agent-bundle.mjs && node scripts/verify-render-bundle.mjs && node scripts/verify-wechat-publish-bundle.mjs && node scripts/verify-package-contents.mjs && node scripts/verify-version-consistency.mjs && node scripts/verify-constants.mjs",
89
- "verify:unit": "node scripts/verify-clip-bridge.mjs && node scripts/verify-seed-error-policy.mjs && node scripts/verify-render-sanitizer.mjs && node scripts/verify-secret-masking.mjs && node scripts/verify-write-policy.mjs && node scripts/verify-workspace.mjs && node scripts/verify-restart-drain.mjs && node scripts/verify-language-pin.mjs && node scripts/verify-prompt.mjs && node scripts/verify-conflict-config-guards.mjs && node scripts/verify-config-patch.mjs && node scripts/verify-tool-views.mjs && npx tsx scripts/verify-plugin-runtime.mjs && node scripts/verify-wechat-adapters.mjs && node scripts/verify-wechat-docs-seed.mjs && node scripts/verify-wechat-publish.mjs && node scripts/verify-frame-guards.mjs && npx tsx scripts/verify-ready-policy.mjs && npx tsx scripts/verify-frame-surface.mjs && npx tsx scripts/verify-editor-popup.mjs && npx tsx scripts/verify-status-cache.mjs",
89
+ "verify:unit": "node scripts/verify-clip-bridge.mjs && node scripts/verify-seed-error-policy.mjs && node scripts/verify-render-sanitizer.mjs && node scripts/verify-secret-masking.mjs && node scripts/verify-write-policy.mjs && node scripts/verify-workspace.mjs && node scripts/verify-restart-drain.mjs && node scripts/verify-language-pin.mjs && node scripts/verify-prompt.mjs && node scripts/verify-conflict-config-guards.mjs && node scripts/verify-config-patch.mjs && node scripts/verify-tool-views.mjs && npx tsx scripts/verify-plugin-runtime.mjs && node scripts/verify-wechat-adapters.mjs && node scripts/verify-wechat-docs-seed.mjs && node scripts/verify-wechat-publish.mjs && npx tsx scripts/verify-tw-origin.mjs && node scripts/verify-frame-guards.mjs && npx tsx scripts/verify-ready-policy.mjs && npx tsx scripts/verify-frame-surface.mjs && npx tsx scripts/verify-editor-popup.mjs && npx tsx scripts/verify-status-cache.mjs",
90
90
  "verify:e2e": "node scripts/verify-auth.mjs && node scripts/verify-anon-username.mjs && node scripts/verify-tools.mjs && node scripts/verify-audit-fixes.mjs && node scripts/verify-git-resolve.mjs && node scripts/verify-resilience.mjs && node scripts/verify-seed-send-to-agent.mjs && node scripts/verify-seeds-admin.mjs && node scripts/verify-wiki-switch.mjs",
91
91
  "verify:large": "node scripts/verify-large-wiki.mjs",
92
92
  "verify": "npm run verify:static && npm run verify:unit && npm run verify:e2e && npm run verify:large",
@@ -48,6 +48,33 @@ export const ADMIN_WIKI_LOCATION_ENDPOINT = `${ROUTE_PREFIX}/admin/wiki/location
48
48
  export const ADMIN_WIKI_SWITCH_ENDPOINT = `${ROUTE_PREFIX}/admin/wiki/switch`
49
49
  export const ADMIN_WIKI_RESET_ENDPOINT = `${ROUTE_PREFIX}/admin/wiki/reset`
50
50
 
51
+ /**
52
+ * Resolve a TW proxy URL for THIS document (v0.26.7).
53
+ *
54
+ * On an http(s) page the relative proxy path is right — it keeps working behind
55
+ * whatever host/domain/HTTPS the user reached DSH on. But TiddlyWiki's own
56
+ * TiddlyWeb sync adaptor only loads when the DOCUMENT's protocol starts with
57
+ * `http` (`plugins/tiddlywiki/tiddlyweb/tiddlywebadaptor.js`:
58
+ * `if($tw.browser && document.location.protocol.substr(0,4) === "http")`), and
59
+ * the DSH **desktop** app serves its renderer from the custom `dsh-app:`
60
+ * scheme: framed from there the wiki had no sync adaptor at all, so
61
+ * `$:/status/IsReadOnly` was never written, TW's read-only stylesheet (which
62
+ * treats a missing status as read-only) hid 添加条目 / 编辑 / 导入 … and no edit
63
+ * could be saved.
64
+ *
65
+ * `absolute` is the host's own loopback HTTP base (`twProxyAbsolute` /
66
+ * `twUrlAbsolute`, built from the port it actually listens on); it is used only
67
+ * when this page is NOT on http(s), i.e. exactly the desktop case, so an
68
+ * http(s) embedder is never sent to a different origin than its own.
69
+ */
70
+ export function resolveTwUrl(relative: string, absolute?: string): string {
71
+ const protocol = typeof location === 'undefined' ? '' : location.protocol
72
+ const origin = typeof location === 'undefined' ? undefined : location.origin
73
+ const notHttp = protocol !== 'http:' && protocol !== 'https:'
74
+ if (notHttp && typeof absolute === 'string' && absolute.length > 0) return absolute
75
+ return new URL(relative, origin).href
76
+ }
77
+
51
78
  /** The subset of the `/sync` JSON body both client callers report on. */
52
79
  export interface SyncResultPayload {
53
80
  ok?: boolean
@@ -30,7 +30,7 @@ import { toast } from './toast.ts'
30
30
  import { openEditorPopup, isEditorPopupOpen, isEditorPopupBlank } from './editor-popup.ts'
31
31
  import { buildMarkdownEditor, type MarkdownEditor } from './markdown-editor.ts'
32
32
  import { fetchStatus } from './status-cache.ts'
33
- import { EDIT_ENDPOINT, GET_ENDPOINT, NOTE_ENDPOINT, RECENT_ENDPOINT, TAGS_ENDPOINT, UPLOAD_ENDPOINT } from './endpoints.ts'
33
+ import { EDIT_ENDPOINT, GET_ENDPOINT, NOTE_ENDPOINT, RECENT_ENDPOINT, TAGS_ENDPOINT, UPLOAD_ENDPOINT, resolveTwUrl } from './endpoints.ts'
34
34
 
35
35
  const MAX_UPLOAD_BYTES = 64 * 1024 * 1024
36
36
 
@@ -557,7 +557,7 @@ export function createNoteWidget(): NoteWidgetHandle {
557
557
  signal: AbortSignal.timeout(10_000),
558
558
  })
559
559
  const payload = (await res.json().catch(() => null)) as
560
- | { ok?: boolean; title?: string; draftTitle?: string; twUrl?: string; error?: string }
560
+ | { ok?: boolean; title?: string; draftTitle?: string; twUrl?: string; twUrlAbsolute?: string; error?: string }
561
561
  | null
562
562
  if (!res.ok || payload?.ok !== true) {
563
563
  toast(`打开失败:${payload?.error ?? `HTTP ${res.status}`}`)
@@ -575,9 +575,12 @@ export function createNoteWidget(): NoteWidgetHandle {
575
575
  persistedSignature = draftSignature(title, text, tags)
576
576
  loadedToken = null
577
577
  // twUrl is the same-origin proxy path (e.g. /dsh-tiddlywiki/tw/);
578
- // resolve it against this page's origin so the popup works from any
579
- // host/domain DSH is reached on (loopback, LAN, Tailscale, HTTPS).
580
- const popupUrl = `${new URL(payload.twUrl, location.origin).href}#${encodeURIComponent(payload.draftTitle)}`
578
+ // resolveTwUrl turns it into an absolute URL against this page's origin
579
+ // so the popup works from any host/domain DSH is reached on (loopback,
580
+ // LAN, Tailscale, HTTPS) — and, on the DSH desktop app (`dsh-app:`), to
581
+ // the host's loopback HTTP base, without which TW's sync adaptor refuses
582
+ // to load and the native editor would come up read-only.
583
+ const popupUrl = `${resolveTwUrl(payload.twUrl, payload.twUrlAbsolute)}#${encodeURIComponent(payload.draftTitle)}`
581
584
  openEditorPopup(popupUrl, payload.title ?? title)
582
585
  toast(`已在弹出窗口打开「${payload.title ?? title}」编辑器`)
583
586
  return true
@@ -23,6 +23,13 @@ export interface StatusPayload {
23
23
  url?: string
24
24
  /** Same-origin TW proxy path (e.g. /dsh-tiddlywiki/tw/); the iframe base. */
25
25
  twProxy?: string
26
+ /**
27
+ * Absolute twin of `twProxy` (host loopback HTTP base, v0.26.7). Used only
28
+ * when this document is not on http(s) — the DSH desktop app's `dsh-app:`
29
+ * renderer, where a relative path would leave TW without a sync adaptor.
30
+ * See `resolveTwUrl`.
31
+ */
32
+ twProxyAbsolute?: string
26
33
  wikiPath?: string
27
34
  error?: string
28
35
  git?: {
@@ -27,7 +27,7 @@
27
27
  * @module dsh-tiddlywiki/client/tw-frame
28
28
  */
29
29
  import * as React from 'react'
30
- import { RESTART_ENDPOINT } from './endpoints.ts'
30
+ import { RESTART_ENDPOINT, resolveTwUrl } from './endpoints.ts'
31
31
  import { fetchStatus } from './status-cache.ts'
32
32
  import { attachThemeSync, setThemeSyncConfig } from './theme-sync.ts'
33
33
 
@@ -234,8 +234,21 @@ export function createTwFrameSurface(skin: TwFrameSkin): TwFrameSurface {
234
234
  const tryOnce = (attempt: number): void => {
235
235
  if (disposed) return // unmounted: do not keep waiting or touch the frame
236
236
  if (pendingHash !== hash) return // superseded by a newer request
237
- const frameTw = win as { $tw?: unknown }
238
- if (typeof frameTw.$tw !== 'object' || frameTw.$tw === null) {
237
+ // Cross-origin frame (v0.26.7): on the DSH desktop app the TW frame is
238
+ // loaded from the host's loopback HTTP origin — the only way TW's
239
+ // TiddlyWeb sync adaptor will load there — which makes it a different
240
+ // origin than the DSH page. Reading `$tw` inside it raises SecurityError,
241
+ // so we cannot watch for TW's boot: go straight to the full
242
+ // reload-with-hash fallback (TW honours the hash at startup).
243
+ let frameIsTw = false
244
+ try {
245
+ const frameTw = win as { $tw?: unknown }
246
+ frameIsTw = typeof frameTw.$tw === 'object' && frameTw.$tw !== null
247
+ } catch {
248
+ fallbackLoad(hash)
249
+ return
250
+ }
251
+ if (!frameIsTw) {
239
252
  if (attempt < 40) {
240
253
  // Tracked so dispose() cancels the chain (v0.19.1 — the untracked
241
254
  // 40×150ms retry kept the iframe/closure alive after unmount).
@@ -302,7 +315,10 @@ export function createTwFrameSurface(skin: TwFrameSkin): TwFrameSurface {
302
315
  // matter which host/domain the user reached DSH on. Fall back to the
303
316
  // legacy loopback `url` for older servers that do not send twProxy.
304
317
  if (typeof payload.twProxy === 'string') {
305
- showFrame(new URL(payload.twProxy, window.location.origin).href)
318
+ // resolveTwUrl prefers the host's ABSOLUTE loopback base when THIS page
319
+ // is not on http(s) (the DSH desktop app's `dsh-app:` renderer), because
320
+ // TW refuses to load its sync adaptor anywhere else — see resolveTwUrl.
321
+ showFrame(resolveTwUrl(payload.twProxy, payload.twProxyAbsolute))
306
322
  } else if (typeof payload.url === 'string') {
307
323
  showFrame(payload.url)
308
324
  } else {
package/src/host/http.ts CHANGED
@@ -198,7 +198,40 @@ export function rejectCrossSiteWrite(
198
198
  return true
199
199
  }
200
200
 
201
- /** Read-only route guard: GET/HEAD only, no side effects on any other method. */
201
+ /**
202
+ * Read-only route guard: GET/HEAD only, no side effects on any other method. */
202
203
  export function rejectNonRead(req: IncomingMessage, res: ServerResponse): boolean {
203
204
  return rejectCrossSiteWrite(req, res, ['GET', 'HEAD'])
204
205
  }
206
+
207
+ /**
208
+ * Absolute loopback base for a same-origin proxy path, from a request's `Host`
209
+ * header (v0.26.7). Returns undefined when the header is unusable, in which
210
+ * case the caller simply omits the absolute twin and clients keep using the
211
+ * relative path.
212
+ *
213
+ * WHY THIS EXISTS: the DSH **desktop** app serves its renderer from the custom
214
+ * `dsh-app:` scheme, and TiddlyWiki's own TiddlyWeb sync adaptor refuses to load
215
+ * on a document whose protocol does not start with `http`
216
+ * (`plugins/tiddlywiki/tiddlyweb/tiddlywebadaptor.js`:
217
+ * `if($tw.browser && document.location.protocol.substr(0,4) === "http")`).
218
+ * Embedded from `dsh-app://app/…` the wiki therefore had NO sync adaptor:
219
+ * `$:/status/IsReadOnly` was never written, TW's read-only stylesheet (which
220
+ * treats a MISSING status as read-only) hid 添加条目 / 日志 / 导入 / 管理器 /
221
+ * 编辑 …, and the saver fell back to local-only dirty tracking — an edit made
222
+ * there could not be saved either. The client falls back to this absolute URL
223
+ * only while its own document is not on http(s) (see `resolveTwUrl` in
224
+ * `src/client/endpoints.ts`), so http(s) embedders — loopback, LAN, Tailscale,
225
+ * domain, HTTPS — are never moved off their own origin.
226
+ *
227
+ * `http` is deliberate rather than `x-forwarded-proto`: the desktop app's host
228
+ * always listens on plain HTTP loopback, and only a renderer able to reach it
229
+ * ever reads this value.
230
+ */
231
+ export function absoluteHostBase(hostHeader: string | string[] | undefined, proxyPath: string): string | undefined {
232
+ const host = typeof hostHeader === 'string' ? hostHeader.trim() : Array.isArray(hostHeader) ? String(hostHeader[0] ?? '').trim() : ''
233
+ // `host[:port]` only — a smuggled path or credentials must never reach the
234
+ // URL handed back to the browser.
235
+ if (!/^[A-Za-z0-9.\-[\]:]+$/.test(host)) return undefined
236
+ return `http://${host}${proxyPath}`
237
+ }
@@ -60,7 +60,7 @@ import { GitConflictStateError, type GitFace, type GitStatusView } from './git.t
60
60
  import { PATH_PREFIX, TW_PROXY_PREFIX, TW_PROXY_PATH } from './wiki.ts'
61
61
  import { writeSessionSummary, type SessionQueryFace, type SessionSummaryResult } from './session-summary.ts'
62
62
  import { WORKSPACE_TAG_PREFIX } from './workspace.ts'
63
- import { readBody, readBodyBuffer, json, guardHandler, errorStatus, rejectCrossSiteWrite, rejectNonRead, safeTokenEqual, MAX_PROXY_BODY_BYTES, MAX_UPLOAD_BYTES } from './http.ts'
63
+ import { readBody, readBodyBuffer, json, guardHandler, errorStatus, rejectCrossSiteWrite, rejectNonRead, safeTokenEqual, absoluteHostBase, MAX_PROXY_BODY_BYTES, MAX_UPLOAD_BYTES } from './http.ts'
64
64
  import { sanitizeTwFragment } from './sanitize.ts'
65
65
  import { drainThenStop } from './seeds.ts'
66
66
  import { snippetOf, formatLocalMinute } from './text-util.ts'
@@ -628,6 +628,15 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
628
628
  const referencesBlockedTitle = (value: string | undefined): boolean =>
629
629
  value !== undefined && BLOCKED_PROXY_TITLE_PREFIXES.some((prefix) => value.includes(prefix))
630
630
 
631
+ /**
632
+ * Absolute twin of `twProxy` for embedders whose own document is not on
633
+ * http(s) — the DSH desktop app's `dsh-app:` renderer (v0.26.7). See
634
+ * `absoluteHostBase()` in http.ts for the full why; clients only consult it
635
+ * while their own page is not http(s), so nothing changes for http(s)
636
+ * deployments.
637
+ */
638
+ const twProxyAbsoluteBase = (req: IncomingMessage): string | undefined => absoluteHostBase(req.headers.host, TW_PROXY_PATH)
639
+
631
640
  const handleStatus = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
632
641
  if (rejectNonRead(req, res)) return
633
642
  const view = deps.server.status()
@@ -639,6 +648,10 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
639
648
  // the spawn line's `password=…` (or a forwarded Authorization header).
640
649
  logs: redactLogLines(view.logs),
641
650
  twProxy: TW_PROXY_PATH,
651
+ // Absolute twin of `twProxy` for embedders whose own document is not on
652
+ // http(s) — the DSH desktop app (`dsh-app://app`); see
653
+ // twProxyAbsoluteBase(). Absent when the Host header is unusable.
654
+ twProxyAbsolute: twProxyAbsoluteBase(req),
642
655
  git: gitSummary,
643
656
  note: { tag: deps.noteDefaults().tag },
644
657
  ui: deps.uiDefaults(),
@@ -707,7 +720,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
707
720
  })
708
721
  }
709
722
  const result = await pendingSummary
710
- json(res, { ok: true, ...result, twUrl: TW_PROXY_PATH })
723
+ json(res, { ok: true, ...result, twUrl: TW_PROXY_PATH, twUrlAbsolute: twProxyAbsoluteBase(req) })
711
724
  } catch (err) {
712
725
  json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
713
726
  }
@@ -1071,7 +1084,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1071
1084
  })
1072
1085
  deps.autoCommit()
1073
1086
  invalidateGitStatus()
1074
- json(res, { ok: true, ...result, twUrl: TW_PROXY_PATH })
1087
+ json(res, { ok: true, ...result, twUrl: TW_PROXY_PATH, twUrlAbsolute: twProxyAbsoluteBase(req) })
1075
1088
  } catch (err) {
1076
1089
  if (err instanceof WriteConflictError) {
1077
1090
  json(res, { ok: false, error: err.message, conflict: true }, 409)