思源源码解析外传:Obsidian 源码插件系统逆向分析(一)

本贴最后更新于 441 天前,其中的信息可能已经时移世异

打开 Obsidian 的开发者工具,我们可以发现其文件结构比较简单,功能都被压缩在几个源码文件里,而主要内容就是 app.js。

追踪 Obsidian 源码的目的是研究其插件实现原理,借助 vscode,才得以实现。

逆向思路

0x00 根据 API 进行反推

Obsidian 前端源码是做了混淆的,并且全局的 require 函数和插件里的 require 函数也不相同。在控制台使用的 require 是 electron 提供的 require,无法像插件里那样 require('obsidian')来获取 API。因此我们可以直接查询一个 API 关键词来获取,例如 AbstractTextComponent。得到以下源码

var p99 = {};
n.r(p99),
  n.d(p99, {
    AbstractTextComponent: () => JA,
    App: () => SW,
    BaseComponent: () => YA,
    ButtonComponent: () => XA,
    ColorComponent: () => aL,
    Component: () => OM,
    DropdownComponent: () => rL,
    EditableFileView: () => _O,
    Editor: () => TL,
    EditorSuggest: () => dH,
    Events: () => rD,
    ExtraButtonComponent: () => ZA,
    FileManager: () => FH,
    FileSystemAdapter: () => tA,
    FileView: () => qO,
    FuzzySuggestModal: () => _H,
    HoverPopover: () => WN,
    ItemView: () => zO,
    Keymap: () => JT,
    MarkdownPreviewRenderer: () => uT,
    MarkdownPreviewSection: () => aT,
    MarkdownPreviewView: () => $F,
    MarkdownRenderChild: () => oT,
    MarkdownRenderer: () => GF,
    MarkdownSourceView: () => LN,
    MarkdownView: () => FN,
    Menu: () => OL,
    MenuItem: () => IL,
    MenuSeparator: () => FL,
    MetadataCache: () => PA,
    Modal: () => eD,
    MomentFormatComponent: () => iL,
    Notice: () => iD,
    Platform: () => Mw,
    Plugin: () => fD,
    PluginSettingTab: () => mD,
    PopoverState: () => DN,
    PopoverSuggest: () => fR,
    Scope: () => ZT,
    Setting: () => KA,
    SettingTab: () => ET,
    SliderComponent: () => oL,
    SuggestModal: () => UH,
    TAbstractFile: () => oA,
    TFile: () => sA,
    TFolder: () => lA,
    TextAreaComponent: () => nL,
    TextComponent: () => eL,
    TextFileView: () => PN,
    ToggleComponent: () => QA,
    ValueComponent: () => $A,
    Vault: () => cA,
    View: () => sL,
    ViewRegistry: () => $N,
    Workspace: () => lV,
    WorkspaceContainer: () => rH,
    WorkspaceFloating: () => rV,
    WorkspaceItem: () => eH,
    WorkspaceLeaf: () => lH,
    WorkspaceParent: () => tH,
    WorkspaceRibbon: () => nV,
    WorkspaceRoot: () => iV,
    WorkspaceSidedock: () => iH,
    WorkspaceSplit: () => nH,
    WorkspaceTabs: () => aH,
    WorkspaceWindow: () => oV,
    addIcon: () => YM,
    apiVersion: () => bW,
    arrayBufferToBase64: () => z,
    arrayBufferToHex: () => W,
    base64ToArrayBuffer: () => V,
    debounce: () => aw,
    editorEditorField: () => hO,
    editorInfoField: () => dO,
    editorLivePreviewField: () => fO,
    editorViewField: () => pO,
    finishRenderMath: () => UC,
    fuzzySearch: () => Xw,
    getAllTags: () => UD,
    getBlobArrayBuffer: () => cw,
    getIcon: () => GM,
    getIconIds: () => XM,
    getLinkpath: () => HD,
    hexToArrayBuffer: () => j,
    htmlToMarkdown: () => PL,
    iterateCacheRefs: () => zD,
    iterateRefs: () => qD,
    livePreviewState: () => FB,
    loadMathJax: () => NC,
    loadMermaid: () => IC,
    loadPdfJs: () => AC,
    loadPrism: () => OC,
    moment: () => PB,
    normalizePath: () => Je,
    parseFrontMatterAliases: () => MM,
    parseFrontMatterEntry: () => EM,
    parseFrontMatterStringArray: () => SM,
    parseFrontMatterTags: () => TM,
    parseLinktext: () => VD,
    parseYaml: () => AB,
    prepareFuzzySearch: () => Kw,
    prepareQuery: () => Gw,
    prepareSimpleSearch: () => ek,
    removeIcon: () => $M,
    renderMatches: () => nk,
    renderMath: () => RC,
    renderResults: () => tk,
    request: () => Fw,
    requestUrl: () => Iw,
    requireApiVersion: () => kW,
    resolveSubpath: () => KD,
    sanitizeHTMLToDom: () => FM,
    setIcon: () => KM,
    sortSearchResults: () => Zw,
    stringifyYaml: () => LB,
    stripHeading: () => WD,
    stripHeadingForLink: () => GD,
  });

追踪 n.rn.d 函数,可以找到

function n(i) {
        var r = t[i];
        if (void 0 !== r)
            return r.exports;
        var o = t[i] = {
            exports: {}
        };
        return e[i].call(o.exports, o, o.exports, n),
        o.exports
    }
    n.n = e=>{
        var t = e && e.__esModule ? ()=>e.default : ()=>e;
        return n.d(t, {
            a: t
        }),
        t
    }
    ,
    n.d = (e,t)=>{
        for (var i in t)
            n.o(t, i) && !n.o(e, i) && Object.defineProperty(e, i, {
                enumerable: !0,
                get: t[i]
            })
    }
    ,
    n.g = function() {
        if ("object" == typeof globalThis)
            return globalThis;
        try {
            return this || new Function("return this")()
        } catch (e) {
            if ("object" == typeof window)
                return window
        }
    }(),
    n.o = (e,t)=>Object.prototype.hasOwnProperty.call(e, t),
    n.r = e=>{
        "undefined" != typeof Symbol && Symbol.toStringTag && Object.defineProperty(e, Symbol.toStringTag, {
            value: "Module"
        }),
        Object.defineProperty(e, "__esModule", {
            value: !0
        })
    }

直接阅读比较困难,不过看到 __esModule,估计是 webpack 生成的模块化相关的代码,上 Google 直接把代码粘上去,也的确能印证这段代码是 webpack 生成的标准代码,除了变量名因为每次编译可能发生变化之外,结构是固定的。

因此我们可以发觉 p99 变量应该就是暴露 API 相关的内容。顺着变量往下找。

PS: 这里的 p99 并不是原始代码,而是利用 VScode 的 rename 功能把变量重命名,方便后面进行查找。

0x01 追踪

可以返现这个变量就在一个地方使用了

var pD99 = {
    obsidian: p99,
    "@codemirror/autocomplete": a,
    "@codemirror/collab": s,
    "@codemirror/commands": l,
    "@codemirror/language": o,
    "@codemirror/lint": c,
    "@codemirror/search": u,
    "@codemirror/state": e,
    "@codemirror/text": e,
    "@codemirror/view": t,
    "@lezer/common": i,
    "@lezer/lr": h,
    "@lezer/highlight": r
}
  , dD = {
    "@codemirror/closebrackets": a,
    "@codemirror/comment": l,
    "@codemirror/fold": o,
    "@codemirror/gutter": t,
    "@codemirror/highlight": o,
    "@codemirror/history": l,
    "@codemirror/matchbrackets": o,
    "@codemirror/panel": t,
    "@codemirror/rangeset": e,
    "@codemirror/rectangular-selection": t,
    "@codemirror/stream-parser": o,
    "@codemirror/tooltip": t
}

这么一看,终于找到了 obsidian 这个包是怎么获取的了,显然就是在这里定义的,那么是怎么实现的 require 函数呢?我们接着往下看

e.prototype.loadPlugin = function(e) {
    return g(this, void 0, Promise, (function() {
        var t, n, i, r, o, a, s, l;
        return v(this, (function(c) {
            switch (c.label) {
            case 0:
                return this.isEnabled() ? (t = this.plugins[e]) ? [2, t] : (n = this.manifests[e]) ? [4, this.app.vault.adapter.read(n.dir + "/" + aD)] : [2, null] : [2];
            case 1:
                if (i = c.sent(),
                localStorage.getItem("debug-plugin") || (i = i.replace(vD, "")),
                r = e,
                o = function(e) {
                    return dD.hasOwnProperty(e) && console.error(new Error("[CM6][".concat(r, '] Using a deprecated package: "').concat(e, '".\n').concat(lD))),
                    dD[e] || pD99[e] || rt(e)
                }
                ,
                s = {
                    exports: a = {}
                },
                function(e, t) {
                    return window.eval("(function anonymous(require,module,exports){".concat(e, "\n})\n//# sourceURL=").concat(t, "\n"))
                }(i, "plugin:" + encodeURIComponent(e))(o, s, a),
                !(l = (a = s.exports || a).default || s.exports))
                    throw new Error("Failed to load plugin " + e + ". No exports detected.");
                if (!((t = new l(this.app,n))instanceof fD))
                    throw new Error("Failed to load plugin " + e);
                return this.plugins[e] = t,
                [4, t.load()];
            case 2:
                return c.sent(),
                [4, t.loadCSS()];
            case 3:
                return c.sent(),
                [2, t]
            }
        }
        ))
    }
    ))
}

loadPlugin 看来就是加载插件的源码了,这个方法名也没有混淆,很方便找。

这个 gv 按照刚才的方法进行学习,可以了解是跟 Promise 异步处理相关的代码,或者将 async 语法编译为 Promise 之后生成的代码。主要看内部。

function(e, t) {
    return window.eval("(function anonymous(require,module,exports){".concat(e, "\n})\n//# sourceURL=").concat(t, "\n"))
}(i, "plugin:" + encodeURIComponent(e))(o, s, a)

0x02 结果分析

原来是通过 eval 来实现的代码加载。这个结果与在 devtool 中查看到的源码 plugin:obsidian-day-planner 源码一致,就是在插件编译后的源码前后添加了 function anonymous(require,module,exports){//# sourceURL=。这里的 osa,分别对应了 requiremoduleexports 函数或对象。也就是说插件里的 require 方法,完全不是 electron 提供的 require 方法。通过这种方法,即能向模块提供私有 API,同时也能够污染插件内的 require,避免其调用可提供 API 之外的包,保证安全性。

而在插件的开发过程中,插件一般用 Typescript 进行编写,import { BaseComponent } from 'obsidian' 这种方法,一方面 npm 提供了 obsidian 的 npm 包,内置 API 的类型声明,同时编译后对 const BaseComponent = require('obisidan').BaseComponent 也能正常运行,开发体验和运行都得到了保障

0x03 推广到 siyuan

思源目前的挂件系统就是 iframe 实现,做的还比较粗糙,我们后续提供插件系统可以借鉴这种方式,来为开发者提供此种开发体验。

  • 思源笔记

    思源笔记是一款隐私优先的个人知识管理系统,支持完全离线使用,同时也支持端到端加密同步。

    融合块、大纲和双向链接,重构你的思维。

    18672 引用 • 69600 回帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...