erosman / support

Support Location for all my extensions
Mozilla Public License 2.0
172 stars 12 forks source link

[Firemonkey] xxx Failed to insert unsafeWindow is not defined #608

Open ngdangtu-vn opened 11 months ago

ngdangtu-vn commented 11 months ago

In the document:

unsafeWindow in FireMonkey is an alias for window.wrappedJSObject

But my script couldn't use it. Here is my code:

// ==UserScript==
// @name         Dynasty Scans debug
// @version      0.1
// @description  Caching chapters so you don't have to reload it everytime you want to read it again.
// @author       ngdangtu
// @namespace    https://userscript.ngdangtu.dev/dynasty-scans.com
// @icon         https://icons.duckduckgo.com/i/dynasty-scans.com.ico
// @supportURL   https://gitlab.com/ndt-browserkit/userscript/-/issues/new
// @updateURL    https://gitlab.com/ndt-browserkit/userscript/-/raw/main/dynasty-scans.com/chapter.js
// @match        *://dynasty-scans.com/chapters/*
// @run-at       document-body
// @grant        unsafeWindow
// @grant        GM_log
// @grant        GM_getResourceText
// @resource     style  https://gitlab.com/ndt-browserkit/userscript/-/raw/main/dynasty-scans.com/style/chapter.css
// ==/UserScript==

/** ⦿ Conditional Functions */
/**
 * Check if the str is blob URI
 *
 * @param {string} uri
 * @returns {boolean} true if the string is Blob URI, otherwise returns false
 */
const is_blob = (uri) => typeof uri === 'string' ? uri.slice(0, 5) === 'blob:' : false

/** ⦿ Convertor Functions */

/** ⦿ Extractor Functions */
/**
 * Extract width and height from an image source
 * @param {ImageBitmapSource} img_src
 * @returns {Promise<{width:number,height:number}>}
 */
async function get_imgsz(img_src)
{
    const { width, height, close } = await createImageBitmap(img_src)
    close()
    return { width, height }
}

function set_el_attr(el, kv)
{
    if (!el || !kv) return void 0

    const attr_ls = Object.entries(kv)
    if (attr_ls.length < 1) return void console.error(`Key-Value map of fn ${set_el_attr.name} must has at least 1 pair.`)

    for (const [key, value] of attr_ls)
    {
        if (key === 'class')
        {
            Array.isArray(value)
                ? el.classList.add(...value)
                : el.classList.add(value)
            continue
        }
        const [_0, cssvar] = key.match(/^--(.+)/i) ?? []
        if (cssvar)
        {
            el.style.setProperty(`--${cssvar}`, value)
            continue
        }
        el.setAttribute(key, value)
    }
}

/**
 * A shortcut to generate DOM Element
 *
 * @param {string} name Element name (img, a, p, section)
 * @param {string|null} ref Reference name of Element
 * @param {Record<string,string>|null} attr_kv Element attributes (id, class, href)
 * @param {Record<string,Node>|null} content Node children (TextNode, Element)
 * @returns {Record<string,HTMLElement|Node>|HTMLElement} If reference is set, it returns
 *      a Node list include itself and its children. Otherwise, it simply returns a Node.
 *
 * @example
 * // Create a simple element
 * const el_toolbar = el('menu')
 *
 * // Create with a CSS var
 * const el_toolbar = el('menu', null, { 'cssvar-c-bg': '#111' })
 *
 * // Create with a nest child
 * const { el_toolbar, el_btn_load } = el(
 *   'menu',
 *   'el_toolbar',
 *   { 'cssvar-c-bg': '#111' },
 *   el('li', 'el_btn_load'),
 * )
 */
function emt(name, ref = null, attr_kv = null, content = null)
{
    const el = document.createElement(name)

    set_el_attr(el, attr_kv)
    const children = Object.values(content || {})
    console.log(el, children)
    el.append(...children)
    console.log(el.children)

    if (ref) return { [ref]: el, ...content }
    return el
}

function tmpl_el(name, attr_kv = null, content = null)
{
    const tmpl = emt(name, null, attr_kv, content)
    console.log(tmpl)

    return (attr_kv = null, content = null) =>
    {
        const el = tmpl.cloneNode()

        set_el_attr(el, attr_kv)
        if (content instanceof Node) el.append(content)
        el.innerHTML += content

        console.log(tmpl, el)
        return el
    }
}

class ConvenientCache
{
    /** @type {string} */
    #ns = undefined

    /** @type {string} */
    #c_name = undefined

    /** @type {Promise<Cache>} */
    #c = undefined

    constructor(ns)
    {
        this.#ns = ns
    }

    create(...key)
    {
        if (!key) throw new Error(`The method ${this.create.name} requires at least 1 key to create cache.`)

        this.#c_name = this.#ns
        if (key.length = 1)
        {
            this.#c_name += '-' + key[0]
        } else
        {
            this.#c_name += '-' + key.shift()
            this.#c_name += '/' + key.join('/')
        }
        this.#c = caches.open(this.#c_name)

        return this
    }

    /**
     * Search and get Response from key,
     * if it does not exist, attempt to fetch & cache once.
     *
     * @param {RequestInfo} key
     * @param {CacheQueryOptions} opt
     * @returns {Promise<Response|undefined>}
     */
    async lookup(key, opt = null)
    {
        const res = await this.match(key, opt)
        if (res) return res

        await this.add(key)
        return this.match(key, opt)
    }

    /**
     * cache.match() but Blob is returned instead of Response
     *
     * @param {RequestInfo} req
     * @param {CacheQueryOptions} opt
     * @returns {Promise<Blob|undefined>}
     */
    async get_blob(req, opt = null)
    {
        const not_req_request = !(req instanceof Request)
        const not_req_str = typeof req !== 'string'
        if (!req || not_req_request || not_req_str) return void 0

        // if Blob URI string
        if (is_blob(req)) return void console.error(`The method ${this.get_blob.name} doesn't accept blob URI!`)

        // if Request
        let res = await this.lookup(req, opt)
        if (!res) return void console.error('Unable to fetch the image: ', req)
        return await res.blob()
    }

    drop_current_cache()
    {
        return caches.delete(this.#c_name)
    }

    /**
     * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CacheStorage/match)
     *
     * @param {RequestInfo} req
     * @param {CacheQueryOptions} opt
     * @returns {Promise<Response|undefined>}
     */
    async match(req, opt = null)
    {
        return (await this.#c).match(req, opt)
    }

    /**
     * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CacheStorage/add)
     *
     * @param {RequestInfo} req
     * @returns {Promise<void>}
     */
    async add(req)
    {
        return (await this.#c).add(req)
    }

    /**
     * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CacheStorage/delete)
     *
     * @param {RequestInfo} req
     * @param {CacheQueryOptions} opt
     * @returns {Promise<boolean>}
     */
    async delete(req, opt = null)
    {
        return (await this.#c).delete(req, opt)
    }
}

class DsCache extends ConvenientCache
{
    constructor()
    {
        super('ds')
    }

    static any(key)
    {
        return new this().create(key)
    }
    static series(key)
    {
        return new this().create('s', key)
    }
    static chapter(key)
    {
        return new this().create('c', key)
    }
}

/**
 * @TODO remove this when I have better solution.
 *
 * Require cache.js in here as a temporary solution
 * as TamperMonkey doesn't support nesting meta require yet.
 */

/** ⦿ Predefined Global Variables */
window.is_load = false // true if all chapter's images are loaded.
window.no_offline_cache = true // true if user decides to keep cache
const doc = unsafeWindow.document

/** ⦿ Setup Style */
doc.head.innerHTML += `<style>${GM_getResourceText('style')}</style>`

/** ⦿ Preparation */
const is_mobile = matchMedia('(max-width: 767px)')
const dc = DsCache.chapter(window.location.pathname.split('/').pop())
const reader =
{
    pg_ls: new Map(),
    el_self: doc.querySelector('#reader'),
    el_display: doc.querySelector('#image'),
    get_pad: (up_scale) => (is_mobile.matches ? 10 : 120) * up_scale, // responsive #reader{padding-inline}
    get_width: function () { return this.el_self.offsetWidth - this.get_pad(2) },
    save_pg: async function (inx, req)
    {
        const blob = req instanceof Response
            ? req.blob()
            : await dc.get_blob(req)
        if (!blob) return false

        this.pg_ls.set('pg-' + inx, {
            blob: URL.createObjectURL(blob),
            sz: await get_imgsz(blob),
        })
        return true
    }
}

/** ⦿ Setup New Components */
const tpml_btn = tmpl_el('a', null, {
    'class': ['btn', 'btn-mini'],
    'href': 'javascript:void 0'
})
const { el_toolbar, el_btn_preload, el_btn_cache, el_btn_clear } = emt(
    'menu', 'el_toolbar', { 'class': 'btn-toolbar' },
    emt('li', 'el_btn_grp', { 'class': 'btn-group' }, {
        'el_btn_preload': tpml_btn(null, `<i class='icon-download'></i> Preload`),
        'el_btn_cache': tpml_btn(null, `<i class='icon-download-alt'></i> Cache NOW`),
        // 'el_btn_reload': tpml_btn(null, `<i class='icon-refresh'></i> Force Reload`),
        'el_btn_clear': tpml_btn({ 'disabled': true }, `<i class='icon-trash'></i> Clear Cache`),
    })
)
reader.el_self.prepend(el_toolbar)

/** ⦿ Helper Functions */
const set_btn_on = (btn) => btn.setAttribute('disabled', false)
const set_btn_off = (btn) => btn.setAttribute('disabled', true)
function get_id(inx = null)
{
    if (['number', 'string'].includes(typeof inx)) return 'pg-' + inx

    const { hash } = doc.location
    if (!hash) return 'pg-1'
    return hash.toLowerCase() === '#last'
        ? 'pg-' + unsafeWindow.pages.length
        : hash.replace(/^\#/, 'pg-')
}

/** ⦿ Action Functions */
function resize_img({ width: w, height: h })
{
    const max_width = reader.get_width()
    const max_height = 1200

    if (is_mobile.matches) return {
        w: max_width,
        h: Math.floor(max_width * h / w)
    }

    const xw = Math.min(w, max_width)
    const xh = Math.min(h, max_height)

    const is_portrait = h > w
    return is_portrait
        ? { h: xh, w: Math.floor(xh * w / h) }
        : { w: xw, h: Math.floor(xw * h / w) }
}
async function show_img(inx = null)
{
    const pg = reader.pg_ls.get(get_id(inx))
    if (!pg) return void 0

    window.is_load && unsafeWindow.stop()
    reader.el_display.querySelector('img')?.remove()

    const { w, h } = resize_img(pg.sz)
    set_el_attr(reader.el_display, {
        '--w': w + 'px',
        '--h': h + 'px',
        '--xw': pg.sz.width + 'px',
        '--xh': pg.sz.height + 'px',
        '--img': `url(${pg.blob})`,
    })
}
async function show_thumb(i, data)
{
    if (!i || !data) return void 0

    const query = `.pages-list a:nth-of-type(${i + 1})`// Index must start from 1 + 1 next nav_item (not .page)
    const el = reader.el_display.querySelector(query)
    el.style.backgroundImage = `url(${data.blob})`
    el.style.paddingBlockStart = `${el.clientWidth * data.sz.height / data.sz.width}px`
}
async function prepare_img(check_only = false)
{
    const ls = unsafeWindow.pages
    const dl_ls_status = []
    for (let i = 0; i < ls.length; i++)
    {
        const url = window.location.origin + ls[i].image
        const load_or_reuse = check_only ? await dc.match(url) : url
        const result = await reader.save_pg(i + 1, load_or_reuse)

        const status = typeof result === 'object'
        if (status) show_thumb(i + 1, result)
        dl_ls_status.push(status)
    }

    window.is_load = dl_ls_status.reduce((prv, cur) => prv && cur, true)
    if (window.is_load || check_only) reader
        .el_display.querySelector('.pages-list')
        .classList.add('loaded')
    GM_log(check_only ? 'Checking Complete :3' : 'Load Complete :D')
}

/** ⦿ Window Events */
unsafeWindow.addEventListener(
    'beforeunload',
    _ => window.no_offline_cache && dc.drop_current_cache()
)

/** ⦿ Component Events */
el_btn_preload.addEventListener('click', e =>
{
    set_btn_off(e.currentTarget)
    window.no_offline_cache = true
    prepare_img()
})
el_btn_cache.addEventListener('click', async e =>
{
    set_btn_off(e.currentTarget)
    set_btn_off(el_btn_preload)
    window.no_offline_cache = false
    await prepare_img()
    set_btn_on(el_btn_clear)
})
el_btn_clear.addEventListener('click', async e =>
{
    set_btn_off(e.currentTarget)
    const is_done = await dc.drop_current_cache()
    if (is_done) set_btn_on(el_btn_cache)
})

/** ⦿ MAIN */
prepare_img(true).then(show_img)
unsafeWindow.addEventListener('hashchange', show_img)

It wasn't like this in Tampermonkey. What should I fix?

UPDATE: pc spec

ngdangtu-vn commented 11 months ago

The GM_getResourceText doesn't work as well.

erosman commented 11 months ago

@run-at document-body

document-body is not supported in FM|GM|VM. It will default to document-idle in FireMonkey.

It wasn't like this in Tampermonkey.

Does it work in Violentmonkey?

ngdangtu-vn commented 11 months ago

document-body is not supported in FM|GM|VM. It will default to document-idle in FireMonkey.

Yeah I also found after submit the issue, still, the unsafeWindow is not available even after I set document-end to meta @run-at.

Does it work in Violentmonkey?

Yes, it works ok.

More info: Because unsafeWindow didn't work, I switched to window.wrappedJSObject, so it runs now. However, GM_getResourceText is the next undefined value. One funny thing is I still got the error Failed to insert unsafeWindow is not defined when I tried to wrap it inside another variable for compatibility with tampermonkey:

const uw = unsafeWindow ?? window.wrappedJSObject // Fail with same error
const uw = unsafeWindow || window.wrappedJSObject // Fail with same error

It like the unsafeWindow is treated as function and demand to executed immediately :? Funny right?

ngdangtu-vn commented 11 months ago

Also, the @require doesn't seems to work as well. Like this is the userscript before compiled:

// ==UserScript==
// @name         Dynasty Scans Chapter Caching 
// @version      0.0.1
// @description  Caching chapters so you don't have to reload it everytime you want to read it again.
// @author       ngdangtu
// @namespace    https://userscript.ngdangtu.dev/dynasty-scans.com
// @icon         https://icons.duckduckgo.com/i/dynasty-scans.com.ico
// @supportURL   https://gitlab.com/ndt-browserkit/userscript/-/issues/new
// @updateURL    https://gitlab.com/ndt-browserkit/userscript/-/raw/main/dynasty-scans.com/chapter.js
// @match        *://dynasty-scans.com/chapters/*
// @run-at       document-end
// @resource     style  https://gitlab.com/ndt-browserkit/userscript/-/raw/main/dynasty-scans.com/style/chapter.css
// @grant        unsafeWindow
// @grant        GM_getResourceText
// @require      https://gitlab.com/ndt-browserkit/userscript/-/raw/main/lib/utility.js
// @require      https://gitlab.com/ndt-browserkit/userscript/-/raw/main/lib/cache.js
// @require      https://gitlab.com/ndt-browserkit/userscript/-/raw/main/lib/dom.js
// @require      https://gitlab.com/ndt-browserkit/userscript/-/raw/main/dynasty-scans.com/lib/cache.js
// ==/UserScript==

/**
 * @TODO remove this when I have better solution.
 * 
 * Require cache.js in here as a temporary solution
 * as FireMonkey doesn't support nesting meta require yet.
 */

/** ⦿ Predefined Global Variables */
const uw = window.wrappedJSObject
window.is_load = false // true if all chapter's images are loaded.
window.no_offline_cache = true // true if user decides to keep cache
const doc = uw.document

/** ⦿ Setup Style */
doc.head.innerHTML += `<style>${"GM_getResourceText('style')"}</style>`

/** ⦿ Preparation */
const is_mobile = matchMedia('(max-width: 767px)')
const dc = DsCache.chapter(window.location.pathname.split('/').pop())
const reader =
{
    pg_ls: new Map(),
    el_self: doc.querySelector('#reader'),
    el_display: doc.querySelector('#image'),
    get_pad: (up_scale) => (is_mobile.matches ? 10 : 120) * up_scale, // responsive #reader{padding-inline}
    get_width: function () { return this.el_self.offsetWidth - this.get_pad(2) },
    save_pg: async function (inx, req)
    {
        const blob = req instanceof Response
            ? req.blob()
            : await dc.get_blob(req)
        if (!blob) return false

        this.pg_ls.set('pg-' + inx, {
            blob: URL.createObjectURL(blob),
            sz: await get_imgsz(blob),
        })
        return true
    }
}

/** ⦿ Setup New Components */
const tpml_btn = tmpl_el('a', null, {
    'class': ['btn', 'btn-mini'],
    'href': 'javascript:void 0'
})
const { el_toolbar, el_btn_preload, el_btn_cache, el_btn_clear } = emt(
    'menu', 'el_toolbar', { 'class': 'btn-toolbar' },
    emt('li', 'el_btn_grp', { 'class': 'btn-group' }, {
        'el_btn_preload': tpml_btn(null, `<i class='icon-download'></i> Preload`),
        'el_btn_cache': tpml_btn(null, `<i class='icon-download-alt'></i> Cache NOW`),
        // 'el_btn_reload': tpml_btn(null, `<i class='icon-refresh'></i> Force Reload`),
        'el_btn_clear': tpml_btn({ 'disabled': true }, `<i class='icon-trash'></i> Clear Cache`),
    })
)
reader.el_self.prepend(el_toolbar)

/** ⦿ Helper Functions */
const set_btn_on = (btn) => btn.setAttribute('disabled', false)
const set_btn_off = (btn) => btn.setAttribute('disabled', true)
function get_id(inx = null)
{
    if (['number', 'string'].includes(typeof inx)) return 'pg-' + inx

    const { hash } = doc.location
    if (!hash) return 'pg-1'
    return hash.toLowerCase() === '#last'
        ? 'pg-' + uw.pages.length
        : hash.replace(/^\#/, 'pg-')
}

/** ⦿ Action Functions */
function resize_img({ width: w, height: h })
{
    const max_width = reader.get_width()
    const max_height = 1200

    if (is_mobile.matches) return {
        w: max_width,
        h: Math.floor(max_width * h / w)
    }

    const xw = Math.min(w, max_width)
    const xh = Math.min(h, max_height)

    const is_portrait = h > w
    return is_portrait
        ? { h: xh, w: Math.floor(xh * w / h) }
        : { w: xw, h: Math.floor(xw * h / w) }
}
async function show_img(inx = null)
{
    const pg = reader.pg_ls.get(get_id(inx))
    if (!pg) return void 0

    window.is_load && uw.stop()
    reader.el_display.querySelector('img')?.remove()

    const { w, h } = resize_img(pg.sz)
    set_el_attr(reader.el_display, {
        '--w': w + 'px',
        '--h': h + 'px',
        '--xw': pg.sz.width + 'px',
        '--xh': pg.sz.height + 'px',
        '--img': `url(${pg.blob})`,
    })
}
async function show_thumb(i, data)
{
    if (!i || !data) return void 0

    const query = `.pages-list a:nth-of-type(${i + 1})`// Index must start from 1 + 1 next nav_item (not .page)
    const el = reader.el_display.querySelector(query)
    el.style.backgroundImage = `url(${data.blob})`
    el.style.paddingBlockStart = `${el.clientWidth * data.sz.height / data.sz.width}px`
}
async function prepare_img(check_only = false)
{
    const ls = uw.pages
    const dl_ls_status = []
    for (let i = 0; i < ls.length; i++)
    {
        const url = window.location.origin + ls[i].image
        const load_or_reuse = check_only ? await dc.match(url) : url
        const result = await reader.save_pg(i + 1, load_or_reuse)

        const status = typeof result === 'object'
        if (status) show_thumb(i + 1, result)
        dl_ls_status.push(status)
    }

    window.is_load = dl_ls_status.reduce((prv, cur) => prv && cur, true)
    if (window.is_load || check_only) reader
        .el_display.querySelector('.pages-list')
        .classList.add('loaded')
    console.log(check_only ? 'Checking Complete :3' : 'Load Complete :D')
}

/** ⦿ Window Events */
uw.addEventListener(
    'beforeunload',
    _ => window.no_offline_cache && dc.drop_current_cache()
)

/** ⦿ Component Events */
el_btn_preload.addEventListener('click', e =>
{
    set_btn_off(e.currentTarget)
    window.no_offline_cache = true
    prepare_img()
})
el_btn_cache.addEventListener('click', async e =>
{
    set_btn_off(e.currentTarget)
    set_btn_off(el_btn_preload)
    window.no_offline_cache = false
    await prepare_img()
    set_btn_on(el_btn_clear)
})
el_btn_clear.addEventListener('click', async e =>
{
    set_btn_off(e.currentTarget)
    const is_done = await dc.drop_current_cache()
    if (is_done) set_btn_on(el_btn_cache)
})

/** ⦿ MAIN */
prepare_img(true).then(show_img)
uw.addEventListener('hashchange', show_img)

It failed to fetch file cache.js from https://gitlab.com/ndt-browserkit/userscript/-/raw/main/dynasty-scans.com/lib/cache.js

Btw, I update my pc spec in case you need.

ngdangtu-vn commented 11 months ago

@erosman For now, I've moved on this issue by changing the @run-at to document-end and @inject-into page (I don't know how this works tbh). However, the behaviou is nothing like in the doc. I think document-start will favor window.wrappedJSObject and document-end favors unsafeWindow. By favouring, I mean window.wrappedJSObject can't be used in document-end and vice versa.

erosman commented 10 months ago

unsafeWindow is simply the page window.wrappedJSObject in normal injection and window in page context. There is no need to swap them.

At document-start, page window may have not loaded so it can fail in any userscript script manager. document-end & document-idle doesn't affect unsafeWindow.

@inject-into page

In this option, there will be no GM API support (except GM info). See also: FireMonkey Help: inject-into

ngdangtu-vn commented 10 months ago

In this option, there will be no GM API support (except GM info).

But without that option, I will not be able to use unsafeWindow.

erosman commented 10 months ago

unsafeWindow is designed to give the userscripts access to the window of the webpage, when userscript is running in OTHER contexts such as conteScripts or userScripts context.

If there script is injected into the page context i.e. webpage, there is no real need for it since the window in page context is directly accessible by the userscript.

If you post a minimal test script, I can test what you are trying to achieve.